From 51ef2528ceac55f920e8daac88afcd202ef93c44 Mon Sep 17 00:00:00 2001 From: ljxbx Date: Thu, 11 Jun 2026 21:21:59 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E7=BE=A4?= =?UTF-8?q?=E6=9C=8D=E4=BA=92=E9=80=9A=E4=BA=91=E9=93=BE=E7=89=88Ultra?= =?UTF-8?q?=E7=89=88=E5=92=8C=E4=B8=80=E7=B3=BB=E5=88=97=E8=81=94=E5=8A=A8?= =?UTF-8?q?=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__init__.py" | 1006 ++++ .../datas.json" | 10 + .../readme.txt" | 49 + .../__init__.py" | 260 + .../datas.json" | 12 + .../guild_cloud_interop/api.py" | 1824 +++++++ .../guild_cloud_interop/config.py" | 1698 +++++++ .../guild_cloud_interop/config_watcher.py" | 72 + .../guild_cloud_interop/control.py" | 425 ++ .../guild_cloud_interop/handlers.py" | 2405 +++++++++ .../guild_cloud_interop/handlers_quick.py" | 486 ++ .../guild_cloud_interop/logic.py" | 1152 +++++ .../guild_cloud_interop/matchers.py" | 147 + .../guild_cloud_interop/models.py" | 736 +++ .../guild_cloud_interop/prompts.py" | 65 + .../guild_cloud_interop/service.py" | 47 + .../guild_cloud_interop/ui.py" | 225 + .../guild_cloud_interop/validators.py" | 65 + .../readme.md" | 125 + ...31\350\257\257\345\244\204\347\220\206.md" | 101 + .../__init__.py" | 915 ++-- .../datas.json" | 8 +- ...03\347\224\250\346\226\207\346\241\243.md" | 501 ++ .../__init__.py" | 426 +- .../binding_mixin.py" | 665 +++ .../config_editor_mixin.py" | 611 +++ .../config_mixin.py" | 2194 +++++--- .../datas.json" | 14 +- .../orion_mixin.py" | 1938 ++++--- .../qq_mixin.py" | 4452 ++++++++++++++--- .../runtime_mixin.py" | 1609 +++--- .../websocket/_abnf.py" | 976 ++-- .../websocket/_app.py" | 1362 ++--- .../websocket/_core.py" | 1263 ++--- .../websocket/_handshake.py" | 28 +- .../websocket/_http.py" | 806 +-- .../websocket/_socket.py" | 385 +- .../websocket/_url.py" | 383 +- .../websocket/_utils.py" | 935 ++-- .../websocket/_wsdump.py" | 538 +- .../__init__.py" | 2352 +++++++++ .../config.py" | 56 + .../datas.json" | 11 + .../geometry.py" | 98 + .../models.py" | 193 + 45 files changed, 27062 insertions(+), 6567 deletions(-) create mode 100644 "\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" create mode 100644 "\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" create mode 100644 "\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/readme.txt" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/api.py" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config_watcher.py" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/matchers.py" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/prompts.py" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/service.py" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/validators.py" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/readme.md" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/\351\224\231\350\257\257\345\244\204\347\220\206.md" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/API\350\260\203\347\224\250\346\226\207\346\241\243.md" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" create mode 100644 "\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" create mode 100644 "\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/config.py" create mode 100644 "\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" create mode 100644 "\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/geometry.py" create mode 100644 "\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/models.py" diff --git "a/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" new file mode 100644 index 00000000..3d762c52 --- /dev/null +++ "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -0,0 +1,1006 @@ +"""Cloud-linked quest system plugin.""" + +import copy +import os +import time +import threading +from dataclasses import dataclass +from typing import Any +from tooldelta import ( + cfg as config, + utils, + fmts, + game_utils, + Plugin, + Player, + TYPE_CHECKING, + plugin_entry, +) + + +CONFIG_FILE_DIR = "插件配置文件" +DYNAMIC_LOAD_SETTINGS_KEY = "动态载入设置" +DYNAMIC_LOAD_ENABLED_KEY = "是否启用动态载入配置文件(仅用于本插件)" +DYNAMIC_LOAD_INTERVAL_KEY = "动态载入检测时间间隔(单位:秒)" +DYNAMIC_LOAD_DEFAULT_INTERVAL = 5 + + +@dataclass +class Quest: + """Runtime definition for one configured quest.""" + + tag_name: str + "标签名, 即文件夹/文件名去json" + show_name: str + "展示名" + description: str + "描述" + detect_cmds: list[str] + "检测命令" + need_items: dict[str, list] + "需要的物品" + cooldown: int + "任务冷却的秒数" + exec_cmds_when_finished: list[str] + "完成时执行的指令" + items_give_when_finished: dict + "完成时给予的物品" + start_quest_when_finished: list[str] + "完成时开始的任务" + command_block_only: bool + "只能由命令方块来完成任务" + # EXTRA + need_quests_prefix: list[str] | None + + def __hash__(self) -> int: + return id(self) + + +class TaskSystemCloudInterop(Plugin): + """ToolDelta plugin that manages cloud-linked quest workflows.""" + + name = "任务系统云链联动版" + author = "SuperScript" + version = (0, 0, 5) + + def __init__(self, frame): + super().__init__(frame) + self._config_reload_stop = threading.Event() + self._config_file_state = None + self.QUEST_PATH = os.path.join(self.data_path, "任务") + self.QUEST_DATA_PATH = os.path.join(self.data_path, "任务数据") + self.tmpjson = utils.tempjson + self.quest_data_paths: set[str] = set() + self.in_plot_running = {} + self.quests: dict[str, Quest] = {} + for ipath in [self.QUEST_PATH, self.QUEST_DATA_PATH]: + os.makedirs(ipath, exist_ok=True) + CFG_STD = { + DYNAMIC_LOAD_SETTINGS_KEY: { + DYNAMIC_LOAD_ENABLED_KEY: bool, + DYNAMIC_LOAD_INTERVAL_KEY: config.PInt, + }, + "任务设置": { + "任务列表显示格式": config.JsonList(str), + "接到新任务时执行的指令": config.JsonList(str), + "任务无法提交的显示": { + "格式": str, + }, + "任务完成执行的指令": config.JsonList(str), + "任务无法开始的显示": { + "格式": str, + }, + } + } + CFG_DEFAULT = { + DYNAMIC_LOAD_SETTINGS_KEY: { + DYNAMIC_LOAD_ENABLED_KEY: True, + DYNAMIC_LOAD_INTERVAL_KEY: DYNAMIC_LOAD_DEFAULT_INTERVAL, + }, + "任务设置": { + "任务列表显示格式": [ + "§7▶ 当前正在进行的任务:", + " §f[i] §7- §f[任务显示名]\n §7[任务描述] ", + "§7在15s内输入§f任务前的序号§r§7可以提交此任务 §f其他§7以退出", + ], + "接到新任务时执行的指令": [ + "/execute as @a[name=[玩家名]] at @s run playsound note.pling @s ~~~ 1 1.4", + ( + '/tellraw @a[name=[玩家名]] ' + '{"rawtext":[{"text":"§d▶ §e收到新任务 §f[任务显示名]\n' + ' §7[任务描述] \n§3输入§b.rw§3以提交任务"}]}' + ), + ], + "任务无法提交的显示": { + "格式": "§c任务无法达成, 原因:\n [原因]", + }, + "任务无法开始的显示": { + "格式": "§c任务无法开始, 原因:\n [原因]"}, + "任务完成执行的指令": [ + '/tellraw @a[name=[玩家名]] {"rawtext":[{"text":"§a任务完成"}]}', + "/execute as @a[name=[玩家名]] at @s run playsound random.levelup @s", + ], + }, + } + self._cfg_std = CFG_STD + self._cfg_default = CFG_DEFAULT + QUEST_STD = { + "显示名": str, + "描述": str, + "检测的指令": config.JsonList(str), + "需要的物品": config.AnyKeyValue(config.JsonList((str, int))), + "只能由命令方块触发完成": bool, + "任务模式(-1=一次性 0=可重复做 >0为任务冷却秒数)": int, + "任务完成": { + "执行的指令": config.JsonList(str), + "给予的物品": config.AnyKeyValue(config.JsonList((str, int), 2)), + "开启的新任务": config.JsonList(str), + }, + } + self._quest_std = QUEST_STD + self.load_runtime_config(announce=False) + self.load_quest_configs(announce=True) + self.refresh_config_file_state() + self.config_thread = utils.createThread( + self.config_reload_task, + usage="任务系统配置热更新任务", + ) + self.ListenPreload(self.on_def) + self.ListenActive(self.on_inject) + self.ListenPlayerJoin(self.on_player_join) + self.ListenFrameExit(self.on_frame_exit) + + # --- Config hot reload --- + + @classmethod + def _merge_config_with_default(cls, raw: Any, default: Any): + if isinstance(default, dict): + result = { + key: cls._merge_config_with_default( + raw.get(key) if isinstance(raw, dict) else None, + value, + ) + for key, value in default.items() + } + if isinstance(raw, dict): + for key, value in raw.items(): + if key not in result: + result[key] = copy.deepcopy(value) + return result + return copy.deepcopy( + raw) if raw is not None else copy.deepcopy(default) + + @staticmethod + def _trim_fixed_keys(raw: Any, default: dict[str, Any]) -> dict[str, Any]: + raw = raw if isinstance(raw, dict) else {} + return { + key: copy.deepcopy(raw.get(key, value)) + for key, value in default.items() + } + + @staticmethod + def _normalize_bool(value: Any, fallback: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in ("true", "1", "yes", "y", "on", "启用", "是", "真"): + return True + if text in ("false", "0", "no", "n", "off", "禁用", "否", "假"): + return False + if isinstance(value, (int, float)) and not isinstance(value, bool): + return bool(value) + return bool(fallback) + + @staticmethod + def _normalize_positive_int(value: Any, fallback: int) -> int: + if isinstance(value, bool): + return fallback + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + @staticmethod + def _normalize_str( + value: Any, + fallback: str, + *, + allow_empty: bool = False) -> str: + if value is None: + return fallback + text = str(value) + if text or allow_empty: + return text + return fallback + + @classmethod + def _normalize_string_list( + cls, + value: Any, + fallback: list[str], + *, + allow_empty: bool = False, + ) -> list[str]: + if isinstance(value, str): + candidates = [value] + elif isinstance(value, list): + candidates = value + else: + return copy.deepcopy(fallback) + + result: list[str] = [] + for item in candidates: + text = cls._normalize_str(item, "", allow_empty=True).strip() + if text and text not in result: + result.append(text) + if result or allow_empty: + return result + return copy.deepcopy(fallback) + + @classmethod + def _normalize_format_config( + cls, + value: Any, + fallback: dict[str, str], + ) -> dict[str, str]: + value = value if isinstance(value, dict) else {} + return { + "格式": cls._normalize_str( + value.get("格式"), + fallback["格式"], + allow_empty=False, + ) + } + + @classmethod + def _normalize_runtime_config( + cls, + raw_cfg: Any, + default_cfg: dict[str, Any], + ) -> dict[str, Any]: + merged_cfg = cls._merge_config_with_default(raw_cfg, default_cfg) + normalized = cls._trim_fixed_keys(merged_cfg, default_cfg) + + dynamic_default = default_cfg[DYNAMIC_LOAD_SETTINGS_KEY] + dynamic = cls._trim_fixed_keys( + normalized.get(DYNAMIC_LOAD_SETTINGS_KEY), + dynamic_default, + ) + dynamic[DYNAMIC_LOAD_ENABLED_KEY] = cls._normalize_bool( + dynamic.get(DYNAMIC_LOAD_ENABLED_KEY), + dynamic_default[DYNAMIC_LOAD_ENABLED_KEY], + ) + dynamic[DYNAMIC_LOAD_INTERVAL_KEY] = cls._normalize_positive_int( + dynamic.get(DYNAMIC_LOAD_INTERVAL_KEY), + dynamic_default[DYNAMIC_LOAD_INTERVAL_KEY], + ) + normalized[DYNAMIC_LOAD_SETTINGS_KEY] = dynamic + + task_default = default_cfg["任务设置"] + task_settings = cls._trim_fixed_keys( + normalized.get("任务设置"), + task_default, + ) + list_format = cls._normalize_string_list( + task_settings.get("任务列表显示格式"), + task_default["任务列表显示格式"], + ) + task_settings["任务列表显示格式"] = ( + list_format + if len(list_format) >= 3 + else copy.deepcopy(task_default["任务列表显示格式"]) + ) + task_settings["接到新任务时执行的指令"] = cls._normalize_string_list( + task_settings.get("接到新任务时执行的指令"), + task_default["接到新任务时执行的指令"], + allow_empty=True, + ) + task_settings["任务完成执行的指令"] = cls._normalize_string_list( + task_settings.get("任务完成执行的指令"), + task_default["任务完成执行的指令"], + allow_empty=True, + ) + task_settings["任务无法提交的显示"] = cls._normalize_format_config( + task_settings.get("任务无法提交的显示"), + task_default["任务无法提交的显示"], + ) + task_settings["任务无法开始的显示"] = cls._normalize_format_config( + task_settings.get("任务无法开始的显示"), + task_default["任务无法开始的显示"], + ) + normalized["任务设置"] = task_settings + return normalized + + def load_runtime_config(self, announce: bool = False): + try: + raw_cfg, _ = config.get_plugin_config_and_version( + self.name, {}, self._cfg_default, self.version + ) + merged_cfg = self._normalize_runtime_config( + raw_cfg, self._cfg_default) + config.check_auto(self._cfg_std, merged_cfg) + except Exception as err: + fmts.print_err(f"{self.name} 主配置文件自动更新失败,已使用默认配置: {err}") + merged_cfg = self._normalize_runtime_config({}, self._cfg_default) + config.check_auto(self._cfg_std, merged_cfg) + config.upgrade_plugin_config(self.name, merged_cfg, self.version) + self.cfg = merged_cfg + if announce: + fmts.print_suc(f"{self.name} 主配置文件已热更新") + + def load_quest_configs(self, announce: bool = False): + quests: dict[str, Quest] = {} + total_quest_files = 0 + for cfg_quest_dir in os.listdir(self.QUEST_PATH): + file = "" + try: + sub_path = os.path.join(self.QUEST_PATH, cfg_quest_dir) + if not os.path.isdir(sub_path): + continue + for file in os.listdir(sub_path): + if not file.endswith(".json"): + continue + cfg = config.get_cfg( + os.path.join( + sub_path, + file), + self._quest_std) + tag_name = f"{cfg_quest_dir}/{file[:-5]}" + quests[tag_name] = Quest( + tag_name, + cfg["显示名"], + cfg["描述"], + cfg["检测的指令"], + cfg["需要的物品"], + cfg["任务模式(-1=一次性 0=可重复做 >0为任务冷却秒数)"], + cfg["任务完成"]["执行的指令"], + cfg["任务完成"]["给予的物品"], + cfg["任务完成"]["开启的新任务"], + cfg["只能由命令方块触发完成"], + cfg.get("需要完成的前置任务"), + ) + total_quest_files += 1 + except config.ConfigError as err: + fmts.print_err(f"任务系统云链联动版: 任务配置文件 {file} 出错: ") + fmts.print_err(err.args[0]) + old_quests = self.quests + self.quests = quests + for quest in self.quests.values(): + for i in quest.start_quest_when_finished: + try: + if (quest := self.get_quest(i)) is None: + file = i + raise config.ConfigError(f"任务 {i} 不存在") + if quest.need_quests_prefix: + for i in quest.need_quests_prefix: + if self.get_quest(i) is None: + file = i + raise config.ConfigError(f"要求的前置任务 {i} 不存在") + except config.ConfigError as err: + fmts.print_err(f"任务系统云链联动版: 任务配置文件 {file} 出错: ") + fmts.print_err(err.args[0]) + if not self.quests and old_quests: + self.quests = old_quests + fmts.print_err(f"{self.name}: 任务配置热更新未加载到有效任务,已保留旧任务表") + return + if announce: + fmts.print_with_info( + f"§a共加载 §b{total_quest_files}§a 个任务文件.", "§b Task §r" + ) + + def config_file_path(self) -> str: + return os.path.join(CONFIG_FILE_DIR, f"{self.name}.json") + + @staticmethod + def file_state(path: str) -> tuple[int, int] | None: + try: + stat = os.stat(path) + except OSError: + return None + return stat.st_mtime_ns, stat.st_size + + def quest_config_state(self) -> tuple[tuple[str, tuple[int, int]], ...]: + states: list[tuple[str, tuple[int, int]]] = [] + for root, _dirs, files in os.walk(self.QUEST_PATH): + for filename in files: + if not filename.endswith(".json"): + continue + path = os.path.join(root, filename) + state = self.file_state(path) + if state is not None: + states.append( + (os.path.relpath( + path, self.QUEST_PATH), state)) + return tuple(sorted(states)) + + def refresh_config_file_state(self): + self._config_file_state = self.file_state(self.config_file_path()) + self._quest_config_state = self.quest_config_state() + + def is_dynamic_config_reload_enabled(self) -> bool: + settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return True + return bool(settings.get(DYNAMIC_LOAD_ENABLED_KEY, True)) + + def dynamic_config_reload_interval(self) -> int: + settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return DYNAMIC_LOAD_DEFAULT_INTERVAL + try: + interval = int(settings.get(DYNAMIC_LOAD_INTERVAL_KEY, + DYNAMIC_LOAD_DEFAULT_INTERVAL)) + except (TypeError, ValueError): + return DYNAMIC_LOAD_DEFAULT_INTERVAL + return interval if interval > 0 else DYNAMIC_LOAD_DEFAULT_INTERVAL + + def config_reload_task(self): + while not self._config_reload_stop.wait( + self.dynamic_config_reload_interval()): + if not self.is_dynamic_config_reload_enabled(): + self.refresh_config_file_state() + continue + current_cfg_state = self.file_state(self.config_file_path()) + current_quest_state = self.quest_config_state() + if ( + current_cfg_state == self._config_file_state + and current_quest_state == self._quest_config_state + ): + continue + try: + if current_cfg_state != self._config_file_state: + self.load_runtime_config(announce=True) + if current_quest_state != self._quest_config_state: + self.load_quest_configs(announce=True) + self.refresh_config_file_state() + except Exception as err: + self._config_file_state = current_cfg_state + self._quest_config_state = current_quest_state + fmts.print_err(f"{self.name} 配置文件热更新失败: {err}") + + def api_reload_task_config(self) -> tuple[bool, str]: + try: + self.load_runtime_config(announce=False) + self.load_quest_configs(announce=False) + self.refresh_config_file_state() + except Exception as err: + return False, f"任务系统配置重载失败: {err}" + return True, f"任务系统配置已重载,当前加载 {len(self.quests)} 个任务" + + # --- API --- + + def get_quest(self, tag_name: str) -> Quest | None: + """ + 根据任务名获取任务对象 + + Args: + tag_name (str): 任务名 + + Returns: + Quest | None: 任务对象 (或找不到任务) + """ + return self.quests.get(tag_name) + + def get_online_player(self, player_name: str) -> Player | None: + return self.frame.get_players().getPlayerByName(player_name) + + @staticmethod + def get_quest_label(quest: Quest) -> str: + if quest.show_name == quest.tag_name: + return quest.tag_name + return f"{quest.show_name} ({quest.tag_name})" + + def find_quest(self, quest_query: str) -> tuple[Quest | None, str]: + quest_query = quest_query.strip() + if not quest_query: + return None, "任务标识不能为空" + if quest := self.get_quest(quest_query): + return quest, "" + + exact_matches = [ + i for i in self.quests.values() if i.show_name == quest_query] + if len(exact_matches) == 1: + return exact_matches[0], "" + if len(exact_matches) > 1: + labels = "、".join(self.get_quest_label(i) + for i in exact_matches[:5]) + return None, f"匹配到多个同名任务,请改用任务标签名:{labels}" + + query_lower = quest_query.casefold() + fuzzy_matches = [ + i + for i in self.quests.values() + if query_lower in i.tag_name.casefold() + or query_lower in i.show_name.casefold() + ] + if len(fuzzy_matches) == 1: + return fuzzy_matches[0], "" + if len(fuzzy_matches) > 1: + labels = "、".join(self.get_quest_label(i) + for i in fuzzy_matches[:5]) + if len(fuzzy_matches) > 5: + labels += "……" + return None, f"匹配到多个任务:{labels}" + return None, f"任务不存在:{quest_query}" + + def can_add_quest(self, player: Player, quest: Quest) -> tuple[bool, str]: + quests = self.read_quests(player) + if quest in quests: + return False, "当前任务正在进行中,无法重复领取" + quest_time = self.read_quests_finished(player).get(quest, None) + quest_mode = quest.cooldown + if quest_mode == -1 and quest_time is not None: + return False, "你已经完成该任务" + if ( + quest_time is not None + and quest_mode > 0 + and time.time() - quest_time < quest.cooldown + ): + fmt_text = r"%d 天 %H 时 %M 分" + left_time = self.sec_to_timer( + quest.cooldown - int(time.time()) + quest_time, fmt_text + ) + return False, f"该任务仍在冷却中,还需等待:{left_time}" + return True, "" + + def get_online_player_task_progress( + self, player_name: str + ) -> tuple[bool, dict | str]: + player = self.get_online_player(player_name) + if player is None: + return False, f"玩家不在线或不存在:{player_name}" + in_progress = [] + for quest in self.read_quests(player): + if quest is None: + continue + in_progress.append( + { + "tag_name": quest.tag_name, + "show_name": quest.show_name, + "description": quest.description, + } + ) + completed = [] + for quest, finished_time in sorted( + self.read_quests_finished(player).items(), + key=lambda item: item[1], + reverse=True, + ): + completed.append( + { + "tag_name": quest.tag_name, + "show_name": quest.show_name, + "description": quest.description, + "finished_time": finished_time, + } + ) + return True, { + "player_name": player.name, + "in_progress": in_progress, + "completed": completed, + } + + def add_quest_to_online_player( + self, player_name: str, quest_query: str + ) -> tuple[bool, str]: + player = self.get_online_player(player_name) + if player is None: + return False, f"玩家不在线或不存在:{player_name}" + quest, err = self.find_quest(quest_query) + if quest is None: + return False, err + ok, reason = self.can_add_quest(player, quest) + if not ok: + return False, reason + if not self.add_quest(player, quest): + return False, f"下发任务失败:{self.get_quest_label(quest)}" + return True, f"已向玩家 {player.name} 下发任务:{self.get_quest_label(quest)}" + + def finish_quest_for_online_player( + self, player_name: str, quest_query: str + ) -> tuple[bool, str]: + player = self.get_online_player(player_name) + if player is None: + return False, f"玩家不在线或不存在:{player_name}" + quest, err = self.find_quest(quest_query) + if quest is None: + return False, err + if not self.is_quest_in_progress(player, quest): + return False, f"该任务未解锁或已经完成:{self.get_quest_label(quest)}" + if not self.finish_quest(player, quest): + return False, f"完成任务失败:{self.get_quest_label(quest)}" + return True, f"已为玩家 {player.name} 完成任务:{self.get_quest_label(quest)}" + + def list_available_quests(self) -> list[dict[str, str]]: + quests = sorted(self.quests.values(), key=lambda item: item.tag_name) + return [ + { + "tag_name": quest.tag_name, + "show_name": quest.show_name, + "description": quest.description, + } + for quest in quests + ] + + def add_quest(self, player: Player, quest: Quest) -> bool: + """ + 向玩家下发任务 + + Args: + player (Player): 玩家对象 + quest (Quest): 任务对象 + + Returns: + bool: 是否下发成功 + """ + ok, reason = self.can_add_quest(player, quest) + if not ok: + player.show( + utils.simple_fmt( + {"[玩家名]": player.name, "[原因]": f"§c{reason}"}, + self.cfg["任务设置"]["任务无法开始的显示"]["格式"], + ), + ) + return False + for cmd in self.cfg["任务设置"]["接到新任务时执行的指令"]: + s_cmd = utils.simple_fmt( + { + "[任务显示名]": quest.show_name, + "[任务描述]": quest.description, + "[玩家名]": player.name, + }, + cmd, + ) + self.game_ctrl.sendwocmd(s_cmd) + o = self.read_player_quest_data(player) + o["in_quests"].append(quest.tag_name) + self.write_player_quest_data(player, o) + return True + + def is_quest_in_progress(self, player: Player, quest: Quest) -> bool: + o = self.read_player_quest_data(player) + return quest.tag_name in o["in_quests"] + + def detect_quest( + self, player: Player, quest: Quest, allow_command_block: bool = False + ) -> tuple[bool, str]: + """ + 检测任务是否可以提交 + + Args: + player (Player): 玩家对象 + quest (Quest): 任务对象 + + Returns: + tuple[bool, str]: 是否可提交; 无法提交的信息 + """ + o = self.read_player_quest_data(player) + if quest.tag_name not in o["in_quests"]: + if quest.tag_name in o["quests_ok"]: + return False, "§6该任务已经完成" + return False, "§6该任务未解锁或已经完成" + if quest.cooldown == -1 and quest.tag_name in o["quests_ok"]: + return False, "§6该任务已经完成" + if quest.command_block_only and not allow_command_block: + return False, "§6无法手动提交该任务" + if quest.need_quests_prefix: + err_strs = [] + player_finished_quests = self.read_quests_finished(player) + for quest_name in quest.need_quests_prefix: + need_quest = self.get_quest(quest_name) + if need_quest is None: + err_strs.append(f"{quest_name} (任务配置不存在)") + continue + if need_quest not in player_finished_quests: + err_strs.append(need_quest.show_name) + if err_strs: + return False, "需要完成任务:\n " + "\n ".join(err_strs) + if quest.need_items: + err_strs = [] + for item_name, (item_id, *ext_data) in quest.need_items.items(): + if len(ext_data) == 2: + count, data = ext_data + else: + count = ext_data[0] + data = 0 + if (item_count_now := player.getItemCount(item_id, data)) < count: + err_strs.append( + f"§f{item_name} §7(§c{item_count_now}§7/§f{count}§7)" + ) + if err_strs: + return False, "缺少物品: \n " + "\n ".join(err_strs) + if quest.detect_cmds: + for cmd in quest.detect_cmds: + if not game_utils.isCmdSuccess( + utils.simple_fmt({"[玩家名]": player.name}, cmd) + ): + return False, "§6未达成条件" + return True, "" + + def finish_quest(self, player: Player, quest: Quest) -> bool: + """ + 令玩家完成任务 (强制性, 无论条件是否满足) + + Args: + player (Player): 玩家对象 + quest (Quest): 任务对象 + """ + o = self.read_player_quest_data(player) + if quest.tag_name not in o["in_quests"]: + if quest.tag_name in o["quests_ok"]: + self.show_fail(player, "该任务已经完成") + return False + self.show_fail(player, "该任务未解锁或已经完成") + return False + if quest.cooldown == -1 and quest.tag_name in o["quests_ok"]: + self.show_fail(player, "该任务已经完成") + return False + o["quests_ok"][quest.tag_name] = int(time.time()) + o["in_quests"] = [tag_name for tag_name in o["in_quests"] + if tag_name != quest.tag_name] + self.write_player_quest_data(player, o) + self.game_ctrl.sendwocmd( + f"/execute as @a[name={player.name}] at @s run playsound random.levelup @s" + ) + player.show("§a۞ §l任务完成 §r§e奖励已下发~") + for cmd in quest.exec_cmds_when_finished: + self.game_ctrl.sendwocmd( + utils.simple_fmt({"[玩家名]": player.name}, cmd)) + for item_name, (item_id, + count) in quest.items_give_when_finished.items(): + self.game_ctrl.sendwocmd( + f"give @a[name={player.name}] {item_id} {count}") + player.show(f" §7 + {count}x§f{item_name}") + self.show_succ(player, "任务已提交, 请退出聊天栏") + for new_quest_name in quest.start_quest_when_finished: + new_quest = self.get_quest(new_quest_name) + if new_quest is None: + fmts.print_err( + f"{self.name}: 完成任务后开始的任务不存在: {new_quest_name}" + ) + continue + self.add_quest(player, new_quest) + return True + + # ------------- + + def on_def(self): + self.interper = self.GetPluginAPI("ZBasic", (0, 0, 1), False) + self.chatbar = self.GetPluginAPI("聊天栏菜单") + self.cb2bot = self.GetPluginAPI("Cb2Bot通信") + if TYPE_CHECKING: + from ZBasic_Lang_中文编程 import ToolDelta_ZBasic + from 前置_聊天栏菜单 import ChatbarMenu + from 前置_Cb2Bot通信 import TellrawCb2Bot + + self.interper: ToolDelta_ZBasic + self.chatbar: ChatbarMenu + self.cb2bot: TellrawCb2Bot + self.cb2bot.regist_message_cb("quest.ok", self.on_quest_ok) + self.cb2bot.regist_message_cb("quest.start", self.on_quest_start) + + def show_succ(self, player: Player, msg): + player.show(f"§7<§a§o√§r§7> §a{msg}") + + def show_warn(self, player: Player, msg): + player.show(f"§7<§6§o!§r§7> §6{msg}") + + def show_fail(self, player: Player, msg): + player.show(f"§7<§c§o!§r§7> §c{msg}") + + def show_inf(self, player: Player, msg): + player.show(f"§7<§f§o!§r§7> §f{msg}") + + @utils.thread_func("任务的游戏初始化") + def on_inject(self): + self.cmp_scripts = {} + self.chatbar.add_new_trigger( + [".rw", ".任务"], + [], + "查看正在进行的任务列表", + lambda player, _: self.list_player_quests(player), + ) + self.chatbar.add_new_trigger( + [".addrw", ".添加任务"], + [("任务标签名", str, None)], + "向玩家添加任务", + self.force_add_quest_menu, + op_only=True, + ) + for player in self.frame.get_players().getAllPlayers(): + self.init_player(player) + + @utils.thread_func("初始化玩家剧情任务数据") + def on_player_join(self, player: Player): + self.init_player(player) + + def on_quest_ok(self, args: list[str]): + target_name, quest_name = args + quest = self.get_quest(quest_name) + target = self.frame.get_players().getPlayerByName(target_name) + if target is None: + self.print(f"§6on_quest_ok: 玩家 {target_name} 不存在") + return + if quest is not None: + ok, reason = self.detect_quest( + target, quest, allow_command_block=True) + if not ok: + self.show_fail(target, reason) + return + self.finish_quest(target, quest) + + def on_quest_start(self, args: list[str]): + target_name, quest_name = args + quest = self.get_quest(quest_name) + target = self.frame.get_players().getPlayerByName(target_name) + if target is None: + self.print(f"§6on_quest_ok: 玩家 {target_name} 不存在") + return + if quest is not None: + self.add_quest(target, quest) + + def init_player(self, player: Player): + quest_path = self.get_player_quest_data_path(player) + if not os.path.isfile(quest_path): + self.write_player_quest_data(player, self.init_quest_file()) + else: + self.read_player_quest_data(player) + + def init_quest_file(self): + return {"in_quests": [], "quests_ok": {}} + + def get_player_quest_data_path(self, player: Player) -> str: + path = os.path.join(self.QUEST_DATA_PATH, player.xuid + ".json") + self.quest_data_paths.add(path) + return path + + def read_player_quest_data(self, player: Player) -> dict: + data = self.tmpjson.load_and_read( + self.get_player_quest_data_path(player), + need_file_exists=False, + default=self.init_quest_file(), + ) + if not isinstance(data, dict): + return self.init_quest_file() + if not isinstance(data.get("in_quests"), list): + data["in_quests"] = [] + if not isinstance(data.get("quests_ok"), dict): + data["quests_ok"] = {} + return data + + def write_player_quest_data(self, player: Player, data: dict): + path = self.get_player_quest_data_path(player) + self.tmpjson.load_and_write(path, data, need_file_exists=False) + self.tmpjson.flush(path) + + def on_frame_exit(self, _): + self._config_reload_stop.set() + for path in tuple(self.quest_data_paths): + try: + self.tmpjson.unload(path) + except Exception: + pass + + def read_quests(self, player: Player) -> list[Quest]: + o = self.read_player_quest_data(player) + output = [] + o = o or {"in_quests": []} + for i in o["in_quests"]: + output.append(self.get_quest(i)) + return output + + def read_quests_finished(self, player: Player) -> dict[Quest, int]: + o = self.read_player_quest_data(player) + output = {} + for k, v in o["quests_ok"].items(): + quest = self.get_quest(k) + if quest: + output[quest] = v + return output + + @utils.thread_func("管理员向玩家添加任务") + def force_add_quest_menu(self, player: Player, args: tuple): + # with utils.ChatbarLock(player, lambda _: + # print(utils.chatbar_lock_list)): + (quest_tagname,) = args + if (quest := self.get_quest(quest_tagname)) is None: + player.show("§c任务标签名不存在") + return + onlines = self.frame.get_players().getAllPlayers() + self.show_inf(player, "§6选择一个玩家以向他添加任务:") + for i, j in enumerate(onlines): + player.show(f" §a{i + 1}§7 - §f{j.name}") + resp = utils.try_int(player.input()) + self.show_inf(player, "§7输入玩家名前的§6序号§7:") + if resp is None: + player.show("§c序号错误, 已退出") + return + if resp not in range(1, len(onlines) + 1): + player.show("§c序号不在范围内, 已退出") + return + getting = onlines[resp - 1] + player.show( + f"§6向玩家{getting}添加任务" + + ["§c失败", "§a成功"][self.add_quest(getting, quest)], + ) + + @utils.thread_func("列出任务列表") + def list_player_quests(self, player: Player): + # with utils.ChatbarLock(player): + player_quests = self.read_quests(player) + if not player_quests: + self.show_fail(player, "你没有正在进行的任务") + return + else: + player.show(self.cfg["任务设置"]["任务列表显示格式"][0]) + for i, quest_data in enumerate(player_quests): + if quest_data is None: + player.show( + utils.simple_fmt( + { + "[任务显示名]": "§c<任务失效>§f", + "[任务描述]": "--", + "[i]": i + 1, + }, + self.cfg["任务设置"]["任务列表显示格式"][1], + ), + ) + else: + player.show( + utils.simple_fmt( + { + "[任务显示名]": quest_data.show_name, + "[任务描述]": quest_data.description, + "[i]": i + 1, + }, + self.cfg["任务设置"]["任务列表显示格式"][1], + ), + ) + resp = player.input( + utils.simple_fmt( + {"[任务数量]": len(player_quests)}, + self.cfg["任务设置"]["任务列表显示格式"][2], + ) + ) + if resp is None: + return + resp = utils.try_int(resp.strip("[]")) + if resp is None: + player.show("§c序号不合法") + return + if resp not in range(1, len(player_quests) + 1): + self.show_fail(player, "序号超出范围") + return + getting_quest = player_quests[resp - 1] + if getting_quest is None: + self.show_fail(player, "无法完成失效的任务") + return + ok, reason = self.detect_quest(player, getting_quest) + if not ok: + player.show( + utils.simple_fmt( + {"[玩家名]": player.name, "[原因]": reason}, + self.cfg["任务设置"]["任务无法提交的显示"]["格式"], + ), + ) + return + else: + self.finish_quest(player, getting_quest) + + def sec_to_timer(self, timesec: int, fmt: str): + days, left = divmod(timesec, 86400) + hrs, left = divmod(left, 3600) + mins, secs = divmod(left, 60) + if secs > 0 and mins == 0: + mins = 1 + return utils.simple_fmt( + {"%d": days, "%H": hrs, "%M": mins, "%S": secs}, fmt) + + +entry = plugin_entry( + TaskSystemCloudInterop, + "任务系统云链联动版", + (0, 0, 1), +) diff --git "a/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" new file mode 100644 index 00000000..f34db710 --- /dev/null +++ "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" @@ -0,0 +1,10 @@ +{ + "author": "任务系统", + "version": "0.0.5", + "plugin-type": "classic", + "description": "为租赁服添加一套自定义的任务(云链互通版),支持配置文件与任务文件热载入。", + "pre-plugins": { + "聊天栏菜单": "0.0.1" + }, + "plugin-id": "任务系统云链联动版" +} diff --git "a/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/readme.txt" "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/readme.txt" new file mode 100644 index 00000000..6fa5b14a --- /dev/null +++ "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/readme.txt" @@ -0,0 +1,49 @@ +插件数据存放在 插件数据文件/任务系统云链联动版/ 文件夹内。 + +任务数据的路径应当如下: + + 插件数据文件/ + 任务系统云链联动版/ + 任务组xxxx/ + 任务1.json + 任务2.json + yyyyyyyyy/ + 任务1.json + xxxx任务.json + +任务的标签名就是 任务组文件夹名/任务文件名 + +标准的任务配置文件: + +{ + "显示名": "空岛の源初日", + "描述": "获取10个树苗和1组圆石", + "检测的指令": [], + "需要的物品": { + "树苗": [ + "sapling", + 10 + ], + "圆石": [ + "cobblestone", + 64 + ] + }, + "只能由命令方块触发完成": false, + "任务模式(-1=一次性 0=可重复做 >0为任务冷却秒数)": -1, + "任务完成": { + "执行的指令": [ + "xp 1L [玩家名]" + ], + "给予的物品": {}, + "开启的新任务": [ + "开局任务/刷怪" + ] + } +} + +用命令方块向玩家开始任务: +tellraw @a[tag=robot] {"rawtext":[{"text":"quest.start"},{"selector":"(选择开始任务的玩家的选择器)"},{"text":"(任务标签名)"}]} +如: +§btellraw @a[tag=robot] {"rawtext":[{"text":"quest.start"},{"selector":"@p"},{"text":"任务1"}]} +将命令方块设置为脉冲+红石控制, 激活命令方块即可。 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" new file mode 100644 index 00000000..43eab27a --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -0,0 +1,260 @@ +"""Guild cloud interop ToolDelta plugin entrypoint.""" + +from threading import Event +from typing import Dict + +from tooldelta import plugin_entry, Plugin, ToolDelta, TYPE_CHECKING, utils, Player, FrameExit +from tooldelta.constants import PacketIDS +from tooldelta.utils import tempjson + +from guild_cloud_interop.matchers import ItemNameMatcher +from guild_cloud_interop.handlers import handlers +from guild_cloud_interop.handlers_quick import handlers_quick +from guild_cloud_interop.logic import logic_functions +from guild_cloud_interop.api import guild_api_functions +from guild_cloud_interop.control import GuildManager +from guild_cloud_interop.config import Config, PLUGIN_ENABLED_KEY +from guild_cloud_interop.config_watcher import config_reload_task, refresh_config_file_state +from guild_cloud_interop.ui import wrap_player + + +def _normalize_chatbar_trigger(trigger: object, fallback: str = "公会") -> str: + """Return the trigger token expected by 聊天栏菜单.add_new_trigger.""" + if not isinstance(trigger, str): + return fallback + normalized = trigger.strip() + while normalized.startswith("."): + normalized = normalized[1:].strip() + return normalized or fallback + + +# FIRE 公会插件主类 FIRE +class GuildPlugin(Plugin): + """ToolDelta plugin entrypoint for guild cloud interop.""" + + name = "公会系统云链联动版" + author = "星林 & 夏至" + version = (0, 1, 7) + + def __init__(self, frame: ToolDelta): + super().__init__(frame) + + self.config = Config.load(self.name, self.version) + self.guilds_file = self.format_data_path("公会数据文件.json") + self.guild_manager = GuildManager(self.guilds_file) + self.guild_chat_mode: Dict[str, bool] = {} + self._stop_event = Event() + self._effect_refresh_cache = {} + self._guild_menu_callback = None + self._guild_menu_chatbar_entry = None + self._guild_runtime_events = {} + self.item_matcher = ItemNameMatcher() + self.ListenPreload(self.on_def) + self.ListenActive(self.on_inject) + self.ListenPlayerJoin(self.on_player_join) + self.ListenFrameExit(self.on_frame_exit) + self.exp_thread = utils.createThread( + self.guild_exp_task, usage="公会经验增加任务") + self.online_thread = utils.createThread( + self.update_online_task, usage="在线状态更新任务") + refresh_config_file_state(self) + self.config_thread = utils.createThread( + config_reload_task, args=(self,), usage="公会配置热更新任务") + + def _plugin_enabled(self) -> bool: + return bool(getattr(self, "config", {}).get(PLUGIN_ENABLED_KEY, False)) + + def get_config_file_state(self): + """Return the last observed runtime config file state.""" + return getattr(self, "_config_file_state", None) + + def set_config_file_state(self, state) -> None: + """Store the last observed runtime config file state.""" + self._config_file_state = state + + def reset_effect_refresh_cache(self) -> None: + """Clear cached guild effect refresh timestamps.""" + self._effect_refresh_cache = {} + + def should_stop_runtime_task(self) -> bool: + """Return whether background runtime tasks should exit.""" + return self._stop_event.is_set() + + def wait_runtime_task_or_stopped(self, seconds: float) -> bool: + """Wait for a background interval or until shutdown is requested.""" + return self._stop_event.wait(seconds) + + def on_def(self): + if not self._plugin_enabled(): + return + + self.chatbar = self.GetPluginAPI("聊天栏菜单") + self.xuidm = self.GetPluginAPI("XUID获取") + + if TYPE_CHECKING: + from 前置_聊天栏菜单 import ChatbarMenu + from 前置_玩家XUID获取 import XUIDGetter + + self.chatbar: ChatbarMenu + self.xuidm: XUIDGetter + + def ui_callback(self, callback): + def wrapped(player, args): + return callback(wrap_player(player), args) + + return wrapped + + def _guild_menu_commands(self) -> list[str]: + raw_triggers = getattr(Config, "GUILD_MENU_TRIGGER", ["公会"]) + if isinstance(raw_triggers, str): + raw_triggers = [raw_triggers] + if not isinstance(raw_triggers, list): + raw_triggers = ["公会"] + + commands: list[str] = [] + for trigger in raw_triggers: + command = _normalize_chatbar_trigger(trigger, "") + if command and command not in commands: + commands.append(command) + return commands or ["公会"] + + def _find_guild_menu_chatbar_entry(self): + entry = getattr(self, "_guild_menu_chatbar_entry", None) + if entry is not None: + return entry + + chatbar = getattr(self, "chatbar", None) + chatbar_triggers = getattr(chatbar, "chatbar_triggers", None) + if not isinstance(chatbar_triggers, list): + return None + + callback = getattr(self, "_guild_menu_callback", None) + for candidate in chatbar_triggers: + if getattr(candidate, "usage", None) != "公会系统指令": + continue + if callback is not None and getattr( + candidate, "func", None) is not callback: + continue + self._guild_menu_chatbar_entry = candidate + return candidate + return None + + def sync_runtime_config_bindings(self): + """Apply hot-reloaded config values that are registered outside Config.""" + entry = self._find_guild_menu_chatbar_entry() + if entry is None: + return + + commands = self._guild_menu_commands() + current_commands = list(getattr(entry, "triggers", [])) + if current_commands == commands: + return + + entry.triggers = commands + + def on_inject(self): + if not self._plugin_enabled(): + return + + self.game_ctrl.sendwocmd( + f"/scoreboard objectives add {Config.GUILD_SCOREBOARD} dummy 积分") + self.game_ctrl.sendwocmd( + f"/scoreboard players add @a {Config.GUILD_SCOREBOARD} 0") + guild_menu_commands = self._guild_menu_commands() + self._guild_menu_callback = self.ui_callback(self.guild_menu_cb) + self.chatbar.add_new_trigger( + guild_menu_commands, + [("", str, "")], + "公会系统指令", + self._guild_menu_callback, + ) + chatbar_triggers = getattr(self.chatbar, "chatbar_triggers", None) + if isinstance(chatbar_triggers, list) and chatbar_triggers: + self._guild_menu_chatbar_entry = chatbar_triggers[-1] + + trigger_configs = [ + {"commands": ["gc", "公会聊天"], + "args": [("message", str, "")], + "description": "公会聊天频道", "callback": self.guild_chat_cb, }, + {"commands": ["仓库出售", "出售"], + "args": + [("item_id", str, ""), + ("count", int, 1), + ("price", int, 0)], + "description": "快速出售物品到仓库", "callback": self.quick_vault_sell, }, + {"commands": ["自定义出售"], + "args": + [("item_id", str, ""), + ("count", int, 1), + ("price", int, 0)], + "description": "自定义价格出售物品到仓库", + "callback": self.custom_vault_sell, }, + {"commands": ["物品列表", "支持物品"], + "args": [("", str, "")], + "description": "查看支持的物品名称列表", "callback": self.show_item_list, }, + {"commands": ["清理公会数据"], + "args": [("confirm", str, "")], + "description": "清理所有公会数据 (管理员专用)", + "callback": self.admin_clear_guild_data, }, + {"commands": ["调试公会菜单"], + "args": [("", str, "")], + "description": "调试公会菜单显示问题", "callback": self.debug_guild_menu, }, + {"commands": ["调试据点功能"], + "args": [("", str, "")], + "description": "调试据点功能问题", + "callback": self.debug_base_function, },] + + # 注册数据更新菜单 + self.frame.add_console_cmd_trigger( + ["更新公会数据"], + "", "更新由于版本更新导致的数据丢失", + self.guild_update_data + ) + for trigger in trigger_configs: + self.chatbar.add_new_trigger( + trigger["commands"], + trigger["args"], + trigger["description"], + self.ui_callback(trigger["callback"]), + ) + + self.ListenPacket(PacketIDS.IDText, self.on_chat_packet) + self.ListenPacket(PacketIDS.IDPlayerAction, self.on_player_action) + + def on_player_join(self, player: Player): + if not self._plugin_enabled(): + return + + player_name = getattr(player, "safe_name", player.name) + self.game_ctrl.sendcmd( + f"/scoreboard players add {player_name} {Config.GUILD_SCOREBOARD} 0") + self._apply_guild_effects_to_player( + player.name, force=True, command_delay=0) + + def on_frame_exit(self, _: FrameExit): + self._stop_event.set() + try: + if not self.guild_manager.flush_dirty_guilds(): + self.print_err("保存公会数据失败") + except Exception as err: + self.print_err(f"保存公会数据失败: {err}") + try: + tempjson.flush(self.guilds_file) + except Exception: + pass + for attr in ("exp_thread", "online_thread", "config_thread"): + thread = getattr(self, attr, None) + if thread is None: + continue + try: + thread.stop() + except Exception: + pass + + +for name, func in {**handlers, **handlers_quick, ** + logic_functions, **guild_api_functions}.items(): + setattr(GuildPlugin, name, func) + + +entry = plugin_entry(GuildPlugin, "guild-cloud-interop") diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" new file mode 100644 index 00000000..607f74de --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" @@ -0,0 +1,12 @@ +{ + "author": "星林 & 夏至", + "version": "0.1.7", + "description": "允许玩家创建公会,云链联动版", + "pre-plugins": { + "基本插件功能库": "0.0.1", + "聊天栏菜单": "0.0.1", + "XUID获取": "0.0.1" + }, + "plugin-id": "guild-cloud-interop", + "plugin-type": "classic" +} diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/api.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/api.py" new file mode 100644 index 00000000..0ef4a9dd --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/api.py" @@ -0,0 +1,1824 @@ +"""Public QQ and plugin API operations for guild cloud interop.""" + +import copy +import json +import os +import re +import shutil +import time +import uuid +from datetime import datetime +from typing import Any, Optional + +from tooldelta import fmts + +from guild_cloud_interop.config import Config +from guild_cloud_interop.models import ( + GuildBase, + GuildData, + GuildMember, + GuildRank, + GuildStats, + GuildTask, + VaultItem, +) +from guild_cloud_interop.validators import InputValidator + + +COLOR_CODE_RE = re.compile(r"§.") + + +def _plain(text: object) -> str: + return COLOR_CODE_RE.sub("", str(text)) + + +def _now() -> float: + return time.time() + + +def _actor(actor: str | None) -> str: + text = str(actor or "").strip() + return text or "QQ管理" + + +def _to_int(value: object, field_name: str, minimum: int | + None = None) -> tuple[bool, str, int]: + try: + parsed = int(str(value).strip()) + except (TypeError, ValueError): + return False, f"{field_name}必须是整数", 0 + if minimum is not None and parsed < minimum: + return False, f"{field_name}不能小于 {minimum}", 0 + return True, "", parsed + + +def _to_float(value: object, field_name: str) -> tuple[bool, str, float]: + try: + return True, "", float(str(value).strip()) + except (TypeError, ValueError): + return False, f"{field_name}必须是数字", 0.0 + + +def _ensure_settings(guild: GuildData) -> dict[str, Any]: + if not isinstance(guild.settings, dict): + guild.settings = {} + return guild.settings + + +def _rebuild_player_cache(self, guilds: dict[str, GuildData]) -> None: + self.guild_manager.rebuild_player_cache(guilds) + + +def _save_guilds(self, + guilds: dict[str, + GuildData], + force: bool = True) -> bool: + _rebuild_player_cache(self, guilds) + ok = self.guild_manager.save_guilds(guilds, force=force) + if ok: + self.guild_manager.load_guilds(force_reload=True) + return ok + + +def _load_guilds(self) -> dict[str, GuildData]: + return self.guild_manager.load_guilds(force_reload=True) + + +def _find_guild( + self, + guild_query: object, + guilds: Optional[dict[str, GuildData]] = None, +) -> tuple[Optional[GuildData], str]: + query = str(guild_query or "").strip() + if not query: + return None, "公会不能为空" + + guild_map = guilds if guilds is not None else _load_guilds(self) + if query in guild_map: + return guild_map[query], "" + + exact = [guild for guild in guild_map.values() if guild.name == query] + if len(exact) == 1: + return exact[0], "" + if len(exact) > 1: + return None, f"存在多个同名公会:{query},请使用公会ID" + + query_lower = query.casefold() + fuzzy = [ + guild + for guild in guild_map.values() + if query_lower in guild.name.casefold() or query_lower in guild.guild_id.casefold() + ] + if len(fuzzy) == 1: + return fuzzy[0], "" + if len(fuzzy) > 1: + names = "、".join(guild.name for guild in fuzzy[:5]) + if len(fuzzy) > 5: + names += "……" + return None, f"匹配到多个公会:{names}" + return None, f"公会不存在:{query}" + + +def _find_player_guild( + self, + player_name: object, + guilds: Optional[dict[str, GuildData]] = None, +) -> tuple[Optional[GuildData], str]: + name = str(player_name or "").strip() + if not name: + return None, "玩家名不能为空" + + guild_map = guilds if guilds is not None else _load_guilds(self) + for guild in guild_map.values(): + if guild.get_member(name): + return guild, "" + return None, f"玩家 {name} 不在任何公会" + + +def _find_player_context( + self, + player_name: object, + guilds: Optional[dict[str, GuildData]] = None, +) -> tuple[str, Optional[GuildData], Optional[GuildMember], str]: + name = str(player_name or "").strip() + if not name: + return "", None, None, "玩家名不能为空" + guild, err = _find_player_guild(self, name, guilds) + if guild is None: + return name, None, None, err + member = guild.get_member(name) + if member is None: + return name, guild, None, "成员数据异常" + return name, guild, member, "" + + +def _find_task(guild: GuildData, + task_query: object) -> tuple[Optional[GuildTask], str]: + query = str(task_query or "").strip() + if not query: + return None, "任务不能为空" + for task in guild.tasks: + if task.task_id == query or task.name == query: + return task, "" + query_lower = query.casefold() + matched = [ + task + for task in guild.tasks + if query_lower in task.task_id.casefold() or query_lower in task.name.casefold() + ] + if len(matched) == 1: + return matched[0], "" + if len(matched) > 1: + names = "、".join( + f"{task.name}({task.task_id})" for task in matched[:5]) + return None, f"匹配到多个任务:{names}" + return None, f"任务不存在:{query}" + + +def _member_summary(member: GuildMember) -> dict[str, Any]: + return { + "name": member.name, + "rank": member.rank.value, + "rank_name": _plain(member.rank.display_name), + "join_time": member.join_time, + "contribution": member.contribution, + "last_online": member.last_online, + } + + +def _base_summary(base: GuildBase | None) -> dict[str, Any] | None: + if base is None: + return None + return { + "dimension": base.dimension, + "x": base.x, + "y": base.y, + "z": base.z, + } + + +def _vault_item_summary(item: VaultItem, index: int | + None = None) -> dict[str, Any]: + data = item.to_dict() + if index is not None: + data["index"] = index + return data + + +def _task_summary(task: GuildTask) -> dict[str, Any]: + return task.to_dict() + + +def _guild_summary(guild: GuildData) -> dict[str, Any]: + settings = _ensure_settings(guild) + return { + "guild_id": guild.guild_id, + "name": guild.name, + "owner": guild.owner, + "level": guild.level, + "exp": guild.exp, + "create_time": guild.create_time, + "member_count": len(guild.members), + "max_members": Config.MAX_GUILD_MEMBERS, + "base": _base_summary(guild.base), + "vault_count": len(guild.vault_items), + "vault_capacity": Config.VAULT_INITIAL_SLOTS, + "announcement": guild.announcement, + "purchased_effects": dict(guild.purchased_effects), + "funds": int(settings.get("funds", 0) or 0), + "frozen": bool(settings.get("frozen", False)), + "frozen_reason": str(settings.get("frozen_reason", "")), + "base_locked": bool(settings.get("base_locked", False)), + "active_tasks": len([task for task in guild.tasks if not task.completed]), + "completed_tasks": len([task for task in guild.tasks if task.completed]), + "total_contribution": guild.stats.total_contribution, + } + + +def _apply_level_ups(guild: GuildData) -> list[int]: + level_ups: list[int] = [] + next_level = guild.level + 1 + required = Config.GUILD_LEVEL_EXP.get(next_level) + while required and guild.exp >= required: + guild.exp -= required + guild.level = next_level + level_ups.append(next_level) + guild.add_log(f"公会升级到 {next_level} 级") + next_level = guild.level + 1 + required = Config.GUILD_LEVEL_EXP.get(next_level) + return level_ups + + +def _activity_multiplier(self, key: str) -> float: + event = getattr(self, "_guild_runtime_events", {}).get(key) + if not isinstance(event, dict): + return 1.0 + expires_at = float(event.get("expires_at", 0) or 0) + if expires_at > 0 and expires_at <= _now(): + getattr(self, "_guild_runtime_events", {}).pop(key, None) + return 1.0 + try: + multiplier = float(event.get("multiplier", 1.0)) + except (TypeError, ValueError): + return 1.0 + return max(1.0, multiplier) + + +def guild_get_activity_multiplier(self, key: str) -> float: + return _activity_multiplier(self, key) + + +def guild_apply_reward_multipliers( + self, + exp: int | float = 0, + contribution: int | float = 0, +) -> tuple[int, int]: + exp_out = int(float(exp) * _activity_multiplier(self, "exp")) + contribution_out = int(float(contribution) * + _activity_multiplier(self, "contribution")) + return max(0, exp_out), max(0, contribution_out) + + +def guild_is_frozen(self, guild: GuildData | None) -> bool: + if guild is None: + return False + settings = getattr(guild, "settings", {}) + return isinstance(settings, dict) and bool(settings.get("frozen", False)) + + +def guild_frozen_message(self, guild: GuildData | None) -> str: + if guild is None: + return "公会已冻结" + settings = getattr(guild, "settings", {}) + reason = "" + if isinstance(settings, dict): + reason = str(settings.get("frozen_reason", "") or "").strip() + suffix = f":{reason}" if reason else "" + return f"公会 {guild.name} 已被冻结{suffix}" + + +def show_guild_frozen(self, player, guild: GuildData | None) -> None: + player.show(f"§l§a公会 §d>> §c{self.guild_frozen_message(guild)}") + + +def api_list_guilds(self) -> tuple[bool, str, list[dict[str, Any]]]: + guilds = _load_guilds(self) + data = [_guild_summary(guild) for guild in guilds.values()] + data.sort(key=lambda item: (-int(item["level"]), - + int(item["member_count"]), item["name"])) + return True, f"共 {len(data)} 个公会", data + + +def api_get_guild( + self, guild_query: str) -> tuple[bool, str, Optional[dict[str, Any]]]: + guild, err = _find_guild(self, guild_query) + if guild is None: + return False, err, None + data = _guild_summary(guild) + data["members"] = [_member_summary(member) for member in guild.members] + data["tasks"] = [_task_summary(task) for task in guild.tasks] + return True, "查询成功", data + + +def api_get_player_record( + self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: + guilds = _load_guilds(self) + guild, err = _find_player_guild(self, player_name, guilds) + if guild is None: + return False, err, None + member = guild.get_member(str(player_name).strip()) + if member is None: + return False, "成员数据异常", None + records = [ + log.to_dict() + for log in guild.audit_logs + if log.actor == member.name or log.target == member.name + ] + vault_records = [ + log.to_dict() + for log in guild.vault_trade_logs + if log.actor == member.name or log.seller == member.name or log.buyer == member.name + ] + return True, "查询成功", { + "guild": _guild_summary(guild), + "member": _member_summary(member), + "audit_logs": records[-50:], + "vault_trade_logs": vault_records[-50:], + } + + +def api_get_player_guild_menu_state( + self, player_name: str) -> tuple[bool, str, dict[str, Any]]: + """Return safe QQ-side guild menu state for one player identity.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if not name: + return False, err, {} + if guild is None: + return True, f"玩家 {name} 暂未加入公会", { + "player_name": name, + "in_guild": False, + "guild": None, + "member": None, + "permissions": [], + "is_owner": False, + "is_frozen": False, + } + if member is None: + return False, err, {} + return True, "查询成功", { + "player_name": name, + "in_guild": True, + "guild": _guild_summary(guild), + "member": _member_summary(member), + "permissions": guild.get_member_permissions(name), + "is_owner": member.rank == GuildRank.OWNER, + "is_frozen": bool(_ensure_settings(guild).get("frozen", False)), + } + + +def api_get_own_guild_logs(self, + player_name: str, + limit: int = 20) -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Return logs for the guild that the player belongs to.""" + ok, _err, parsed_limit = _to_int(limit, "日志数量", 1) + if not ok: + parsed_limit = 20 + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + data: dict[str, Any] = {"logs": guild.logs[-parsed_limit:]} + if guild.has_permission(name, "audit_log"): + data["audit_logs"] = [log.to_dict() + for log in guild.audit_logs[-parsed_limit:]] + data["vault_trade_logs"] = [log.to_dict() + for log in guild.vault_trade_logs[-parsed_limit:]] + else: + data["audit_logs"] = [] + data["vault_trade_logs"] = [] + return True, "查询成功", data + + +def api_get_own_guild_vault( + self, player_name: str) -> tuple[bool, str, Optional[list[dict[str, Any]]]]: + """Return the current player's guild vault after normal guild permission checks.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + if not guild.has_permission(name, "vault"): + return False, "你没有使用仓库权限", None + data = [_vault_item_summary(item, index) + for index, item in enumerate(guild.vault_items, start=1)] + return True, f"{guild.name} 仓库共有 {len(data)} 件上架物品", data + + +def api_get_own_guild_tasks( + self, player_name: str) -> tuple[bool, str, Optional[list[dict[str, Any]]]]: + """Return the task list for the guild that the player belongs to.""" + guilds = _load_guilds(self) + _name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + return True, f"{guild.name} 共有 {len(guild.tasks)} 个任务", [ + _task_summary(task) for task in guild.tasks] + + +def api_request_join_guild_as_player( + self, + player_name: str, + guild_query: str, + reason: str = "", +) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Submit a normal player join request for a guild.""" + name = str(player_name or "").strip() + if not name: + return False, "玩家名不能为空", None + guilds = _load_guilds(self) + current_guild, _ = _find_player_guild(self, name, guilds) + if current_guild is not None: + return False, f"你已经加入了公会 { + current_guild.name}", _guild_summary(current_guild) + target_guild, err = _find_guild(self, guild_query, guilds) + if target_guild is None: + return False, err, None + if _ensure_settings(target_guild).get("frozen", False): + return False, f"公会 { + target_guild.name} 已被冻结,暂不能提交申请", _guild_summary(target_guild) + if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: + return False, "该公会已满员", _guild_summary(target_guild) + if not target_guild.add_join_request(name, reason): + return False, "申请提交失败,可能已有待处理申请或队列已满", _guild_summary(target_guild) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + notify = getattr(self, "_notify_join_request_admins", None) + if callable(notify): + notify(target_guild, name) + return True, f"申请已提交至 { + target_guild.name} 的申请队列", _guild_summary(target_guild) + + +def api_leave_guild_as_player( + self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Leave the current guild as a normal member.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + if member.rank == GuildRank.OWNER: + return False, "会长不能退出公会,只能解散公会", _guild_summary(guild) + guild.members = [item for item in guild.members if item.name != name] + guild.add_log(f"{name} 退出公会") + guild.add_audit_log("member_leave", name, target=name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已退出公会 {guild.name}", _guild_summary(guild) + + +def api_disband_owned_guild_as_player( + self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Disband the player's own guild, only when the player is the owner.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + if member.rank != GuildRank.OWNER: + return False, "你不是当前公会的会长", _guild_summary(guild) + summary = _guild_summary(guild) + guild_name = guild.name + online_members = [ + item.name + for item in guild.members + if item.name in getattr(self.game_ctrl, "allplayers", []) + ] + del guilds[guild.guild_id] + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + for member_name in online_members: + self.game_ctrl.sendcmd( + f'/tellraw {member_name} {{"rawtext":[{{"text":"§l§a公会 §d>> §r公会 §e{guild_name}§r 已被解散"}}]}}' + ) + return True, f"已解散公会 {guild_name}", summary + + +def api_set_announcement_as_player( + self, + player_name: str, + announcement: str, +) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Set the current guild announcement through normal guild permission checks.""" + text = str(announcement or "").strip() + is_valid, error_msg = InputValidator.validate_announcement(text) + if not is_valid: + return False, error_msg, None + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + if _ensure_settings(guild).get("frozen", False): + return False, guild_frozen_message(self, guild), _guild_summary(guild) + if not guild.has_permission(name, "announce"): + return False, "你没有设置公会公告权限", _guild_summary(guild) + guild.announcement = text + guild.add_log(f"{name} 更新了公告") + guild.add_audit_log("announcement_set", name, detail=text) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + message = "§l§a公会 §d>> §r公告已更新,输入 .公会 公告 查看" + for member_item in guild.members: + if member_item.name in getattr(self.game_ctrl, "allplayers", []): + self.game_ctrl.sendcmd( + f'/tellraw {member_item.name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + return True, "公告已更新", _guild_summary(guild) + + +def api_join_guild_task_as_player( + self, + player_name: str, + task_query: str, +) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Join an active guild task as the current player.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + if _ensure_settings(guild).get("frozen", False): + return False, guild_frozen_message(self, guild), None + task, err = _find_task(guild, task_query) + if task is None: + return False, err, None + if task.completed: + return False, "该任务已完成", _task_summary(task) + if name in task.participants: + return True, f"你已经参与了任务:{task.name}", _task_summary(task) + task.participants.append(name) + guild.add_log(f"{name} 参与了任务: {task.name}") + guild.add_audit_log("task_join", name, detail=task.name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已参与任务:{task.name}", _task_summary(task) + + +def api_return_to_guild_base_as_player( + self, player_name: str) -> tuple[bool, str]: + """Teleport the player to their guild base after normal permission checks.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err + if name not in getattr(self.game_ctrl, "allplayers", []): + return False, f"玩家 {name} 当前不在线,无法传送" + if _ensure_settings(guild).get("frozen", False): + return False, guild_frozen_message(self, guild) + if not guild.has_permission(name, "return_base"): + return False, "你没有返回公会据点权限" + if _ensure_settings(guild).get("base_locked", False): + return False, f"公会 {guild.name} 据点已锁定" + if not guild.base: + return False, f"公会 {guild.name} 尚未设置据点" + base = guild.base + self.game_ctrl.sendwocmd( + f"tp {name} {float(base.x)} {float(base.y)} {float(base.z)}") + return True, f"已传送到公会 {guild.name} 据点" + + +def api_force_disband_guild(self, guild_query: str, + actor: str = "QQ管理") -> tuple[bool, str]: + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err + name = guild.name + del guilds[guild.guild_id] + if not _save_guilds(self, guilds): + return False, "保存公会数据失败" + return True, f"已强制解散公会 {name}" + + +def api_rename_guild(self, guild_query: str, new_name: str, + actor: str = "QQ管理") -> tuple[bool, str, Optional[dict[str, Any]]]: + new_name = str(new_name or "").strip() + if not new_name: + return False, "新公会名不能为空", None + if len(new_name) < 2 or len(new_name) > 20: + return False, "新公会名长度必须在 2-20 之间", None + guilds = _load_guilds(self) + if any(guild.name == new_name for guild in guilds.values()): + return False, f"公会名已存在:{new_name}", None + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + old_name = guild.name + guild.name = new_name + guild.add_log(f"{_actor(actor)} 将公会名从 {old_name} 修改为 {new_name}") + guild.add_audit_log( + "guild_rename", + _actor(actor), + target=old_name, + detail=new_name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已修改公会名称:{old_name} -> {new_name}", _guild_summary(guild) + + +def api_set_guild_level(self, + guild_query: str, + level: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + ok, err, parsed = _to_int(level, "公会等级", 1) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + old = guild.level + guild.level = parsed + guild.add_log(f"{_actor(actor)} 将公会等级从 {old} 修改为 {parsed}") + guild.add_audit_log( + "guild_set_level", + _actor(actor), + detail=f"{old}->{parsed}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已设置 {guild.name} 等级为 {parsed}", _guild_summary(guild) + + +def api_set_guild_exp(self, guild_query: str, exp: int, + actor: str = "QQ管理") -> tuple[bool, str, Optional[dict[str, Any]]]: + ok, err, parsed = _to_int(exp, "公会经验", 0) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + old = guild.exp + guild.exp = parsed + level_ups = _apply_level_ups(guild) + guild.add_log(f"{_actor(actor)} 将公会经验从 {old} 修改为 {parsed}") + guild.add_audit_log( + "guild_set_exp", + _actor(actor), + detail=f"{old}->{parsed}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + suffix = f",触发升级到 {level_ups[-1]} 级" if level_ups else "" + return True, f"已设置 { + guild.name} 经验为 { + guild.exp}{suffix}", _guild_summary(guild) + + +def api_transfer_guild_owner(self, + guild_query: str, + new_owner: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + target_name = str(new_owner or "").strip() + if not target_name: + return False, "新会长不能为空", None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + target = guild.get_member(target_name) + if target is None: + target = GuildMember( + name=target_name, + rank=GuildRank.MEMBER, + join_time=_now()) + guild.members.append(target) + for member in guild.members: + if member.rank == GuildRank.OWNER and member.name != target_name: + member.rank = GuildRank.DEPUTY + target.rank = GuildRank.OWNER + old_owner = guild.owner + guild.owner = target.name + guild.add_log(f"{_actor(actor)} 强制将会长从 {old_owner} 转让给 {target.name}") + guild.add_audit_log("guild_force_transfer_owner", _actor(actor), + target=target.name, detail=old_owner) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已将 {guild.name} 会长转让给 {target.name}", _guild_summary(guild) + + +def api_force_join_guild(self, + guild_query: str, + player_name: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + name = str(player_name or "").strip() + if not name: + return False, "玩家名不能为空", None + guilds = _load_guilds(self) + target_guild, err = _find_guild(self, guild_query, guilds) + if target_guild is None: + return False, err, None + old_guild, _ = _find_player_guild(self, name, guilds) + if old_guild and old_guild.guild_id == target_guild.guild_id: + return True, f"{name} 已在公会 { + target_guild.name}", _guild_summary(target_guild) + if old_guild: + old_guild.members = [ + member for member in old_guild.members if member.name != name] + old_guild.add_log(f"{_actor(actor)} 强制移出 {name}") + old_guild.add_audit_log("guild_force_leave", _actor( + actor), target=name, detail=target_guild.name) + target_guild.members.append(GuildMember( + name=name, rank=GuildRank.MEMBER, join_time=_now())) + target_guild.add_log(f"{_actor(actor)} 强制加入成员 {name}") + target_guild.add_audit_log("guild_force_join", _actor(actor), target=name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已将 {name} 加入公会 { + target_guild.name}", _guild_summary(target_guild) + + +def api_force_leave_guild(self, + player_name: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + name = str(player_name or "").strip() + if not name: + return False, "玩家名不能为空", None + guilds = _load_guilds(self) + guild, err = _find_player_guild(self, name, guilds) + if guild is None: + return False, err, None + guild.members = [member for member in guild.members if member.name != name] + if not guild.members: + old_name = guild.name + del guilds[guild.guild_id] + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已移除 {name},公会 {old_name} 已无成员并被解散", None + if guild.owner == name or not any( + member.rank == GuildRank.OWNER for member in guild.members): + new_owner = guild.members[0] + new_owner.rank = GuildRank.OWNER + guild.owner = new_owner.name + guild.add_log(f"{_actor(actor)} 强制移出成员 {name}") + guild.add_audit_log("guild_force_leave", _actor(actor), target=name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已将 {name} 移出公会 {guild.name}", _guild_summary(guild) + + +def api_force_kick_member(self, + player_name: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + return api_force_leave_guild(self, player_name, actor) + + +def api_set_guild_frozen(self, + guild_query: str, + frozen: bool, + reason: str = "", + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + settings = _ensure_settings(guild) + settings["frozen"] = bool(frozen) + settings["frozen_reason"] = str(reason or "") + settings["frozen_at"] = _now() if frozen else 0 + action = "冻结" if frozen else "解冻" + guild.add_log(f"{_actor(actor)} {action}了公会") + guild.add_audit_log("guild_freeze" if frozen else "guild_unfreeze", + _actor(actor), detail=str(reason or "")) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已{action}公会 {guild.name}", _guild_summary(guild) + + +def api_get_guild_vault( + self, guild_query: str) -> tuple[bool, str, Optional[list[dict[str, Any]]]]: + guild, err = _find_guild(self, guild_query) + if guild is None: + return False, err, None + data = [_vault_item_summary(item, index) + for index, item in enumerate(guild.vault_items, start=1)] + return True, f"{guild.name} 仓库共有 {len(data)} 件上架物品", data + + +def api_backup_guild_vault(self, + guild_query: str, + label: str = "", + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + settings = _ensure_settings(guild) + backups = settings.setdefault("vault_backups", []) + if not isinstance(backups, list): + backups = [] + settings["vault_backups"] = backups + backup = { + "label": str(label or ""), + "actor": _actor(actor), + "created_at": _now(), + "items": [item.to_dict() for item in guild.vault_items], + } + backups.insert(0, backup) + settings["vault_backups"] = backups[:10] + guild.add_audit_log("vault_backup", _actor(actor), detail=str(label or "")) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已备份 { + guild.name} 仓库,当前保留 { + len( + settings['vault_backups'])} 份", backup + + +def api_clear_guild_vault(self, + guild_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + api_backup_guild_vault(self, guild.guild_id, "clear-before", actor) + guilds = _load_guilds(self) + guild = guilds[guild.guild_id] + removed = len(guild.vault_items) + guild.vault_items = [] + guild.add_log(f"{_actor(actor)} 清空了公会仓库") + guild.add_audit_log( + "vault_clear", + _actor(actor), + detail=f"removed={removed}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已清空 { + guild.name} 仓库,共删除 {removed} 件物品", _guild_summary(guild) + + +def api_delete_guild_vault_item(self, + guild_query: str, + index: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + ok, err, parsed = _to_int(index, "仓库序号", 1) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + zero_index = parsed - 1 + if zero_index >= len(guild.vault_items): + return False, f"仓库序号超出范围:{parsed}", None + item = guild.vault_items.pop(zero_index) + guild.add_vault_trade_log( + "admin_delete", + item, + _actor(actor), + detail="管理员删除") + guild.add_audit_log("vault_item_delete", _actor( + actor), target=item.seller, detail=item.item_id) + guild.add_log(f"{_actor(actor)} 删除了仓库物品 {item.item_id} x{item.count}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已删除 { + guild.name} 仓库第 {parsed} 件物品", _vault_item_summary( + item, parsed) + + +def api_rollback_guild_vault(self, + guild_query: str, + backup_index: int = 1, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + ok, err, parsed = _to_int(backup_index, "备份序号", 1) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + backups = _ensure_settings(guild).get("vault_backups", []) + if not isinstance(backups, list) or parsed > len(backups): + return False, f"仓库备份不存在:{parsed}", None + selected_backup = copy.deepcopy(backups[parsed - 1]) + api_backup_guild_vault(self, guild.guild_id, "rollback-before", actor) + guilds = _load_guilds(self) + guild = guilds[guild.guild_id] + guild.vault_items = [ + VaultItem.from_dict(item) + for item in selected_backup.get("items", []) + if isinstance(item, dict) + ] + guild.add_log(f"{_actor(actor)} 回滚了公会仓库") + guild.add_audit_log("vault_rollback", _actor(actor), detail=str(parsed)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已回滚 {guild.name} 仓库到备份 {parsed}", { + "guild": _guild_summary(guild), + "backup": selected_backup, + } + + +def api_export_guild_vault( + self, guild_query: str) -> tuple[bool, str, Optional[dict[str, Any]]]: + guild, err = _find_guild(self, guild_query) + if guild is None: + return False, err, None + data = { + "guild": _guild_summary(guild), "vault_items": [ + _vault_item_summary( + item, index) for index, item in enumerate( + guild.vault_items, start=1)], "vault_trade_logs": [ + log.to_dict() for log in guild.vault_trade_logs], } + data["json"] = json.dumps(data, ensure_ascii=False, indent=2) + return True, f"已导出 {guild.name} 仓库数据", data + + +def _make_task_from_template( + template: dict[str, Any], prefix: str = "auto") -> GuildTask: + now = _now() + deadline_seconds = int(getattr(Config, "GUILD_TASK_CONFIG", + {}).get("自动任务默认有效期秒", 172800)) + return GuildTask( + task_id=f"{prefix} -{uuid.uuid4().hex[: 8]} ", + name=str(template.get("name", "公会任务"))[: 20], + description=str(template.get("description", ""))[: 100], + task_type=str(template.get("task_type", "trade")), + target=str(template.get("target", "trade_count")), + target_count=max(1, int(template.get("target_count", 1))), + current_count=max(0, int(template.get("current_count", 0))), + reward_exp=max(0, int(template.get("reward_exp", 0))), + reward_contribution=max( + 0, int(template.get("reward_contribution", 0))), + create_time=now, deadline=now + deadline_seconds + if deadline_seconds > 0 else 0,) + + +def api_refresh_guild_tasks( + self, guild_query: str, actor: str = "QQ管理") -> tuple[bool, str, list[dict[str, Any]]]: + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, [] + templates = getattr(Config, "GUILD_TASK_CONFIG", {}).get("自动任务模板列表", []) + if not templates: + return False, "没有配置自动任务模板", [] + active_keys = {(task.name, task.task_type, task.target) + for task in guild.tasks if not task.completed} + created: list[GuildTask] = [] + for template in templates: + key = ( + template.get("name"), + template.get("task_type"), + template.get("target")) + if key in active_keys: + continue + task = _make_task_from_template(template) + guild.tasks.append(task) + created.append(task) + guild.add_log(f"{_actor(actor)} 刷新了公会任务,新增 {len(created)} 个") + guild.add_audit_log( + "task_refresh", + _actor(actor), + detail=str( + len(created))) + if created and not _save_guilds(self, guilds): + return False, "保存公会数据失败", [] + return True, f"已为 { + guild.name} 刷新任务,新增 { + len(created)} 个", [ + _task_summary(task) for task in created] + + +def api_create_global_task( + self, + name: str, + task_type: str, + target: str, + target_count: int, + reward_exp: int = 0, + reward_contribution: int = 0, + description: str = "", + deadline_seconds: int = 0, + actor: str = "QQ管理", +) -> tuple[bool, str, list[dict[str, Any]]]: + task_name = str(name or "").strip() + if not task_name: + return False, "任务名称不能为空", [] + ok, err, count = _to_int(target_count, "目标数量", 1) + if not ok: + return False, err, [] + _, _, exp = _to_int(reward_exp, "经验奖励", 0) + _, _, contribution = _to_int(reward_contribution, "贡献奖励", 0) + _, _, seconds = _to_int(deadline_seconds, "截止秒数", 0) + guilds = _load_guilds(self) + now = _now() + created: list[dict[str, Any]] = [] + for guild in guilds.values(): + task = GuildTask( + task_id=f"global-{uuid.uuid4().hex[:8]}", + name=task_name[:20], + description=str(description or task_name)[:100], + task_type=str(task_type or "trade"), + target=str(target or "trade_count"), + target_count=count, + reward_exp=exp, + reward_contribution=contribution, + create_time=now, + deadline=now + seconds if seconds > 0 else 0, + ) + guild.tasks.append(task) + guild.add_log(f"{_actor(actor)} 创建了全服任务 {task.name}") + created.append({"guild_id": guild.guild_id, + "guild_name": guild.name, "task": _task_summary(task)}) + if created and not _save_guilds(self, guilds): + return False, "保存公会数据失败", [] + return True, f"已向 {len(created)} 个公会创建全服任务", created + + +def api_delete_guild_task(self, + guild_query: str, + task_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + task, err = _find_task(guild, task_query) + if task is None: + return False, err, None + guild.tasks = [ + item for item in guild.tasks if item.task_id != task.task_id] + guild.add_log(f"{_actor(actor)} 删除了任务 {task.name}") + guild.add_audit_log("task_delete", _actor(actor), detail=task.name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已删除任务 {task.name}", _task_summary(task) + + +def api_reset_guild_task_progress(self, + guild_query: str, + task_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + task, err = _find_task(guild, task_query) + if task is None: + return False, err, None + task.current_count = 0 + task.completed = False + task.participants = [] + guild.add_log(f"{_actor(actor)} 重置了任务 {task.name}") + guild.add_audit_log("task_reset", _actor(actor), detail=task.name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已重置任务 {task.name}", _task_summary(task) + + +def api_force_complete_guild_task(self, + guild_query: str, + task_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + task, err = _find_task(guild, task_query) + if task is None: + return False, err, None + task.current_count = task.target_count + task.completed = True + guild.add_log(f"{_actor(actor)} 强制完成了任务 {task.name}") + guild.add_audit_log("task_force_complete", _actor(actor), detail=task.name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已强制完成任务 {task.name}", _task_summary(task) + + +def api_teleport_player_to_guild_base( + self, player_name: str, guild_query: str | None = None) -> tuple[bool, str]: + name = str(player_name or "").strip() + if not name: + return False, "玩家名不能为空" + guilds = _load_guilds(self) + if guild_query: + guild, err = _find_guild(self, guild_query, guilds) + else: + guild, err = _find_player_guild(self, name, guilds) + if guild is None: + return False, err + if _ensure_settings(guild).get("base_locked", False): + return False, f"公会 {guild.name} 据点已锁定" + if not guild.base: + return False, f"公会 {guild.name} 尚未设置据点" + base = guild.base + self.game_ctrl.sendwocmd( + f"tp {name} {float(base.x)} {float(base.y)} {float(base.z)}") + return True, f"已传送 {name} 到公会 {guild.name} 据点" + + +def api_delete_guild_base(self, + guild_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + guild.base = None + guild.add_log(f"{_actor(actor)} 删除了公会据点") + guild.add_audit_log("base_delete", _actor(actor)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已删除 {guild.name} 据点", _guild_summary(guild) + + +def api_set_guild_base(self, + guild_query: str, + dimension: int, + x: float, + y: float, + z: float, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + ok, err, dim = _to_int(dimension, "维度") + if not ok: + return False, err, None + coord_values = [] + for field_name, value in (("x", x), ("y", y), ("z", z)): + ok, err, parsed = _to_float(value, field_name) + if not ok: + return False, err, None + coord_values.append(parsed) + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + guild.base = GuildBase( + dim, + coord_values[0], + coord_values[1], + coord_values[2]) + guild.add_log(f"{_actor(actor)} 修改了公会据点") + guild.add_audit_log("base_set", _actor( + actor), detail=f"{dim},{coord_values}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已设置 {guild.name} 据点", _guild_summary(guild) + + +def api_set_guild_base_locked(self, + guild_query: str, + locked: bool, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + _ensure_settings(guild)["base_locked"] = bool(locked) + action = "锁定" if locked else "解锁" + guild.add_log(f"{_actor(actor)} {action}了公会据点") + guild.add_audit_log( + "base_lock" if locked else "base_unlock", + _actor(actor)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已{action} {guild.name} 据点", _guild_summary(guild) + + +def api_clear_guild_effects(self, + guild_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + guild.purchased_effects = {} + guild.add_log(f"{_actor(actor)} 清空了公会效果") + guild.add_audit_log("effect_clear", _actor(actor)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已清空 {guild.name} 效果", _guild_summary(guild) + + +def api_set_guild_effect(self, + guild_query: str, + effect_key: str, + level: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + key = str(effect_key or "").strip() + if key not in Config.EFFECTS_CONFIG: + names = { + str(value.get("name", "")): effect_id + for effect_id, value in Config.EFFECTS_CONFIG.items() + if isinstance(value, dict) + } + key = names.get(key, key) + if key not in Config.EFFECTS_CONFIG: + return False, f"效果不存在:{effect_key}", None + ok, err, parsed_level = _to_int(level, "效果等级", 0) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + if parsed_level <= 0: + guild.purchased_effects.pop(key, None) + else: + guild.purchased_effects[key] = parsed_level + guild.add_log(f"{_actor(actor)} 设置效果 {key} 为 {parsed_level} 级") + guild.add_audit_log( + "effect_set", + _actor(actor), + detail=f"{key}={parsed_level}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已设置 { + guild.name} 效果 {key} 为 {parsed_level} 级", _guild_summary(guild) + + +def api_add_guild_funds(self, + guild_query: str, + amount: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + ok, err, parsed = _to_int(amount, "资金数量") + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + settings = _ensure_settings(guild) + settings["funds"] = int(settings.get("funds", 0) or 0) + parsed + guild.add_log(f"{_actor(actor)} 调整公会资金 {parsed:+d}") + guild.add_audit_log("funds_add", _actor(actor), detail=str(parsed)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已调整 { + guild.name} 资金,当前 { + settings['funds']}", _guild_summary(guild) + + +def api_set_guild_funds(self, + guild_query: str, + amount: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + ok, err, parsed = _to_int(amount, "资金余额", 0) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + _ensure_settings(guild)["funds"] = parsed + guild.add_log(f"{_actor(actor)} 设置公会资金为 {parsed}") + guild.add_audit_log("funds_set", _actor(actor), detail=str(parsed)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已设置 {guild.name} 资金为 {parsed}", _guild_summary(guild) + + +def api_add_member_contribution(self, + player_name: str, + amount: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + ok, err, parsed = _to_int(amount, "贡献值") + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_player_guild(self, player_name, guilds) + if guild is None: + return False, err, None + member = guild.get_member(str(player_name).strip()) + if member is None: + return False, "成员数据异常", None + member.contribution += parsed + guild.stats.total_contribution += max(0, parsed) + guild.add_log(f"{_actor(actor)} 调整 {member.name} 贡献 {parsed:+d}") + guild.add_audit_log("member_contribution_add", _actor(actor), + target=member.name, detail=str(parsed)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已调整 { + member.name} 贡献,当前 { + member.contribution}", _member_summary(member) + + +def api_set_member_contribution(self, + player_name: str, + amount: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + ok, err, parsed = _to_int(amount, "贡献值", 0) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_player_guild(self, player_name, guilds) + if guild is None: + return False, err, None + member = guild.get_member(str(player_name).strip()) + if member is None: + return False, "成员数据异常", None + old = member.contribution + member.contribution = parsed + guild.add_log(f"{_actor(actor)} 将 {member.name} 贡献从 {old} 设置为 {parsed}") + guild.add_audit_log("member_contribution_set", _actor( + actor), target=member.name, detail=f"{old}->{parsed}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已设置 {member.name} 贡献为 {parsed}", _member_summary(member) + + +def api_reset_guild_contributions(self, + guild_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + for member in guild.members: + member.contribution = 0 + guild.stats.total_contribution = 0 + guild.add_log(f"{_actor(actor)} 重置了所有成员贡献") + guild.add_audit_log("member_contribution_reset_all", _actor(actor)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已重置 {guild.name} 所有成员贡献", _guild_summary(guild) + + +def api_reset_market_prices(self, + guild_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + removed = len(guild.custom_item_values) + guild.custom_item_values = {} + guild.add_log(f"{_actor(actor)} 重置了市场价格") + guild.add_audit_log( + "market_price_reset", + _actor(actor), + detail=str(removed)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已重置 { + guild.name} 市场价格,删除 {removed} 条自定义价格", _guild_summary(guild) + + +def api_get_guild_logs(self, guild_query: str, + limit: int = 20) -> tuple[bool, str, Optional[dict[str, Any]]]: + ok, _err, parsed_limit = _to_int(limit, "日志数量", 1) + if not ok: + parsed_limit = 20 + guild, err = _find_guild(self, guild_query) + if guild is None: + return False, err, None + return True, "查询成功", { + "logs": guild.logs[-parsed_limit:], + "audit_logs": [log.to_dict() for log in guild.audit_logs[-parsed_limit:]], + "vault_trade_logs": [log.to_dict() for log in guild.vault_trade_logs[-parsed_limit:]], + } + + +def api_get_abnormal_trades(self, + guild_query: str | None = None, + ratio: float = 3.0) -> tuple[bool, + str, + list[dict[str, + Any]]]: + try: + threshold = float(ratio) + except (TypeError, ValueError): + threshold = 3.0 + guilds = _load_guilds(self) + target_guilds: list[GuildData] + if guild_query: + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, [] + target_guilds = [guild] + else: + target_guilds = list(guilds.values()) + results = [] + for guild in target_guilds: + for log in guild.vault_trade_logs: + suggested = guild.get_item_value( + log.item_id) * max(1, int(log.count or 1)) + if suggested > 0 and log.price >= suggested * threshold: + data = log.to_dict() + data["guild_id"] = guild.guild_id + data["guild_name"] = guild.name + data["suggested_price"] = suggested + data["ratio"] = round(log.price / suggested, 2) + results.append(data) + results.sort(key=lambda item: item.get("timestamp", 0), reverse=True) + return True, f"共 {len(results)} 条异常交易记录", results + + +def api_get_donation_rankings(self, + guild_query: str | None = None, + limit: int = 10) -> tuple[bool, + str, + list[dict[str, + Any]]]: + ok, _err, parsed_limit = _to_int(limit, "排行数量", 1) + if not ok: + parsed_limit = 10 + guilds = _load_guilds(self) + records = [] + for guild in guilds.values(): + if guild_query and guild.name != guild_query and guild.guild_id != guild_query: + continue + for member in guild.members: + records.append({ + "guild_id": guild.guild_id, + "guild_name": guild.name, + "player_name": member.name, + "rank": member.rank.value, + "contribution": member.contribution, + }) + records.sort(key=lambda item: int(item["contribution"]), reverse=True) + return True, f"贡献排行前 {min(parsed_limit, len(records))} 名", records[:parsed_limit] + + +def api_get_guild_rankings(self, sort_by: str = "level", + limit: int = 10) -> tuple[bool, str, list[dict[str, Any]]]: + """返回适合外部插件消费的公会排行榜数据。""" + raw_sort_by = str(sort_by or "level").strip() + sort_key = { + "等级": "level", + "level": "level", + "成员": "members", + "members": "members", + "贡献": "contribution", + "contribution": "contribution", + "活跃": "activity", + "activity": "activity", + }.get(raw_sort_by, raw_sort_by) + ok, _err, parsed_limit = _to_int(limit, "排行数量", 1) + if not ok: + parsed_limit = 10 + rankings = _get_guild_rankings(self, sort_key)[:parsed_limit] + data: list[dict[str, Any]] = [] + for index, (guild, score) in enumerate(rankings, start=1): + item = _guild_summary(guild) + item["rank"] = index + item["score"] = score + item["sort_by"] = sort_key + data.append(item) + return True, f"公会排行前 {len(data)} 名", data + + +def api_reload_guild_config(self) -> tuple[bool, str, dict[str, Any]]: + self.config = Config.load(self.name, self.version) + return True, "公会系统配置已重新加载", copy.deepcopy(self.config) + + +def api_save_guild_data(self) -> tuple[bool, str]: + guilds = _load_guilds(self) + if not _save_guilds(self, guilds, force=True): + return False, "保存公会数据失败" + return True, "公会数据已强制保存" + + +def api_backup_guild_data(self) -> tuple[bool, str, Optional[str]]: + if not os.path.exists(self.guilds_file): + return False, "公会数据文件不存在", None + data_dir = os.path.dirname(self.guilds_file) + backup_dir = os.path.join(data_dir, "公会数据备份") + os.makedirs(backup_dir, exist_ok=True) + backup_path = os.path.join( + backup_dir, f"公会数据文件-api-{time.strftime('%Y%m%d-%H%M%S')}.json") + shutil.copy2(self.guilds_file, backup_path) + return True, "公会数据备份已创建", backup_path + + +def api_repair_guild_data( + self, actor: str = "QQ管理") -> tuple[bool, str, dict[str, Any]]: + guilds = _load_guilds(self) + fixed = {"guild_id": 0, "level": 0, "exp": 0, + "owner": 0, "vault": 0, "removed_empty": 0} + for outer_id in list(guilds.keys()): + guild = guilds[outer_id] + if not guild.members: + del guilds[outer_id] + fixed["removed_empty"] += 1 + continue + if guild.guild_id != outer_id: + guild.guild_id = outer_id + fixed["guild_id"] += 1 + if not isinstance(guild.level, int) or guild.level < 1: + guild.level = 1 + fixed["level"] += 1 + if not isinstance(guild.exp, (int, float)) or guild.exp < 0: + guild.exp = 0 + fixed["exp"] += 1 + owners = [member for member in guild.members + if member.rank == GuildRank.OWNER] + if len(owners) != 1: + preferred = guild.get_member(guild.owner) or guild.members[0] + for member in guild.members: + member.rank = GuildRank.MEMBER + preferred.rank = GuildRank.OWNER + guild.owner = preferred.name + fixed["owner"] += 1 + if len(guild.vault_items) > Config.VAULT_INITIAL_SLOTS: + guild.vault_items = guild.vault_items[:Config.VAULT_INITIAL_SLOTS] + fixed["vault"] += 1 + if not isinstance(guild.stats, GuildStats): + guild.stats = GuildStats() + guild.add_audit_log("data_repair", _actor( + actor), detail=json.dumps(fixed, ensure_ascii=False)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", fixed + return True, "公会数据修复完成", fixed + + +def api_get_guild_statistics(self) -> tuple[bool, str, dict[str, Any]]: + guilds = _load_guilds(self) + total_members = sum(len(guild.members) for guild in guilds.values()) + total_vault_items = sum(len(guild.vault_items) + for guild in guilds.values()) + total_tasks = sum(len(guild.tasks) for guild in guilds.values()) + active_tasks = sum( + len([task for task in guild.tasks if not task.completed]) + for guild in guilds.values()) + frozen_count = sum(1 for guild in guilds.values() + if _ensure_settings(guild).get("frozen", False)) + data = { + "guild_count": len(guilds), + "member_count": total_members, + "vault_item_count": total_vault_items, + "task_count": total_tasks, + "active_task_count": active_tasks, + "frozen_guild_count": frozen_count, + "activity_status": api_get_guild_activity_status(self)[2], + } + return True, "查询成功", data + + +def api_start_guild_activity( + self, + activity: str, + duration_seconds: int = 3600, + multiplier: float = 2.0, + actor: str = "QQ管理", +) -> tuple[bool, str, dict[str, Any]]: + activity_key = str(activity or "").strip().lower() + aliases = { + "双倍经验": "exp", + "经验": "exp", + "exp": "exp", + "双倍贡献": "contribution", + "贡献": "contribution", + "contribution": "contribution", + "公会争霸": "contest", + "争霸": "contest", + "contest": "contest", + } + activity_key = aliases.get(activity_key, activity_key) + if activity_key not in ("exp", "contribution", "contest"): + return False, f"未知活动类型:{activity}", {} + ok, err, seconds = _to_int(duration_seconds, "活动时长秒", 1) + if not ok: + return False, err, {} + try: + parsed_multiplier = max(1.0, float(multiplier)) + except (TypeError, ValueError): + parsed_multiplier = 2.0 + if not hasattr(self, "_guild_runtime_events"): + self._guild_runtime_events = {} + event = { + "activity": activity_key, + "multiplier": parsed_multiplier, + "started_at": _now(), + "expires_at": _now() + seconds, + "actor": _actor(actor), + } + self._guild_runtime_events[activity_key] = event + return True, f"已开启 {activity_key} 活动 {seconds} 秒,倍率 {parsed_multiplier}", copy.deepcopy( + event) + + +def api_stop_guild_activity(self, activity: str) -> tuple[bool, str]: + activity_key = str(activity or "").strip().lower() + aliases = {"经验": "exp", "双倍经验": "exp", "贡献": "contribution", + "双倍贡献": "contribution", "争霸": "contest", "公会争霸": "contest"} + activity_key = aliases.get(activity_key, activity_key) + removed = getattr( + self, + "_guild_runtime_events", + {}).pop( + activity_key, + None) + if removed is None: + return False, f"活动未开启:{activity}" + return True, f"已停止活动 {activity_key}" + + +def api_get_guild_activity_status(self) -> tuple[bool, str, dict[str, Any]]: + events = getattr(self, "_guild_runtime_events", {}) + now = _now() + active = {} + for key, event in list(events.items()): + expires_at = float(event.get("expires_at", 0) or 0) + if expires_at > 0 and expires_at <= now: + events.pop(key, None) + continue + active[key] = copy.deepcopy(event) + active[key]["remaining_seconds"] = max( + 0, int(expires_at - now)) if expires_at > 0 else 0 + return True, f"当前 {len(active)} 个活动运行中", active + + +def api_settle_guild_ranking_rewards( + self, + sort_by: str = "level", + top: int = 3, + reward_exp: int = 0, + reward_funds: int = 0, + actor: str = "QQ管理", +) -> tuple[bool, str, list[dict[str, Any]]]: + ok, err, parsed_top = _to_int(top, "排行数量", 1) + if not ok: + return False, err, [] + _, _, exp = _to_int(reward_exp, "经验奖励", 0) + _, _, funds = _to_int(reward_funds, "资金奖励", 0) + guilds = _load_guilds(self) + ranking = _get_guild_rankings(self, sort_by) + rewarded = [] + for guild, score in ranking[:parsed_top]: + latest = guilds.get(guild.guild_id) + if latest is None: + continue + latest.exp += exp + _apply_level_ups(latest) + settings = _ensure_settings(latest) + settings["funds"] = int(settings.get("funds", 0) or 0) + funds + latest.add_log(f"{_actor(actor)} 发放排行榜奖励:经验 {exp},资金 {funds}") + latest.add_audit_log("ranking_reward", _actor(actor), + detail=f"{sort_by}:{score}") + rewarded.append({"guild_id": latest.guild_id, + "guild_name": latest.name, "score": score}) + if rewarded and not _save_guilds(self, guilds): + return False, "保存公会数据失败", [] + return True, f"已结算 {len(rewarded)} 个公会的排行榜奖励", rewarded + + +def api_broadcast_guild_announcement( + self, message: str, actor: str = "QQ管理") -> tuple[bool, str]: + text = str(message or "").strip() + if not text: + return False, "公告内容不能为空" + payload = json.dumps( + {"rawtext": [{"text": f"§l§a公会公告 §d>> §r{text}"}]}, ensure_ascii=False) + self.game_ctrl.sendcmd(f"/tellraw @a {payload}") + fmts.print_inf(f"{_actor(actor)} 发布公会全服公告:{text}") + return True, "全服公告已发送" + + +def _get_guild_rankings( + self, sort_by: str = "level") -> list[tuple[GuildData, Any]]: + return self.get_guild_rankings(sort_by) + + +guild_api_functions = { + "guild_get_activity_multiplier": guild_get_activity_multiplier, + "guild_apply_reward_multipliers": guild_apply_reward_multipliers, + "guild_is_frozen": guild_is_frozen, + "guild_frozen_message": guild_frozen_message, + "show_guild_frozen": show_guild_frozen, + "api_list_guilds": api_list_guilds, + "api_get_guild": api_get_guild, + "api_get_player_record": api_get_player_record, + "api_get_player_guild_menu_state": api_get_player_guild_menu_state, + "api_get_own_guild_logs": api_get_own_guild_logs, + "api_get_own_guild_vault": api_get_own_guild_vault, + "api_get_own_guild_tasks": api_get_own_guild_tasks, + "api_request_join_guild_as_player": api_request_join_guild_as_player, + "api_leave_guild_as_player": api_leave_guild_as_player, + "api_disband_owned_guild_as_player": api_disband_owned_guild_as_player, + "api_set_announcement_as_player": api_set_announcement_as_player, + "api_join_guild_task_as_player": api_join_guild_task_as_player, + "api_return_to_guild_base_as_player": api_return_to_guild_base_as_player, + "api_force_disband_guild": api_force_disband_guild, + "api_rename_guild": api_rename_guild, + "api_set_guild_level": api_set_guild_level, + "api_set_guild_exp": api_set_guild_exp, + "api_transfer_guild_owner": api_transfer_guild_owner, + "api_force_join_guild": api_force_join_guild, + "api_force_leave_guild": api_force_leave_guild, + "api_force_kick_member": api_force_kick_member, + "api_set_guild_frozen": api_set_guild_frozen, + "api_get_guild_vault": api_get_guild_vault, + "api_backup_guild_vault": api_backup_guild_vault, + "api_clear_guild_vault": api_clear_guild_vault, + "api_delete_guild_vault_item": api_delete_guild_vault_item, + "api_rollback_guild_vault": api_rollback_guild_vault, + "api_export_guild_vault": api_export_guild_vault, + "api_refresh_guild_tasks": api_refresh_guild_tasks, + "api_create_global_task": api_create_global_task, + "api_delete_guild_task": api_delete_guild_task, + "api_reset_guild_task_progress": api_reset_guild_task_progress, + "api_force_complete_guild_task": api_force_complete_guild_task, + "api_teleport_player_to_guild_base": api_teleport_player_to_guild_base, + "api_delete_guild_base": api_delete_guild_base, + "api_set_guild_base": api_set_guild_base, + "api_set_guild_base_locked": api_set_guild_base_locked, + "api_clear_guild_effects": api_clear_guild_effects, + "api_set_guild_effect": api_set_guild_effect, + "api_add_guild_funds": api_add_guild_funds, + "api_set_guild_funds": api_set_guild_funds, + "api_add_member_contribution": api_add_member_contribution, + "api_set_member_contribution": api_set_member_contribution, + "api_reset_guild_contributions": api_reset_guild_contributions, + "api_reset_market_prices": api_reset_market_prices, + "api_get_guild_logs": api_get_guild_logs, + "api_get_abnormal_trades": api_get_abnormal_trades, + "api_get_donation_rankings": api_get_donation_rankings, + "api_get_guild_rankings": api_get_guild_rankings, + "api_reload_guild_config": api_reload_guild_config, + "api_save_guild_data": api_save_guild_data, + "api_backup_guild_data": api_backup_guild_data, + "api_repair_guild_data": api_repair_guild_data, + "api_get_guild_statistics": api_get_guild_statistics, + "api_start_guild_activity": api_start_guild_activity, + "api_stop_guild_activity": api_stop_guild_activity, + "api_get_guild_activity_status": api_get_guild_activity_status, + "api_settle_guild_ranking_rewards": api_settle_guild_ranking_rewards, + "api_broadcast_guild_announcement": api_broadcast_guild_announcement, +} diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" new file mode 100644 index 00000000..db834e16 --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" @@ -0,0 +1,1698 @@ +"""Runtime configuration loader for the guild plugin. + +ToolDelta creates the runtime config file from DEFAULT_CONFIG when the +plugin is loaded. Do not ship a plugin-local ``plugin config`` directory. +""" + +from __future__ import annotations + +import copy +import json +from typing import Any + +from tooldelta import cfg, fmts + + +PLUGIN_ENABLED_KEY = "是否启用插件" + +DEFAULT_CONFIG_JSON = r'''{ + "配置版本": "0.1.7", + "动态载入设置": { + "是否启用动态载入配置文件(仅用于本插件)": true, + "动态载入检测时间间隔(单位:秒)": 5 + }, + "基础配置": { + "是否启用插件": false, + "公会菜单唤醒词": [ + ".公会" + ], + "积分计分板名称": "money", + "缓存有效时间秒": 300, + "批量保存间隔秒": 5, + "批量保存最大数量": 10 + }, + "功能开关": { + "公会仓库": true, + "公会据点": true, + "公会捐献": true, + "公会任务": true, + "公会效果": true, + "公会排行": true + }, + "公会配置": { + "公会最大成员数": 30, + "在线成员每次增加经验": 10, + "在线经验增加间隔秒": 600, + "每日登录经验": 5, + "捐献转经验倍率": 0.1, + "每页显示数量": 6, + "公会各等级升级所需经验": { + "2": 100, + "3": 200, + "4": 400, + "5": 800, + "6": 1600, + "7": 3200, + "8": 6400, + "9": 12800, + "10": 25600 + } + }, + "创建配置": { + "创建公会消耗积分": 5000, + "创建冷却秒": 300, + "最短公会名长度": 2, + "最长公会名长度": 20, + "禁止公会名列表": [ + "管理员", + "系统", + "官方" + ], + "禁止公会名包含词": [ + "admin", + "operator" + ], + "启用同名模糊检测": true, + "模糊检测最小相似度": 0.88, + "创建后全服公告": true + }, + "仓库配置": { + "初始容量": 54, + "每级增加容量": 0, + "交易税率": 0.05, + "市场配置": { + "启用交易日志": true, + "交易日志保留数量": 120, + "启用撤回出售": true, + "只允许撤回自己的物品": false, + "撤回后返还物品": true, + "允许购买自己出售的物品": false, + "单个成员最大上架数量": 18, + "单次出售最大数量": 64, + "单笔价格下限": 1, + "单笔价格上限": 100000, + "建议价最小倍率": 0.2, + "建议价最大倍率": 5.0, + "高价交易审计倍率": 3.0 + } + }, + "经验配置": { + "在线经验": { + "每次增加经验": 10, + "增加间隔秒": 600 + }, + "登录经验": { + "每日登录经验": 5 + }, + "捐献经验": { + "转经验倍率": 0.1 + } + }, + "据点配置": { + "维度名称映射": { + "0": "主世界", + "1": "地狱", + "2": "末地" + } + }, + "效果系统": { + "刷新间隔秒": 3600, + "效果列表": { + "speed": { + "name": "速度提升", + "levels": { + "1": 1, + "2": 2 + }, + "costs": { + "1": 10, + "2": 20 + } + }, + "haste": { + "name": "急迫", + "levels": { + "1": 1, + "2": 2 + }, + "costs": { + "1": 10, + "2": 20 + } + }, + "strength": { + "name": "力量", + "levels": { + "1": 1, + "2": 2 + }, + "costs": { + "1": 10, + "2": 20 + } + }, + "resistance": { + "name": "抗性提升", + "levels": { + "1": 1, + "2": 2 + }, + "costs": { + "1": 10, + "2": 20 + } + }, + "regeneration": { + "name": "生命恢复", + "levels": { + "1": 1, + "2": 2 + }, + "costs": { + "1": 10, + "2": 20 + } + }, + "night_vision": { + "name": "夜视", + "levels": { + "1": 1, + "2": 2 + }, + "costs": { + "1": 10, + "2": 20 + } + }, + "jump_boost": { + "name": "跳跃提升", + "levels": { + "1": 1, + "2": 2 + }, + "costs": { + "1": 10, + "2": 20 + } + } + } + }, + "申请配置": { + "启用离线申请队列": true, + "申请有效期秒": 86400, + "重复申请冷却秒": 300, + "每个玩家最多待处理申请数": 3, + "每个公会最多待处理申请数": 30, + "申请理由最大长度": 60, + "满员时自动拒绝新申请": true, + "申请提交后通知在线管理员": true, + "批准后通知全体在线成员": true, + "拒绝后保留记录": true + }, + "任务系统": { + "启用自动任务模板": true, + "自动任务生成间隔秒": 86400, + "每次生成自动任务数量": 3, + "自动任务最大同时存在数量": 6, + "自动任务默认有效期秒": 172800, + "成员每日可完成任务数量": 5, + "创建任务名称最大长度": 20, + "创建任务描述最大长度": 100, + "创建任务目标数量上限": 10000, + "创建任务贡献奖励上限": 1000, + "创建任务经验奖励上限": 1000, + "自动任务模板列表": [ + { + "name": "每日仓库交易", + "description": "完成一次公会仓库交易", + "task_type": "trade", + "target": "trade_count", + "target_count": 1, + "reward_exp": 30, + "reward_contribution": 10 + }, + { + "name": "钻石补给", + "description": "收集钻石补给公会", + "task_type": "collect", + "target": "minecraft:diamond", + "target_count": 8, + "reward_exp": 80, + "reward_contribution": 25 + }, + { + "name": "基础建材", + "description": "提交圆石作为公会建材", + "task_type": "collect", + "target": "minecraft:cobblestone", + "target_count": 64, + "reward_exp": 40, + "reward_contribution": 12 + } + ] + }, + "权限系统": { + "职位权限配置": { + "会长": { + "踢出成员权限": true, + "处理/同意加入公会申请权限": true, + "设置公会公告权限": true, + "管理公会任务权限": true, + "公会仓库使用权限": true, + "设置公会据点权限": true, + "返回公会据点权限": true, + "购买公会效果权限": true, + "设置仓库物品价值权限": true, + "出售仓库物品权限": true, + "购买仓库物品权限": true, + "撤回自己出售物品权限": true, + "撤回任意仓库物品权限": true, + "处理加入申请队列权限": true, + "查看审计日志权限": true, + "设置成员职位权限": true, + "转让会长权限": true, + "创建公会任务权限": true, + "删除公会任务权限": true, + "强制完成公会任务权限": true + }, + "副会长": { + "踢出成员权限": true, + "处理/同意加入公会申请权限": true, + "设置公会公告权限": true, + "管理公会任务权限": true, + "公会仓库使用权限": true, + "设置公会据点权限": false, + "返回公会据点权限": true, + "购买公会效果权限": true, + "设置仓库物品价值权限": true, + "出售仓库物品权限": true, + "购买仓库物品权限": true, + "撤回自己出售物品权限": true, + "撤回任意仓库物品权限": true, + "处理加入申请队列权限": true, + "查看审计日志权限": true, + "设置成员职位权限": true, + "转让会长权限": false, + "创建公会任务权限": true, + "删除公会任务权限": true, + "强制完成公会任务权限": true + }, + "长老": { + "踢出成员权限": false, + "处理/同意加入公会申请权限": true, + "设置公会公告权限": true, + "管理公会任务权限": true, + "公会仓库使用权限": true, + "设置公会据点权限": false, + "返回公会据点权限": true, + "购买公会效果权限": true, + "设置仓库物品价值权限": false, + "出售仓库物品权限": true, + "购买仓库物品权限": true, + "撤回自己出售物品权限": true, + "撤回任意仓库物品权限": false, + "处理加入申请队列权限": true, + "查看审计日志权限": false, + "设置成员职位权限": false, + "转让会长权限": false, + "创建公会任务权限": true, + "删除公会任务权限": false, + "强制完成公会任务权限": false + }, + "成员": { + "踢出成员权限": false, + "处理/同意加入公会申请权限": false, + "设置公会公告权限": false, + "管理公会任务权限": false, + "公会仓库使用权限": true, + "设置公会据点权限": false, + "返回公会据点权限": true, + "购买公会效果权限": false, + "设置仓库物品价值权限": false, + "出售仓库物品权限": true, + "购买仓库物品权限": true, + "撤回自己出售物品权限": true, + "撤回任意仓库物品权限": false, + "处理加入申请队列权限": false, + "查看审计日志权限": false, + "设置成员职位权限": false, + "转让会长权限": false, + "创建公会任务权限": false, + "删除公会任务权限": false, + "强制完成公会任务权限": false + } + } + }, + "数据安全": { + "启用保存前备份": true, + "备份目录名": "公会数据备份", + "最大备份数量": 10, + "强制保存时也备份": true, + "启用异常数据跳过": true, + "启动时自动修复缺失字段": true, + "修复前写入备份": true, + "审计日志保留数量": 200 + }, + "提示词配置": { + "已有公会提示词": "§c❀ §r你已经有公会了", + "快捷创建缺少名称提示词": "§a❀ §r请输入公会名称,例如: 公会创建 我的公会", + "快捷创建名称长度无效提示词": "§c❀ §r公会名必须在2-16个字符之间", + "创建公会余额不足提示词": ( + "§c❀ §r创建公会需要 §e{consume}§r 点 " + "§b{scoreboard}§r 计分板积分\n" + "§c❀ §r当前余额: §f{balance}" + ), + "创建公会提示词": ( + "§a❀ §r创建公会将消耗 §e{consume} §b{scoreboard} \n" + "§a❀ §r当前余额: §f{balance}\n" + "§a❀ §r输入 §a确认§7 继续创建,输入 §cq§7 取消" + ), + "创建公会回复超时提示词": "§c❀ §r回复超时,已取消创建公会", + "创建公会取消提示词": "§c❀ §r已取消创建公会", + "创建公会输入名称提示词": "§a❀ §r请输入公会名字:\n§a❀ §r要求: 2-20个字符,不能包含特殊符号", + "创建公会名称无效提示词": "§c❀ §r{error}", + "创建公会二次余额不足提示词": "§c❀ §r当前 §b{scoreboard}§r 余额不足,需要 §e{consume}§r,当前 §f{balance}", + "创建公会成功提示词": "§a❀ §r已创建公会 §e{guild}", + "创建公会全服公告提示词": "§a❀ §r§e{player}§r 创建了公会 §e{guild}§r!", + "创建公会名称已存在提示词": "§c❀ §r该公会名已存在", + "菜单回复超时提示词": "§c❀ §r回复超时!已退出公会系统", + "无效指令提示词": "§c❀ §r无效的指令", + "通用分页为空提示词": "§c❀ §r{title}为空", + "通用分页超时提示词": "§c❀ §r操作超时", + "通用分页退出提示词": "§a❀ §r已退出", + "通用分页无效选择提示词": "§c❀ §r无效的选择", + "公会列表为空提示词": "§c❀ §r公会列表为空", + "公会列表分页超时提示词": "§c❀ §r操作超时", + "公会列表分页退出提示词": "§a❀ §r已退出" + }, + "功能列表配置": { + "菜单标题": "公会管理系统", + "游客身份显示": "[§7游客§f]", + "成员身份显示模板": "[{rank}§f] §e{guild}", + "输入数字提示模板": "§a❀ §r输入 §e[1-{count}]§r 之间的数字选择功能", + "输入名称提示词": "§a❀ §r也可输入功能名称,输入 §cq §r退出", + "基础功能": { + "创建": { + "名称": "创建", + "描述": "创建自己的公会" + }, + "列表": { + "名称": "列表", + "描述": "查看所有公会" + }, + "查看": { + "名称": "查看", + "描述": "查看公会详情" + }, + "成员": { + "名称": "成员", + "描述": "查看成员列表" + }, + "日志": { + "名称": "日志", + "描述": "查看公会日志" + }, + "公告": { + "名称": "公告", + "描述": "查看/设置公告" + }, + "加入": { + "名称": "加入", + "描述": "加入一个公会" + }, + "退出": { + "名称": "退出", + "描述": "退出当前公会" + }, + "管理": { + "名称": "管理", + "描述": "管理公会成员" + }, + "解散": { + "名称": "解散", + "描述": "解散公会" + } + }, + "可选功能": { + "仓库": { + "名称": "仓库", + "描述": "公会仓库" + }, + "据点": { + "名称": "据点", + "描述": "据点相关操作" + }, + "捐献": { + "名称": "捐献", + "描述": "捐献物品到公会" + }, + "任务": { + "名称": "任务", + "描述": "公会任务系统" + }, + "效果": { + "名称": "效果", + "描述": "通过钻石获得效果增益" + }, + "排行": { + "名称": "排行", + "描述": "查看公会排行榜" + } + } + }, + "经济系统": { + "默认物品价值": { + "minecraft:diamond": 50, + "minecraft:emerald": 25, + "minecraft:gold_ingot": 10, + "minecraft:iron_ingot": 5, + "minecraft:copper_ingot": 2, + "minecraft:coal": 1, + "minecraft:redstone": 2, + "minecraft:lapis_lazuli": 3, + "minecraft:quartz": 2, + "minecraft:netherite_ingot": 200, + "minecraft:ancient_debris": 150, + "minecraft:ender_pearl": 20, + "minecraft:blaze_rod": 15, + "minecraft:ghast_tear": 25, + "minecraft:shulker_shell": 100, + "minecraft:nautilus_shell": 30, + "minecraft:heart_of_the_sea": 150, + "minecraft:stone": 1, + "minecraft:cobblestone": 1, + "minecraft:dirt": 1, + "minecraft:sand": 1, + "minecraft:gravel": 1, + "minecraft:obsidian": 10, + "minecraft:crying_obsidian": 15, + "minecraft:netherrack": 1, + "minecraft:end_stone": 5, + "minecraft:oak_log": 2, + "minecraft:birch_log": 2, + "minecraft:spruce_log": 2, + "minecraft:jungle_log": 2, + "minecraft:acacia_log": 2, + "minecraft:dark_oak_log": 2, + "minecraft:oak_planks": 1, + "minecraft:birch_planks": 1, + "minecraft:spruce_planks": 1, + "minecraft:jungle_planks": 1, + "minecraft:acacia_planks": 1, + "minecraft:dark_oak_planks": 1, + "minecraft:apple": 2, + "minecraft:golden_apple": 20, + "minecraft:enchanted_golden_apple": 100, + "minecraft:bread": 3, + "minecraft:beef": 3, + "minecraft:cooked_beef": 5, + "minecraft:porkchop": 3, + "minecraft:cooked_porkchop": 5, + "minecraft:chicken": 2, + "minecraft:cooked_chicken": 4, + "minecraft:cod": 2, + "minecraft:cooked_cod": 4, + "minecraft:salmon": 3, + "minecraft:cooked_salmon": 5, + "minecraft:carrot": 1, + "minecraft:golden_carrot": 8, + "minecraft:potato": 1, + "minecraft:baked_potato": 2, + "minecraft:diamond_block": 450, + "minecraft:emerald_block": 225, + "minecraft:gold_block": 90, + "minecraft:iron_block": 45, + "minecraft:copper_block": 18, + "minecraft:coal_block": 9, + "minecraft:redstone_block": 18, + "minecraft:lapis_block": 27, + "minecraft:quartz_block": 18, + "minecraft:netherite_block": 1800, + "minecraft:string": 1, + "minecraft:leather": 3, + "minecraft:feather": 2, + "minecraft:gunpowder": 5, + "minecraft:bone": 2, + "minecraft:bone_meal": 1, + "minecraft:spider_eye": 3, + "minecraft:slime_ball": 8, + "minecraft:magma_cream": 10, + "minecraft:wheat": 1, + "minecraft:wheat_seeds": 1, + "minecraft:sugar_cane": 2, + "minecraft:bamboo": 1, + "minecraft:kelp": 1, + "minecraft:cactus": 2 + }, + "中文物品名称": { + "钻石": "minecraft:diamond", + "绿宝石": "minecraft:emerald", + "金锭": "minecraft:gold_ingot", + "铁锭": "minecraft:iron_ingot", + "铜锭": "minecraft:copper_ingot", + "煤炭": "minecraft:coal", + "木炭": "minecraft:charcoal", + "红石": "minecraft:redstone", + "青金石": "minecraft:lapis_lazuli", + "石英": "minecraft:quartz", + "下界合金锭": "minecraft:netherite_ingot", + "远古残骸": "minecraft:ancient_debris", + "下界石英": "minecraft:nether_quartz", + "紫水晶碎片": "minecraft:amethyst_shard", + "紫水晶": "minecraft:amethyst_shard", + "末影珍珠": "minecraft:ender_pearl", + "烈焰棒": "minecraft:blaze_rod", + "恶魂之泪": "minecraft:ghast_tear", + "潜影贝壳": "minecraft:shulker_shell", + "鹦鹉螺壳": "minecraft:nautilus_shell", + "海洋之心": "minecraft:heart_of_the_sea", + "圆石": "minecraft:cobblestone", + "石头": "minecraft:stone", + "花岗岩": "minecraft:granite", + "闪长岩": "minecraft:diorite", + "安山岩": "minecraft:andesite", + "深板岩": "minecraft:deepslate", + "黑石": "minecraft:blackstone", + "玄武岩": "minecraft:basalt", + "末地石": "minecraft:end_stone", + "下界岩": "minecraft:netherrack", + "灵魂沙": "minecraft:soul_sand", + "灵魂土": "minecraft:soul_soil", + "橡木原木": "minecraft:oak_log", + "白桦原木": "minecraft:birch_log", + "云杉原木": "minecraft:spruce_log", + "丛林原木": "minecraft:jungle_log", + "金合欢原木": "minecraft:acacia_log", + "深色橡木原木": "minecraft:dark_oak_log", + "绯红菌柄": "minecraft:crimson_stem", + "诡异菌柄": "minecraft:warped_stem", + "橡木木板": "minecraft:oak_planks", + "白桦木板": "minecraft:birch_planks", + "云杉木板": "minecraft:spruce_planks", + "丛林木板": "minecraft:jungle_planks", + "金合欢木板": "minecraft:acacia_planks", + "深色橡木木板": "minecraft:dark_oak_planks", + "苹果": "minecraft:apple", + "金苹果": "minecraft:golden_apple", + "附魔金苹果": "minecraft:enchanted_golden_apple", + "面包": "minecraft:bread", + "牛肉": "minecraft:beef", + "熟牛肉": "minecraft:cooked_beef", + "猪肉": "minecraft:porkchop", + "熟猪肉": "minecraft:cooked_porkchop", + "鸡肉": "minecraft:chicken", + "熟鸡肉": "minecraft:cooked_chicken", + "羊肉": "minecraft:mutton", + "熟羊肉": "minecraft:cooked_mutton", + "鱼": "minecraft:cod", + "熟鱼": "minecraft:cooked_cod", + "鲑鱼": "minecraft:salmon", + "熟鲑鱼": "minecraft:cooked_salmon", + "胡萝卜": "minecraft:carrot", + "金胡萝卜": "minecraft:golden_carrot", + "土豆": "minecraft:potato", + "烤土豆": "minecraft:baked_potato", + "甜菜根": "minecraft:beetroot", + "甜菜汤": "minecraft:beetroot_soup", + "蘑菇煲": "minecraft:mushroom_stew", + "兔肉煲": "minecraft:rabbit_stew", + "木棍": "minecraft:stick", + "线": "minecraft:string", + "皮革": "minecraft:leather", + "羽毛": "minecraft:feather", + "火药": "minecraft:gunpowder", + "骨头": "minecraft:bone", + "骨粉": "minecraft:bone_meal", + "蜘蛛眼": "minecraft:spider_eye", + "腐肉": "minecraft:rotten_flesh", + "史莱姆球": "minecraft:slime_ball", + "岩浆膏": "minecraft:magma_cream", + "小麦": "minecraft:wheat", + "小麦种子": "minecraft:wheat_seeds", + "南瓜": "minecraft:pumpkin", + "南瓜种子": "minecraft:pumpkin_seeds", + "西瓜": "minecraft:melon", + "西瓜种子": "minecraft:melon_seeds", + "甘蔗": "minecraft:sugar_cane", + "竹子": "minecraft:bamboo", + "海带": "minecraft:kelp", + "仙人掌": "minecraft:cactus", + "墨囊": "minecraft:ink_sac", + "玫瑰红": "minecraft:red_dye", + "橙色染料": "minecraft:orange_dye", + "黄色染料": "minecraft:yellow_dye", + "黄绿色染料": "minecraft:lime_dye", + "绿色染料": "minecraft:green_dye", + "青色染料": "minecraft:cyan_dye", + "淡蓝色染料": "minecraft:light_blue_dye", + "蓝色染料": "minecraft:blue_dye", + "紫色染料": "minecraft:purple_dye", + "品红色染料": "minecraft:magenta_dye", + "粉红色染料": "minecraft:pink_dye", + "白色染料": "minecraft:white_dye", + "淡灰色染料": "minecraft:light_gray_dye", + "灰色染料": "minecraft:gray_dye", + "黑色染料": "minecraft:black_dye", + "棕色染料": "minecraft:brown_dye", + "泥土": "minecraft:dirt", + "草方块": "minecraft:grass_block", + "沙子": "minecraft:sand", + "红沙": "minecraft:red_sand", + "砂砾": "minecraft:gravel", + "粘土": "minecraft:clay", + "雪球": "minecraft:snowball", + "冰": "minecraft:ice", + "浮冰": "minecraft:packed_ice", + "蓝冰": "minecraft:blue_ice", + "黑曜石": "minecraft:obsidian", + "哭泣的黑曜石": "minecraft:crying_obsidian", + "基岩": "minecraft:bedrock", + "海绵": "minecraft:sponge", + "湿海绵": "minecraft:wet_sponge", + "钻石块": "minecraft:diamond_block", + "绿宝石块": "minecraft:emerald_block", + "金块": "minecraft:gold_block", + "铁块": "minecraft:iron_block", + "铜块": "minecraft:copper_block", + "煤炭块": "minecraft:coal_block", + "红石块": "minecraft:redstone_block", + "青金石块": "minecraft:lapis_block", + "石英块": "minecraft:quartz_block", + "下界合金块": "minecraft:netherite_block" + }, + "物品别名": { + "钻": "钻石", + "diamond": "钻石", + "钻石锭": "钻石", + "绿宝": "绿宝石", + "emerald": "绿宝石", + "村民币": "绿宝石", + "翡翠": "绿宝石", + "金": "金锭", + "gold": "金锭", + "黄金": "金锭", + "金子": "金锭", + "铁": "铁锭", + "iron": "铁锭", + "铜": "铜锭", + "copper": "铜锭", + "煤": "煤炭", + "coal": "煤炭", + "煤块": "煤炭", + "红石粉": "红石", + "redstone": "红石", + "青金": "青金石", + "lapis": "青金石", + "蓝宝石": "青金石", + "石英晶体": "石英", + "quartz": "石英", + "下界合金": "下界合金锭", + "netherite": "下界合金锭", + "合金": "下界合金锭", + "下合金": "下界合金锭", + "残骸": "远古残骸", + "ancient_debris": "远古残骸", + "远古": "远古残骸", + "橡木": "橡木原木", + "oak": "橡木原木", + "白桦": "白桦原木", + "birch": "白桦原木", + "云杉": "云杉原木", + "spruce": "云杉原木", + "丛林": "丛林原木", + "jungle": "丛林原木", + "金合欢": "金合欢原木", + "acacia": "金合欢原木", + "深色橡木": "深色橡木原木", + "dark_oak": "深色橡木原木", + "苹果": "苹果", + "apple": "苹果", + "金苹": "金苹果", + "golden_apple": "金苹果", + "附魔苹果": "附魔金苹果", + "神苹": "附魔金苹果", + "notch苹果": "附魔金苹果", + "石": "石头", + "stone": "石头", + "泥": "泥土", + "dirt": "泥土", + "土": "泥土", + "沙": "沙子", + "sand": "沙子", + "末影珠": "末影珍珠", + "ender_pearl": "末影珍珠", + "传送珠": "末影珍珠", + "烈焰": "烈焰棒", + "blaze_rod": "烈焰棒", + "火棒": "烈焰棒", + "恶魂泪": "恶魂之泪", + "ghast_tear": "恶魂之泪", + "鬼泪": "恶魂之泪" + } + } +}''' +DEFAULT_CONFIG: dict[str, Any] = json.loads(DEFAULT_CONFIG_JSON) + +LEVEL_EXP_CONFIG_KEY = "公会各等级升级所需经验" + +CONFIG_ATTRIBUTE_ALIASES = { + "GUILD_LEVEL_EXP": LEVEL_EXP_CONFIG_KEY, + "GUILD_MENU_TRIGGER": "公会菜单唤醒词", + "GUILD_CREATION_COST": "创建公会消耗积分", + "MAX_GUILD_MEMBERS": "公会最大成员数", + "GUILD_SCOREBOARD": "积分计分板名称", + "GUILD_FUNCTION_VAULT": "公会仓库", + "GUILD_FUNCTION_BASE": "公会据点", + "GUILD_FUNCTION_DONATION": "公会捐献", + "GUILD_FUNCTION_TASKS": "公会任务", + "GUILD_FUNCTION_EFFECT": "公会效果", + "GUILD_FUNCTION_RANKINGS": "公会排行", + "EXP_PER_ONLINE_MEMBER": "在线成员每次增加经验", + "EXP_UPDATE_INTERVAL": "在线经验增加间隔秒", + "DAILY_LOGIN_EXP": "每日登录经验", + "DONATION_EXP_RATE": "捐献转经验倍率", + "ITEMS_PER_PAGE": "每页显示数量", + "VAULT_INITIAL_SLOTS": "初始容量", + "VAULT_SLOTS_PER_LEVEL": "每级增加容量", + "VAULT_TRADE_TAX": "交易税率", + "DIMENSION_NAMES": "维度名称映射", + "CACHE_DURATION": "缓存有效时间秒", + "EFFECT_REFRESH_INTERVAL": "刷新间隔秒", + "EFFECTS_CONFIG": "效果列表", + "BATCH_SAVE_INTERVAL": "批量保存间隔秒", + "MAX_BATCH_SIZE": "批量保存最大数量", + "PERMISSIONS": "职位权限配置", + "GUILD_CREATE_CONFIG": "创建配置", + "GUILD_JOIN_REQUEST_CONFIG": "申请配置", + "GUILD_VAULT_CONFIG": "市场配置", + "GUILD_TASK_CONFIG": "任务系统", + "GUILD_DATA_SAFETY_CONFIG": "数据安全", + "PROMPT_CONFIG": "提示词配置", + "MENU_CONFIG": "功能列表配置", + "DEFAULT_ITEM_VALUES": "默认物品价值", + "CHINESE_ITEM_NAMES": "中文物品名称", + "ITEM_ALIASES": "物品别名", +} + +CONFIG_ATTRIBUTE_KEYS = { + config_key: attr_name for attr_name, + config_key in CONFIG_ATTRIBUTE_ALIASES.items()} + +REMOVED_CONFIG_ATTRIBUTES = ("GUILD_LEVELS",) + +CONFIG_VERSION_KEY = "配置版本" +GROUPED_CONFIG_VERSION = "0.1.7" +CONFIG_FILE_DIR = "插件配置文件" +RUNTIME_CONFIG_RELOAD_INTERVAL = 5 +DYNAMIC_LOAD_SETTINGS_KEY = "动态载入设置" +DYNAMIC_LOAD_ENABLED_KEY = "是否启用动态载入配置文件(仅用于本插件)" +DYNAMIC_LOAD_INTERVAL_KEY = "动态载入检测时间间隔(单位:秒)" +DYNAMIC_LOAD_DEFAULT = { + DYNAMIC_LOAD_ENABLED_KEY: True, + DYNAMIC_LOAD_INTERVAL_KEY: RUNTIME_CONFIG_RELOAD_INTERVAL, +} + + +def _merge_config(raw: Any, default: Any) -> Any: + if isinstance(default, dict): + merged = { + key: _merge_config(raw.get(key) if isinstance(raw, dict) else None, value) + for key, value in default.items() + } + + if isinstance(raw, dict): + for key, value in raw.items(): + if key not in merged: + merged[key] = copy.deepcopy(value) + + return merged + + return copy.deepcopy(raw) if raw is not None else copy.deepcopy(default) + + +def _normalize_bool(value: Any, fallback: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in ("true", "1", "yes", "y", "on", "启用", "是", "真"): + return True + if text in ("false", "0", "no", "n", "off", "禁用", "否", "假"): + return False + if isinstance(value, (int, float)) and not isinstance(value, bool): + return bool(value) + return bool(fallback) + + +def _normalize_str( + value: Any, + fallback: str, + *, + allow_empty: bool = False) -> str: + if value is None: + return fallback + text = str(value).strip() + if text or allow_empty: + return text + return fallback + + +def _normalize_number(value: Any, fallback: int | float) -> int | float: + if isinstance(value, bool): + return fallback + if isinstance(value, (int, float)): + return value + try: + number = float(str(value).strip()) + except (TypeError, ValueError): + return fallback + if number.is_integer(): + return int(number) + return number + + +def _normalize_positive_int(value: Any, fallback: int) -> int: + if isinstance(value, bool): + return fallback + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + +def _normalize_non_negative_int(value: Any, fallback: int) -> int: + if isinstance(value, bool): + return fallback + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result >= 0 else fallback + + +def _normalize_string_list( + value: Any, + fallback: list[str], + *, + allow_empty: bool = False) -> list[str]: + if isinstance(value, str): + candidates = [value] + elif isinstance(value, list): + candidates = value + else: + return copy.deepcopy(fallback) + + result: list[str] = [] + for item in candidates: + if not isinstance(item, str): + continue + text = item.strip() + if text and text not in result: + result.append(text) + if result or allow_empty: + return result + return copy.deepcopy(fallback) + + +def _normalize_any_key_dict( + raw: Any, fallback: dict[str, Any], value_normalizer) -> dict[str, Any]: + source = raw if isinstance(raw, dict) else fallback + default_values = fallback if isinstance(fallback, dict) else {} + result: dict[str, Any] = {} + for key, value in source.items(): + text_key = str(key) + fallback_value = default_values.get(text_key) + if fallback_value is None and default_values: + fallback_value = next(iter(default_values.values())) + result[text_key] = value_normalizer(value, fallback_value) + return result + + +def _normalize_menu_items( + raw: Any, fallback: dict[str, Any]) -> dict[str, Any]: + source = _merge_config(raw, fallback) + result: dict[str, Any] = {} + for key, item_fallback in fallback.items(): + item = source.get(key) if isinstance(source, dict) else None + if not isinstance(item, dict): + item = {} + result[key] = { + "名称": _normalize_str( + item.get("名称"), + item_fallback["名称"]), + "描述": _normalize_str( + item.get("描述"), + item_fallback["描述"], + allow_empty=True), + } + return result + + +def _trim_fixed_keys(raw: dict[str, Any], + default: dict[str, Any]) -> dict[str, Any]: + return { + key: raw.get(key, copy.deepcopy(value)) + for key, value in default.items() + } + + +def _normalize_market_config( + raw: Any, fallback: dict[str, Any]) -> dict[str, Any]: + raw = raw if isinstance(raw, dict) else {} + result = copy.deepcopy(fallback) + bool_keys = ( + "启用交易日志", + "启用撤回出售", + "只允许撤回自己的物品", + "撤回后返还物品", + "允许购买自己出售的物品", + ) + positive_int_keys = ( + "交易日志保留数量", + "单个成员最大上架数量", + "单次出售最大数量", + "单笔价格下限", + "单笔价格上限", + ) + number_keys = ( + "建议价最小倍率", + "建议价最大倍率", + "高价交易审计倍率", + ) + for key in bool_keys: + result[key] = _normalize_bool(raw.get(key, result[key]), result[key]) + for key in positive_int_keys: + result[key] = _normalize_positive_int( + raw.get(key, result[key]), result[key]) + for key in number_keys: + result[key] = _normalize_number(raw.get(key, result[key]), result[key]) + return result + + +def _normalize_effects_config( + raw: Any, fallback: dict[str, Any]) -> dict[str, Any]: + source = raw if isinstance(raw, dict) else fallback + default_effect = next(iter(fallback.values())) if fallback else {} + result: dict[str, Any] = {} + for effect_id, effect_cfg in source.items(): + if not isinstance(effect_cfg, dict): + effect_cfg = {} + default = fallback.get(str(effect_id), default_effect) + result[str(effect_id)] = { + "name": _normalize_str(effect_cfg.get("name"), default.get("name", str(effect_id))), + "levels": _normalize_any_key_dict( + effect_cfg.get("levels"), + default.get("levels", {}), + lambda value, fallback_value: _normalize_non_negative_int( + value, + int(fallback_value or 0), + ), + ), + "costs": _normalize_any_key_dict( + effect_cfg.get("costs"), + default.get("costs", {}), + lambda value, fallback_value: _normalize_non_negative_int( + value, + int(fallback_value or 0), + ), + ), + } + return result + + +def _normalize_permissions( + raw: Any, fallback: dict[str, Any]) -> dict[str, Any]: + source = raw if isinstance(raw, dict) else fallback + default_role = next(iter(fallback.values())) if fallback else {} + result: dict[str, dict[str, bool]] = {} + for role, permissions in source.items(): + if not isinstance(permissions, dict): + permissions = {} + default_permissions = fallback.get(str(role), default_role) + merged_permissions = _merge_config(permissions, default_permissions) + result[str(role)] = { + str(key): _normalize_bool(value, bool(default_permissions.get(key, False))) + for key, value in merged_permissions.items() + } + return result + + +def _normalize_task_templates( + raw: Any, fallback: list[dict[str, Any]]) -> list[dict[str, Any]]: + source = raw if isinstance(raw, list) else fallback + default_template = fallback[0] if fallback else {} + result: list[dict[str, Any]] = [] + for template in source: + if not isinstance(template, dict): + continue + default = _merge_config(template, default_template) + result.append( + { + "name": _normalize_str( + template.get("name"), + default["name"]), + "description": _normalize_str( + template.get("description"), + default["description"]), + "task_type": _normalize_str( + template.get("task_type"), + default["task_type"]), + "target": _normalize_str( + template.get("target"), + default["target"]), + "target_count": _normalize_positive_int( + template.get("target_count"), + default["target_count"], + ), + "reward_exp": _normalize_non_negative_int( + template.get("reward_exp"), + default["reward_exp"], + ), + "reward_contribution": _normalize_non_negative_int( + template.get("reward_contribution"), + default["reward_contribution"], + ), + }) + return result or copy.deepcopy(fallback) + + +def _normalize_grouped_config(raw: dict[str, Any]) -> dict[str, Any]: + default = DEFAULT_CONFIG + config = _merge_config(raw, default) + config = {key: copy.deepcopy(config[key]) for key in default} + config[CONFIG_VERSION_KEY] = GROUPED_CONFIG_VERSION + + config[DYNAMIC_LOAD_SETTINGS_KEY] = _trim_fixed_keys( + config[DYNAMIC_LOAD_SETTINGS_KEY], + default[DYNAMIC_LOAD_SETTINGS_KEY], + ) + dynamic = config[DYNAMIC_LOAD_SETTINGS_KEY] + dynamic_default = default[DYNAMIC_LOAD_SETTINGS_KEY] + dynamic[DYNAMIC_LOAD_ENABLED_KEY] = _normalize_bool( + dynamic.get(DYNAMIC_LOAD_ENABLED_KEY), + dynamic_default[DYNAMIC_LOAD_ENABLED_KEY], + ) + dynamic[DYNAMIC_LOAD_INTERVAL_KEY] = _normalize_positive_int( + dynamic.get(DYNAMIC_LOAD_INTERVAL_KEY), + dynamic_default[DYNAMIC_LOAD_INTERVAL_KEY], + ) + + config["基础配置"] = _trim_fixed_keys(config["基础配置"], default["基础配置"]) + base = config["基础配置"] + base_default = default["基础配置"] + base["是否启用插件"] = _normalize_bool( + base.get("是否启用插件"), base_default["是否启用插件"]) + base["公会菜单唤醒词"] = _normalize_string_list( + base.get("公会菜单唤醒词"), + base_default["公会菜单唤醒词"], + ) + base["积分计分板名称"] = _normalize_str( + base.get("积分计分板名称"), + base_default["积分计分板名称"]) + for key in ("缓存有效时间秒", "批量保存间隔秒", "批量保存最大数量"): + base[key] = _normalize_positive_int(base.get(key), base_default[key]) + + config["公会配置"] = _trim_fixed_keys(config["公会配置"], default["公会配置"]) + guild = config["公会配置"] + guild_default = default["公会配置"] + for key in ( + "公会最大成员数", + "在线经验增加间隔秒", + "每页显示数量", + ): + guild[key] = _normalize_positive_int( + guild.get(key), guild_default[key]) + for key in ("在线成员每次增加经验", "每日登录经验"): + guild[key] = _normalize_non_negative_int( + guild.get(key), guild_default[key]) + guild["捐献转经验倍率"] = _normalize_number( + guild.get("捐献转经验倍率"), + guild_default["捐献转经验倍率"], + ) + guild[LEVEL_EXP_CONFIG_KEY] = _normalize_any_key_dict( + guild.get(LEVEL_EXP_CONFIG_KEY), + guild_default[LEVEL_EXP_CONFIG_KEY], + lambda value, fallback_value: _normalize_positive_int( + value, int(fallback_value or 1)), + ) + + config["功能开关"] = _trim_fixed_keys(config["功能开关"], default["功能开关"]) + feature_switches = config["功能开关"] + for key, fallback in default["功能开关"].items(): + feature_switches[key] = _normalize_bool( + feature_switches.get(key), fallback) + + config["仓库配置"] = _trim_fixed_keys(config["仓库配置"], default["仓库配置"]) + vault = config["仓库配置"] + vault_default = default["仓库配置"] + vault["初始容量"] = _normalize_positive_int( + vault.get("初始容量"), vault_default["初始容量"]) + vault["每级增加容量"] = _normalize_non_negative_int( + vault.get("每级增加容量"), + vault_default["每级增加容量"], + ) + vault["交易税率"] = _normalize_number(vault.get("交易税率"), vault_default["交易税率"]) + vault["市场配置"] = _normalize_market_config( + vault.get("市场配置"), vault_default["市场配置"]) + + config["经验配置"] = _trim_fixed_keys(config["经验配置"], default["经验配置"]) + config["经验配置"]["在线经验"] = _trim_fixed_keys( + config["经验配置"]["在线经验"], + default["经验配置"]["在线经验"], + ) + config["经验配置"]["登录经验"] = _trim_fixed_keys( + config["经验配置"]["登录经验"], + default["经验配置"]["登录经验"], + ) + config["经验配置"]["捐献经验"] = _trim_fixed_keys( + config["经验配置"]["捐献经验"], + default["经验配置"]["捐献经验"], + ) + exp = config["经验配置"] + exp_default = default["经验配置"] + exp["在线经验"]["每次增加经验"] = _normalize_non_negative_int( + exp["在线经验"].get("每次增加经验"), + exp_default["在线经验"]["每次增加经验"], + ) + exp["在线经验"]["增加间隔秒"] = _normalize_positive_int( + exp["在线经验"].get("增加间隔秒"), + exp_default["在线经验"]["增加间隔秒"], + ) + exp["登录经验"]["每日登录经验"] = _normalize_non_negative_int( + exp["登录经验"].get("每日登录经验"), + exp_default["登录经验"]["每日登录经验"], + ) + exp["捐献经验"]["转经验倍率"] = _normalize_number( + exp["捐献经验"].get("转经验倍率"), + exp_default["捐献经验"]["转经验倍率"], + ) + + config["据点配置"] = _trim_fixed_keys(config["据点配置"], default["据点配置"]) + config["据点配置"]["维度名称映射"] = _normalize_any_key_dict( + config["据点配置"].get("维度名称映射"), + default["据点配置"]["维度名称映射"], + lambda value, + fallback_value: _normalize_str(value, str(fallback_value or "")),) + + config["效果系统"] = _trim_fixed_keys(config["效果系统"], default["效果系统"]) + effects = config["效果系统"] + effects_default = default["效果系统"] + effects["刷新间隔秒"] = _normalize_positive_int( + effects.get("刷新间隔秒"), + effects_default["刷新间隔秒"], + ) + effects["效果列表"] = _normalize_effects_config( + effects.get("效果列表"), + effects_default["效果列表"], + ) + + config["创建配置"] = _trim_fixed_keys(config["创建配置"], default["创建配置"]) + create_cfg = config["创建配置"] + create_default = default["创建配置"] + create_cfg["创建公会消耗积分"] = _normalize_non_negative_int( + create_cfg.get("创建公会消耗积分"), + create_default["创建公会消耗积分"], + ) + for key in ("创建冷却秒", "最短公会名长度", "最长公会名长度"): + create_cfg[key] = _normalize_positive_int( + create_cfg.get(key), create_default[key]) + create_cfg["禁止公会名列表"] = _normalize_string_list( + create_cfg.get("禁止公会名列表"), + create_default["禁止公会名列表"], + allow_empty=True, + ) + create_cfg["禁止公会名包含词"] = _normalize_string_list( + create_cfg.get("禁止公会名包含词"), + create_default["禁止公会名包含词"], + allow_empty=True, + ) + create_cfg["启用同名模糊检测"] = _normalize_bool( + create_cfg.get("启用同名模糊检测"), + create_default["启用同名模糊检测"], + ) + create_cfg["模糊检测最小相似度"] = _normalize_number( + create_cfg.get("模糊检测最小相似度"), + create_default["模糊检测最小相似度"], + ) + create_cfg["创建后全服公告"] = _normalize_bool( + create_cfg.get("创建后全服公告"), + create_default["创建后全服公告"], + ) + + config["申请配置"] = _trim_fixed_keys(config["申请配置"], default["申请配置"]) + join_cfg = config["申请配置"] + join_default = default["申请配置"] + for key, fallback in join_default.items(): + if isinstance(fallback, bool): + join_cfg[key] = _normalize_bool(join_cfg.get(key), fallback) + else: + join_cfg[key] = _normalize_positive_int( + join_cfg.get(key), fallback) + + config["任务系统"] = _trim_fixed_keys(config["任务系统"], default["任务系统"]) + task_cfg = config["任务系统"] + task_default = default["任务系统"] + task_cfg["启用自动任务模板"] = _normalize_bool( + task_cfg.get("启用自动任务模板"), + task_default["启用自动任务模板"], + ) + for key in ( + "自动任务生成间隔秒", + "每次生成自动任务数量", + "自动任务最大同时存在数量", + "自动任务默认有效期秒", + "成员每日可完成任务数量", + "创建任务名称最大长度", + "创建任务描述最大长度", + "创建任务目标数量上限", + ): + task_cfg[key] = _normalize_positive_int( + task_cfg.get(key), task_default[key]) + for key in ("创建任务贡献奖励上限", "创建任务经验奖励上限"): + task_cfg[key] = _normalize_non_negative_int( + task_cfg.get(key), task_default[key]) + task_cfg["自动任务模板列表"] = _normalize_task_templates( + task_cfg.get("自动任务模板列表"), + task_default["自动任务模板列表"], + ) + + config["权限系统"] = _trim_fixed_keys(config["权限系统"], default["权限系统"]) + config["权限系统"]["职位权限配置"] = _normalize_permissions( + config["权限系统"].get("职位权限配置"), + default["权限系统"]["职位权限配置"], + ) + + config["数据安全"] = _trim_fixed_keys(config["数据安全"], default["数据安全"]) + safety = config["数据安全"] + safety_default = default["数据安全"] + safety["备份目录名"] = _normalize_str( + safety.get("备份目录名"), + safety_default["备份目录名"]) + for key in ( + "启用保存前备份", + "强制保存时也备份", + "启用异常数据跳过", + "启动时自动修复缺失字段", + "修复前写入备份", + ): + safety[key] = _normalize_bool(safety.get(key), safety_default[key]) + safety["最大备份数量"] = _normalize_positive_int( + safety.get("最大备份数量"), + safety_default["最大备份数量"], + ) + safety["审计日志保留数量"] = _normalize_positive_int( + safety.get("审计日志保留数量"), + safety_default["审计日志保留数量"], + ) + + prompt_config = _merge_config(config.get("提示词配置"), default["提示词配置"]) + config["提示词配置"] = _normalize_any_key_dict( + prompt_config, + prompt_config, + lambda value, fallback_value: _normalize_str( + value, + str(fallback_value or ""), + allow_empty=True, + ), + ) + + config["功能列表配置"] = _trim_fixed_keys(config["功能列表配置"], default["功能列表配置"]) + menu_config = config["功能列表配置"] + menu_default = default["功能列表配置"] + for key in ( + "菜单标题", + "游客身份显示", + "成员身份显示模板", + "输入数字提示模板", + "输入名称提示词", + ): + menu_config[key] = _normalize_str(menu_config.get( + key), menu_default[key], allow_empty=True) + menu_config["基础功能"] = _normalize_menu_items( + menu_config.get("基础功能"), + menu_default["基础功能"], + ) + menu_config["可选功能"] = _normalize_menu_items( + menu_config.get("可选功能"), + menu_default["可选功能"], + ) + + config["经济系统"] = _trim_fixed_keys(config["经济系统"], default["经济系统"]) + economy = config["经济系统"] + economy_default = default["经济系统"] + economy["默认物品价值"] = _normalize_any_key_dict( + economy.get("默认物品价值"), + economy_default["默认物品价值"], + lambda value, + fallback_value: _normalize_number( + value, + fallback_value or 1), + ) + for key in ("中文物品名称", "物品别名"): + economy[key] = _normalize_any_key_dict( + economy.get(key), + economy_default[key], + lambda value, fallback_value: _normalize_str( + value, str(fallback_value or "")), + ) + + return config + + +def grouped_config_std() -> dict[str, Any]: + """Return the ToolDelta schema for the grouped guild config.""" + number = (int, float) + string_map = cfg.AnyKeyValue(str) + number_map = cfg.AnyKeyValue(number) + int_map = cfg.AnyKeyValue(cfg.NNInt) + menu_item_std = { + "名称": str, + "描述": str, + } + effect_std = cfg.AnyKeyValue( + { + "name": str, + "levels": int_map, + "costs": int_map, + } + ) + permission_std = cfg.AnyKeyValue(cfg.AnyKeyValue(bool)) + task_template_std = { + "name": str, + "description": str, + "task_type": str, + "target": str, + "target_count": cfg.PInt, + "reward_exp": cfg.NNInt, + "reward_contribution": cfg.NNInt, + } + + return { + CONFIG_VERSION_KEY: str, + DYNAMIC_LOAD_SETTINGS_KEY: { + DYNAMIC_LOAD_ENABLED_KEY: bool, + DYNAMIC_LOAD_INTERVAL_KEY: cfg.PInt, + }, + "基础配置": { + "是否启用插件": bool, + "公会菜单唤醒词": cfg.JsonList(str, -1), + "积分计分板名称": str, + "缓存有效时间秒": cfg.PInt, + "批量保存间隔秒": cfg.PInt, + "批量保存最大数量": cfg.PInt, + }, + "功能开关": { + "公会仓库": bool, + "公会据点": bool, + "公会捐献": bool, + "公会任务": bool, + "公会效果": bool, + "公会排行": bool, + }, + "公会配置": { + "公会最大成员数": cfg.PInt, + "在线成员每次增加经验": cfg.NNInt, + "在线经验增加间隔秒": cfg.PInt, + "每日登录经验": cfg.NNInt, + "捐献转经验倍率": number, + "每页显示数量": cfg.PInt, + LEVEL_EXP_CONFIG_KEY: cfg.AnyKeyValue(cfg.PInt), + }, + "创建配置": { + "创建公会消耗积分": cfg.NNInt, + "创建冷却秒": cfg.PInt, + "最短公会名长度": cfg.PInt, + "最长公会名长度": cfg.PInt, + "禁止公会名列表": cfg.JsonList(str, -1), + "禁止公会名包含词": cfg.JsonList(str, -1), + "启用同名模糊检测": bool, + "模糊检测最小相似度": number, + "创建后全服公告": bool, + }, + "仓库配置": { + "初始容量": cfg.PInt, + "每级增加容量": cfg.NNInt, + "交易税率": number, + "市场配置": { + "启用交易日志": bool, + "交易日志保留数量": cfg.PInt, + "启用撤回出售": bool, + "只允许撤回自己的物品": bool, + "撤回后返还物品": bool, + "允许购买自己出售的物品": bool, + "单个成员最大上架数量": cfg.PInt, + "单次出售最大数量": cfg.PInt, + "单笔价格下限": cfg.PInt, + "单笔价格上限": cfg.PInt, + "建议价最小倍率": number, + "建议价最大倍率": number, + "高价交易审计倍率": number, + }, + }, + "经验配置": { + "在线经验": { + "每次增加经验": cfg.NNInt, + "增加间隔秒": cfg.PInt, + }, + "登录经验": { + "每日登录经验": cfg.NNInt, + }, + "捐献经验": { + "转经验倍率": number, + }, + }, + "据点配置": { + "维度名称映射": string_map, + }, + "效果系统": { + "刷新间隔秒": cfg.PInt, + "效果列表": effect_std, + }, + "申请配置": { + "启用离线申请队列": bool, + "申请有效期秒": cfg.PInt, + "重复申请冷却秒": cfg.PInt, + "每个玩家最多待处理申请数": cfg.PInt, + "每个公会最多待处理申请数": cfg.PInt, + "申请理由最大长度": cfg.PInt, + "满员时自动拒绝新申请": bool, + "申请提交后通知在线管理员": bool, + "批准后通知全体在线成员": bool, + "拒绝后保留记录": bool, + }, + "任务系统": { + "启用自动任务模板": bool, + "自动任务生成间隔秒": cfg.PInt, + "每次生成自动任务数量": cfg.PInt, + "自动任务最大同时存在数量": cfg.PInt, + "自动任务默认有效期秒": cfg.PInt, + "成员每日可完成任务数量": cfg.PInt, + "创建任务名称最大长度": cfg.PInt, + "创建任务描述最大长度": cfg.PInt, + "创建任务目标数量上限": cfg.PInt, + "创建任务贡献奖励上限": cfg.NNInt, + "创建任务经验奖励上限": cfg.NNInt, + "自动任务模板列表": cfg.JsonList(task_template_std, -1), + }, + "权限系统": { + "职位权限配置": permission_std, + }, + "数据安全": { + "启用保存前备份": bool, + "备份目录名": str, + "最大备份数量": cfg.PInt, + "强制保存时也备份": bool, + "启用异常数据跳过": bool, + "启动时自动修复缺失字段": bool, + "修复前写入备份": bool, + "审计日志保留数量": cfg.PInt, + }, + "提示词配置": string_map, + "功能列表配置": { + "菜单标题": str, + "游客身份显示": str, + "成员身份显示模板": str, + "输入数字提示模板": str, + "输入名称提示词": str, + "基础功能": cfg.AnyKeyValue(menu_item_std), + "可选功能": cfg.AnyKeyValue(menu_item_std), + }, + "经济系统": { + "默认物品价值": number_map, + "中文物品名称": string_map, + "物品别名": string_map, + }, + } + + +def _int_keyed_dict(raw: Any) -> Any: + if not isinstance(raw, dict): + return raw + + result = {} + for key, value in raw.items(): + try: + normalized_key = int(key) + except (TypeError, ValueError): + normalized_key = key + result[normalized_key] = value + return result + + +def _require_current_config_format(raw_config: Any) -> None: + if not isinstance(raw_config, dict): + raise ValueError("配置项必须是对象") + + +def _normalize_effect_runtime_config(raw: Any) -> Any: + effects = copy.deepcopy(raw) + if not isinstance(effects, dict): + return effects + + for effect in effects.values(): + if not isinstance(effect, dict): + continue + for key in ("levels", "costs"): + if key in effect: + effect[key] = _int_keyed_dict(effect[key]) + return effects + + +def _build_runtime_config(grouped_config: dict[str, Any]) -> dict[str, Any]: + base = grouped_config["基础配置"] + features = grouped_config["功能开关"] + guild = grouped_config["公会配置"] + create = grouped_config["创建配置"] + vault = grouped_config["仓库配置"] + exp = grouped_config["经验配置"] + base_point = grouped_config["据点配置"] + effects = grouped_config["效果系统"] + permissions = grouped_config["权限系统"] + economy = grouped_config["经济系统"] + + return { + "是否启用插件": copy.deepcopy(base["是否启用插件"]), + "公会菜单唤醒词": copy.deepcopy(base["公会菜单唤醒词"]), + "积分计分板名称": copy.deepcopy(base["积分计分板名称"]), + "缓存有效时间秒": copy.deepcopy(base["缓存有效时间秒"]), + "批量保存间隔秒": copy.deepcopy(base["批量保存间隔秒"]), + "批量保存最大数量": copy.deepcopy(base["批量保存最大数量"]), + "公会仓库": copy.deepcopy(features["公会仓库"]), + "公会据点": copy.deepcopy(features["公会据点"]), + "公会捐献": copy.deepcopy(features["公会捐献"]), + "公会任务": copy.deepcopy(features["公会任务"]), + "公会效果": copy.deepcopy(features["公会效果"]), + "公会排行": copy.deepcopy(features["公会排行"]), + "公会最大成员数": copy.deepcopy(guild["公会最大成员数"]), + "在线成员每次增加经验": copy.deepcopy(exp["在线经验"]["每次增加经验"]), + "在线经验增加间隔秒": copy.deepcopy(exp["在线经验"]["增加间隔秒"]), + "每日登录经验": copy.deepcopy(exp["登录经验"]["每日登录经验"]), + "捐献转经验倍率": copy.deepcopy(exp["捐献经验"]["转经验倍率"]), + "每页显示数量": copy.deepcopy(guild["每页显示数量"]), + LEVEL_EXP_CONFIG_KEY: _int_keyed_dict(guild[LEVEL_EXP_CONFIG_KEY]), + "创建公会消耗积分": copy.deepcopy(create["创建公会消耗积分"]), + "创建配置": copy.deepcopy(create), + "申请配置": copy.deepcopy(grouped_config["申请配置"]), + "初始容量": copy.deepcopy(vault["初始容量"]), + "每级增加容量": copy.deepcopy(vault["每级增加容量"]), + "交易税率": copy.deepcopy(vault["交易税率"]), + "市场配置": copy.deepcopy(vault["市场配置"]), + "维度名称映射": _int_keyed_dict(base_point["维度名称映射"]), + "刷新间隔秒": copy.deepcopy(effects["刷新间隔秒"]), + "效果列表": _normalize_effect_runtime_config(effects["效果列表"]), + "任务系统": copy.deepcopy(grouped_config["任务系统"]), + "职位权限配置": copy.deepcopy(permissions["职位权限配置"]), + "数据安全": copy.deepcopy(grouped_config["数据安全"]), + "提示词配置": copy.deepcopy(grouped_config["提示词配置"]), + "功能列表配置": copy.deepcopy(grouped_config["功能列表配置"]), + "默认物品价值": copy.deepcopy(economy["默认物品价值"]), + "中文物品名称": copy.deepcopy(economy["中文物品名称"]), + "物品别名": copy.deepcopy(economy["物品别名"]), + } + + +def _set_config_attributes( + config_cls: type, normalized_config: dict[str, Any]) -> None: + for attr_name in REMOVED_CONFIG_ATTRIBUTES: + if hasattr(config_cls, attr_name): + delattr(config_cls, attr_name) + + for key, value in normalized_config.items(): + attr_name = CONFIG_ATTRIBUTE_KEYS.get(key, key) + setattr(config_cls, attr_name, value) + + +class Config: + """Runtime facade exposing loaded config as Config.X attributes.""" + + _loaded = False + _config: dict[str, Any] = {} + _grouped_config: dict[str, Any] = copy.deepcopy(DEFAULT_CONFIG) + + @classmethod + def grouped_config_std(cls) -> dict[str, Any]: + return grouped_config_std() + + @classmethod + def is_dynamic_load_enabled(cls) -> bool: + settings = cls._grouped_config.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return DYNAMIC_LOAD_DEFAULT[DYNAMIC_LOAD_ENABLED_KEY] + return bool( + settings.get( + DYNAMIC_LOAD_ENABLED_KEY, + DYNAMIC_LOAD_DEFAULT[DYNAMIC_LOAD_ENABLED_KEY])) + + @classmethod + def dynamic_load_interval(cls) -> int: + settings = cls._grouped_config.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return RUNTIME_CONFIG_RELOAD_INTERVAL + return _normalize_positive_int( + settings.get( + DYNAMIC_LOAD_INTERVAL_KEY, + RUNTIME_CONFIG_RELOAD_INTERVAL), + RUNTIME_CONFIG_RELOAD_INTERVAL, + ) + + @classmethod + def load(cls, + plugin_name: str, + version: tuple[int, + int, + int]) -> dict[str, + Any]: + default_config = copy.deepcopy(DEFAULT_CONFIG) + + try: + raw_config, _ = cfg.get_plugin_config_and_version( + plugin_name, + {}, + default_config, + version, + ) + except Exception as err: + fmts.print_err( + f"{plugin_name} config load failed, using defaults: {err}") + raw_config = default_config + + try: + _require_current_config_format(raw_config) + grouped_config = _normalize_grouped_config(raw_config) + cfg.check_auto(cls.grouped_config_std(), grouped_config) + except Exception as err: + fmts.print_err( + f"{plugin_name} config validation failed, using defaults: {err}") + grouped_config = _normalize_grouped_config({}) + cfg.check_auto(cls.grouped_config_std(), grouped_config) + cfg.upgrade_plugin_config(plugin_name, grouped_config, version) + + runtime_config = _build_runtime_config(grouped_config) + _set_config_attributes(cls, runtime_config) + + cls._config = runtime_config + cls._grouped_config = grouped_config + cls._loaded = True + + return copy.deepcopy(runtime_config) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config_watcher.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config_watcher.py" new file mode 100644 index 00000000..4cebea11 --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config_watcher.py" @@ -0,0 +1,72 @@ +"""Runtime config hot-reload helpers.""" + +from __future__ import annotations + +import os +from typing import Any + +from tooldelta import fmts + +from guild_cloud_interop.config import CONFIG_FILE_DIR, Config +from guild_cloud_interop.matchers import ItemNameMatcher + + +def get_config_path(plugin_name: str) -> str: + return os.path.join(CONFIG_FILE_DIR, f"{plugin_name}.json") + + +def get_file_state(path: str) -> tuple[int, int] | None: + try: + stat = os.stat(path) + except OSError: + return None + return stat.st_mtime_ns, stat.st_size + + +def refresh_config_file_state(plugin: Any) -> None: + config_path = get_config_path(plugin.name) + plugin.set_config_file_state(get_file_state(config_path)) + + +def apply_runtime_config(plugin: Any, *, announce: bool = False) -> None: + plugin.config = Config.load(plugin.name, plugin.version) + + guild_manager = getattr(plugin, "guild_manager", None) + if guild_manager is not None: + guild_manager.cache_duration = Config.CACHE_DURATION + + plugin.item_matcher = ItemNameMatcher() + plugin.reset_effect_refresh_cache() + + sync_runtime_config_bindings = getattr( + plugin, "sync_runtime_config_bindings", None) + if callable(sync_runtime_config_bindings): + sync_runtime_config_bindings() + + if announce: + fmts.print_suc(f"{plugin.name} 配置文件已热更新") + + +def config_reload_task(plugin: Any) -> None: + config_path = get_config_path(plugin.name) + plugin.set_config_file_state(get_file_state(config_path)) + + while not plugin.should_stop_runtime_task(): + interval = Config.dynamic_load_interval() + if plugin.wait_runtime_task_or_stopped(interval): + break + + if not Config.is_dynamic_load_enabled(): + plugin.set_config_file_state(get_file_state(config_path)) + continue + + current_state = get_file_state(config_path) + if current_state == plugin.get_config_file_state(): + continue + + try: + apply_runtime_config(plugin, announce=True) + plugin.set_config_file_state(get_file_state(config_path)) + except Exception as err: + plugin.set_config_file_state(current_state) + fmts.print_err(f"{plugin.name} 配置文件热更新失败: {err}") diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" new file mode 100644 index 00000000..dbba8c1f --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" @@ -0,0 +1,425 @@ +"""Guild data persistence and cache management.""" + +import os +import shutil +import time +from typing import Dict, List, Optional, Tuple, Any, Set + + +from tooldelta import fmts +from tooldelta.utils import tempjson +from guild_cloud_interop.config import Config +from guild_cloud_interop.models import GuildData, GuildMember, GuildRank +from guild_cloud_interop.service import DataTransaction + +# FIRE 公会管理器 FIRE + + +class GuildManager: + """公会管理器,负责公会数据的增删改查""" + + def __init__(self, file_path: str): + self.file_path = file_path + self._cache: Optional[Dict[str, GuildData]] = None + self._last_load_time = 0 + self.cache_duration = Config.CACHE_DURATION + self._player_guild_cache: Dict[str, str] = {} + self._dirty_guilds: Set[str] = set() # 标记需要保存的公会 + self._last_save_time = 0 + self._batch_operations: List[callable] = [] # 批量操作队列 + + def validate_guild_data( + self, guild_data: GuildData) -> Tuple[bool, List[str]]: + """验证公会数据的完整性""" + errors = [] + + # 检查基本字段 + if not guild_data.name: + errors.append("公会名称为空 Err:event.check.data.guild_name") + + if not guild_data.guild_id: + errors.append("公会ID为空 Err:event.check.data.guild_id") + + if not guild_data.members: + errors.append("公会没有成员 Msg:event.check.data.member_length_LMIN") + + # 检查会长 + owners = [m for m in guild_data.members if m.rank == GuildRank.OWNER] + if len(owners) != 1: + errors.append( + f"公会应该有且仅有一个会长,当前有{ + len(owners)}个 Err:event.check.data.owner_length_LMAX") + + # 检查成员数量 + if len(guild_data.members) > Config.MAX_GUILD_MEMBERS: + member_count = len(guild_data.members) + errors.append( + f"成员数量超过限制:{member_count}/{Config.MAX_GUILD_MEMBERS} " + "Err:event.check.data.member_length_LMAX" + ) + + # 检查仓库物品 + if len(guild_data.vault_items) > Config.VAULT_INITIAL_SLOTS: + vault_count = len(guild_data.vault_items) + errors.append( + f"仓库物品超过容量:{vault_count}/{Config.VAULT_INITIAL_SLOTS} " + "Err:event.check.data.vault_items_LMAX" + ) + + # 检查数据类型 + if not isinstance(guild_data.exp, (int, float)) or guild_data.exp < 0: + errors.append("公会经验值无效 Err:event.check.data.exp_type_or_negative") + + if not isinstance(guild_data.level, int) or guild_data.level < 1: + errors.append("公会等级无效 Err:event.check.data.level_type_or_range") + + return len(errors) == 0, errors + + def create_transaction(self): + """创建数据事务""" + return DataTransaction(self) + + def _load_guilds(self, force_reload: bool = False) -> Dict[str, GuildData]: + """加载公会数据,带缓存""" + current_time = time.time() + if (not force_reload and self._cache is not None and + current_time - self._last_load_time < self.cache_duration): + return self._cache + + raw_data = tempjson.load_and_read( + self.file_path, need_file_exists=False, default={}) + self._cache = {} + self._player_guild_cache.clear() + + load_errors = [] + for guild_id, guild_dict in raw_data.items(): + try: + guild = GuildData.from_dict(guild_dict, outer_key=guild_id) + + # 验证数据完整性 + is_valid, errors = self.validate_guild_data(guild) + if not is_valid: + load_errors.extend( + [f"公会{guild_id}: {error}" for error in errors]) + continue + + self._cache[guild_id] = guild + # 更新玩家-公会缓存 + for member in guild.members: + self._player_guild_cache[member.name] = guild_id + except Exception as e: + load_errors.append(f"加载公会 {guild_id} 时出错: {e}") + continue + + # 记录加载错误 + if load_errors: + fmts.print_err(f"加载公会数据时发现 {len(load_errors)} 个错误") + for error in load_errors[:3]: # 只显示前3个错误 + fmts.print_err(f" - {error}") + if len(load_errors) > 3: + fmts.print_err(f" ... 还有 {len(load_errors) - 3} 个错误") + + self._last_load_time = current_time + return self._cache + + def load_guilds(self, force_reload: bool = False) -> Dict[str, GuildData]: + """Return guild data through the manager cache boundary.""" + return self._load_guilds(force_reload=force_reload) + + def rebuild_player_cache(self, guilds: Dict[str, GuildData]) -> None: + """Rebuild the player-to-guild lookup from the provided guild map.""" + self._player_guild_cache.clear() + for guild_id, guild in guilds.items(): + for member in guild.members: + self._player_guild_cache[member.name] = guild_id + + def cache_player_guild(self, player_name: str, guild_id: str) -> None: + """Record one player's guild id in the runtime cache.""" + self._player_guild_cache[player_name] = guild_id + + def get_cached_guild_id(self, player_name: str) -> Optional[str]: + """Return the cached guild id for a player, if present.""" + return self._player_guild_cache.get(player_name) + + def reset_runtime_state(self) -> None: + """Clear in-memory guild caches and pending save state.""" + self._cache = None + self._player_guild_cache.clear() + self._dirty_guilds.clear() + self._last_load_time = 0 + self._last_save_time = 0 + + def save_guilds(self, + guilds: Dict[str, + GuildData], + force: bool = False) -> bool: + """保存公会数据,支持批量保存优化""" + try: + current_time = time.time() + + # 如果不是强制保存且距离上次保存时间不足,则延迟保存 + if not force and current_time - self._last_save_time < Config.BATCH_SAVE_INTERVAL: + self._dirty_guilds.update(guilds.keys()) + return True + + # 确保数据目录存在 + data_dir = os.path.dirname(self.file_path) + if not os.path.exists(data_dir): + try: + os.makedirs(data_dir) + fmts.print_inf(f"创建公会系统数据目录: {data_dir}") + except Exception as e: + fmts.print_err(f"创建公会系统数据目录失败: {e}") + return False + + self._backup_before_save(force=force) + + raw_data = {} + for gid, guild in guilds.items(): + try: + guild_dict = guild.to_dict() + raw_data[gid] = guild_dict + except Exception as e: + fmts.print_err(f"转换公会 {gid} 数据时出错: {e}") + continue + + # 写入文件 + tempjson.write(self.file_path, raw_data) + if force: + tempjson.flush(self.file_path) + + # 更新缓存和状态 + self._cache = guilds.copy() + self._last_load_time = current_time + self._last_save_time = current_time + self._dirty_guilds.clear() + + return True + + except Exception as e: + fmts.print_err(f"保存公会数据时出错: {e}") + import traceback + fmts.print_err(traceback.format_exc()) + return False + + def _backup_before_save(self, force: bool = False) -> None: + """按配置在写入前备份当前数据文件。""" + safety_config = getattr(Config, "GUILD_DATA_SAFETY_CONFIG", {}) + if not safety_config.get("启用保存前备份", True): + return + if force and not safety_config.get("强制保存时也备份", True): + return + if not self.file_path or not os.path.exists(self.file_path): + return + + data_dir = os.path.dirname(self.file_path) + backup_dir = os.path.join( + data_dir, safety_config.get( + "备份目录名", "公会数据备份")) + os.makedirs(backup_dir, exist_ok=True) + + timestamp = time.strftime("%Y%m%d-%H%M%S") + backup_path = os.path.join(backup_dir, f"公会数据文件-{timestamp}.json") + shutil.copy2(self.file_path, backup_path) + + max_backups = int(safety_config.get("最大备份数量", 10)) + if max_backups <= 0: + return + + backups = [ + os.path.join(backup_dir, name) + for name in os.listdir(backup_dir) + if name.startswith("公会数据文件-") and name.endswith(".json") + ] + backups.sort(key=lambda path: os.path.getmtime(path), reverse=True) + for old_path in backups[max_backups:]: + try: + os.remove(old_path) + except OSError as err: + fmts.print_err(f"删除旧公会备份失败: {err}") + + def mark_guild_dirty(self, guild_id: str) -> None: + """标记公会数据已修改,需要保存""" + self._dirty_guilds.add(guild_id) + + def flush_dirty_guilds(self) -> bool: + """强制保存所有标记为脏的公会数据""" + if not self._dirty_guilds: + return True + + if self._cache is None: + self._load_guilds(force_reload=True) + if self._cache is None: + return False + return self.save_guilds(self._cache, force=True) + + def get_guild_by_player( + self, + player_name: str, + force_reload: bool = False) -> Optional[GuildData]: + """根据玩家名获取其所在公会""" + guilds = self._load_guilds(force_reload=force_reload) + guild_id = self._player_guild_cache.get(player_name) + return guilds.get(guild_id) if guild_id else None + + def get_guild_by_name(self, guild_name: str) -> Optional[GuildData]: + """根据公会名获取公会""" + guilds = self._load_guilds() + for guild in guilds.values(): + if guild.name == guild_name: + return guild + return None + + def create_guild( + self, + owner_xuid: str, + owner_name: str, + guild_name: str) -> bool: + """创建公会""" + guilds = self._load_guilds(force_reload=True) + + # 检查是否已有同名公会 + if any(g.name == guild_name for g in guilds.values()): + return False + + owner_member = GuildMember( + name=owner_name, + rank=GuildRank.OWNER, + join_time=time.time() + ) + + new_guild = GuildData( + guild_id=owner_xuid, + name=guild_name, + owner=owner_name, + members=[owner_member] + ) + new_guild.add_log(f"公会 {guild_name} 成立") + + guilds[owner_xuid] = new_guild + self.save_guilds(guilds) + return True + + def add_member( + self, + guild_name: str, + player_name: str, + inviter: str = None) -> bool: + """添加成员到公会""" + guilds = self._load_guilds(force_reload=True) + guild = self.get_guild_by_name(guild_name) + + if not guild or len(guild.members) >= Config.MAX_GUILD_MEMBERS: + return False + + new_member = GuildMember( + name=player_name, + rank=GuildRank.MEMBER, + join_time=time.time() + ) + + guild.members.append(new_member) + guild.add_log(f"{player_name} 加入公会" + + (f" (邀请人: {inviter})" if inviter else "")) + + # 更新缓存 + self._player_guild_cache[player_name] = guild.guild_id + self.save_guilds(guilds) + return True + + def remove_member(self, player_name: str) -> Optional[str]: + """从公会移除成员,返回公会名""" + guilds = self._load_guilds(force_reload=True) + guild = self.get_guild_by_player(player_name) + + if not guild: + return None + + # 检查是否是会长 + member = guild.get_member(player_name) + if member and member.rank == GuildRank.OWNER: + return None # 会长不能退出公会,只能解散 + + guild.members = [m for m in guild.members if m.name != player_name] + guild.add_log(f"{player_name} 退出公会") + + # 更新缓存 + if player_name in self._player_guild_cache: + del self._player_guild_cache[player_name] + self.save_guilds(guilds) + return guild.name + + def set_member_rank( + self, + guild: GuildData, + player_name: str, + new_rank: GuildRank) -> bool: + """设置成员职位""" + guilds = self._load_guilds(force_reload=True) + current_guild = guilds.get(guild.guild_id) + if not current_guild: + return False + + member = current_guild.get_member(player_name) + + if not member or member.rank == GuildRank.OWNER: + return False + + old_rank = member.rank + member.rank = new_rank + current_guild.add_log( + f"{player_name} 职位变更: {old_rank.display_name} -> {new_rank.display_name}") + + self.save_guilds(guilds, force=True) + return True + + def update_contribution(self, player_name: str, amount: int) -> bool: + """更新成员贡献度""" + guild = self.get_guild_by_player(player_name) + + if not guild: + return False + + member = guild.get_member(player_name) + if member: + member.contribution += amount + guild.stats.total_contribution += amount + self.mark_guild_dirty(guild.guild_id) + return True + return False + + def batch_update_members(self, updates: List[Tuple[str, str, Any]]) -> int: + """批量更新成员信息""" + success_count = 0 + + for player_name, field_name, value in updates: + guild = self.get_guild_by_player(player_name) + if not guild: + continue + + member = guild.get_member(player_name) + if not member: + continue + + if hasattr(member, field_name): + setattr(member, field_name, value) + self.mark_guild_dirty(guild.guild_id) + success_count += 1 + + # 批量保存 + if success_count > 0: + self.flush_dirty_guilds() + + return success_count + + def update_online_status(self, online_players: List[str]) -> None: + """更新在线状态""" + guilds = self._load_guilds(force_reload=True) + current_time = time.time() + + for guild in guilds.values(): + for member in guild.members: + if member.name in online_players: + member.last_online = current_time + + self.save_guilds(guilds) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" new file mode 100644 index 00000000..ef0ba507 --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" @@ -0,0 +1,2405 @@ +"""Interactive guild menu handlers.""" + +import json +import time +import uuid +from datetime import datetime + +from tooldelta import Player, game_utils, fmts +from guild_cloud_interop.models import GuildData, GuildMember, GuildTask, GuildBase, GuildRank, VaultItem +from guild_cloud_interop.config import Config +from guild_cloud_interop.prompts import render_config_prompt, render_create_guild_prompt +from guild_cloud_interop.ui import ORION_BORDER, TITLE_PREFIX, format_page_footer +from guild_cloud_interop.validators import InputValidator + + +def _handle_effect(self, player: Player) -> bool: + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + if not guild.has_permission(player.name, "effect_buy"): + player.show("§l§a公会 §d>> §r你没有购买公会效果权限") + return True + + level = guild.level + + # 显示可选效果 + player.show("§l§a公会 §d>> §r可用效果列表:") + + for key, val in Config.EFFECTS_CONFIG.items(): + try: + # 检查配置完整性 + if "name" not in val: + fmts.print_err(f"效果配置错误: {key} 缺少 name 字段") + continue + + if "costs" not in val: + fmts.print_err(f"效果配置错误: {key} 缺少 costs 字段") + continue + + purchased_lv = guild.purchased_effects.get(key) + costs_str_list = [] + + for lv, cost in val["costs"].items(): + if purchased_lv == lv: + color = "§b" + else: + color = "§7" + costs_str_list.append(f"{color}Lv{lv}:{cost}钻§r") + + costs_str = " ".join(costs_str_list) + player.show(f"§r§f>> {val['name']} §r({costs_str})") + + except Exception as e: + fmts.print_err(f"处理效果 {key} 时出错: {e}") + continue + + player.show("§r§7>> 输入效果选择升级") + choice = game_utils.waitMsg(player.name) + + effect_key = None + for k, v in Config.EFFECTS_CONFIG.items(): + if v['name'] == choice: + effect_key = k + break + + if not effect_key: + player.show("§c无效效果") + return True + + selected = Config.EFFECTS_CONFIG[effect_key] + + # 判断钻石数量 + diamond_count = player.getItemCount("minecraft:diamond") + player.show(f"§7当前钻石数量: {diamond_count}") + + player.show("§7请输入等级 (1/2/3)") + lv_choice = game_utils.waitMsg(player.name) + if not lv_choice.isdigit() or int(lv_choice) not in selected["costs"]: + player.show("§c无效等级") + return True + + lv_choice = int(lv_choice) + cost = selected["costs"][lv_choice] + + if diamond_count < cost: + player.show(f"§c钻石不足,{cost}钻石才能购买") + return True + + # 扣除钻石 + self.game_ctrl.sendwocmd( + f"clear {player.safe_name} minecraft:diamond 0 {cost}") + + # 保存公会已购买效果 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_id = guild.guild_id + if guild_id in guilds: + guild = guilds[guild_id] + guild.purchased_effects[effect_key] = lv_choice + guild.add_log( + f"{player.name} 为公会购买了 {selected['name']} 等级{lv_choice} 效果") + self.guild_manager.save_guilds(guilds) + else: + player.show("§c保存失败,公会不存在") + return True + + # 购买后立即给在线成员补发效果,后续由低频兜底刷新,避免周期性全量刷命令。 + for member in guild.members: + if member.name in self.game_ctrl.allplayers: + self._apply_guild_effects_to_player( + member.name, + guild=guild, + force=True, + effect_names={effect_key}, + ) + + player.show(f"§a已激活效果: {selected['name']} 等级{lv_choice}") + + return True + + +def _handle_rankings(self, player: Player) -> bool: + """处理公会排行榜""" + player.show("§r========== §a公会排行榜§r ==========") + player.show("§e1. §f等级排行") + player.show("§e2. §f成员数量排行") + player.show("§e3. §f贡献度排行") + player.show("§e4. §f活跃度排行") + player.show("§7输入选项序号:") + + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "1": + rankings = self.get_guild_rankings("level") + title = "公会等级排行榜" + + def formatter(i, data): return ( + f"§e{i}. §r{data[0].name} §7Lv.{data[1]}\n" + f" §7会长: §f{data[0].owner} §7| 成员: §a{len(data[0].members)}\n" + ) + elif choice == "2": + rankings = self.get_guild_rankings("members") + title = "公会成员数排行榜" + + def formatter(i, data): return ( + f"§e{i}. §r{data[0].name} §7成员: §a{data[1]}\n" + f" §7会长: §f{data[0].owner} §7| 等级: §e{data[0].level}\n" + ) + elif choice == "3": + rankings = self.get_guild_rankings("contribution") + title = "公会贡献度排行榜" + + def formatter(i, data): return ( + f"§e{i}. §r{data[0].name} §7贡献: §b{data[1]}\n" + f" §7会长: §f{data[0].owner} §7| 等级: §e{data[0].level}\n" + ) + elif choice == "4": + rankings = self.get_guild_rankings("activity") + title = "公会活跃度排行榜" + + def formatter( + i, + data): return ( + f"§e{i}. §r{ + data[0].name}\n" f" §7会长: §f{ + data[0].owner} §7| 最近活跃: §a{ + self._format_time_ago( + data[1])}\n") + else: + player.show("§c无效选项") + return True + + if not rankings: + player.show("§l§a公会 §d>> §r暂无公会数据") + return True + + self._paginate_display(player, rankings, title, formatter) + return True + + +def _format_time_ago(self, timestamp: float) -> str: + """格式化时间差显示""" + if timestamp == 0: + return "从未" + + current_time = time.time() + diff = current_time - timestamp + + if diff < 60: + return "刚刚" + elif diff < 3600: + return f"{int(diff // 60)}分钟前" + elif diff < 86400: + return f"{int(diff // 3600)}小时前" + else: + return f"{int(diff // 86400)}天前" + + +def _handle_view_guild(self, player: Player) -> bool: + """查看公会详细信息""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + + level = guild.level + exp = guild.exp + required_exp = Config.GUILD_LEVEL_EXP.get(level + 1, "MAX") + + msg = f"§l§a{guild.name}§r §7(ID: {guild.guild_id[:8]}...)\n" + msg += f"§7创建时间: §f{ + datetime.fromtimestamp( + guild.create_time).strftime('%Y-%m-%d')}\n" + msg += f"§7会长: §e{guild.owner}\n" + msg += f"§7等级: §e{level} §7经验: §b{exp}/{required_exp}\n" + msg += f"§7成员: §a{len(guild.members)}/{Config.MAX_GUILD_MEMBERS}\n" + msg += f"§7仓库容量: §a{Config.VAULT_INITIAL_SLOTS} 格\n" + + if guild.announcement: + msg += f"\n§e公告: §f{guild.announcement}\n" + + if guild.base: + base = guild.base + dim_name = Config.DIMENSION_NAMES.get( + base.dimension, f"维度{base.dimension}") + msg += f"\n§7据点: §f{dim_name} ({ + base.x:.1f}, { + base.y:.1f}, { + base.z:.1f})" + + player.show(msg) + return True + + +def _handle_view_members(self, player: Player) -> bool: + """查看成员列表""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + + # 按职位和贡献度排序 + sorted_members = sorted( + guild.members, + key=lambda m: ( + ["owner", "deputy", "elder", "member"].index(m.rank.value), + -m.contribution + ) + ) + + def formatter(i, member: GuildMember): + online = member.name in self.game_ctrl.allplayers + online_status = "§a在线" if online else "§7离线" + days_since_join = (time.time() - member.join_time) / 86400 + + return (f"§e{i}. {member.rank.display_name} §f{member.name} " + f"[{online_status}§f]\n" + f" §7贡献: §b{member.contribution} §7| " + f"加入: §f{int(days_since_join)}天前\n") + + self._paginate_display( + player, sorted_members, f"{ + guild.name} 成员列表", formatter) + return True + + +def _handle_view_logs(self, player: Player) -> bool: + """查看公会日志""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + + msg = f"§r========== §a{guild.name} 日志§r ==========\n" + if guild.logs: + for log in guild.logs[-20:]: + msg += f"§7{log}\n" + else: + msg += "§7暂无普通日志记录\n" + + if guild.has_permission(player.name, "audit_log"): + msg += f"\n§r========== §a{guild.name} 审计日志§r ==========\n" + if guild.audit_logs: + for log in guild.audit_logs[-20:]: + time_str = datetime.fromtimestamp( + log.timestamp).strftime("%m-%d %H:%M") + target = f" §7| 目标: §f{log.target}" if log.target else "" + detail = f" §7| 详情: §f{log.detail}" if log.detail else "" + msg += ( + f"§7[{time_str}] §f{log.action} §7| 操作者: §e{log.actor}" + f"{target}{detail} §7| 结果: §f{log.result}\n" + ) + else: + msg += "§7暂无审计日志记录\n" + + player.show(msg) + return True + + +def _handle_announcement(self, player: Player) -> bool: + """处理公会公告""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + + # 显示当前公告 + if guild.announcement: + player.show(f"§l§a公会公告§r\n§f{guild.announcement}") + else: + player.show("§l§a公会 §d>> §r当前没有公告") + + # 检查权限 + if not guild.has_permission(player.name, "announce"): + return True + + player.show("§7输入 '设置' 来修改公告,其他任意键返回") + choice = game_utils.waitMsg(player.name, timeout=20) + + if choice == "设置": + player.show("§l§a公会 §d>> §r请输入新的公告内容:") + player.show("§7要求: 不超过200个字符,不能为空") + new_announcement = game_utils.waitMsg(player.name, timeout=60) + + # 使用新的输入验证 + is_valid, error_msg = InputValidator.validate_announcement( + new_announcement) + if not is_valid: + player.show(f"§l§a公会 §d>> §r{error_msg}") + return True + + try: + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_data = guilds.get(guild.guild_id) + if not guild_data: + player.show("§l§a公会 §d>> §r公会数据异常") + return True + + guild_data.announcement = new_announcement + guild_data.add_log(f"{player.name} 更新了公告") + self.guild_manager.save_guilds(guilds) + player.show("§l§a公会 §d>> §r公告已更新") + + # 通知在线成员 + message = "§l§a公会 §d>> §r公告已更新,输入 .公会 公告 查看" + for member in guild_data.members: + if member.name in self.game_ctrl.allplayers: + self.game_ctrl.sendcmd( + f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + except Exception as e: + player.show("§l§a公会 §d>> §r更新公告失败") + fmts.print_err(f"更新公告时出错:{e}") + + return True + + +def _handle_tasks(self, player: Player) -> bool: + """处理公会任务系统""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + while True: + latest_guild = self.guild_manager.get_guild_by_player( + player.name, force_reload=True) + if latest_guild: + guild = latest_guild + + player.show("§r========== §a公会任务§r ==========") + + # 显示活跃任务统计 + active_tasks = [t for t in guild.tasks if not t.completed] + completed_tasks = [t for t in guild.tasks if t.completed] + + player.show( + f"§7活跃任务: §e{ + len(active_tasks)} §7| 已完成: §a{ + len(completed_tasks)}") + can_manage_legacy = guild.has_permission(player.name, "task_manage") + can_create = guild.has_permission( + player.name, "task_create") or can_manage_legacy + can_delete = guild.has_permission( + player.name, "task_delete") or can_manage_legacy + can_complete = guild.has_permission( + player.name, "task_complete") or can_manage_legacy + can_manage = can_delete or can_complete + can_auto = ( + can_create + and getattr(Config, "GUILD_TASK_CONFIG", {}).get("启用自动任务模板", True) + ) + + menu_options = [ + ("查看", "查看所有任务", True), + ("参与", "参与任务", len(active_tasks) > 0), + ("创建", "创建新任务", can_create), + ("自动", "生成自动任务模板", can_auto), + ("管理", "管理任务", can_manage), + ("退出", "退出任务系统", True) + ] + + available_options = [(cmd, desc) + for cmd, desc, cond in menu_options if cond] + + for cmd, desc in available_options: + player.show(f"§e● {cmd} §7- {desc}") + + player.show("§7输入选项:") + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "查看": + self._handle_view_tasks(player, guild) + elif choice == "参与" and len(active_tasks) > 0: + self._handle_join_task(player, guild) + elif choice == "创建" and can_create: + self._handle_create_task(player, guild) + elif choice == "自动" and can_auto: + self._handle_generate_auto_tasks(player, guild) + elif choice == "管理" and can_manage: + self._handle_manage_tasks(player, guild) + elif choice == "退出" or choice is None: + break + else: + player.show("§c无效选项") + + return True + + +def _handle_view_tasks(self, player: Player, guild: GuildData) -> bool: + """查看任务列表""" + if not guild.tasks: + player.show("§l§a公会任务 §d>> §r暂无任务") + return True + + def formatter(i, task: GuildTask): + status = "§a已完成" if task.completed else f"§e进行中 ({ + task.current_count}/{ + task.target_count})" + progress_bar = self._create_progress_bar( + task.current_count, task.target_count) + + deadline_str = "" + if task.deadline > 0: + remaining = task.deadline - time.time() + if remaining > 0: + deadline_str = f" §7| 剩余: §f{ + self._format_time_duration(remaining)}" + else: + deadline_str = " §7| §c已过期" + + return ( + f"§e{i}. §f{task.name} [{status}§f]\n" + f" §7{task.description}\n" + f" {progress_bar}{deadline_str}\n" + f" §7奖励: §b{task.reward_contribution}贡献点 " + f"§7+ §e{task.reward_exp}经验\n" + ) + + self._paginate_display(player, guild.tasks, "公会任务列表", formatter) + return True + + +def _handle_join_task(self, player: Player, guild: GuildData) -> bool: + """参与任务""" + active_tasks = [t for t in guild.tasks if not t.completed] + + if not active_tasks: + player.show("§l§a公会任务 §d>> §r暂无可参与的任务") + return True + + def formatter(i, task: GuildTask): + status = f"§e进行中 ({task.current_count}/{task.target_count})" + is_participant = player.name in task.participants + participant_status = " §a[已参与]" if is_participant else " §7[未参与]" + + return ( + f"§e{i}. §f{ + task.name} [{status}§f]{participant_status}\n" f" §7{ + task.description}\n" f" §7奖励: §b{ + task.reward_contribution}贡献点 §7+ §e{ + task.reward_exp}经验\n") + + idx = self._paginate_display( + player, active_tasks, "选择参与任务", formatter, True) + if idx is None: + return True + + task = active_tasks[idx] + + if player.name in task.participants: + player.show("§l§a公会任务 §d>> §r你已经参与了这个任务") + return True + + # 加入任务 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild: + for t in guild.tasks: + if t.task_id == task.task_id: + t.participants.append(player.name) + guild.add_log(f"{player.name} 参与了任务: {t.name}") + break + + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会任务 §d>> §r已参与任务: {task.name}") + + return True + + +def _handle_create_task(self, player: Player, guild: GuildData) -> bool: + """创建新任务""" + if not ( + guild.has_permission( + player.name, + "task_create") or guild.has_permission( + player.name, + "task_manage")): + player.show("§l§a公会任务 §d>> §r你没有创建任务权限") + return True + + task_config = getattr(Config, "GUILD_TASK_CONFIG", {}) + max_name_len = int(task_config.get("创建任务名称最大长度", 20)) + max_desc_len = int(task_config.get("创建任务描述最大长度", 100)) + max_target_count = int(task_config.get("创建任务目标数量上限", 10000)) + max_contribution_reward = int(task_config.get("创建任务贡献奖励上限", 1000)) + max_exp_reward = int(task_config.get("创建任务经验奖励上限", 1000)) + + player.show("§r========== §a创建任务§r ==========") + player.show("§7任务类型:") + player.show("§e1. §f收集任务 (收集指定物品)") + player.show("§e2. §f建造任务 (放置指定方块)") + player.show("§e3. §f贸易任务 (进行仓库交易)") + player.show("§7输入任务类型序号:") + + task_type_choice = game_utils.waitMsg(player.name, timeout=30) + + if task_type_choice == "1": + task_type = "collect" + type_name = "收集任务" + elif task_type_choice == "2": + task_type = "build" + type_name = "建造任务" + elif task_type_choice == "3": + task_type = "trade" + type_name = "贸易任务" + else: + player.show("§c无效的任务类型") + return True + + # 获取任务名称 + player.show(f"§l§a创建{type_name} §d>> §r请输入任务名称:") + task_name = game_utils.waitMsg(player.name, timeout=30) + if not task_name or len(task_name) > max_name_len: + player.show("§c任务名称无效或过长") + return True + + # 获取任务描述 + player.show("§l§a创建任务 §d>> §r请输入任务描述:") + description = game_utils.waitMsg(player.name, timeout=60) + if not description or len(description) > max_desc_len: + player.show("§c任务描述无效或过长") + return True + + # 获取目标 + if task_type == "collect": + player.show("§l§a创建任务 §d>> §r请输入目标物品名称:") + player.show("§7支持中文名称,如: 钻石、铁锭、金块等") + player.show("§7也支持英文ID,如: minecraft:diamond") + + user_input = game_utils.waitMsg(player.name, timeout=30) + if not user_input: + player.show("§c输入为空") + return True + + # 使用智能匹配查找物品ID + item_id, suggestions = self.item_matcher.validate_and_suggest( + user_input) + + if not item_id: + player.show("§c未找到匹配的物品") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + return True + + target = item_id + chinese_name = self.item_matcher.get_chinese_name(item_id) + player.show(f"§l§a创建任务 §d>> §r目标物品: §f{chinese_name}") + + elif task_type == "build": + player.show("§l§a创建任务 §d>> §r请输入目标方块名称:") + player.show("§7支持中文名称,如: 石头、圆石、橡木等") + player.show("§7也支持英文ID,如: minecraft:stone") + + user_input = game_utils.waitMsg(player.name, timeout=30) + if not user_input: + player.show("§c输入为空") + return True + + # 使用智能匹配查找方块ID + block_id, suggestions = self.item_matcher.validate_and_suggest( + user_input) + + if not block_id: + player.show("§c未找到匹配的方块") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + return True + + target = block_id + chinese_name = self.item_matcher.get_chinese_name(block_id) + player.show(f"§l§a创建任务 §d>> §r目标方块: §f{chinese_name}") + + else: # trade + target = "trade_count" + + # 获取目标数量 + player.show("§l§a创建任务 §d>> §r请输入目标数量:") + count_str = game_utils.waitMsg(player.name, timeout=30) + if not count_str or not count_str.isdigit(): + player.show("§c无效的数量") + return True + + target_count = int(count_str) + if target_count <= 0 or target_count > max_target_count: + player.show(f"§c数量必须在1-{max_target_count}之间") + return True + + # 获取奖励 + player.show("§l§a创建任务 §d>> §r请输入贡献点奖励:") + contrib_str = game_utils.waitMsg(player.name, timeout=30) + if not contrib_str or not contrib_str.isdigit(): + player.show("§c无效的贡献点数量") + return True + + reward_contribution = int(contrib_str) + if reward_contribution < 0 or reward_contribution > max_contribution_reward: + player.show(f"§c贡献点奖励必须在0-{max_contribution_reward}之间") + return True + + player.show("§l§a创建任务 §d>> §r请输入经验奖励:") + exp_str = game_utils.waitMsg(player.name, timeout=30) + if not exp_str or not exp_str.isdigit(): + player.show("§c无效的经验数量") + return True + + reward_exp = int(exp_str) + if reward_exp < 0 or reward_exp > max_exp_reward: + player.show(f"§c经验奖励必须在0-{max_exp_reward}之间") + return True + + # 创建任务 + import uuid + task_id = uuid.uuid4().hex[:8] + new_task = GuildTask( + task_id=task_id, + name=task_name, + description=description, + task_type=task_type, + target=target, + target_count=target_count, + reward_contribution=reward_contribution, + reward_exp=reward_exp + ) + + # 保存任务 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild: + guild.tasks.append(new_task) + guild.add_log(f"{player.name} 创建了任务: {task_name}") + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会任务 §d>> §r任务 '{task_name}' 创建成功!") + + return True + + +def _handle_generate_auto_tasks( + self, + player: Player, + guild: GuildData) -> bool: + """按配置模板生成自动任务""" + if not ( + guild.has_permission( + player.name, + "task_create") or guild.has_permission( + player.name, + "task_manage")): + player.show("§l§a公会任务 §d>> §r你没有生成任务权限") + return True + + task_config = getattr(Config, "GUILD_TASK_CONFIG", {}) + if not task_config.get("启用自动任务模板", True): + player.show("§l§a公会任务 §d>> §r自动任务模板未启用") + return True + + templates = task_config.get("自动任务模板列表", []) + if not templates: + player.show("§l§a公会任务 §d>> §r暂无自动任务模板") + return True + + guilds = self.guild_manager.load_guilds(force_reload=True) + latest_guild = guilds.get(guild.guild_id) + if not latest_guild: + player.show("§l§a公会任务 §d>> §r公会数据异常") + return True + + active_auto_tasks = [ + task for task in latest_guild.tasks + if not task.completed and task.task_id.startswith("auto-") + ] + max_active = int(task_config.get("自动任务最大同时存在数量", 6)) + if max_active > 0 and len(active_auto_tasks) >= max_active: + player.show(f"§l§a公会任务 §d>> §r自动任务数量已达上限 {max_active}") + return True + + generate_count = int(task_config.get("每次生成自动任务数量", 3)) + if max_active > 0: + generate_count = min( + generate_count, + max_active - + len(active_auto_tasks)) + if generate_count <= 0: + player.show("§l§a公会任务 §d>> §r无需生成新的自动任务") + return True + + active_keys = { + (task.name, task.task_type, task.target) + for task in latest_guild.tasks + if not task.completed + } + candidates = [ + template for template in templates + if ( + template.get("name"), + template.get("task_type"), + template.get("target"), + ) not in active_keys + ] + if not candidates: + player.show("§l§a公会任务 §d>> §r所有自动任务模板都已存在") + return True + + now = time.time() + deadline_seconds = int(task_config.get("自动任务默认有效期秒", 172800)) + deadline = now + deadline_seconds if deadline_seconds > 0 else 0 + created_tasks = [] + for template in candidates[:generate_count]: + task = GuildTask( + task_id=f"auto-{uuid.uuid4().hex[: 8]} ", + name=str(template.get("name", "自动任务"))[: 20], + description=str(template.get("description", ""))[: 100], + task_type=str(template.get("task_type", "trade")), + target=str(template.get("target", "trade_count")), + target_count=max(1, int(template.get("target_count", 1))), + reward_exp=max(0, int(template.get("reward_exp", 0))), + reward_contribution=max( + 0, int(template.get("reward_contribution", 0))), + create_time=now, deadline=deadline,) + latest_guild.tasks.append(task) + created_tasks.append(task) + + latest_guild.add_log(f"{player.name} 生成了 {len(created_tasks)} 个自动任务") + latest_guild.add_audit_log( + "task_auto_generate", + player.name, + detail=",".join(task.name for task in created_tasks), + ) + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会任务 §d>> §r已生成 {len(created_tasks)} 个自动任务") + return True + + +def _handle_manage_tasks(self, player: Player, guild: GuildData) -> bool: + """管理任务""" + can_manage_legacy = guild.has_permission(player.name, "task_manage") + can_delete = guild.has_permission( + player.name, "task_delete") or can_manage_legacy + can_complete = guild.has_permission( + player.name, "task_complete") or can_manage_legacy + + if not can_delete and not can_complete: + player.show("§l§a公会任务 §d>> §r你没有管理任务权限") + return True + + player.show("§r========== §a任务管理§r ==========") + if can_delete: + player.show("§e1. §f删除任务") + if can_complete: + player.show("§e2. §f完成任务") + player.show("§7输入选项序号:") + + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "1" and can_delete: + if not guild.tasks: + player.show("§l§a公会任务 §d>> §r暂无任务") + return True + # 删除任务 + + def formatter(i, task: GuildTask): + status = "§a已完成" if task.completed else "§e进行中" + return f"§e{i}. §f{ + task.name} [{status}§f]\n §7{ + task.description}\n" + + idx = self._paginate_display( + player, guild.tasks, "选择删除任务", formatter, True) + if idx is not None: + task = guild.tasks[idx] + player.show(f"§l§a任务管理 §d>> §r确认删除任务 '{task.name}'?输入 '确认' 继续") + confirm = game_utils.waitMsg(player.name, timeout=20) + + if confirm == "确认": + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild and idx < len(guild.tasks): + removed_task = guild.tasks.pop(idx) + guild.add_log(f"{player.name} 删除了任务: {removed_task.name}") + guild.add_audit_log("task_delete", player.name, + detail=removed_task.name) + self.guild_manager.save_guilds(guilds) + player.show( + f"§l§a任务管理 §d>> §r任务 '{ + removed_task.name}' 已删除") + + elif choice == "2" and can_complete: + # 强制完成任务 + active_tasks = [t for t in guild.tasks if not t.completed] + if not active_tasks: + player.show("§l§a任务管理 §d>> §r暂无进行中的任务") + return True + + def formatter(i, task: GuildTask): + return f"§e{i}. §f{ + task.name} §7({ + task.current_count}/{ + task.target_count})\n §7{ + task.description}\n" + + idx = self._paginate_display( + player, active_tasks, "选择完成任务", formatter, True) + if idx is not None: + task = active_tasks[idx] + player.show(f"§l§a任务管理 §d>> §r确认强制完成任务 '{task.name}'?输入 '确认' 继续") + confirm = game_utils.waitMsg(player.name, timeout=20) + + if confirm == "确认": + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild: + for t in guild.tasks: + if t.task_id == task.task_id: + t.completed = True + t.current_count = t.target_count + guild.add_log(f"{player.name} 强制完成了任务: {t.name}") + guild.add_audit_log("task_force_complete", + player.name, detail=t.name) + break + + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a任务管理 §d>> §r任务 '{task.name}' 已完成") + else: + player.show("§c无效选项") + + return True + + +def _handle_manage_members(self, player: Player) -> bool: + """管理公会成员""" + guild = self.guild_manager.get_guild_by_player(player.name) + member = guild.get_member(player.name) if guild else None + + if not guild or not member or not any( + guild.has_permission(player.name, permission) + for permission in ("kick", "set_rank", "transfer_owner", "join_queue") + ): + player.show("§l§a公会 §d>> §r你没有管理权限") + return True + + player.show("§r========== §a成员管理§r ==========") + options = [ + ("1", "踢出成员", guild.has_permission(player.name, "kick")), + ("2", "设置职位", guild.has_permission(player.name, "set_rank")), + ("3", "转让会长", guild.has_permission(player.name, "transfer_owner")), + ("4", "申请队列", guild.has_permission(player.name, "join_queue")), + ] + for key, label, enabled in options: + if enabled: + player.show(f"§e{key}. §f{label}") + player.show("§7输入选项序号,q 返回") + + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "1" and guild.has_permission(player.name, "kick"): + return self._handle_kick_member(player) + elif choice == "2" and guild.has_permission(player.name, "set_rank"): + return self._handle_set_rank(player) + elif choice == "3" and guild.has_permission(player.name, "transfer_owner"): + return self._handle_transfer_ownership(player) + elif choice == "4" and guild.has_permission(player.name, "join_queue"): + return self._handle_join_request_queue(player, guild) + + return True + + +def _notify_join_request_admins( + self, + guild: GuildData, + applicant_name: str) -> None: + """通知在线的申请队列处理人。""" + if not getattr( + Config, + "GUILD_JOIN_REQUEST_CONFIG", + {}).get( + "申请提交后通知在线管理员", + True): + return + + for member in guild.members: + if ( + member.name in self.game_ctrl.allplayers + and guild.has_permission(member.name, "join_queue") + ): + message = ( + f"§l§a公会 §d>> §r§e{applicant_name} " + f"§f申请加入公会 §e{guild.name}\\n" + "§f请在成员管理的 §a申请队列 §f中处理" + ) + self.game_ctrl.sendcmd( + f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + + +def _handle_join_request_queue(self, player: Player, guild: GuildData) -> bool: + """处理加入申请队列""" + if not guild.has_permission(player.name, "join_queue"): + player.show("§l§a公会 §d>> §r你没有处理申请队列权限") + return True + + pending_requests = guild.pending_join_requests() + if not pending_requests: + player.show("§l§a公会 §d>> §r暂无待处理加入申请") + return True + + def formatter(i, request): + age = self._format_time_duration(time.time() - request.create_time) + reason = request.reason or "无" + return ( + f"§e{i}. §f{request.player_name}\n" + f" §7理由: §f{reason} §7| 提交: §f{age}前\n" + ) + + idx = self._paginate_display( + player, + pending_requests, + "加入申请队列", + formatter, + True) + if idx is None: + return True + + selected = pending_requests[idx] + player.show(f"§l§a公会 §d>> §r处理 §e{selected.player_name} §r的加入申请") + player.show("§e1. §f同意") + player.show("§e2. §f拒绝") + player.show("§7输入选项序号:") + choice = game_utils.waitMsg(player.name, timeout=30) + approved = choice in ("1", "同意", "批准") + rejected = choice in ("2", "拒绝") + if not approved and not rejected: + player.show("§l§a公会 §d>> §r操作已取消") + return True + + guilds = self.guild_manager.load_guilds(force_reload=True) + latest_guild = guilds.get(guild.guild_id) + if not latest_guild: + player.show("§l§a公会 §d>> §r公会数据异常") + return True + + if approved and len(latest_guild.members) >= Config.MAX_GUILD_MEMBERS: + latest_guild.resolve_join_request( + selected.player_name, + player.name, + False, + result_reason="公会已满员", + ) + self.guild_manager.save_guilds(guilds) + player.show("§l§a公会 §d>> §r公会已满员,已拒绝该申请") + return True + + if not latest_guild.resolve_join_request( + selected.player_name, + player.name, + approved, + result_reason="管理员处理", + ): + player.show("§l§a公会 §d>> §r申请已不存在或已过期") + return True + + if approved: + new_member = GuildMember( + name=selected.player_name, + rank=GuildRank.MEMBER, + join_time=time.time(), + ) + latest_guild.members.append(new_member) + latest_guild.add_log( + f"{selected.player_name} 加入公会 (审核人: {player.name})") + self.guild_manager.cache_player_guild( + selected.player_name, latest_guild.guild_id) + + self.guild_manager.save_guilds(guilds) + result_text = "已同意" if approved else "已拒绝" + player.show(f"§l§a公会 §d>> §r{result_text} {selected.player_name} 的加入申请") + + if selected.player_name in self.game_ctrl.allplayers: + message = ( + f"§l§a公会 §d>> §r你的公会申请已通过,已加入 §e{latest_guild.name}" + if approved + else f"§l§a公会 §d>> §r你加入 §e{latest_guild.name} §r的申请被拒绝" + ) + self.game_ctrl.sendcmd( + f'/tellraw {selected.player_name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + + if approved and getattr( + Config, + "GUILD_JOIN_REQUEST_CONFIG", + {}).get( + "批准后通知全体在线成员", + True): + for member in latest_guild.members: + if member.name in self.game_ctrl.allplayers and member.name != selected.player_name: + message = f"§l§a公会 §d>> §r§e{selected.player_name}§r 加入了公会" + self.game_ctrl.sendcmd( + f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + + return True + + +def _handle_set_rank(self, player: Player) -> bool: + """设置成员职位""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + return True + if not guild.has_permission(player.name, "set_rank"): + player.show("§l§a公会 §d>> §r你没有设置成员职位权限") + return True + + # 过滤可管理的成员 + manageable_members = [ + m for m in guild.members + if guild.can_manage_member(player.name, m.name) + ] + + if not manageable_members: + player.show("§l§a公会 §d>> §r没有可管理的成员") + return True + + def formatter(i, m): return f"§e{i}. {m.rank.display_name} §f{m.name}\n" + idx = self._paginate_display( + player, + manageable_members, + "选择成员", + formatter, + True) + + if idx is None: + return True + + target_member = manageable_members[idx] + + # 选择新职位 + player.show("§r========== §a设置职位§r ==========") + player.show("§e1. §6副会长") + player.show("§e2. §e长老") + player.show("§e3. §a成员") + player.show("§7输入选项序号") + + rank_choice = game_utils.waitMsg(player.name, timeout=30) + rank_map = { + "1": GuildRank.DEPUTY, + "2": GuildRank.ELDER, + "3": GuildRank.MEMBER} + + new_rank = rank_map.get(rank_choice) + if new_rank: + if self.guild_manager.set_member_rank( + guild, target_member.name, new_rank): + player.show( + f"§l§a公会 §d>> §r已将 { + target_member.name} 的职位设置为 { + new_rank.display_name}") + + return True + + +def _handle_donation(self, player: Player) -> bool: + """处理物品捐献""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + player.show("§l§a公会捐献§r") + player.show("§7支持捐献的物品类型:") + player.show("§e钻石 §7- 50贡献/个") + player.show("§e绿宝石 §7- 25贡献/个") + player.show("§e金锭 §7- 10贡献/个") + player.show("§e铁锭 §7- 5贡献/个") + player.show("§e下界合金锭 §7- 200贡献/个") + player.show("§e远古残骸 §7- 150贡献/个") + player.show("§7以及其他有价值的物品...") + player.show("§7请输入要捐献的物品名称 (支持中文名称):") + + user_input = game_utils.waitMsg(player.name, timeout=30) + + if not user_input or user_input.lower() == 'q': + return True + + # 使用智能匹配查找物品ID + item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) + + if not item_id: + player.show("§l§a公会捐献 §d>> §r未找到匹配的物品") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + return True + + # 获取物品的贡献点价值 + contrib_per_item = guild.get_item_value(item_id) + exp_per_item = contrib_per_item * 0.5 # 经验为贡献点的一半 + + chinese_name = self.item_matcher.get_chinese_name(item_id) + player.show(f"§l§a公会捐献 §d>> §r选择物品: §f{chinese_name}") + player.show(f"§7价值: §e{contrib_per_item}贡献点/个 §7+ §b{exp_per_item}经验/个") + + player.show(f"§7你有 {player.getItemCount(item_id)} 个{chinese_name}") + player.show("§7输入要捐献的数量:") + + amount_str = game_utils.waitMsg(player.name, timeout=30) + if not amount_str or not amount_str.isdigit(): + return True + + amount = int(amount_str) + if amount <= 0 or amount > player.getItemCount(item_id): + player.show("§l§a公会 §d>> §r数量无效") + return True + + self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {amount}") + exp, contribution = self.guild_apply_reward_multipliers( + int(amount * exp_per_item), + amount * contrib_per_item, + ) + + # 重新加载公会数据并更新经验值 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_id = guild.guild_id + + if guild_id in guilds: + # 更新贡献与公会经验 + latest_guild = guilds[guild_id] + member = latest_guild.get_member(player.name) + if member: + member.contribution += contribution + latest_guild.stats.total_contribution += contribution + latest_guild.exp += exp + latest_guild.add_log(f"{player.name} 捐献了 {amount} 个{chinese_name}") + + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会 §d>> §r捐献成功!获得 {contribution} 贡献度,公会获得 {exp} 经验") + else: + player.show("§l§a公会 §d>> §r捐献失败,公会数据异常") + fmts.print_err(f"捐献失败: 公会ID {guild_id} 不存在于加载的数据中") + + return True + + +def _handle_vault(self, player: Player) -> bool: + """处理公会仓库""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + if not guild.has_permission(player.name, "vault"): + player.show("§l§a公会仓库 §d>> §r你没有使用仓库权限") + return True + + while True: + latest_guild = self.guild_manager.get_guild_by_player( + player.name, force_reload=True) + if latest_guild: + guild = latest_guild + + player.show("§r========== §a公会仓库§r ==========") + max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 + player.show(f"§7容量: §a{len(guild.vault_items)}/{max_slots}") + + member = guild.get_member(player.name) + if member: + player.show(f"§7你的贡献点: §e{member.contribution}") + + can_buy = guild.has_permission(player.name, "vault_buy") + can_sell = guild.has_permission(player.name, "vault_sell") + can_cancel = ( + getattr(Config, "GUILD_VAULT_CONFIG", {}).get("启用撤回出售", True) + and ( + guild.has_permission(player.name, "vault_cancel_own") + or guild.has_permission(player.name, "vault_cancel_any") + ) + ) + can_view_logs = getattr( + Config, "GUILD_VAULT_CONFIG", {}).get( + "启用交易日志", True) + can_settings = guild.has_permission(player.name, "vault_settings") + + menu_options = [ + ("查看", "查看仓库物品", True), + ("购买", "购买仓库物品", can_buy), + ("出售", "出售物品到仓库", can_sell), + ("撤回", "撤回已上架物品", can_cancel), + ("日志", "查看仓库交易日志", can_view_logs), + ("设置", "设置仓库物品价值", can_settings), + ("退出", "退出仓库", True) + ] + + available_options = [(cmd, desc) + for cmd, desc, cond in menu_options if cond] + + for cmd, desc in available_options: + player.show(f"§e● {cmd} §7- {desc}") + + player.show("§7输入选项:") + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "查看": + self._handle_vault_view(player, guild) + elif choice == "购买" and can_buy: + self._handle_vault_buy(player, guild) + elif choice == "出售" and can_sell: + self._handle_vault_sell(player, guild) + elif choice == "撤回" and can_cancel: + self._handle_vault_cancel(player, guild) + elif choice == "日志" and can_view_logs: + self._handle_vault_logs(player, guild) + elif choice == "设置" and can_settings: + self._handle_vault_settings(player, guild) + elif choice == "退出" or choice is None: + break + else: + player.show("§c无效选项") + + return True + + +def _handle_vault_view(self, player: Player, guild: GuildData) -> bool: + """查看仓库物品""" + if not guild.vault_items: + player.show("§l§a公会仓库 §d>> §r仓库为空") + return True + + def formatter(i, item: VaultItem): + # 获取物品显示名称 + item_name = self._get_item_display_name(item.item_id) + time_str = datetime.fromtimestamp( + item.timestamp).strftime("%m-%d %H:%M") + return (f"§e{i}. §f{item_name} §7x{item.count}\n" + f" §7价格: §e{item.price}贡献点 §7| 卖家: §a{item.seller}\n" + f" §7时间: §f{time_str}\n") + + self._paginate_display(player, guild.vault_items, "仓库物品", formatter) + return True + + +def _handle_vault_buy(self, player: Player, guild: GuildData) -> bool: + """购买仓库物品""" + if not guild.has_permission(player.name, "vault_buy"): + player.show("§l§a公会仓库 §d>> §r你没有购买仓库物品权限") + return True + + if not guild.vault_items: + player.show("§l§a公会仓库 §d>> §r仓库为空") + return True + + member = guild.get_member(player.name) + if not member: + return True + + def formatter(i, item: VaultItem): + item_name = self._get_item_display_name(item.item_id) + time_str = datetime.fromtimestamp( + item.timestamp).strftime("%m-%d %H:%M") + affordable = "§a可购买" if member.contribution >= item.price else "§c贡献点不足" + return (f"§e{i}. §f{item_name} §7x{item.count}\n" + f" §7价格: §e{item.price}贡献点 §7| {affordable}\n" + f" §7卖家: §a{item.seller} §7| 时间: §f{time_str}\n") + + idx = self._paginate_display( + player, + guild.vault_items, + "选择购买物品", + formatter, + True) + if idx is None: + return True + + item = guild.vault_items[idx] + if (item.seller == player.name and not getattr( + Config, "GUILD_VAULT_CONFIG", {}).get("允许购买自己出售的物品", False)): + player.show("§l§a公会仓库 §d>> §r不能购买自己出售的物品") + return True + + # 检查贡献点 + if member.contribution < item.price: + player.show("§l§a公会仓库 §d>> §r贡献点不足") + return True + + # 检查背包空间 + if not self._has_inventory_space(player, item.item_id, item.count): + player.show("§l§a公会仓库 §d>> §r背包空间不足") + return True + + # 确认购买 + item_name = self._get_item_display_name(item.item_id) + player.show( + f"§l§a公会仓库 §d>> §r确认购买 §f{item_name} §7x{ + item.count} §r花费 §e{ + item.price}贡献点§r?") + player.show("§7输入 '确认' 继续购买") + + confirm = game_utils.waitMsg(player.name, timeout=20) + if confirm != "确认": + player.show("§l§a公会仓库 §d>> §r购买已取消") + return True + + # 执行购买 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if not guild: + player.show("§l§a公会仓库 §d>> §r公会数据异常") + return True + + # 重新检查物品是否还存在 + if idx >= len( + guild.vault_items) or guild.vault_items[idx].item_id != item.item_id: + player.show("§l§a公会仓库 §d>> §r物品已被其他人购买") + return True + item = guild.vault_items[idx] + if (item.seller == player.name and not getattr( + Config, "GUILD_VAULT_CONFIG", {}).get("允许购买自己出售的物品", False)): + player.show("§l§a公会仓库 §d>> §r不能购买自己出售的物品") + return True + + # 扣除贡献点 + buyer_member = guild.get_member(player.name) + if not buyer_member or buyer_member.contribution < item.price: + player.show("§l§a公会仓库 §d>> §r贡献点不足") + return True + + buyer_member.contribution -= item.price + + # 给卖家贡献点(扣除税费) + tax = int(item.price * Config.VAULT_TRADE_TAX) + seller_income = item.price - tax + seller_member = guild.get_member(item.seller) + if seller_member: + seller_member.contribution += seller_income + + # 给玩家物品 + self.game_ctrl.sendwocmd(f"give {player.name} {item.item_id} {item.count}") + + guild.vault_items.pop(idx) + + guild.add_log( + f"{player.name} 购买了 {item_name} x{item.count} (花费{item.price}贡献点)") + if getattr(Config, "GUILD_VAULT_CONFIG", {}).get("启用交易日志", True): + guild.add_vault_trade_log( + "buy", + item, + player.name, + buyer=player.name, + detail=f"税费{tax},卖家收入{seller_income}", + ) + suggested_value = guild.get_item_value(item.item_id) * item.count + audit_ratio = float( + getattr( + Config, + "GUILD_VAULT_CONFIG", + {}).get( + "高价交易审计倍率", + 3.0)) + if suggested_value > 0 and item.price >= suggested_value * audit_ratio: + guild.add_audit_log( + "vault_high_price_buy", + player.name, + target=item.seller, + detail=f"{item.item_id} x{item.count} price={item.price}", + ) + + self.guild_manager.save_guilds(guilds) + + player.show(f"§l§a公会仓库 §d>> §r购买成功!花费 §e{item.price}贡献点") + + # 更新贸易任务进度 + self.check_and_complete_trade_tasks(player.name) + + # 通知卖家 + if seller_member and item.seller in self.game_ctrl.allplayers: + message = ( + f"§l§a公会仓库 §d>> §r你的 {item_name} x{item.count} " + f"被 {player.name} 购买了,获 {seller_income} 贡献点" + ) + self.game_ctrl.sendcmd( + f'/tellraw {item.seller} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + + return True + + +def _handle_vault_sell(self, player: Player, guild: GuildData) -> bool: + """出售物品到仓库""" + if not guild.has_permission(player.name, "vault_sell"): + player.show("§l§a公会仓库 §d>> §r你没有出售仓库物品权限") + return True + + # 检查仓库容量 + max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + + player.show("§l§a公会仓库 §d>> §r请输入要出售的物品名称") + player.show("§7支持中文名称,如: 钻石、铁锭、金块等") + player.show("§7也支持英文ID,如: minecraft:diamond") + + user_input = game_utils.waitMsg(player.name, timeout=30) + + if not user_input: + player.show("§l§a公会仓库 §d>> §r输入为空") + return True + + # 使用智能匹配查找物品ID + item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) + + if not item_id: + player.show("§l§a公会仓库 §d>> §r未找到匹配的物品") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + player.show("§7请重新输入准确的物品名称") + return True + + # 显示找到的物品 + chinese_name = self.item_matcher.get_chinese_name(item_id) + player.show(f"§l§a公会仓库 §d>> §r找到物品: §f{chinese_name} §7({item_id})") + + # 检查玩家是否有该物品 + item_count = player.getItemCount(item_id) + if item_count <= 0: + player.show("§l§a公会仓库 §d>> §r你没有这个物品") + return True + + player.show(f"§l§a公会仓库 §d>> §r你有 {item_count} 个该物品") + player.show("§7请输入要出售的数量:") + + count_str = game_utils.waitMsg(player.name, timeout=30) + if not count_str or not count_str.isdigit(): + player.show("§l§a公会仓库 §d>> §r无效的数量") + return True + + count = int(count_str) + if count <= 0 or count > item_count: + player.show("§l§a公会仓库 §d>> §r数量无效") + return True + max_sell_count = int( + getattr( + Config, + "GUILD_VAULT_CONFIG", + {}).get( + "单次出售最大数量", + 64)) + if max_sell_count > 0 and count > max_sell_count: + player.show(f"§l§a公会仓库 §d>> §r单次最多出售 {max_sell_count} 个") + return True + + # 获取建议价格 + suggested_price = guild.get_item_value(item_id) * count + player.show(f"§l§a公会仓库 §d>> §r建议价格: §e{suggested_price}贡献点") + player.show("§7请输入出售价格 (贡献点,你可以自定义任意价格):") + + price_str = game_utils.waitMsg(player.name, timeout=30) + if not price_str or not price_str.isdigit(): + player.show("§l§a公会仓库 §d>> §r无效的价格") + return True + + price = int(price_str) + if price <= 0: + player.show("§l§a公会仓库 §d>> §r价格必须大于0") + return True + vault_config = getattr(Config, "GUILD_VAULT_CONFIG", {}) + min_price = int(vault_config.get("单笔价格下限", 1)) + max_price = int(vault_config.get("单笔价格上限", 100000)) + if price < min_price or price > max_price: + player.show(f"§l§a公会仓库 §d>> §r价格必须在 {min_price}-{max_price} 之间") + return True + + # 确认出售 + item_name = self._get_item_display_name(item_id) + player.show( + f"§l§a公会仓库 §d>> §r确认出售 §f{item_name} §7x{count} §r价格 §e{price}贡献点§r?") + player.show("§7输入 '确认' 继续出售") + + confirm = game_utils.waitMsg(player.name, timeout=20) + if confirm != "确认": + player.show("§l§a公会仓库 §d>> §r出售已取消") + return True + + # 执行出售 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if not guild: + player.show("§l§a公会仓库 §d>> §r公会数据异常") + return True + + # 再次检查仓库容量 + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + max_listing = int(vault_config.get("单个成员最大上架数量", 18)) + if max_listing > 0: + own_listing_count = sum( + 1 for item in guild.vault_items if item.seller == player.name) + if own_listing_count >= max_listing: + player.show(f"§l§a公会仓库 §d>> §r你最多同时上架 {max_listing} 件物品") + return True + + # 扣除物品 + self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {count}") + + # 添加到仓库 + vault_item = VaultItem( + item_id=item_id, + count=count, + price=price, + seller=player.name + ) + + guild.vault_items.append(vault_item) + guild.add_log(f"{player.name} 出售了 {item_name} x{count} (价格{price}贡献点)") + if vault_config.get("启用交易日志", True): + guild.add_vault_trade_log( + "sell", vault_item, player.name, detail="上架出售") + + audit_ratio = float(vault_config.get("高价交易审计倍率", 3.0)) + if suggested_price > 0 and price >= suggested_price * audit_ratio: + guild.add_audit_log( + "vault_high_price_sell", + player.name, + detail=f"{item_id} x{count} price={price}", + ) + + # 保存数据 + self.guild_manager.save_guilds(guilds) + + player.show(f"§l§a公会仓库 §d>> §r出售成功!{item_name} x{count} 已上架") + + # 更新贸易任务进度 + self.check_and_complete_trade_tasks(player.name) + + return True + + +def _handle_vault_logs(self, player: Player, guild: GuildData) -> bool: + """查看仓库交易日志""" + if not getattr(Config, "GUILD_VAULT_CONFIG", {}).get("启用交易日志", True): + player.show("§l§a公会仓库 §d>> §r交易日志未启用") + return True + + if not guild.vault_trade_logs: + player.show("§l§a公会仓库 §d>> §r暂无仓库交易日志") + return True + + action_names = { + "sell": "上架", + "buy": "购买", + "cancel": "撤回", + } + + def formatter(i, log): + item_name = self._get_item_display_name(log.item_id) + time_str = datetime.fromtimestamp( + log.timestamp).strftime("%m-%d %H:%M") + action_name = action_names.get(log.action, log.action) + participants = [] + if log.seller: + participants.append(f"卖家:{log.seller}") + if log.buyer: + participants.append(f"买家:{log.buyer}") + participant_text = " §7| ".join( + participants) if participants else f"操作者:{log.actor}" + detail = f"\n §7说明: §f{log.detail}" if log.detail else "" + return ( + f"§e{i}. §f{action_name} §f{item_name} §7x{log.count}\n" + f" §7价格: §e{log.price}贡献点 §7| {participant_text} §7| 时间: §f{time_str}" + f"{detail}\n" + ) + + logs = list(reversed(guild.vault_trade_logs[-50:])) + self._paginate_display(player, logs, "仓库交易日志", formatter) + return True + + +def _handle_vault_cancel(self, player: Player, guild: GuildData) -> bool: + """撤回仓库上架物品""" + vault_config = getattr(Config, "GUILD_VAULT_CONFIG", {}) + if not vault_config.get("启用撤回出售", True): + player.show("§l§a公会仓库 §d>> §r撤回出售未启用") + return True + + can_cancel_own = guild.has_permission(player.name, "vault_cancel_own") + can_cancel_any = guild.has_permission(player.name, "vault_cancel_any") + only_own = vault_config.get("只允许撤回自己的物品", False) + + cancelable_items = [] + for index, item in enumerate(guild.vault_items): + is_own = item.seller == player.name + if is_own and can_cancel_own: + cancelable_items.append((index, item)) + elif not only_own and can_cancel_any: + cancelable_items.append((index, item)) + + if not cancelable_items: + player.show("§l§a公会仓库 §d>> §r没有可撤回的上架物品") + return True + + def formatter(i, data): + _index, item = data + item_name = self._get_item_display_name(item.item_id) + time_str = datetime.fromtimestamp( + item.timestamp).strftime("%m-%d %H:%M") + return ( + f"§e{i}. §f{item_name} §7x{ + item.count}\n" f" §7价格: §e{ + item.price}贡献点 §7| 卖家: §a{ + item.seller} §7| 时间: §f{time_str}\n") + + idx = self._paginate_display( + player, + cancelable_items, + "选择撤回物品", + formatter, + True) + if idx is None: + return True + + original_index, selected_item = cancelable_items[idx] + selected_name = self._get_item_display_name(selected_item.item_id) + player.show( + f"§l§a公会仓库 §d>> §r确认撤回 §f{selected_name} §7x{ + selected_item.count}§r?") + player.show("§7输入 '确认' 继续撤回") + + confirm = game_utils.waitMsg(player.name, timeout=20) + if confirm != "确认": + player.show("§l§a公会仓库 §d>> §r撤回已取消") + return True + + guilds = self.guild_manager.load_guilds(force_reload=True) + latest_guild = guilds.get(guild.guild_id) + if not latest_guild: + player.show("§l§a公会仓库 §d>> §r公会数据异常") + return True + + if original_index >= len(latest_guild.vault_items): + player.show("§l§a公会仓库 §d>> §r物品已不存在") + return True + + latest_item = latest_guild.vault_items[original_index] + if ( + latest_item.item_id != selected_item.item_id + or latest_item.count != selected_item.count + or latest_item.price != selected_item.price + or latest_item.seller != selected_item.seller + ): + player.show("§l§a公会仓库 §d>> §r物品状态已变化,请重新打开仓库") + return True + + is_own = latest_item.seller == player.name + if not ((is_own and can_cancel_own) or (not only_own and can_cancel_any)): + player.show("§l§a公会仓库 §d>> §r你没有撤回该物品的权限") + return True + + removed_item = latest_guild.cancel_vault_item(player.name, original_index) + if not removed_item: + player.show("§l§a公会仓库 §d>> §r撤回失败") + return True + + if vault_config.get("撤回后返还物品", True): + self.game_ctrl.sendwocmd( + f"give { + removed_item.seller} { + removed_item.item_id} { + removed_item.count}") + + self.guild_manager.save_guilds(guilds) + player.show( + f"§l§a公会仓库 §d>> §r已撤回 §f{selected_name} §7x{ + removed_item.count}") + if removed_item.seller != player.name and removed_item.seller in self.game_ctrl.allplayers: + message = ( + f"§l§a公会仓库 §d>> §r你上架的 {selected_name} " + f"x{removed_item.count} 已被 {player.name} 撤回" + ) + self.game_ctrl.sendcmd( + f'/tellraw {removed_item.seller} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + + return True + + +def _handle_vault_settings(self, player: Player, guild: GuildData) -> bool: + """设置物品价值""" + if not guild.has_permission(player.name, "vault_settings"): + player.show("§l§a公会仓库 §d>> §r你没有设置仓库物品价值权限") + return True + + player.show("§r========== §a物品价值设置§r ==========") + player.show("§e1. §f查看当前设置") + player.show("§e2. §f设置物品价值") + player.show("§e3. §f删除自定义价值") + player.show("§7输入选项序号:") + + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "1": + # 显示当前设置 + if guild.custom_item_values: + player.show("§l§a自定义物品价值§r") + for item_id, value in guild.custom_item_values.items(): + item_name = self._get_item_display_name(item_id) + player.show(f"§f{item_name}: §e{value}贡献点") + else: + player.show("§l§a公会仓库 §d>> §r暂无自定义物品价值") + + player.show("\n§l§a默认物品价值§r") + for item_id, value in Config.DEFAULT_ITEM_VALUES.items(): + item_name = self._get_item_display_name(item_id) + player.show(f"§f{item_name}: §e{value}贡献点") + + elif choice == "2": + # 设置物品价值 + player.show("§l§a公会仓库 §d>> §r请输入物品ID (如: minecraft:diamond):") + item_id = game_utils.waitMsg(player.name, timeout=30) + + if not item_id or not item_id.startswith("minecraft:"): + player.show("§l§a公会仓库 §d>> §r无效的物品ID") + return True + + player.show("§l§a公会仓库 §d>> §r请输入贡献点价值:") + value_str = game_utils.waitMsg(player.name, timeout=30) + + if not value_str or not value_str.isdigit(): + player.show("§l§a公会仓库 §d>> §r无效的价值") + return True + + value = int(value_str) + if value <= 0: + player.show("§l§a公会仓库 §d>> §r价值必须大于0") + return True + + # 保存设置 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild: + guild.custom_item_values[item_id] = value + item_name = self._get_item_display_name(item_id) + guild.add_log(f"{player.name} 设置了 {item_name} 的价值为 {value} 贡献点") + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会仓库 §d>> §r已设置 {item_name} 的价值为 {value} 贡献点") + + elif choice == "3": + # 删除自定义价值 + if not guild.custom_item_values: + player.show("§l§a公会仓库 §d>> §r暂无自定义物品价值") + return True + + player.show("§l§a当前自定义价值§r") + items = list(guild.custom_item_values.items()) + for i, (item_id, value) in enumerate(items, 1): + item_name = self._get_item_display_name(item_id) + player.show(f"§e{i}. §f{item_name}: §e{value}贡献点") + + player.show("§7输入序号删除:") + idx_str = game_utils.waitMsg(player.name, timeout=30) + + if idx_str and idx_str.isdigit(): + idx = int(idx_str) - 1 + if 0 <= idx < len(items): + item_id, _ = items[idx] + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild and item_id in guild.custom_item_values: + del guild.custom_item_values[item_id] + item_name = self._get_item_display_name(item_id) + guild.add_log(f"{player.name} 删除了 {item_name} 的自定义价值") + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会仓库 §d>> §r已删除 {item_name} 的自定义价值") + + return True + + +def _handle_create_guild(self, player: Player, player_xuid: str) -> bool: + """处理创建公会""" + guild = self.guild_manager.get_guild_by_player(player.name) + if guild: + player.show(render_create_guild_prompt("已有公会提示词")) + return True + + score = player.getScore(Config.GUILD_SCOREBOARD) + if score < Config.GUILD_CREATION_COST: + player.show(render_create_guild_prompt("创建公会余额不足提示词", balance=score)) + return True + + player.show(render_create_guild_prompt("创建公会提示词", balance=score)) + confirm = game_utils.waitMsg(player.name, timeout=30) + if confirm is None: + player.show(render_create_guild_prompt("创建公会回复超时提示词")) + return True + if confirm.strip().lower() not in ("确认", "继续", "是", "y", "yes"): + player.show(render_create_guild_prompt("创建公会取消提示词")) + return True + + player.show(render_create_guild_prompt("创建公会输入名称提示词")) + guild_name = game_utils.waitMsg(player.name, timeout=30) + + # 使用新的输入验证 + is_valid, error_msg = InputValidator.validate_guild_name(guild_name) + if not is_valid: + player.show(render_create_guild_prompt("创建公会名称无效提示词", error=error_msg)) + return True + + score = player.getScore(Config.GUILD_SCOREBOARD) + if score < Config.GUILD_CREATION_COST: + player.show(render_create_guild_prompt("创建公会二次余额不足提示词", balance=score)) + return True + + # 扣除配置的计分板积分并创建公会 + self.game_ctrl.sendwocmd( + f"scoreboard players remove { + player.name} { + Config.GUILD_SCOREBOARD} { + Config.GUILD_CREATION_COST}") + + if self.guild_manager.create_guild(player_xuid, player.name, guild_name): + player.show(render_create_guild_prompt( + "创建公会成功提示词", guild=guild_name, player=player.name)) + # 通知所有在线玩家 + announcement = render_create_guild_prompt( + "创建公会全服公告提示词", + guild=guild_name, + player=player.name, + ) + payload = json.dumps( + {"rawtext": [{"text": announcement}]}, ensure_ascii=False) + self.game_ctrl.sendcmd(f"/tellraw @a {payload}") + else: + player.show(render_create_guild_prompt( + "创建公会名称已存在提示词", guild=guild_name, player=player.name)) + self.game_ctrl.sendwocmd( + f"scoreboard players add { + player.name} { + Config.GUILD_SCOREBOARD} { + Config.GUILD_CREATION_COST}") + + return True + + +def _handle_list_guilds(self, player: Player) -> bool: + """处理查看公会列表""" + guilds = list(self.guild_manager.load_guilds().values()) + if not guilds: + player.show(render_config_prompt("公会列表为空提示词")) + return True + + # 按等级和成员数排序 + guilds.sort(key=lambda g: (g.level, len(g.members)), reverse=True) + + def formatter(i, g): return ( + f"§e{i}. §r{g.name} §7Lv.{g.level}\n" + f" §7会长: §f{g.owner} §7| 成员: §a{len(g.members)}/{Config.MAX_GUILD_MEMBERS}\n" + ) + + page = 1 + max_page = (len(guilds) + Config.ITEMS_PER_PAGE - + 1) // Config.ITEMS_PER_PAGE + while True: + page = max(1, min(page, max_page)) + start = (page - 1) * Config.ITEMS_PER_PAGE + end = start + Config.ITEMS_PER_PAGE + + msg = ( + f"{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b公会列表§d" + f"\n{ORION_BORDER}" + ) + for i, guild in enumerate(guilds[start:end], start=1): + msg += "\n" + formatter(start + i, guild).rstrip() + msg += "\n" + format_page_footer(page, max_page, start + 1, end, False) + + player.show(msg) + + choice = game_utils.waitMsg(player.name, timeout=20) + if choice is None: + player.show(render_config_prompt("公会列表分页超时提示词")) + return True + if choice == "+": + page = min(page + 1, max_page) + elif choice == "-": + page = max(page - 1, 1) + elif choice == "q": + player.show(render_config_prompt("公会列表分页退出提示词")) + return True + + return True + + +def _handle_join_guild(self, player: Player) -> bool: + """处理加入公会申请""" + if self.guild_manager.get_guild_by_player(player.name): + player.show("§l§a公会 §d>> §r你已经加入了一个公会") + return True + + player.show("§l§a公会 §d>> §r请输入公会名字(支持模糊搜索)") + search_name = game_utils.waitMsg(player.name, timeout=30) + + if not search_name: + player.show("§l§a公会 §d>> §r公会名字不能为空") + return True + + # 搜索匹配的公会 + guilds = self.guild_manager.load_guilds() + matched_guilds = [g for g in guilds.values() if search_name.lower() + in g.name.lower()] + + if not matched_guilds: + player.show("§l§a公会 §d>> §r未找到匹配的公会") + return True + + # 选择公会 + if len(matched_guilds) == 1: + target_guild = matched_guilds[0] + else: + def formatter(i, g): return f"{i}. {g.name} (会长:{g.owner})\n" + idx = self._paginate_display( + player, matched_guilds, "选择公会", formatter, True) + if idx is None: + return True + target_guild = matched_guilds[idx] + + # 检查人数上限 + if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: + player.show("§l§a公会 §d>> §r该公会已满员") + return True + + reason = "" + join_config = getattr(Config, "GUILD_JOIN_REQUEST_CONFIG", {}) + if join_config.get("启用离线申请队列", True): + player.show("§l§a公会 §d>> §r请输入申请理由,可直接发送空白跳过") + reason_input = game_utils.waitMsg(player.name, timeout=60) + reason = reason_input or "" + + guilds = self.guild_manager.load_guilds(force_reload=True) + latest_guild = guilds.get(target_guild.guild_id) + if not latest_guild: + player.show("§l§a公会 §d>> §r公会数据异常") + return True + + if not latest_guild.add_join_request(player.name, reason): + player.show("§l§a公会 §d>> §r申请提交失败,可能已有待处理申请或队列已满") + return True + + self.guild_manager.save_guilds(guilds) + self._notify_join_request_admins(latest_guild, player.name) + player.show(f"§l§a公会 §d>> §r申请已提交至 §e{latest_guild.name} §r的申请队列") + + return True + + +def _handle_leave_guild(self, player: Player) -> bool: + """处理退出公会""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你未加入任何公会") + return True + + member = guild.get_member(player.name) + if member and member.rank == GuildRank.OWNER: + player.show("§l§a公会 §d>> §r会长不能退出公会,只能解散公会") + player.show("§7请使用 '解散' 选项来解散公会") + return True + + guild_name = self.guild_manager.remove_member(player.name) + if guild_name: + player.show(f"§l§a公会 §d>> §r已退出公会 {guild_name}") + else: + player.show("§l§a公会 §d>> §r退出失败") + return True + + +def _handle_dissolve_guild(self, player: Player, player_xuid: str) -> bool: + """处理解散公会""" + guild = self.guild_manager.get_guild_by_player(player.name) + member = guild.get_member(player.name) if guild else None + + if not guild or not member or member.rank != GuildRank.OWNER: + player.show("§l§a公会 §d>> §r你不是任何公会的会长") + return True + + player.show(f"§l§a公会 §d>> §r确定要解散公会 §e{guild.name} §r吗?") + player.show("§c此操作不可撤销!输入'确认解散'继续") + confirm = game_utils.waitMsg(player.name, timeout=30) + + if confirm != "确认解散": + player.show("§l§a公会 §d>> §r操作已取消") + return True + + # 通知所有在线成员 + message = f"§l§a公会 §d>> §r公会 §e{guild.name}§r 已被解散" + for member in guild.members: + if member.name in self.game_ctrl.allplayers: + self.game_ctrl.sendcmd( + f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + + guilds = self.guild_manager.load_guilds(force_reload=True) + if guild.guild_id in guilds: + del guilds[guild.guild_id] + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会 §d>> §r已解散公会 §e{guild.name}") + + return True + + +def _handle_kick_member(self, player: Player) -> bool: + """处理踢出成员""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild or not guild.has_permission(player.name, "kick"): + player.show("§l§a公会 §d>> §r你没有踢人权限") + return True + + # 使用新的权限检查逻辑过滤可踢出的成员 + kickable_members = [] + for member in guild.members: + if guild.can_manage_member(player.name, member.name): + kickable_members.append(member) + + if not kickable_members: + player.show("§l§a公会 §d>> §r没有可踢出的成员") + return True + + def formatter(i, m): return f"§e{i}. {m.rank.display_name} §f{m.name}\n" + idx = self._paginate_display( + player, + kickable_members, + "踢出成员", + formatter, + True) + + if idx is not None: + target = kickable_members[idx] + self.guild_manager.remove_member(target.name) + player.show(f"§l§a公会 §d>> §r已将 {target.name} 踢出公会") + + # 通知被踢玩家 + if target.name in self.game_ctrl.allplayers: + self.game_ctrl.sendcmd( + f'/tellraw {target.name} {{"rawtext":[{{"text":"§l§a公会 §d>> §r你已被踢出公会"}}]}}' + ) + + return True + + +def _handle_set_base(self, player: Player) -> bool: + """处理设置据点 - 增强版本""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + if not guild.has_permission(player.name, "setbase"): + player.show("§l§a公会 §d>> §r你没有设置公会据点权限") + return True + + try: + fmts.print_inf(f"开始为公会 {guild.name} 设置据点") + pos = game_utils.getPos(player.name) + + if not pos: + player.show("§l§a公会 §d>> §r获取位置失败,请稍后重试") + fmts.print_err("获取位置失败: pos为空") + return True + + dimension = pos.get("dimension", -1) + if dimension != 0: + player.show("§l§a公会 §d>> §r据点只能设置在主世界") + fmts.print_err(f"设置据点失败: 维度不是主世界 (维度={dimension})") + return True + + # 确保position字段存在 + if "position" not in pos: + player.show("§l§a公会 §d>> §r获取位置信息不完整,请稍后重试") + fmts.print_err(f"设置据点错误: 位置信息不完整 {pos}") + return True + + position = pos["position"] + x = position.get("x", 0) + y = position.get("y", 0) + z = position.get("z", 0) + + base = GuildBase( + dimension=dimension, + x=x, + y=y, + z=z + ) + + # 打印调试信息 + fmts.print_inf(f"正在设置据点: 维度={dimension}, 坐标=({x}, {y}, {z})") + + # 直接保存到当前公会对象 + guild.base = base + guild.add_log(f"{player.name} 设置了据点") + + # 重新加载公会数据以确保使用最新数据 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_id = guild.guild_id + + # 确保公会ID存在于加载的数据中 + if guild_id not in guilds: + player.show("§l§a公会 §d>> §r公会数据加载失败,请稍后重试") + fmts.print_err(f"设置据点错误: 公会ID {guild_id} 不存在于加载的数据中") + return True + + # 更新公会数据 + guilds[guild_id].base = base + guilds[guild_id].add_log(f"{player.name} 设置了据点") + + # 保存数据 + self.guild_manager.save_guilds(guilds) + + # 再次检查是否保存成功 + check_guilds = self.guild_manager.load_guilds(force_reload=True) + if guild_id in check_guilds and check_guilds[guild_id].base: + check_base = check_guilds[guild_id].base + fmts.print_inf( + f"据点设置成功: 公会={ + guild.name}, 维度={ + check_base.dimension}, 坐标=({ + check_base.x}, { + check_base.y}, { + check_base.z})") + player.show(f"§l§a公会 §d>> §r据点已设置为 ({x:.1f}, {y:.1f}, {z:.1f})") + else: + player.show("§l§a公会 §d>> §r据点设置可能失败,请重试") + fmts.print_err(f"据点设置后验证失败: 公会={guild.name}") + + except Exception as e: + player.show("§l§a公会 §d>> §r获取位置失败,请稍后重试") + fmts.print_err(f"设置据点错误: {e}") + import traceback + fmts.print_err(traceback.format_exc()) + + return True + + +def _handle_return_base(self, player: Player) -> bool: + """处理返回据点 - 增强版本""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你没有加入任何公会") + return True + if not guild.has_permission(player.name, "return_base"): + player.show("§l§a公会 §d>> §r你没有返回公会据点权限") + return True + + # 重新加载公会数据以确保使用最新数据 + try: + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_id = guild.guild_id + + # 确保公会ID存在于加载的数据中 + if guild_id not in guilds: + player.show("§l§a公会 §d>> §r公会数据加载失败,请稍后重试") + fmts.print_err(f"传送失败: 公会ID {guild_id} 不存在于加载的数据中") + return True + + # 使用最新的公会数据 + guild = guilds[guild_id] + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + if isinstance( + getattr( + guild, + "settings", + None), + dict) and guild.settings.get( + "base_locked", + False): + player.show("§l§a公会 §d>> §r公会据点已被锁定") + return True + + base = guild.base + if not base: + player.show("§l§a公会 §d>> §r公会尚未设置据点") + fmts.print_err(f"传送失败: 公会 {guild.name} 没有设置据点") + return True + + # 验证据点数据完整性 + if not all(hasattr(base, attr) + for attr in ['dimension', 'x', 'y', 'z']): + player.show("§l§a公会 §d>> §r据点数据损坏,请重新设置") + fmts.print_err(f"传送失败: 据点数据不完整 {base}") + return True + + # 输出详细的据点信息用于调试 + fmts.print_inf(f"开始传送: 玩家={player.name}, 公会={guild.name}") + fmts.print_inf( + f"据点信息: 维度={ + base.dimension}, 坐标=({ + base.x}, { + base.y}, { + base.z})") + + # 检查维度是否有效 + if base.dimension not in [0, -1, 1]: # 主世界、下界、末地 + player.show("§l§a公会 §d>> §r据点维度无效,请重新设置") + fmts.print_err(f"传送失败: 无效维度 {base.dimension}") + return True + + # 准备传送 + player.show("§l§a公会 §d>> §r准备传送到据点...") + player.show("§7请保持静止3秒...") + + # 延迟传送 + time.sleep(3) + + # 获取坐标并确保为数值类型 + x = float(base.x) + y = float(base.y) + z = float(base.z) + fmts.print_inf(f"传送玩家 {player.name} 到据点: ({x}, {y}, {z})") + + # 尝试多种传送方法,按成功率排序 + success = False + + try: + cmd = f"tp {player.name} {x} {y} {z}" + fmts.print_inf(f"方法1 - 使用sendwocmd执行: {cmd}") + self.game_ctrl.sendwocmd(cmd) + success = True + fmts.print_inf("方法1 - sendwocmd传送成功") + except Exception as e: + fmts.print_err(f"方法1 - sendwocmd失败: {e}") + + if success: + player.show(f"§l§a公会 §d>> §r已传送到公会据点 ({x:.1f}, {y:.1f}, {z:.1f})") + fmts.print_inf(f"传送成功: {player.name} -> ({x}, {y}, {z})") + else: + player.show("§l§a公会 §d>> §r传送失败,所有传送方式都无效") + fmts.print_err("所有传送指令都失败了") + + except Exception as e: + player.show("§l§a公会 §d>> §r传送过程中发生错误,请稍后重试") + fmts.print_err(f"传送到据点时发生异常: {e}") + import traceback + fmts.print_err(traceback.format_exc()) + + return True + + +def _handle_transfer_ownership(self, player: Player) -> bool: + """转让会长""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + return True + if not guild.has_permission(player.name, "transfer_owner"): + player.show("§l§a公会 §d>> §r你没有转让会长权限") + return True + + # 选择新会长 + other_members = [m for m in guild.members if m.name != player.name] + if not other_members: + player.show("§l§a公会 §d>> §r没有其他成员") + return True + + def formatter(i, m): return f"§e{i}. {m.rank.display_name} §f{m.name}\n" + idx = self._paginate_display( + player, other_members, "选择新会长", formatter, True) + + if idx is None: + return True + + new_owner = other_members[idx] + + player.show(f"§l§a公会 §d>> §r确定要将会长转让给 §e{new_owner.name}§r 吗?") + player.show("§c此操作不可撤销!输入'确认'继续") + + confirm = game_utils.waitMsg(player.name, timeout=30) + if confirm != "确认": + player.show("§l§a公会 §d>> §r操作已取消") + return True + + guilds = self.guild_manager.load_guilds(force_reload=True) + latest_guild = guilds.get(guild.guild_id) + if not latest_guild: + player.show("§l§a公会 §d>> §r公会数据异常") + return True + + old_owner = latest_guild.get_member(player.name) + latest_new_owner = latest_guild.get_member(new_owner.name) + if not old_owner or not latest_new_owner: + player.show("§l§a公会 §d>> §r成员数据异常") + return True + + latest_guild.owner = latest_new_owner.name + latest_new_owner.rank = GuildRank.OWNER + old_owner.rank = GuildRank.DEPUTY + + latest_guild.add_log(f"{player.name} 将会长转让给了 {latest_new_owner.name}") + latest_guild.add_audit_log("transfer_owner", player.name, + target=latest_new_owner.name) + self.guild_manager.save_guilds(guilds) + + player.show(f"§l§a公会 §d>> §r已将会长转让给 {latest_new_owner.name}") + + # 通知新会长 + if latest_new_owner.name in self.game_ctrl.allplayers: + self.game_ctrl.sendcmd( + f'/tellraw {latest_new_owner.name} {{"rawtext":[{{"text":"§l§a公会 §d>> §r你已成为公会会长!"}}]}}' + ) + + return True + + +handlers = { + '_handle_effect': _handle_effect, + '_handle_rankings': _handle_rankings, + '_format_time_ago': _format_time_ago, + '_handle_view_guild': _handle_view_guild, + '_handle_view_members': _handle_view_members, + '_handle_view_logs': _handle_view_logs, + '_handle_announcement': _handle_announcement, + '_handle_tasks': _handle_tasks, + '_handle_view_tasks': _handle_view_tasks, + '_handle_join_task': _handle_join_task, + '_handle_create_task': _handle_create_task, + '_handle_generate_auto_tasks': _handle_generate_auto_tasks, + '_handle_manage_tasks': _handle_manage_tasks, + '_handle_manage_members': _handle_manage_members, + '_notify_join_request_admins': _notify_join_request_admins, + '_handle_join_request_queue': _handle_join_request_queue, + '_handle_set_rank': _handle_set_rank, + '_handle_donation': _handle_donation, + '_handle_vault': _handle_vault, + '_handle_vault_view': _handle_vault_view, + '_handle_vault_buy': _handle_vault_buy, + '_handle_vault_sell': _handle_vault_sell, + '_handle_vault_logs': _handle_vault_logs, + '_handle_vault_cancel': _handle_vault_cancel, + '_handle_vault_settings': _handle_vault_settings, + '_handle_create_guild': _handle_create_guild, + '_handle_list_guilds': _handle_list_guilds, + '_handle_join_guild': _handle_join_guild, + '_handle_leave_guild': _handle_leave_guild, + '_handle_dissolve_guild': _handle_dissolve_guild, + '_handle_kick_member': _handle_kick_member, + '_handle_set_base': _handle_set_base, + '_handle_return_base': _handle_return_base, + '_handle_transfer_ownership': _handle_transfer_ownership, +} diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" new file mode 100644 index 00000000..22cf49fd --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" @@ -0,0 +1,486 @@ +"""Quick command handlers for common guild operations.""" + +import json + +from tooldelta import Player, game_utils, fmts +from guild_cloud_interop.models import GuildRank, VaultItem +from guild_cloud_interop.config import Config +from guild_cloud_interop.prompts import render_create_guild_prompt + + +def quick_create_guild(self, player: Player, args: tuple): + """快捷创建公会""" + player_xuid = self.xuidm.get_xuid_by_name(player.name, allow_offline=True) + + # 检查玩家是否已有公会 + guild = self.guild_manager.get_guild_by_player(player.name) + if guild: + player.show(render_create_guild_prompt("已有公会提示词")) + return True + + # 获取公会名称 + guild_name = args[0] if args and args[0] else None + + if not guild_name: + player.show(render_create_guild_prompt("快捷创建缺少名称提示词")) + return True + + if len(guild_name) < 2 or len(guild_name) > 16: + player.show(render_create_guild_prompt("快捷创建名称长度无效提示词")) + return True + + # 检查条件 + score = player.getScore(Config.GUILD_SCOREBOARD) + if score < Config.GUILD_CREATION_COST: + player.show(render_create_guild_prompt("创建公会余额不足提示词", balance=score)) + return True + + # 扣除钻石并创建公会 + self.game_ctrl.sendwocmd( + f"scoreboard players remove { + player.name} { + Config.GUILD_SCOREBOARD} { + Config.GUILD_CREATION_COST}") + + if self.guild_manager.create_guild(player_xuid, player.name, guild_name): + player.show(render_create_guild_prompt( + "创建公会成功提示词", guild=guild_name, player=player.name)) + # 通知所有在线玩家 + announcement = render_create_guild_prompt( + "创建公会全服公告提示词", + guild=guild_name, + player=player.name, + ) + payload = json.dumps( + {"rawtext": [{"text": announcement}]}, ensure_ascii=False) + self.game_ctrl.sendcmd(f"/tellraw @a {payload}") + else: + player.show(render_create_guild_prompt( + "创建公会名称已存在提示词", guild=guild_name, player=player.name)) + self.game_ctrl.sendwocmd( + f"scoreboard players add { + player.name} { + Config.GUILD_SCOREBOARD} { + Config.GUILD_CREATION_COST}") + + return True + + +def quick_join_guild(self, player: Player, args: tuple): + """快捷加入公会""" + if self.guild_manager.get_guild_by_player(player.name): + player.show("§l§a公会 §d>> §r你已经加入了一个公会") + return True + + search_name = args[0] if args and args[0] else None + + if not search_name: + player.show("§l§a公会 §d>> §r请输入公会名字,例如: 公会加入 某某公会") + return True + + # 搜索匹配的公会 + guilds = self.guild_manager.load_guilds() + matched_guilds = [g for g in guilds.values() if search_name.lower() + in g.name.lower()] + + if not matched_guilds: + player.show("§l§a公会 §d>> §r未找到匹配的公会") + return True + + # 选择公会 + if len(matched_guilds) == 1: + target_guild = matched_guilds[0] + else: + player.show(f"§l§a公会 §d>> §r找到多个匹配的公会,请选择:") + for i, guild in enumerate(matched_guilds, 1): + player.show(f"§e{i}. §f{guild.name} §7(会长: {guild.owner})") + + player.show("§7请输入序号选择公会:") + choice = game_utils.waitMsg(player.name, timeout=30) + + if not choice or not choice.isdigit() or int( + choice) < 1 or int(choice) > len(matched_guilds): + player.show("§l§a公会 §d>> §r无效的选择") + return True + + target_guild = matched_guilds[int(choice) - 1] + + if self.guild_is_frozen(target_guild): + self.show_guild_frozen(player, target_guild) + return True + + # 检查人数上限 + if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: + player.show("§l§a公会 §d>> §r该公会已满员") + return True + + # 获取有邀请权限的在线成员 + online_inviters = [] + for member in target_guild.members: + if member.name in self.game_ctrl.allplayers and target_guild.has_permission( + member.name, "invite"): + online_inviters.append(member.name) + + if not online_inviters: + player.show("§l§a公会 §d>> §r没有可以处理申请的成员在线") + return True + + # 选择一个在线的管理员发送申请 + inviter = online_inviters[0] # 可以改进为选择职位最高的 + + # 发送申请 + message = ( + f"§l§a公会 §d>> §r§e{player.name} " + f"§f申请加入公会 §e{target_guild.name}\\n" + "§f输入 §a同意 §f或 §c拒绝" + ) + self.game_ctrl.sendcmd( + f'/tellraw {inviter} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + player.show(f"已向 {inviter} 发送加入申请") + + reply = game_utils.waitMsg(inviter, timeout=60) + if reply == "同意": + if self.guild_manager.add_member( + target_guild.name, player.name, inviter): + player.show(f"§l§a公会 §d>> §r你已加入公会 {target_guild.name}") + self.game_ctrl.sendcmd( + f'/tellraw {inviter} {{"rawtext":[{{"text":"§l§a公会 §d>> §r已同意申请"}}]}}' + ) + # 通知其他在线成员 + for member in target_guild.members: + if member.name in self.game_ctrl.allplayers and member.name != player.name: + message = f"§l§a公会 §d>> §r§e{player.name}§r 加入了公会" + self.game_ctrl.sendcmd( + f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + elif reply == "拒绝": + player.show("§l§a公会 §d>> §r你的申请被拒绝了") + else: + player.show("§l§a公会 §d>> §r申请超时") + + return True + + +def quick_view_guild(self, player: Player, args: tuple): + """快捷查看公会信息""" + self._handle_view_guild(player) + return True + + +def quick_view_members(self, player: Player, args: tuple): + """快捷查看成员列表""" + self._handle_view_members(player) + return True + + +def quick_base_action(self, player: Player, args: tuple): + """快捷公会据点操作""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + action = args[0] if args and args[0] else None + + if action == "tp": + if not guild.base: + player.show("§l§a公会 §d>> §r公会尚未设置据点,请先设置") + return True + return self._handle_return_base(player) + elif action == "set": + # 检查权限 - 只有会长可以设置据点 + member = guild.get_member(player.name) + if not member or member.rank != GuildRank.OWNER: + player.show("§l§a公会 §d>> §r只有会长才能设置公会据点") + player.show("§7当前权限: §f" + + (member.rank.display_name if member else "无")) + return True + return self._handle_set_base(player) + else: + # 显示据点信息 + member = guild.get_member(player.name) + is_owner = member and member.rank == GuildRank.OWNER + + if guild.base: + base = guild.base + dim_name = Config.DIMENSION_NAMES.get( + base.dimension, f"维度{base.dimension}") + player.show( + f"§l§a公会据点§r\n§7位置: §f{dim_name} ({ + base.x:.1f}, { + base.y:.1f}, { + base.z:.1f})") + player.show("§7使用 §f.公会据点 tp §7传送到据点") + if is_owner: + player.show("§7使用 §f.公会据点 set §7重新设置据点") + else: + player.show("§l§a公会 §d>> §r公会尚未设置据点") + if is_owner: + player.show("§7使用 §f公会据点 set §7设置据点") + else: + player.show("§7只有会长才能设置据点") + + return True + + +def quick_donate(self, player: Player, args: tuple): + """快捷捐献物品""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + item_name = args[0] if args and args[0] else None + amount = args[1] if args and args[1] else 1 + + if not item_name: + player.show("§l§a公会 §d>> §r请输入物品名称 (如: 钻石, 绿宝石, 金锭, 铁锭)") + return True + + if amount <= 0: + player.show("§l§a公会 §d>> §r数量必须大于0") + return True + + item_map = { + "钻石": ("minecraft:diamond", 10, 5), + "绿宝石": ("minecraft:emerald", 5, 3), + "金锭": ("minecraft:gold_ingot", 2, 1), + "铁锭": ("minecraft:iron_ingot", 1, 0.5) + } + + item_info = item_map.get(item_name.lower()) + if not item_info: + player.show("§l§a公会 §d>> §r不支持的捐献物品") + return True + + item_id, contrib_per_item, exp_per_item = item_info + + if player.getItemCount(item_id) < amount: + player.show( + f"§l§a公会 §d>> §r你只有 { + player.getItemCount(item_id)} 个{item_name}") + return True + + self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {amount}") + + exp, contribution = self.guild_apply_reward_multipliers( + int(amount * exp_per_item), + amount * contrib_per_item, + ) + + # 重新加载公会数据并更新经验值 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_id = guild.guild_id + + if guild_id in guilds: + # 更新贡献与公会经验 + guild = guilds[guild_id] + member = guild.get_member(player.name) + if member: + member.contribution += contribution + guild.stats.total_contribution += contribution + guild.exp += exp + + # 等级提升逻辑 + while True: + next_level = guild.level + 1 + next_exp_needed = Config.GUILD_LEVEL_EXP.get(next_level) + if not next_exp_needed: + break # 已到最大等级 + if guild.exp >= next_exp_needed: + guild.level += 1 + guild.exp -= next_exp_needed + guild.add_log(f"{guild.name} 公会升级到 Lv{guild.level}!") + else: + break + + # 保存公会数据 + self.guild_manager.save_guilds(guilds) + + player.show(f"§l§a公会 §d>> §r捐献成功!获得 {contribution} 贡献度,公会获得 {exp} 经验") + else: + player.show("§l§a公会 §d>> §r捐献失败,公会数据异常") + fmts.print_err(f"捐献失败: 公会ID {guild_id} 不存在于加载的数据中") + + return True + + +def quick_announcement(self, player: Player, args: tuple): + """快捷查看/设置公告""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + + # 显示当前公告 + if guild.announcement: + player.show(f"§l§a公会公告§r\n§f{guild.announcement}") + else: + player.show("§l§a公会 §d>> §r当前没有公告") + + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + # 检查权限 + if not guild.has_permission(player.name, "announce"): + player.show("§l§a公会 §d>> §r你没有设置公告的权限") + return True + + player.show("§7输入 '设置' 来修改公告,其他任意键返回") + choice = game_utils.waitMsg(player.name, timeout=20) + + if choice == "设置": + player.show("§l§a公会 §d>> §r请输入新的公告内容 (最多100字):") + new_announcement = game_utils.waitMsg(player.name, timeout=60) + + if new_announcement and len(new_announcement) <= 100: + guilds = self.guild_manager.load_guilds(force_reload=True) + guild.announcement = new_announcement + guild.add_log(f"{player.name} 更新了公告") + self.guild_manager.save_guilds(guilds) + player.show("§l§a公会 §d>> §r公告已更新") + + # 通知在线成员 + message = "§l§a公会 §d>> §r公告已更新,输入 公会 公告 查看" + for member in guild.members: + if member.name in self.game_ctrl.allplayers: + self.game_ctrl.sendcmd( + f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + else: + player.show("§l§a公会 §d>> §r公告内容无效或过长") + + return True + + +def quick_vault_menu(self, player: Player, args: tuple): + """快捷仓库菜单""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + return self._handle_vault(player) + + +def quick_vault_sell(self, player: Player, args: tuple): + """快速出售物品到仓库""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + # 所有成员都可以使用仓库 + + user_input = args[0] if args and args[0] else None + count = args[1] if len(args) > 1 and args[1] else 1 + price = args[2] if len(args) > 2 and args[2] else 0 + + if not user_input: + player.show("§l§a公会仓库 §d>> §r请输入物品名称,例如: 出售 钻石 10 500") + player.show("§7支持中文名称: 出售 钻石 10 500") + player.show("§7也支持英文ID: 出售 minecraft:diamond 10 500") + return True + + # 使用智能匹配查找物品ID + item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) + + if not item_id: + player.show("§l§a公会仓库 §d>> §r未找到匹配的物品") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + return True + + # 显示找到的物品 + chinese_name = self.item_matcher.get_chinese_name(item_id) + player.show(f"§l§a公会仓库 §d>> §r找到物品: §f{chinese_name}") + + # 检查仓库容量 + max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + + # 检查玩家是否有该物品 + item_count = player.getItemCount(item_id) + if item_count < count: + player.show(f"§l§a公会仓库 §d>> §r你只有 {item_count} 个该物品,无法出售 {count} 个") + return True + + # 如果没有指定价格,使用建议价格 + if price <= 0: + price = guild.get_item_value(item_id) * count + player.show(f"§l§a公会仓库 §d>> §r使用建议价格: {price} 贡献点") + + # 确认出售 + item_name = self._get_item_display_name(item_id) + player.show( + f"§l§a公会仓库 §d>> §r确认出售 §f{item_name} §7x{count} §r价格 §e{price}贡献点§r?") + player.show("§7输入 '确认' 继续出售,其他任意键取消") + + confirm = game_utils.waitMsg(player.name, timeout=20) + if confirm != "确认": + player.show("§l§a公会仓库 §d>> §r出售已取消") + return True + + # 执行出售 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if not guild: + player.show("§l§a公会仓库 §d>> §r公会数据异常") + return True + + # 再次检查仓库容量 + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + + # 扣除物品 + self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {count}") + + # 添加到仓库 + vault_item = VaultItem( + item_id=item_id, + count=count, + price=price, + seller=player.name + ) + + guild.vault_items.append(vault_item) + guild.add_log(f"{player.name} 出售了 {item_name} x{count} (价格{price}贡献点)") + + # 保存数据 + self.guild_manager.save_guilds(guilds) + + player.show( + f"§l§a公会仓库 §d>> §r出售成功!{item_name} x{count} 已上架,价格 {price} 贡献点") + return True + + +handlers_quick = { + "quick_create_guild": quick_create_guild, + "quick_join_guild": quick_join_guild, + "quick_view_guild": quick_view_guild, + "quick_view_members": quick_view_members, + "quick_base_action": quick_base_action, + "quick_donate": quick_donate, + "quick_announcement": quick_announcement, + "quick_vault_menu": quick_vault_menu, + "quick_vault_sell": quick_vault_sell, +} diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" new file mode 100644 index 00000000..b205855b --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" @@ -0,0 +1,1152 @@ +"""Shared guild runtime and gameplay logic.""" + +import os +import time +from typing import List, Optional, Tuple, Any + + +from tooldelta import Player, game_utils, fmts +from tooldelta.utils import tempjson + +from guild_cloud_interop.config import Config +from guild_cloud_interop.models import GuildData, GuildMember, GuildRank, VaultItem +from guild_cloud_interop.prompts import render_config_prompt +from guild_cloud_interop.ui import format_page_footer, format_panel + + +def _format_template(template: str, **values: Any) -> str: + result = str(template) + for key, value in values.items(): + result = result.replace("{" + key + "}", str(value)) + return result + + +def _menu_config() -> dict[str, Any]: + config = getattr(Config, "MENU_CONFIG", {}) + return config if isinstance(config, dict) else {} + + +def _menu_item(group: str, key: str, fallback_name: str, + fallback_desc: str) -> tuple[str, str]: + menu_config = _menu_config() + group_config = menu_config.get(group, {}) + item_config = group_config.get( + key, {}) if isinstance( + group_config, dict) else {} + if not isinstance(item_config, dict): + item_config = {} + name = item_config.get("名称") + desc = item_config.get("描述") + return ( + str(name) if isinstance(name, str) and name else fallback_name, + str(desc) if isinstance(desc, str) else fallback_desc, + ) + + +def _menu_item_name(group: str, key: str, fallback_name: str) -> str: + return _menu_item(group, key, fallback_name, "")[0] + + +def _show_menu( + self, + player: Player, + guild: Optional[GuildData], + member: Optional[GuildMember]) -> Optional[str]: + """显示公会菜单并返回用户选择 - 增强版本""" + + # 数据完整性检查 + if guild and not member: + # 公会存在但成员不存在,尝试重新获取 + fmts.print_err(f"数据不一致:玩家 {player.name} 在公会 {guild.name} 中但成员信息缺失") + member = guild.get_member(player.name) + if not member: + player.show("§c错误:公会数据不一致,请尝试重新加入公会或联系管理员") + fmts.print_err(f"严重错误:无法找到玩家 {player.name} 的成员信息") + return None + + is_member = guild is not None + is_owner = member and member.rank == GuildRank.OWNER + can_manage_members = bool( + guild and member and any( + guild.has_permission( + player.name, + permission) for permission in ( + "kick", + "set_rank", + "transfer_owner", + "join_queue"))) + + # 调试日志 + if guild: + fmts.print_inf( + f"菜单显示 - 玩家: { + player.name}, 公会: { + guild.name}, 成员职位: { + member.rank.value if member else 'None'}, 是否会长: {is_owner}") + + menu_config = _menu_config() + base_items = [ + ("创建", "创建自己的公会", not is_member), + ("列表", "查看所有公会", True), + ("查看", "查看公会详情", is_member), + ("成员", "查看成员列表", is_member), + ("日志", "查看公会日志", is_member), + ("公告", "查看/设置公告", is_member), + ("加入", "加入一个公会", not is_member), + ("退出", "退出当前公会", is_member and not is_owner), + ("管理", "管理公会成员", can_manage_members), + ("解散", "解散公会", is_owner), + ] + menu_items = [ + (*_menu_item("基础功能", key, default_name, default_desc), condition) + for key, default_desc, condition in base_items + for default_name in (key,) + ] + + optional_menu_config = [ + (Config.GUILD_FUNCTION_VAULT, "仓库", "公会仓库", is_member), + (Config.GUILD_FUNCTION_BASE, "据点", "据点相关操作", is_member), + (Config.GUILD_FUNCTION_DONATION, "捐献", "捐献物品到公会", is_member), + (Config.GUILD_FUNCTION_TASKS, "任务", "公会任务系统", is_member), + (Config.GUILD_FUNCTION_EFFECT, "效果", "通过钻石获得效果增益", is_member), + (Config.GUILD_FUNCTION_RANKINGS, "排行", "查看公会排行榜", True), + ] + + for enabled, cmd, desc, condition in optional_menu_config: + if enabled: + menu_items.append((*_menu_item("可选功能", cmd, cmd, desc), condition)) + + available_items = [(cmd, desc) for cmd, desc, cond in menu_items if cond] + + if member and guild: + subtitle_template = menu_config.get("成员身份显示模板", "[{rank}§f] §e{guild}") + subtitle = _format_template( + subtitle_template, rank=member.rank.display_name, guild=guild.name) + else: + subtitle = str(menu_config.get("游客身份显示", "[§7游客§f]")) + + number_prompt = _format_template( + menu_config.get("输入数字提示模板", "§a❀ §r输入 §e[1-{count}]§r 之间的数字选择功能"), + count=len(available_items), + ) + name_prompt = str(menu_config.get("输入名称提示词", "§a❀ §r也可输入功能名称,输入 §cq §r退出")) + player.show(format_panel( + str(menu_config.get("菜单标题", "公会管理系统")), + available_items, + subtitle=subtitle, + footer=f"{number_prompt}\n{name_prompt}", + )) + choice_map = {str(index): cmd for index, (cmd, _desc) + in enumerate(available_items, start=1)} + choice_map.update({cmd: cmd for cmd, _desc in available_items}) + user_input = game_utils.waitMsg(player.name, timeout=30) + if user_input is None: + player.show(render_config_prompt("菜单回复超时提示词")) + return None + return choice_map.get(user_input, user_input) + + +def guild_update_data(self, args: list[str]): + """更新过去的数据,确保所有公会都有 guild_id,且与外层 id 一致""" + updated = False + + # 使用统一接口加载所有公会数据 + guilds = self.guild_manager.load_guilds(force_reload=True) + + for outer_id, guild in guilds.items(): + inner_id = guild.guild_id + if inner_id != outer_id: + guild.guild_id = outer_id + fmts.print_inf(f"已更新 guild_id: {outer_id}(原: {inner_id})") + updated = True + + if updated: + try: + self.guild_manager.save_guilds(guilds) + fmts.print_inf("公会数据文件已更新完成") + except Exception as e: + fmts.print_err(f"保存公会数据时出错: {e}") + else: + fmts.print_inf("无需更新,所有 数据 已正确") + + +def guild_menu_cb(self, player: Player, args: tuple): + """公会菜单回调函数 - 增强版本""" + player_xuid = self.xuidm.get_xuid_by_name(player.name, allow_offline=True) + + # 强制刷新缓存以确保数据最新 + guild = self.guild_manager.get_guild_by_player( + player.name, force_reload=True) + member = guild.get_member(player.name) if guild else None + + # 额外的数据验证 + if guild and not member: + fmts.print_err(f"数据不一致警告:玩家 {player.name} 在公会 {guild.name} 中但找不到成员记录") + # 尝试重新加载数据 + guild = self.guild_manager.get_guild_by_player( + player.name, force_reload=True) + member = guild.get_member(player.name) if guild else None + + subcommand = self._show_menu(player, guild, member) + + if subcommand is None or subcommand in ("q", "Q", ".", "。"): + return True + + if guild and member and self.guild_is_frozen(guild): + frozen_readonly_items = { + _menu_item_name("基础功能", "列表", "列表"), + _menu_item_name("基础功能", "查看", "查看"), + _menu_item_name("基础功能", "成员", "成员"), + _menu_item_name("基础功能", "日志", "日志"), + _menu_item_name("可选功能", "排行", "排行"), + } + if subcommand not in frozen_readonly_items: + self.show_guild_frozen(player, guild) + return True + + # 路由到对应的处理函数 + # 功能项配置:功能是否启用、菜单标题、处理函数 + optional_handlers_config = [ + (Config.GUILD_FUNCTION_VAULT, "仓库", self._handle_vault), + (Config.GUILD_FUNCTION_BASE, "据点", self._handle_base_menu), + (Config.GUILD_FUNCTION_DONATION, "捐献", self._handle_donation), + (Config.GUILD_FUNCTION_TASKS, "任务", self._handle_tasks), + (Config.GUILD_FUNCTION_EFFECT, "效果", self._handle_effect), + (Config.GUILD_FUNCTION_RANKINGS, "排行", self._handle_rankings), + ] + + # 基础菜单项(总是存在) + handlers = { + _menu_item_name("基础功能", "创建", "创建"): lambda: self._handle_create_guild(player, player_xuid), + _menu_item_name("基础功能", "列表", "列表"): lambda: self._handle_list_guilds(player), + _menu_item_name("基础功能", "查看", "查看"): lambda: self._handle_view_guild(player), + _menu_item_name("基础功能", "成员", "成员"): lambda: self._handle_view_members(player), + _menu_item_name("基础功能", "日志", "日志"): lambda: self._handle_view_logs(player), + _menu_item_name("基础功能", "公告", "公告"): lambda: self._handle_announcement(player), + _menu_item_name("基础功能", "加入", "加入"): lambda: self._handle_join_guild(player), + _menu_item_name("基础功能", "退出", "退出"): lambda: self._handle_leave_guild(player), + _menu_item_name("基础功能", "管理", "管理"): lambda: self._handle_manage_members(player), + _menu_item_name("基础功能", "解散", "解散"): lambda: self._handle_dissolve_guild(player, player_xuid), + } + + # 追加可选功能项 + for enabled, title, func in optional_handlers_config: + if enabled: + handlers[_menu_item_name("可选功能", title, title) + ] = lambda f=func: f(player) + + handler = handlers.get(subcommand) + if handler: + return handler() + else: + player.show(render_config_prompt("无效指令提示词")) + + return True + + +def _create_progress_bar( + self, + current: int, + total: int, + length: int = 10) -> str: + """创建进度条""" + if total == 0: + return "§7[§c无效§7]" + + progress = min(current / total, 1.0) + filled = int(progress * length) + empty = length - filled + + bar = "§a" + "█" * filled + "§7" + "░" * empty + percentage = int(progress * 100) + + return f"§7[{bar}§7] §f{percentage}%" + + +def _format_time_duration(self, seconds: float) -> str: + """格式化时间长度""" + if seconds < 60: + return f"{int(seconds)}秒" + elif seconds < 3600: + return f"{int(seconds // 60)}分钟" + elif seconds < 86400: + return f"{int(seconds // 3600)}小时" + else: + return f"{int(seconds // 86400)}天" + + +def _get_item_display_name(self, item_id: str) -> str: + """获取物品显示名称""" + return self.item_matcher.get_chinese_name(item_id) + + +def _has_inventory_space( + self, + player: Player, + item_id: str, + count: int) -> bool: + """检查玩家背包是否有足够空间""" + return True + + +def _handle_base_menu(self, player: Player) -> bool: + """据点菜单""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + can_return = guild.has_permission(player.name, "return_base") + can_set = guild.has_permission(player.name, "setbase") + + player.show("§r========== §a据点菜单§r ==========") + + if guild.base: + base = guild.base + dim_name = Config.DIMENSION_NAMES.get( + base.dimension, f"维度{base.dimension}") + player.show( + f"§7当前据点: §f{dim_name} ({ + base.x:.1f}, { + base.y:.1f}, { + base.z:.1f})") + if can_return: + player.show("§e1. §f传送到据点") + else: + player.show("§7当前据点: §c未设置") + + if can_set: + player.show("§e2. §f设置据点") + + player.show("§7输入选项序号,q 返回") + + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "1" and guild.base and can_return: + return self._handle_return_base(player) + elif choice == "2" and can_set: + return self._handle_set_base(player) + + return True + + +def guild_chat_cb(self, player: Player, args: tuple): + """公会聊天回调""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + message = args[0] if args and args[0] else None + + if not message: + # 切换聊天模式 + current_mode = self.guild_chat_mode.get(player.name, False) + self.guild_chat_mode[player.name] = not current_mode + + if self.guild_chat_mode[player.name]: + player.show("§l§a公会 §d>> §r已切换到公会聊天模式") + else: + player.show("§l§a公会 §d>> §r已切换到公共聊天模式") + else: + # 发送公会消息 + self._send_guild_message(guild, player.name, message) + + return True + + +def _send_guild_message(self, guild: GuildData, sender: str, message: str): + """发送公会聊天消息""" + if self.guild_is_frozen(guild): + return + member = guild.get_member(sender) + if not member: + return + + # 构建消息 + chat_msg = f"§d✧§b[公会]§d✦ { + member.rank.display_name} §e{sender}§7: §f{message}" + + # 发送给所有在线的公会成员 + for member in guild.members: + if member.name in self.game_ctrl.allplayers: + self.game_ctrl.sendcmd( + f'/tellraw {member.name} {{"rawtext":[{{"text":"{chat_msg}"}}]}}' + ) + + +def on_chat_packet(self, packet): + """处理聊天数据包""" + try: + # 检查是否是玩家聊天 + if packet.get("type") != 1: # 1 表示玩家聊天 + return False + + sender = packet.get("source_name", "") + message = packet.get("message", "") + + # 检查是否在公会聊天模式 + if self.guild_chat_mode.get(sender, False): + guild = self.guild_manager.get_guild_by_player(sender) + if guild: + self._send_guild_message(guild, sender, message) + return True # 阻止原始消息 + except BaseException: + pass + + return False + + +def _should_stop(self) -> bool: + stop_event = getattr(self, "_stop_event", None) + return bool(stop_event and stop_event.is_set()) + + +def _wait_or_stopped(self, seconds: float) -> bool: + stop_event = getattr(self, "_stop_event", None) + if stop_event is None: + time.sleep(seconds) + return False + return stop_event.wait(seconds) + + +def _apply_guild_effects_to_player( + self, + player_name: str, + guild: Optional[GuildData] = None, + force: bool = False, + command_delay: float = 0.2, + effect_names: Optional[set] = None, +) -> int: + """按需给单个在线玩家补发公会增益,避免周期性全量刷命令压租赁服。""" + if not Config.GUILD_FUNCTION_EFFECT or player_name not in self.game_ctrl.allplayers: + return 0 + + if guild is None: + guild = self.guild_manager.get_guild_by_player(player_name) + if guild is None: + return 0 + + effects = getattr(guild, "purchased_effects", {}) or {} + if not effects: + return 0 + + now = time.time() + cache = getattr(self, "_effect_refresh_cache", None) + if cache is None: + cache = {} + self._effect_refresh_cache = cache + + refresh_interval = getattr(Config, "EFFECT_REFRESH_INTERVAL", 3600) + sent_count = 0 + + for effect_name, raw_level in effects.items(): + if _should_stop(self): + break + if effect_names is not None and effect_name not in effect_names: + continue + + try: + amplifier = max(int(raw_level) - 1, 0) + except (TypeError, ValueError): + fmts.print_err( + f"公会 { + guild.name} 的效果 {effect_name} 等级无效: {raw_level}") + continue + + cache_key = (player_name, effect_name) + cached = cache.get(cache_key) + if not force and cached: + cached_amplifier, cached_time = cached + if cached_amplifier == amplifier and now - cached_time < refresh_interval: + continue + + if sent_count and command_delay > 0 and _wait_or_stopped( + self, command_delay): + break + + try: + self.game_ctrl.sendwocmd( + f"effect {player_name} {effect_name} 100000 {amplifier} true" + ) + cache[cache_key] = (amplifier, time.time()) + sent_count += 1 + except Exception as e: + fmts.print_err(f"为玩家 {player_name} 添加效果 {effect_name} 时出错: {e}") + + return sent_count + + +def _refresh_online_effects( + self, + online_players: List[str], + guilds: Optional[dict] = None) -> int: + if not Config.GUILD_FUNCTION_EFFECT: + return 0 + + online_set = set(online_players) + cache = getattr(self, "_effect_refresh_cache", None) + if cache: + for cache_key in list(cache): + if cache_key[0] not in online_set: + del cache[cache_key] + + sent_count = 0 + for player_name in online_players: + if _should_stop(self): + break + + guild = None + if guilds is not None: + guild_id = self.guild_manager.get_cached_guild_id(player_name) + if guild_id: + guild = guilds.get(guild_id) + + sent_count += _apply_guild_effects_to_player( + self, player_name, guild=guild) + + return sent_count + + +def guild_exp_task(self): + """公会经验更新任务""" + while not _should_stop(self): + if _wait_or_stopped(self, Config.EXP_UPDATE_INTERVAL): + break + if not self._plugin_enabled(): + continue + + try: + fmts.print_inf("正在更新公会经验...") + + online_players = list(self.game_ctrl.allplayers) + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_online_count = {} + level_ups = [] + + # 统计每个公会在线人数 + for player_name in online_players: + guild = self.guild_manager.get_guild_by_player(player_name) + if guild: + guild_online_count[guild.guild_id] = guild_online_count.get( + guild.guild_id, 0) + 1 + + # 更新经验和等级 + for gid, count in guild_online_count.items(): + if count == 0: + continue + + guild = guilds[gid] + exp_add, _ = self.guild_apply_reward_multipliers( + count * Config.EXP_PER_ONLINE_MEMBER, + 0, + ) + guild.exp += exp_add + + # 处理升级 + level = guild.level + next_required_exp = Config.GUILD_LEVEL_EXP.get(level + 1) + while next_required_exp and guild.exp >= next_required_exp: + guild.exp -= next_required_exp + level += 1 + next_required_exp = Config.GUILD_LEVEL_EXP.get(level + 1) + level_ups.append((guild.name, level)) + guild.add_log(f"公会升级到 {level} 级") + + guild.level = level + + self.guild_manager.save_guilds(guilds) + + for guild_name, new_level in level_ups: + if _should_stop(self): + break + message = f"§l§a公会 §d>> §r公会 §e{guild_name}§r 升级到了 §e{new_level}§r 级!" + self.game_ctrl.sendcmd( + f'/tellraw @a {{"rawtext":[{{"text":"{message}"}}]}}' + ) + except Exception as e: + fmts.print_err(f"更新公会经验出错: {e}") + + +def update_online_task(self): + """更新在线状态任务""" + while not _should_stop(self): + try: + if _wait_or_stopped(self, 300): + break + if not self._plugin_enabled(): + continue + online_players = list(self.game_ctrl.allplayers) # 这里是 List[str] + + self.guild_manager.update_online_status(online_players) # 传名字列表 + guilds = self.guild_manager.load_guilds() + sent_count = _refresh_online_effects( + self, online_players, guilds=guilds) + + if sent_count: + fmts.print_inf(f"已低频补发 {sent_count} 条公会增益命令") + except Exception as e: + fmts.print_err(f"更新在线状态出错: {e}") + + +def on_player_action(self, packet): + """监听玩家行为,用于任务进度跟踪""" + try: + # TODO 等待具体的数据包格式 + pass + except Exception as e: + fmts.print_err(f"处理玩家行为事件出错: {e}") + + +def update_task_progress( + self, + player_name: str, + task_type: str, + target: str, + amount: int = 1): + """更新任务进度""" + try: + guild = self.guild_manager.get_guild_by_player(player_name) + if not guild: + return + if self.guild_is_frozen(guild): + return + + updated = False + for task in guild.tasks: + if (task.completed or + task.task_type != task_type or + task.target != target or + player_name not in task.participants): + continue + + task.current_count += amount + if task.current_count >= task.target_count: + # 任务完成 + task.completed = True + task.current_count = task.target_count + + # 发放奖励 + reward_exp, reward_contribution = self.guild_apply_reward_multipliers( + task.reward_exp, task.reward_contribution, ) + member = guild.get_member(player_name) + if member: + member.contribution += reward_contribution + guild.exp += reward_exp + guild.stats.total_contribution += reward_contribution + + # 通知玩家 + if player_name in self.game_ctrl.allplayers: + message = ( + f"§l§a公会任务 §d>> §r任务 '{task.name}' 已完成!" + f"获得 {reward_contribution} 贡献点和 {reward_exp} 公会经验" + ) + self.game_ctrl.sendcmd( + f'/tellraw {player_name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + + guild.add_log(f"{player_name} 完成了任务: {task.name}") + updated = True + else: + updated = True + + if updated: + self.guild_manager.mark_guild_dirty(guild.guild_id) + + except Exception as e: + fmts.print_err(f"更新任务进度出错: {e}") + + +def check_and_complete_trade_tasks(self, player_name: str): + """检查并完成贸易任务""" + self.update_task_progress(player_name, "trade", "trade_count", 1) + + +def get_guild_rankings( + self, sort_by: str = "level") -> List[Tuple[GuildData, Any]]: + """获取公会排行榜""" + guilds = self.guild_manager.load_guilds() + guild_list = list(guilds.values()) + + if sort_by == "level": + guild_list.sort(key=lambda g: (g.level, g.exp), reverse=True) + return [(g, g.level) for g in guild_list] + elif sort_by == "members": + guild_list.sort(key=lambda g: len(g.members), reverse=True) + return [(g, len(g.members)) for g in guild_list] + elif sort_by == "contribution": + guild_list.sort(key=lambda g: g.stats.total_contribution, reverse=True) + return [(g, g.stats.total_contribution) for g in guild_list] + elif sort_by == "activity": + # 基于最近活跃度排序 + current_time = time.time() + guild_list.sort(key=lambda g: max( + [m.last_online for m in g.members] + [0]), reverse=True) + return [(g, max([m.last_online for m in g.members] + [0])) + for g in guild_list] + else: + return [(g, 0) for g in guild_list] + + +def get_member_rankings( + self, guild_id: str, sort_by: str = "contribution") -> List[Tuple[GuildMember, Any]]: + """获取公会成员排行榜""" + guilds = self.guild_manager.load_guilds() + guild = guilds.get(guild_id) + + if not guild: + return [] + + members = guild.members.copy() + + if sort_by == "contribution": + members.sort(key=lambda m: m.contribution, reverse=True) + return [(m, m.contribution) for m in members] + elif sort_by == "online_time": + current_time = time.time() + members.sort(key=lambda m: current_time - m.last_online) + return [(m, current_time - m.last_online) for m in members] + elif sort_by == "join_time": + members.sort(key=lambda m: m.join_time) + return [(m, m.join_time) for m in members] + else: + return [(m, 0) for m in members] + + +def _paginate_display( + self, + player: Player, + items: List[Any], + title: str, + formatter, + allow_selection: bool = False) -> Optional[int]: + """分页显示通用函数""" + if not items: + player.show(render_config_prompt("通用分页为空提示词", title=title)) + return None + + page = 1 + max_page = (len(items) + Config.ITEMS_PER_PAGE - + 1) // Config.ITEMS_PER_PAGE + + while True: + page = max(1, min(page, max_page)) + start = (page - 1) * Config.ITEMS_PER_PAGE + end = start + Config.ITEMS_PER_PAGE + + msg = format_panel(title, subtitle=f"第 {page}/{max_page} 页") + + for i, item in enumerate(items[start:end], start=1): + msg += "\n" + formatter(start + i, item).rstrip() + + msg += "\n" + format_page_footer(page, max_page, + start + 1, end, allow_selection) + + player.show(msg) + + choice = game_utils.waitMsg(player.name, timeout=20) + if choice is None: + player.show(render_config_prompt("通用分页超时提示词")) + return None + elif choice == "+": + page = min(page + 1, max_page) + elif choice == "-": + page = max(page - 1, 1) + elif choice == "q": + player.show(render_config_prompt("通用分页退出提示词")) + return None + elif allow_selection and choice.isdigit(): + idx = int(choice) + if 1 <= idx <= len(items): + return idx - 1 + else: + player.show(render_config_prompt("通用分页无效选择提示词")) + + +def custom_vault_sell(self, player: Player, args: tuple): + """自定义价格出售物品到仓库""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + user_input = args[0] if args and args[0] else None + count = args[1] if len(args) > 1 and args[1] else 1 + custom_price = args[2] if len(args) > 2 and args[2] else 0 + + if not user_input: + player.show("§l§a公会仓库 §d>> §r请输入物品名称,例如: .自定义出售 钻石 10 800") + player.show("§7格式: 自定义出售 [物品名称] [数量] [自定义价格]") + player.show("§7支持中文: 自定义出售 钻石 10 800") + player.show("§7支持英文: 自定义出售 minecraft:diamond 10 800") + return True + + # 使用智能匹配查找物品ID + item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) + + if not item_id: + player.show("§l§a公会仓库 §d>> §r未找到匹配的物品") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + return True + + # 检查仓库容量 + max_slots = Config.VAULT_INITIAL_SLOTS + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + + # 检查玩家是否有该物品 + item_count = player.getItemCount(item_id) + if item_count < count: + player.show(f"§l§a公会仓库 §d>> §r你只有 {item_count} 个该物品,无法出售 {count} 个") + return True + + # 如果没有指定价格,询问自定义价格 + if custom_price <= 0: + suggested_price = guild.get_item_value(item_id) * count + player.show(f"§l§a公会仓库 §d>> §r建议价格: {suggested_price} 贡献点") + player.show("§7请输入你的自定义价格 (贡献点):") + + price_input = game_utils.waitMsg(player.name, timeout=30) + if not price_input or not price_input.isdigit(): + player.show("§l§a公会仓库 §d>> §r无效的价格") + return True + + custom_price = int(price_input) + if custom_price <= 0: + player.show("§l§a公会仓库 §d>> §r价格必须大于0") + return True + + # 确认出售 + item_name = self._get_item_display_name(item_id) + player.show(f"§l§a公会仓库 §d>> §r确认以自定义价格出售?") + player.show(f"§7物品: §f{item_name} x{count}") + player.show(f"§7自定义价格: §e{custom_price}贡献点") + player.show("§7输入 '确认' 继续出售,其他任意键取消") + + confirm = game_utils.waitMsg(player.name, timeout=20) + if confirm != "确认": + player.show("§l§a公会仓库 §d>> §r出售已取消") + return True + + # 执行出售 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if not guild: + player.show("§l§a公会仓库 §d>> §r公会数据异常") + return True + + # 再次检查仓库容量 + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + + # 扣除物品 + self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {count}") + + # 添加到仓库 + vault_item = VaultItem( + item_id=item_id, + count=count, + price=custom_price, + seller=player.name + ) + + guild.vault_items.append(vault_item) + guild.add_log( + f"{player.name} 自定义价格出售了 {item_name} x{count} (价格{custom_price}贡献点)") + + # 保存数据 + self.guild_manager.save_guilds(guilds) + + player.show( + f"§l§a公会仓库 §d>> §r自定义价格出售成功!{item_name} x{count} 已上架,价格 {custom_price} 贡献点") + + # 更新贸易任务进度 + self.check_and_complete_trade_tasks(player.name) + + return True + + +def show_item_list(self, player: Player, args: tuple): + """显示支持的物品名称列表""" + player.show("§r========== §a支持的物品名称§r ==========") + player.show("§7以下是系统支持的物品名称,您可以在各种功能中使用:") + player.show("") + + # 按类别显示物品 + categories = { + "§e基础材料": ["钻石", "绿宝石", "金锭", "铁锭", "铜锭", "煤炭", "红石", "青金石", "石英"], + "§a稀有材料": ["下界合金锭", "远古残骸", "末影珍珠", "烈焰棒", "恶魂之泪"], + "§b方块材料": ["石头", "圆石", "泥土", "沙子", "砂砾", "黑曜石", "下界岩"], + "§6木材": ["橡木原木", "白桦原木", "云杉原木", "丛林原木", "金合欢原木"], + "§c食物": ["苹果", "金苹果", "面包", "牛肉", "熟牛肉", "胡萝卜", "土豆"], + "§d染料": ["墨囊", "玫瑰红", "橙色染料", "黄色染料", "绿色染料", "蓝色染料"] + } + + for category, items in categories.items(): + player.show(f"{category}:") + item_line = " " + for i, item in enumerate(items): + if i > 0 and i % 4 == 0: # 每行显示4个物品 + player.show(item_line) + item_line = " " + item_line += f"§f{item}§7, " + if item_line.strip() != "": + player.show(item_line.rstrip(", ")) + player.show("") + + player.show("§7提示:") + player.show("§7- 支持中文名称输入,如: §f钻石§7、§f铁锭§7、§f金块") + player.show("§7- 支持模糊匹配,如: 输入 §f铁§7 可匹配 §f铁锭§7、§f铁块") + player.show("§7- 支持别名,如: §f钻§7 可匹配 §f钻石") + player.show("§7- 也支持英文ID,如: §fminecraft:diamond") + player.show("§7- 使用 §e.物品列表§7 随时查看此列表") + + return True + + +def admin_clear_guild_data(self, player: Player, args: tuple): + """管理员清理公会数据功能""" + confirm = args[0] if args and args[0] else "" + + # 简单的管理员验证 (可以根据需要修改) + if player.name not in ["Admin", "管理员", "op"]: # 根据实际情况修改管理员名单 + player.show("§c权限不足:此功能仅限管理员使用") + return True + + if confirm != "确认清理": + player.show("§l§c公会数据清理工具§r") + player.show("§c警告:此操作将删除所有公会数据,包括:") + player.show("§7- 所有公会记录") + player.show("§7- 成员信息") + player.show("§7- 仓库物品") + player.show("§7- 任务记录") + player.show("§7- 公会日志") + player.show("§c此操作不可撤销!") + player.show("§e如果确认要清理,请使用:§f.清理公会数据 确认清理") + return True + + try: + # 备份当前数据 + import shutil + import time + + backup_file = f"{self.guilds_file}.backup_{int(time.time())}" + if os.path.exists(self.guilds_file): + shutil.copy2(self.guilds_file, backup_file) + player.show(f"§a已创建数据备份:{backup_file}") + + # 清理内存中的数据 + self.guild_manager.reset_runtime_state() + + # 清理数据文件 + empty_data = {} + tempjson.load_and_write( + self.guilds_file, + empty_data, + need_file_exists=False) + tempjson.flush(self.guilds_file) + + player.show("§l§a公会数据清理完成§r") + player.show("§a✅ 已清空所有公会记录") + player.show("§a✅ 已重置缓存数据") + player.show("§a✅ 已清空数据文件") + player.show(f"§7备份文件:{backup_file}") + player.show("§7系统已回到初始状态,可以重新创建公会") + + # 记录操作日志 + fmts.print_inf(f"管理员 {player.name} 清理了所有公会数据") + + except Exception as e: + player.show(f"§c清理失败:{str(e)}") + fmts.print_err(f"清理公会数据时出错:{e}") + + return True + + +def debug_guild_menu(self, player: Player, args: tuple): + """调试公会菜单显示问题""" + player.show("§l§c公会菜单调试信息§r") + player.show("=" * 40) + + # 获取公会和成员信息 + guild = self.guild_manager.get_guild_by_player(player.name) + member = guild.get_member(player.name) if guild else None + + player.show(f"§7玩家名称: §f{player.name}") + player.show(f"§7公会存在: §f{guild is not None}") + + if guild: + player.show(f"§7公会名称: §f{guild.name}") + player.show(f"§7公会ID: §f{guild.guild_id}") + player.show(f"§7成员数量: §f{len(guild.members)}") + + if member: + player.show(f"§7成员存在: §f{member is not None}") + player.show(f"§7成员名称: §f{member.name}") + player.show(f"§7成员职位: §f{member.rank}") + player.show(f"§7职位值: §f{member.rank.value}") + player.show(f"§7是否会长: §f{member.rank == GuildRank.OWNER}") + player.show(f"§7是否副会长: §f{member.rank == GuildRank.DEPUTY}") + + # 测试菜单条件 + is_member = guild is not None + is_owner = member and member.rank == GuildRank.OWNER + is_deputy_or_above = member and member.rank in [ + GuildRank.OWNER, GuildRank.DEPUTY] + + player.show("§7菜单条件测试:") + player.show(f" is_member: §f{is_member}") + player.show(f" is_owner: §f{is_owner}") + player.show(f" is_deputy_or_above: §f{is_deputy_or_above}") + + # 测试具体菜单项 + menu_tests = [ + ("查看", is_member), + ("成员", is_member), + ("日志", is_member), + ("公告", is_member), + ("仓库", Config.GUILD_FUNCTION_VAULT and is_member), + ("管理", is_deputy_or_above), + ("解散", is_owner), + ("据点", Config.GUILD_FUNCTION_BASE and is_member), + ("捐献", Config.GUILD_FUNCTION_DONATION and is_member), + ("任务", Config.GUILD_FUNCTION_TASKS and is_member), + ("效果", Config.GUILD_FUNCTION_EFFECT and is_member), + ] + + player.show("§7菜单项显示测试:") + for menu_name, condition in menu_tests: + status = "✅显示" if condition else "❌隐藏" + player.show(f" {menu_name}: §f{status}") + else: + player.show("§c成员信息不存在!") + else: + player.show("§c公会信息不存在!") + + player.show("=" * 40) + return True + + +def debug_base_function(self, player: Player, args: tuple): + """调试据点功能问题""" + player.show("§l§c据点功能调试信息§r") + player.show("=" * 50) + + # 获取公会和成员信息 + guild = self.guild_manager.get_guild_by_player(player.name) + member = guild.get_member(player.name) if guild else None + + player.show(f"§7玩家名称: §f{player.name}") + player.show(f"§7公会存在: §f{guild is not None}") + + if guild: + player.show(f"§7公会名称: §f{guild.name}") + player.show(f"§7公会ID: §f{guild.guild_id}") + + if member: + player.show(f"§7成员职位: §f{member.rank.value}") + player.show(f"§7是否会长: §f{member.rank == GuildRank.OWNER}") + + # 权限检查测试 + has_setbase_old = guild.has_permission(player.name, "setbase") + is_owner_new = member.rank == GuildRank.OWNER + + player.show("§7权限检查结果:") + player.show(f" 旧方式(has_permission): §f{has_setbase_old}") + player.show(f" 新方式(直接检查会长): §f{is_owner_new}") + + if has_setbase_old != is_owner_new: + player.show("§c⚠️ 权限检查结果不一致!") + else: + player.show("§a✅ 权限检查一致") + + # 据点信息检查 + player.show("§7据点信息:") + if guild.base: + base = guild.base + player.show(f" 据点存在: §a是") + player.show(f" 维度: §f{base.dimension}") + player.show(f" 坐标: §f({base.x}, {base.y}, {base.z})") + player.show( + f" 坐标类型: §f{ + type( + base.x).__name__}, { + type( + base.y).__name__}, { + type( + base.z).__name__}") + + # 验证坐标有效性 + try: + x, y, z = float(base.x), float(base.y), float(base.z) + player.show(f" 坐标转换: §a成功 ({x}, {y}, {z})") + except Exception as e: + player.show(f" 坐标转换: §c失败 - {e}") + + # 验证维度有效性 + valid_dimensions = [0, -1, 1] + if base.dimension in valid_dimensions: + player.show(f" 维度有效性: §a有效") + else: + player.show(f" 维度有效性: §c无效 (应为 {valid_dimensions})") + + # 测试传送指令格式和方法 + player.show("§7传送方法测试:") + tp_methods = [ + ("sendwocmd", f"tp {player.name} {base.x} {base.y} {base.z}"), + ] + for i, (method, cmd) in enumerate(tp_methods): + player.show(f" 方法{i + 1}({method}): §f{cmd}") + + else: + player.show(f" 据点存在: §c否") + player.show(f" 原因: 公会未设置据点") + else: + player.show("§c公会信息不存在!") + + player.show("=" * 50) + return True + + +logic_functions = { + "_show_menu": _show_menu, + "guild_update_data": guild_update_data, + "guild_menu_cb": guild_menu_cb, + "_create_progress_bar": _create_progress_bar, + "_format_time_duration": _format_time_duration, + "_get_item_display_name": _get_item_display_name, + "_has_inventory_space": _has_inventory_space, + "_handle_base_menu": _handle_base_menu, + "_send_guild_message": _send_guild_message, + "on_chat_packet": on_chat_packet, + "_should_stop": _should_stop, + "_wait_or_stopped": _wait_or_stopped, + "_apply_guild_effects_to_player": _apply_guild_effects_to_player, + "_refresh_online_effects": _refresh_online_effects, + "guild_exp_task": guild_exp_task, + "update_online_task": update_online_task, + "on_player_action": on_player_action, + "update_task_progress": update_task_progress, + "check_and_complete_trade_tasks": check_and_complete_trade_tasks, + "get_member_rankings": get_member_rankings, + "_paginate_display": _paginate_display, + "custom_vault_sell": custom_vault_sell, + "show_item_list": show_item_list, + "admin_clear_guild_data": admin_clear_guild_data, + "guild_chat_cb": guild_chat_cb, + "debug_guild_menu": debug_guild_menu, + "debug_base_function": debug_base_function, + "get_guild_rankings": get_guild_rankings +} diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/matchers.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/matchers.py" new file mode 100644 index 00000000..92bbf18d --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/matchers.py" @@ -0,0 +1,147 @@ +"""Item name matching helpers for guild vault commands.""" + +from typing import List, Optional, Tuple +from guild_cloud_interop.config import Config + + +class ItemNameMatcher: + """智能物品名称匹配器""" + + def __init__(self): + self.chinese_names = Config.CHINESE_ITEM_NAMES + self.aliases = Config.ITEM_ALIASES + # 创建反向映射:物品ID -> 中文名称 + self.id_to_chinese = {v: k for k, v in self.chinese_names.items()} + + def normalize_input(self, input_text: str) -> str: + """标准化输入文本""" + if not input_text: + return "" + return input_text.strip().lower() + + def find_item_id(self, user_input: str) -> Optional[str]: + """根据用户输入查找物品ID""" + if not user_input: + return None + + user_input = user_input.strip() + + # 1. 直接匹配物品ID(向后兼容) + if user_input.startswith("minecraft:"): + return user_input if user_input in self.id_to_chinese else None + + # 2. 直接匹配中文名称 + if user_input in self.chinese_names: + return self.chinese_names[user_input] + + # 3. 匹配别名 + if user_input in self.aliases: + chinese_name = self.aliases[user_input] + return self.chinese_names.get(chinese_name) + + # 4. 模糊匹配(部分匹配) + matches = self.fuzzy_match(user_input) + if matches: + return self.chinese_names[matches[0]] + + return None + + def fuzzy_match(self, user_input: str, max_results: int = 5) -> List[str]: + """模糊匹配,返回可能的中文名称列表""" + if not user_input: + return [] + + user_input = user_input.lower() + matches = [] + + # 搜索中文名称 + for chinese_name in self.chinese_names.keys(): + if user_input in chinese_name.lower(): + matches.append( + (chinese_name, self._calculate_match_score( + user_input, chinese_name))) + + # 搜索别名 + for alias, chinese_name in self.aliases.items(): + if user_input in alias.lower() and chinese_name not in [ + m[0] for m in matches]: + matches.append( + (chinese_name, + self._calculate_match_score( + user_input, + alias))) + + # 按匹配度排序 + matches.sort(key=lambda x: x[1], reverse=True) + + return [match[0] for match in matches[:max_results]] + + def _calculate_match_score(self, user_input: str, target: str) -> float: + """计算匹配分数""" + user_input = user_input.lower() + target = target.lower() + + # 完全匹配得分最高 + if user_input == target: + return 1.0 + + # 开头匹配得分较高 + if target.startswith(user_input): + return 0.8 + (len(user_input) / len(target)) * 0.2 + + # 包含匹配 + if user_input in target: + return 0.6 + (len(user_input) / len(target)) * 0.2 + + # 字符相似度 + return self._string_similarity(user_input, target) * 0.4 + + def _string_similarity(self, s1: str, s2: str) -> float: + """计算字符串相似度""" + if not s1 or not s2: + return 0.0 + + # 简单的字符重叠度计算 + s1_chars = set(s1) + s2_chars = set(s2) + + intersection = len(s1_chars & s2_chars) + union = len(s1_chars | s2_chars) + + return intersection / union if union > 0 else 0.0 + + def get_chinese_name(self, item_id: str) -> str: + """根据物品ID获取中文名称""" + return self.id_to_chinese.get( + item_id, item_id.replace( + "minecraft:", "").replace( + "_", " ").title()) + + def get_suggestions(self, user_input: str, + max_suggestions: int = 5) -> List[Tuple[str, str]]: + """获取输入建议,返回 (中文名称, 物品ID) 的列表""" + if not user_input: + return [] + + matches = self.fuzzy_match(user_input, max_suggestions) + suggestions = [] + + for chinese_name in matches: + item_id = self.chinese_names[chinese_name] + suggestions.append((chinese_name, item_id)) + + return suggestions + + def validate_and_suggest( + self, user_input: str) -> Tuple[Optional[str], List[str]]: + """验证输入并提供建议 + 返回: (找到的物品ID, 建议列表) + """ + item_id = self.find_item_id(user_input) + + if item_id: + return item_id, [] + + # 如果没找到,提供建议 + suggestions = self.fuzzy_match(user_input, 5) + return None, suggestions diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" new file mode 100644 index 00000000..6cb8112e --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" @@ -0,0 +1,736 @@ +"""Data models for the guild cloud interop plugin.""" + +import uuid +import time + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional, Tuple, Any +from tooldelta import fmts +from datetime import datetime +from guild_cloud_interop.config import Config + +# FIRE 枚举类型 FIRE + + +class GuildRank(Enum): + """Guild membership roles.""" + + OWNER = "owner" + DEPUTY = "deputy" + ELDER = "elder" + MEMBER = "member" + + @property + def display_name(self): + return { + "owner": "§c会长", + "deputy": "§6副会长", + "elder": "§e长老", + "member": "§a成员" + }[self.value] + + @property + def config_key(self): + return { + "owner": "会长", + "deputy": "副会长", + "elder": "长老", + "member": "成员" + }[self.value] + + +PERMISSION_CONFIG_KEYS = { + "kick": "踢出成员权限", + "invite": "处理/同意加入公会申请权限", + "announce": "设置公会公告权限", + "task_manage": "管理公会任务权限", + "vault": "公会仓库使用权限", + "setbase": "设置公会据点权限", + "return_base": "返回公会据点权限", + "effect_buy": "购买公会效果权限", + "vault_settings": "设置仓库物品价值权限", + "vault_sell": "出售仓库物品权限", + "vault_buy": "购买仓库物品权限", + "vault_cancel_own": "撤回自己出售物品权限", + "vault_cancel_any": "撤回任意仓库物品权限", + "join_queue": "处理加入申请队列权限", + "audit_log": "查看审计日志权限", + "set_rank": "设置成员职位权限", + "transfer_owner": "转让会长权限", + "task_create": "创建公会任务权限", + "task_delete": "删除公会任务权限", + "task_complete": "强制完成公会任务权限", +} + + +# FIRE 数据类 FIRE +@dataclass +class GuildBase: + """公会据点信息""" + dimension: int + x: float + y: float + z: float + + def to_dict(self): + return { + "dimension": self.dimension, + "x": self.x, + "y": self.y, + "z": self.z} + + +@dataclass +class VaultItem: + """仓库物品信息""" + item_id: str + count: int + price: int # 贡献点价格 + seller: str + timestamp: float = field(default_factory=time.time) + + def to_dict(self): + return { + "item_id": self.item_id, + "count": self.count, + "price": self.price, + "seller": self.seller, + "timestamp": self.timestamp + } + + @classmethod + def from_dict(cls, data): + return cls( + item_id=data["item_id"], + count=data["count"], + price=data["price"], + seller=data["seller"], + timestamp=data.get("timestamp", time.time()) + ) + + +@dataclass +class GuildMember: + """公会成员信息""" + name: str + rank: GuildRank + join_time: float + contribution: int = 0 + last_online: float = field(default_factory=time.time) + + def to_dict(self): + return { + "name": self.name, + "rank": self.rank.value, + "join_time": self.join_time, + "contribution": self.contribution, + "last_online": self.last_online + } + + @classmethod + def from_dict(cls, data): + return cls( + name=data["name"], + rank=GuildRank(data.get("rank", "member")), + join_time=data.get("join_time", time.time()), + contribution=data.get("contribution", 0), + last_online=data.get("last_online", time.time()) + ) + + +@dataclass +class GuildJoinRequest: + """公会加入申请记录""" + player_name: str + reason: str = "" + create_time: float = field(default_factory=time.time) + status: str = "pending" + handler: str = "" + handle_time: float = 0 + result_reason: str = "" + + def to_dict(self): + return { + "player_name": self.player_name, + "reason": self.reason, + "create_time": self.create_time, + "status": self.status, + "handler": self.handler, + "handle_time": self.handle_time, + "result_reason": self.result_reason, + } + + @classmethod + def from_dict(cls, data): + return cls( + player_name=data["player_name"], + reason=data.get("reason", ""), + create_time=data.get("create_time", time.time()), + status=data.get("status", "pending"), + handler=data.get("handler", ""), + handle_time=data.get("handle_time", 0), + result_reason=data.get("result_reason", ""), + ) + + +@dataclass +class VaultTradeLog: + """公会仓库交易日志""" + action: str + item_id: str + count: int + price: int + actor: str + seller: str = "" + buyer: str = "" + timestamp: float = field(default_factory=time.time) + detail: str = "" + + def to_dict(self): + return { + "action": self.action, + "item_id": self.item_id, + "count": self.count, + "price": self.price, + "actor": self.actor, + "seller": self.seller, + "buyer": self.buyer, + "timestamp": self.timestamp, + "detail": self.detail, + } + + @classmethod + def from_dict(cls, data): + return cls( + action=data["action"], + item_id=data["item_id"], + count=data.get("count", 0), + price=data.get("price", 0), + actor=data.get("actor", ""), + seller=data.get("seller", ""), + buyer=data.get("buyer", ""), + timestamp=data.get("timestamp", time.time()), + detail=data.get("detail", ""), + ) + + +@dataclass +class GuildAuditLog: + """公会审计日志""" + action: str + actor: str + target: str = "" + detail: str = "" + result: str = "success" + timestamp: float = field(default_factory=time.time) + + def to_dict(self): + return { + "action": self.action, + "actor": self.actor, + "target": self.target, + "detail": self.detail, + "result": self.result, + "timestamp": self.timestamp, + } + + @classmethod + def from_dict(cls, data): + return cls( + action=data["action"], + actor=data.get("actor", ""), + target=data.get("target", ""), + detail=data.get("detail", ""), + result=data.get("result", "success"), + timestamp=data.get("timestamp", time.time()), + ) + + +@dataclass +class GuildTask: + """公会任务""" + task_id: str + name: str + description: str + task_type: str # "collect", "kill", "build", "trade" + target: str # 目标物品/怪物等 + target_count: int + current_count: int = 0 + reward_exp: int = 0 + reward_contribution: int = 0 + create_time: float = field(default_factory=time.time) + deadline: float = 0 # 截止时间,0表示无限期 + completed: bool = False + participants: List[str] = field(default_factory=list) # 参与者列表 + + def to_dict(self): + return { + "task_id": self.task_id, + "name": self.name, + "description": self.description, + "task_type": self.task_type, + "target": self.target, + "target_count": self.target_count, + "current_count": self.current_count, + "reward_exp": self.reward_exp, + "reward_contribution": self.reward_contribution, + "create_time": self.create_time, + "deadline": self.deadline, + "completed": self.completed, + "participants": self.participants + } + + @classmethod + def from_dict(cls, data): + return cls( + task_id=data["task_id"], + name=data["name"], + description=data["description"], + task_type=data["task_type"], + target=data["target"], + target_count=data["target_count"], + current_count=data.get("current_count", 0), + reward_exp=data.get("reward_exp", 0), + reward_contribution=data.get("reward_contribution", 0), + create_time=data.get("create_time", time.time()), + deadline=data.get("deadline", 0), + completed=data.get("completed", False), + participants=data.get("participants", []) + ) + + +@dataclass +class GuildStats: + """公会统计数据""" + total_contribution: int = 0 + total_trades: int = 0 + total_online_time: int = 0 + member_count_history: List[Tuple[float, int]] = field(default_factory=list) + level_up_history: List[Tuple[float, int]] = field(default_factory=list) + + def to_dict(self): + return { + "total_contribution": self.total_contribution, + "total_trades": self.total_trades, + "total_online_time": self.total_online_time, + "member_count_history": self.member_count_history, + "level_up_history": self.level_up_history + } + + @classmethod + def from_dict(cls, data): + return cls( + total_contribution=data.get("total_contribution", 0), + total_trades=data.get("total_trades", 0), + total_online_time=data.get("total_online_time", 0), + member_count_history=data.get("member_count_history", []), + level_up_history=data.get("level_up_history", []) + ) + + +@dataclass +class GuildData: + """公会完整数据""" + guild_id: str + name: str + owner: str + level: int = 1 + exp: int = 0 + create_time: float = field(default_factory=time.time) + base: Optional[GuildBase] = None + vault: Dict[str, int] = field(default_factory=dict) # 保留原有仓库格式兼容性 + vault_items: List[VaultItem] = field(default_factory=list) # 新的仓库物品列表 + custom_item_values: Dict[str, int] = field(default_factory=dict) # 自定义物品价值 + announcement: str = "" + members: List[GuildMember] = field(default_factory=list) + logs: List[str] = field(default_factory=list) + purchased_effects: Dict[str, int] = field(default_factory=dict) + stats: GuildStats = field(default_factory=GuildStats) # 新增统计数据 + settings: Dict[str, Any] = field(default_factory=dict) # 公会设置 + tasks: List[GuildTask] = field(default_factory=list) # 公会任务列表 + join_requests: List[GuildJoinRequest] = field(default_factory=list) + vault_trade_logs: List[VaultTradeLog] = field(default_factory=list) + audit_logs: List[GuildAuditLog] = field(default_factory=list) + + def add_log(self, message: str): + """添加日志""" + timestamp = datetime.now().strftime("%m-%d %H:%M") + self.logs.append(f"[{timestamp}] {message}") + # 保留最近50条日志 + if len(self.logs) > 50: + self.logs = self.logs[-50:] + + def add_audit_log( + self, + action: str, + actor: str, + target: str = "", + detail: str = "", + result: str = "success", + now: Optional[float] = None, + ): + """添加审计日志""" + self.audit_logs.append(GuildAuditLog( + action=action, + actor=actor, + target=target, + detail=detail, + result=result, + timestamp=time.time() if now is None else now, + )) + max_logs = int( + getattr( + Config, + "GUILD_DATA_SAFETY_CONFIG", + {}).get( + "审计日志保留数量", + 200)) + if max_logs > 0 and len(self.audit_logs) > max_logs: + self.audit_logs = self.audit_logs[-max_logs:] + + def get_member(self, name: str) -> Optional[GuildMember]: + """获取成员信息""" + for member in self.members: + if member.name == name: + return member + return None + + def pending_join_requests( + self, + now: Optional[float] = None) -> List[GuildJoinRequest]: + """获取未过期的待处理加入申请""" + current_time = time.time() if now is None else now + expire_seconds = int( + getattr( + Config, + "GUILD_JOIN_REQUEST_CONFIG", + {}).get( + "申请有效期秒", + 86400)) + return [ + request + for request in self.join_requests + if request.status == "pending" + and (expire_seconds <= 0 or current_time - request.create_time <= expire_seconds) + ] + + def add_join_request( + self, + player_name: str, + reason: str = "", + now: Optional[float] = None) -> bool: + """添加离线加入申请""" + if self.get_member(player_name): + return False + + current_time = time.time() if now is None else now + join_config = getattr(Config, "GUILD_JOIN_REQUEST_CONFIG", {}) + max_reason_len = int(join_config.get("申请理由最大长度", 60)) + reason = (reason or "")[:max_reason_len] + + for request in self.pending_join_requests(now=current_time): + if request.player_name == player_name: + return False + + max_pending = int(join_config.get("每个公会最多待处理申请数", 30)) + if max_pending > 0 and len( + self.pending_join_requests( + now=current_time)) >= max_pending: + return False + + self.join_requests.append(GuildJoinRequest( + player_name=player_name, + reason=reason, + create_time=current_time, + )) + self.add_log(f"{player_name} 提交了加入申请") + self.add_audit_log("join_request_create", player_name, + detail=reason, now=current_time) + return True + + def resolve_join_request( + self, + player_name: str, + handler: str, + approved: bool, + result_reason: str = "", + now: Optional[float] = None, + ) -> bool: + """处理加入申请""" + current_time = time.time() if now is None else now + for request in self.pending_join_requests(now=current_time): + if request.player_name != player_name: + continue + request.status = "approved" if approved else "rejected" + request.handler = handler + request.handle_time = current_time + request.result_reason = result_reason + self.add_log( + f"{handler} { + '批准' if approved else '拒绝'}了 {player_name} 的加入申请") + self.add_audit_log( + "join_request_approve" if approved else "join_request_reject", + handler, + target=player_name, + detail=result_reason, + now=current_time, + ) + return True + return False + + def has_permission(self, player_name: str, permission: str) -> bool: + """检查成员权限 - 优化版本""" + if not player_name or not permission: + return False + + member = self.get_member(player_name) + if not member: + return False + + rank_permissions = Config.PERMISSIONS.get(member.rank.config_key, {}) + permission_key = PERMISSION_CONFIG_KEYS.get(permission) + + has_perm = ( + isinstance(rank_permissions, dict) + and permission_key is not None + and bool(rank_permissions.get(permission_key, False)) + ) + + if not has_perm: + self.add_log(f"权限检查失败: {player_name} 尝试使用 {permission} 权限") + + return has_perm + + def get_member_permissions(self, player_name: str) -> List[str]: + """获取成员的所有权限列表""" + member = self.get_member(player_name) + if not member: + return [] + + rank_permissions = Config.PERMISSIONS.get(member.rank.config_key, {}) + if not isinstance(rank_permissions, dict): + return [] + + return [ + permission + for permission, config_key in PERMISSION_CONFIG_KEYS.items() + if bool(rank_permissions.get(config_key, False)) + ] + + def can_manage_member(self, manager_name: str, target_name: str) -> bool: + """检查是否可以管理指定成员""" + manager = self.get_member(manager_name) + target = self.get_member(target_name) + + if not manager or not target: + return False + + if manager_name == target_name: + return False + + if manager.rank == GuildRank.OWNER: + return True + + if manager.rank == GuildRank.DEPUTY: + return target.rank in [GuildRank.ELDER, GuildRank.MEMBER] + + return False + + def get_item_value(self, item_id: str) -> int: + """获取物品的贡献点价值""" + # 优先使用自定义价值,否则使用默认价值 + return self.custom_item_values.get( + item_id, Config.DEFAULT_ITEM_VALUES.get(item_id, 1)) + + def add_vault_item(self, item: VaultItem) -> bool: + """添加物品到仓库""" + max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 + if len(self.vault_items) >= max_slots: + return False + self.vault_items.append(item) + return True + + def remove_vault_item(self, index: int) -> Optional[VaultItem]: + """从仓库移除物品""" + if 0 <= index < len(self.vault_items): + return self.vault_items.pop(index) + return None + + def add_vault_trade_log( + self, + action: str, + item: VaultItem, + actor: str, + buyer: str = "", + detail: str = "", + now: Optional[float] = None, + ): + """添加仓库交易日志""" + self.vault_trade_logs.append(VaultTradeLog( + action=action, + item_id=item.item_id, + count=item.count, + price=item.price, + actor=actor, + seller=item.seller, + buyer=buyer, + timestamp=time.time() if now is None else now, + detail=detail, + )) + max_logs = int( + getattr( + Config, + "GUILD_VAULT_CONFIG", + {}).get( + "交易日志保留数量", + 120)) + if max_logs > 0 and len(self.vault_trade_logs) > max_logs: + self.vault_trade_logs = self.vault_trade_logs[-max_logs:] + + def cancel_vault_item( + self, + actor: str, + index: int, + now: Optional[float] = None) -> Optional[VaultItem]: + """撤回仓库上架物品""" + item = self.remove_vault_item(index) + if not item: + return None + + self.add_vault_trade_log( + "cancel", item, actor, detail="撤回上架物品", now=now) + self.add_audit_log("vault_cancel", actor, target=item.seller, + detail=item.item_id, now=now) + self.add_log( + f"{actor} 撤回了 { + item.seller} 上架的 { + item.item_id} x{ + item.count}") + return item + + def to_dict(self): + return { + "guild_id": self.guild_id, + "name": self.name, + "owner": self.owner, + "level": self.level, + "exp": self.exp, + "create_time": self.create_time, + "base": self.base.to_dict() if self.base else None, + "vault": self.vault, + "vault_items": [ + item.to_dict() for item in self.vault_items], + "custom_item_values": self.custom_item_values, + "announcement": self.announcement, + "purchased_effects": self.purchased_effects, + "members": [ + m.to_dict() for m in self.members], + "logs": self.logs, + "stats": self.stats.to_dict(), + "settings": self.settings, + "tasks": [ + task.to_dict() for task in self.tasks], + "join_requests": [ + request.to_dict() for request in self.join_requests], + "vault_trade_logs": [ + log.to_dict() for log in self.vault_trade_logs], + "audit_logs": [ + log.to_dict() for log in self.audit_logs], + } + + @classmethod + def from_dict(cls, data, outer_key=None): + base = None + try: + if data.get("base") and isinstance(data["base"], dict): + base_data = data["base"] + if all( + key in base_data for key in [ + "dimension", + "x", + "y", + "z"]): + base = GuildBase( + dimension=base_data["dimension"], + x=float(base_data["x"]), + y=float(base_data["y"]), + z=float(base_data["z"]) + ) + elif data.get("base"): + fmts.print_err(f"据点数据格式不正确: {data['base']}") + except Exception as e: + fmts.print_err(f"解析据点数据出错: {e}") + import traceback + fmts.print_err(traceback.format_exc()) + + guild_id = data.get("guild_id") + if not guild_id: + guild_id = outer_key if outer_key else uuid.uuid4().hex[:8] + + # 兼容 members 为字符串列表 + members = [] + for m in data.get("members", []): + if isinstance(m, dict): + members.append(GuildMember.from_dict(m)) + elif isinstance(m, str): + members.append(GuildMember( + name=m, + rank=GuildRank.MEMBER, + join_time=time.time() + )) + + # 处理仓库物品 + vault_items = [] + for item_data in data.get("vault_items", []): + if isinstance(item_data, dict): + vault_items.append(VaultItem.from_dict(item_data)) + + # 处理统计数据 + stats_data = data.get("stats", {}) + stats = GuildStats.from_dict(stats_data) if isinstance( + stats_data, dict) else GuildStats() + + # 处理任务数据 + tasks = [] + for task_data in data.get("tasks", []): + if isinstance(task_data, dict): + tasks.append(GuildTask.from_dict(task_data)) + + join_requests = [] + for request_data in data.get("join_requests", []): + if isinstance(request_data, dict): + join_requests.append(GuildJoinRequest.from_dict(request_data)) + + vault_trade_logs = [] + for log_data in data.get("vault_trade_logs", []): + if isinstance(log_data, dict): + vault_trade_logs.append(VaultTradeLog.from_dict(log_data)) + + audit_logs = [] + for log_data in data.get("audit_logs", []): + if isinstance(log_data, dict): + audit_logs.append(GuildAuditLog.from_dict(log_data)) + + return cls( + guild_id=guild_id, + name=data.get("name", ""), + owner=data.get("owner", ""), + level=data.get("level", 1), + exp=data.get("exp", 0), + create_time=data.get("create_time", time.time()), + base=base, + vault=data.get("vault", {}), + vault_items=vault_items, + custom_item_values=data.get("custom_item_values", {}), + announcement=data.get("announcement", ""), + members=members, + purchased_effects=data.get("purchased_effects", {}), + logs=data.get("logs", []), + stats=stats, + settings=data.get("settings", {}), + tasks=tasks, + join_requests=join_requests, + vault_trade_logs=vault_trade_logs, + audit_logs=audit_logs, + ) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/prompts.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/prompts.py" new file mode 100644 index 00000000..d02ccf0f --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/prompts.py" @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Any + +from guild_cloud_interop.config import Config + + +CREATE_GUILD_PROMPT_FALLBACKS = { + "已有公会提示词": "§c❀ §r你已经有公会了", + "快捷创建缺少名称提示词": "§a❀ §r请输入公会名称,例如: 公会创建 我的公会", + "快捷创建名称长度无效提示词": "§c❀ §r公会名必须在2-16个字符之间", + "创建公会余额不足提示词": ( + "§c❀ §r创建公会需要 §e{consume}§r 点 " + "§b{scoreboard}§r 计分板积分\n§c❀ §r当前余额: §f{balance}" + ), + "创建公会提示词": ( + "§a❀ §r创建公会将消耗 §e{consume} §b{scoreboard} \n" + "§a❀ §r当前余额: §f{balance}\n" + "§a❀ §r输入 §a确认§7 继续创建,输入 §cq§7 取消" + ), + "创建公会回复超时提示词": "§c❀ §r回复超时,已取消创建公会", + "创建公会取消提示词": "§c❀ §r已取消创建公会", + "创建公会输入名称提示词": "§a❀ §r请输入公会名字:\n§a❀ §r要求: 2-20个字符,不能包含特殊符号", + "创建公会名称无效提示词": "§c❀ §r{error}", + "创建公会二次余额不足提示词": ( + "§c❀ §r当前 §b{scoreboard}§r 余额不足," + "需要 §e{consume}§r,当前 §f{balance}" + ), + "创建公会成功提示词": "§a❀ §r已创建公会 §e{guild}", + "创建公会全服公告提示词": "§a❀ §r§e{player}§r 创建了公会 §e{guild}§r!", + "创建公会名称已存在提示词": "§c❀ §r该公会名已存在", + "菜单回复超时提示词": "§c❀ §r回复超时!已退出公会系统", + "无效指令提示词": "§c❀ §r无效的指令", + "通用分页为空提示词": "§c❀ §r{title}为空", + "通用分页超时提示词": "§c❀ §r操作超时", + "通用分页退出提示词": "§a❀ §r已退出", + "通用分页无效选择提示词": "§c❀ §r无效的选择", + "公会列表为空提示词": "§c❀ §r公会列表为空", + "公会列表分页超时提示词": "§c❀ §r操作超时", + "公会列表分页退出提示词": "§a❀ §r已退出", +} + + +def render_prompt(template: str, **values: Any) -> str: + for key, value in values.items(): + template = template.replace("{" + key + "}", str(value)) + return template + + +def render_create_guild_prompt(key: str, **values: Any) -> str: + return render_config_prompt(key, **values) + + +def render_config_prompt(key: str, **values: Any) -> str: + prompt_config = getattr(Config, "PROMPT_CONFIG", {}) + fallback = CREATE_GUILD_PROMPT_FALLBACKS.get(key, "") + template = fallback + if isinstance(prompt_config, dict): + configured = prompt_config.get(key) + if isinstance(configured, str) and configured: + template = configured + + values.setdefault("consume", getattr(Config, "GUILD_CREATION_COST", "")) + values.setdefault("scoreboard", getattr(Config, "GUILD_SCOREBOARD", "")) + return render_prompt(template, **values) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/service.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/service.py" new file mode 100644 index 00000000..58b6ecf5 --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/service.py" @@ -0,0 +1,47 @@ +"""Transactional helpers for guild data updates.""" + +from tooldelta import fmts + +# FIRE 数据事务处理器 FIRE + + +class DataTransaction: + """数据事务处理器 - 确保数据一致性""" + + def __init__(self, guild_manager): + self.guild_manager = guild_manager + self.backup_data = None + self.operations = [] + self.success = True + + def __enter__(self): + try: + self.backup_data = self.guild_manager.load_guilds( + force_reload=True) + import copy + self.backup_data = copy.deepcopy(self.backup_data) + except Exception as e: + fmts.print_err(f"事务备份失败:{e}") + self.backup_data = {} + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_type is not None or not self.success: + self.rollback() + fmts.print_err(f"事务回滚:{exc_val if exc_val else '操作失败'}") + return False + return True + + def rollback(self): + """回滚数据到事务开始前的状态""" + if self.backup_data: + try: + self.guild_manager.save_guilds(self.backup_data) + self.guild_manager.reset_runtime_state() + fmts.print_inf("数据已回滚到事务开始前的状态") + except Exception as e: + fmts.print_err(f"回滚失败:{e}") + + def mark_failed(self): + """标记事务失败""" + self.success = False diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" new file mode 100644 index 00000000..52e91b8b --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" @@ -0,0 +1,225 @@ +"""Orion-style UI helpers for the guild cloud interop plugin.""" + +from __future__ import annotations + +import re +from typing import Iterable, Sequence + + +ORION_BORDER = "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧" +TITLE_PREFIX = "§l§d❐§f" +OPTION_PREFIX = "§l§b[ §e{}§b ] §r§e{}" +INFO_PREFIX = "§a❀ §r" +WARN_PREFIX = "§6❀ " +ERROR_PREFIX = "§c❀ " +SUCCESS_PREFIX = "§a❀ " +MAX_CHAT_MESSAGE_CHARS = 240 + + +def split_chat_chunks( + text: str, + max_chars: int = MAX_CHAT_MESSAGE_CHARS) -> list[str]: + """Split rich-text chat output into line-preserving chunks for rental servers.""" + chunks: list[str] = [] + current: list[str] = [] + current_len = 0 + + for line in str(text).splitlines(): + line_len = len(line) + separator_len = 1 if current else 0 + if current and current_len + separator_len + line_len > max_chars: + chunks.append("\n".join(current)) + current = [] + current_len = 0 + + if line_len > max_chars: + if current: + chunks.append("\n".join(current)) + current = [] + current_len = 0 + for start in range(0, line_len, max_chars): + chunks.append(line[start:start + max_chars]) + continue + + current.append(line) + current_len += separator_len + line_len + + if current: + chunks.append("\n".join(current)) + + return chunks or [""] + + +class OrionPlayerView: + """Player proxy that renders plugin messages in the Orion panel style.""" + + def __init__(self, player): + self._player = player + + def __getattr__(self, name): + return getattr(self._player, name) + + def show(self, text): + for chunk in split_chat_chunks(format_message(str(text))): + self._player.show(chunk) + + +def wrap_player(player): + if isinstance(player, OrionPlayerView): + return player + return OrionPlayerView(player) + + +def strip_reset(text: str) -> str: + return text.replace("§r", "") + + +def normalize_inline(text: str) -> str: + text = strip_reset(text) + text = text.replace("§l§a公会仓库 §d>> ", "") + text = text.replace("§l§a公会任务 §d>> ", "") + text = text.replace("§l§a任务管理 §d>> ", "") + text = text.replace("§l§a创建任务 §d>> ", "") + text = text.replace("§l§a公会 §d>> ", "") + text = text.replace("§r§7>> ", "") + text = text.replace("§7>> ", "") + text = text.replace(">>>", "-") + return text.strip() + + +def classify_prefix(text: str) -> str: + if "错误" in text or "失败" in text or "无效" in text or "不足" in text or "权限不足" in text: + return ERROR_PREFIX + if "警告" in text or "超时" in text or "已满" in text or "不存在" in text or "未找到" in text: + return WARN_PREFIX + if "成功" in text or "已创建" in text or "已加入" in text or "已退出" in text or "已更新" in text: + return SUCCESS_PREFIX + return INFO_PREFIX + + +def format_option(line: str) -> str | None: + clean = normalize_inline(line) + + match = re.match(r"^(?:§[0-9a-frlomnk])*([0-9]+)[..、]\s*(.*)$", clean) + if match: + return OPTION_PREFIX.format(match.group(1), match.group(2).strip()) + + match = re.match( + r"^(?:§[0-9a-frlomnk])*●\s*(.+?)(?:\s*§7[-—]\s*|\s+-\s+)(.+)$", clean) + if match: + return f"§l§b[ §e-§b ] §r§e{ + match.group(1).strip()} §7- §f{ + match.group(2).strip()}" + + match = re.match(r"^([^\s]+)\s+§7[-—]\s*(.+)$", clean) + if match: + return f"§l§b[ §e-§b ] §r§e{ + match.group(1).strip()} §7- §f{ + match.group(2).strip()}" + + return None + + +def format_title(title: str) -> str: + title = normalize_inline(title) + title = title.replace("§a", "§6").replace("§c", "§c") + return f"{ORION_BORDER}\n{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b{title}§d\n{ORION_BORDER}" + + +def format_message(text: str) -> str: + lines = str(text).splitlines() + rendered: list[str] = [] + + for raw_line in lines: + if raw_line == "": + rendered.append("") + continue + + line = raw_line.strip() + if ( + line == ORION_BORDER + or line.startswith("§d✧✦") + or line.startswith(TITLE_PREFIX) + or line.startswith("§l§b[") + or line.startswith("§e>§g>§6>") + or "❀" in line + ): + rendered.append(line) + continue + + title_match = re.match(r"^(?:§r)?=+\s*§a(.+?)§r?\s*=+", line) + if title_match: + rendered.append(format_title(title_match.group(1))) + continue + + if re.fullmatch(r"=+", strip_reset(line)): + rendered.append(ORION_BORDER) + continue + + option = format_option(line) + if option is not None: + rendered.append(option) + continue + + clean = normalize_inline(line) + if not clean: + rendered.append("") + continue + + if clean.startswith("§c"): + rendered.append(ERROR_PREFIX + clean[2:]) + elif clean.startswith("§6"): + rendered.append(WARN_PREFIX + clean[2:]) + elif clean.startswith("§a"): + rendered.append(SUCCESS_PREFIX + clean[2:]) + elif clean.startswith("§7"): + rendered.append(INFO_PREFIX + clean[2:]) + elif clean.startswith("§e"): + rendered.append(INFO_PREFIX + clean[2:]) + else: + rendered.append(classify_prefix(clean) + clean) + + return "\n".join(rendered) + + +def format_panel( + title: str, + options: Sequence[tuple[str, str]] = (), + *, + subtitle: str = "", + footer: str = "", + lines: Iterable[str] = (), +) -> str: + output = [ + ORION_BORDER, + f"{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b{title}§d", + ] + if subtitle: + output.append(f"§e>§g>§6>§f{subtitle}") + output.append(ORION_BORDER) + for index, (label, description) in enumerate(options, start=1): + output.append( + OPTION_PREFIX.format( + index, + f"{label} §7- §f{description}")) + for line in lines: + output.append(format_message(line)) + if footer: + output.append(format_message(footer)) + return "\n".join(output) + + +def format_page_footer( + page: int, + total_pages: int, + start: int, + end: int, + allow_selection: bool) -> str: + lines = [ + ORION_BORDER, + f"§l§a[ §e-§a ] §b上页§r§f▶ §7{page}/{total_pages} §f◀§l§b下页 §a[ §e+ §a]", + ] + if allow_selection: + lines.append(f"{INFO_PREFIX}输入 §e[{start}-{end}]§r 之间的数字以选择") + lines.append(f"{INFO_PREFIX}输入 §d-§e 上一页 §r| §d+§e 下一页 §r| §cq§r 退出") + return "\n".join(lines) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/validators.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/validators.py" new file mode 100644 index 00000000..8c51934a --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/validators.py" @@ -0,0 +1,65 @@ +"""Input validation helpers for guild commands.""" + +from typing import Tuple + + +class InputValidator: + """输入验证器""" + + @staticmethod + def validate_guild_name(name: str) -> Tuple[bool, str]: + """验证公会名称""" + if not name: + return False, "公会名称不能为空" + + if len(name) < 2: + return False, "公会名称至少需要2个字符" + + if len(name) > 20: + return False, "公会名称不能超过20个字符" + + # 检查特殊字符 + invalid_chars = ['&', '<', '>', '"', "'", '\\', '/', '|'] + for char in invalid_chars: + if char in name: + return False, f"公会名称不能包含特殊字符: {char}" + + return True, "" + + @staticmethod + def validate_player_name(name: str) -> Tuple[bool, str]: + """验证玩家名称""" + if not name: + return False, "玩家名称不能为空" + + if len(name) < 3 or len(name) > 16: + return False, "玩家名称长度应在3-16个字符之间" + + return True, "" + + @staticmethod + def validate_positive_integer( + value: str, field_name: str = "数值") -> Tuple[bool, int, str]: + """验证正整数""" + if not value: + return False, 0, f"{field_name}不能为空" + + if not value.isdigit(): + return False, 0, f"{field_name}必须是正整数" + + num = int(value) + if num <= 0: + return False, 0, f"{field_name}必须大于0" + + return True, num, "" + + @staticmethod + def validate_announcement(text: str) -> Tuple[bool, str]: + """验证公告内容""" + if not text: + return False, "公告内容不能为空" + + if len(text) > 200: + return False, "公告内容不能超过200个字符" + + return True, "" diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/readme.md" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/readme.md" new file mode 100644 index 00000000..9acb0e02 --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/readme.md" @@ -0,0 +1,125 @@ +# 我的世界基岩版 公会系统插件 + +一个为 Minecraft 基岩版服务器提供完善公会功能的插件。 + +--- + +## 📌 插件功能简介 + +本插件为 Minecraft 基岩版服务器提供较为完善的公会系统,包括以下模块: + +### 1. 公会创建与解散 +- 玩家可花费钻石创建公会。 +- 会长可随时解散公会,解散后所有成员自动退出。 + +### 2. 公会加入与退出 +- 支持模糊搜索加入公会。 +- 玩家可自由退出当前公会。 + +### 3. 公会成员管理 +- 会长可设置成员职位、踢出成员、转让会长。 +- 支持多个等级的职位配置(如副会长、长老等)。 + +### 4. 公会经验与等级(开发中) +- 玩家通过捐献物品为公会获取经验。 +- 经验积累自动触发升级,支持连续多级升级。 +- 升级可提升功能上限或解锁特性。 + +### 5. 公会据点 +- 会长可设置公会主据点。 +- 所有成员可随时传送至据点。 +- 后续将支持跨维度据点(开发计划中)。 + +### 6. 公会聊天频道 +- 每个公会拥有专属聊天频道。 +- 玩家默认处于全局频道,可手动切换。 + +### 7. 公告系统 +- 会长可编辑并发布公会公告。 +- 所有成员可随时查看当前公告内容。 + +### 8. 效果增益系统 +- 公会可使用资源购买增益效果(如速度、力量、夜视等)。 +- 效果将作用于所有在线成员。 +- 支持多等级增益效果,价格逐级递增。 +- 已购买的增益等级在菜单中高亮显示。 + +### 9. 公会仓库(开发中) +- 未来版本将支持可视化的物品仓库系统,用于存取共享资源。 + +--- + +## 📚 主要指令 + +- `公会`:打开公会主菜单界面 +- `gc [消息内容]`:在当前公会频道中发送消息 + +--- + +## 🔧 使用说明 + +1. **注意版本更新的数据兼容性!** + 自 v0.1.0 起,公会数据文件已从 `guilds.json` 更名为 `公会数据文件.json`,结构也有所调整。 + +2. **配置文件路径** + 插件首次运行时会由 ToolDelta 自动生成 `插件配置文件/公会系统云链联动版.json`,默认配置由插件内置。运行期间保存配置文件后,插件会自动热更新配置,无需重载插件。旧配置缺少新版配置项时,插件会自动补齐默认项并写回配置文件。 + +3. **开发状态提示** + 当前仍处于开发阶段,部分功能尚未完善,最终版本可能有所改动。 + +4. **数据迁移警告** + 请不要直接复制旧版 `guilds.json` 到新数据文件中,这可能导致数据损坏或丢失。建议等待下个版本提供迁移工具或格式转换器。 + +5. **Ultra 群菜单联动** + 自 v0.1.7 起,`群服互通云链版Ultra版` 可通过 `guild-cloud-interop` API 接入公会系统菜单。普通群成员需先完成 QQ 与游戏账号绑定,之后按绑定玩家身份使用普通公会菜单;群管理员仍进入管理菜单。 + +6. **验证状态** + 2026-06-07 已使用 Python 3.13.9 执行 `py_compile` 检查本插件和 Ultra 联动相关文件,语法检查通过。 + +--- + +## 🔮 未来计划 + +- [ ] 公会仓库功能完善 +- [ ] 支持跨维度的据点设置与传送 +- [ ] 效果增益系统支持通过“积分”购买 +- [ ] 玩家失效效果时自动重新给予(若公会已购买) +- [ ] 提供可调用的插件 API +- [ ] 优化主菜单过长导致的 UI 溢出问题 +- [ ] 联动地皮插件(开发中) + +--- + +## 🗒️ 更新日志 + +### v0.1.7 +- 新增面向 Ultra 群内普通成员的公会 API:查询个人公会状态、查看本公会日志/仓库/任务、申请加入、退出、会长解散、设置公告、参与任务和返回据点。 +- 普通玩家 API 会按公会成员身份、职位权限和冻结状态校验,不复用 QQ 管理菜单的强制管理接口。 +- 启动和热更新配置时会自动补齐旧配置缺失的新版配置项,并写回配置文件。 +- 同步配置版本与插件市场元数据到 `0.1.7`。 + +### v0.1.2 +- 默认关闭插件,需要在配置中开启 `是否启用插件` 后才注册运行时功能。 +- 插件关闭时跳过计分板初始化、菜单注册、入服处理和后台数据任务。 + +### v0.1.1 +- 更新数据存储逻辑。 +- 优化管理员提示,加入更多信息。 +- 允许用户自己配置是否启用某些功能 +- 修复`get_guild_rankings`没有声明错误 + +### v0.1.0 +- 修复经验无法正常添加的问题。 +- 修复经验溢出后导致无法升级的问题。 +- 添加部分公会仓库原型代码。 +- 增加更多可自定义职位角色。 +- 修复多个细节与已知问题。 +- 添加多项新功能支持。 + +### v0.0.1 +- 初始版本,支持: + - 创建/解散/加入/退出公会 + - 公会经验与升级机制 + - 据点设置与传送功能 + - 公会聊天频道 + - 效果增益系统及菜单显示 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/\351\224\231\350\257\257\345\244\204\347\220\206.md" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/\351\224\231\350\257\257\345\244\204\347\220\206.md" new file mode 100644 index 00000000..da858176 --- /dev/null +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/\351\224\231\350\257\257\345\244\204\347\220\206.md" @@ -0,0 +1,101 @@ +# 公会数据错误解释文档 + +本文档列出了公会数据校验中可能出现的错误、产生原因以及对应的解决建议,便于开发者或数据管理员快速定位和修复异常数据。 + +--- + +## 1. 公会名称为空 + +- **错误码**: `Err:event.check.data.guild_name` +- **问题描述**: `guild_data.name` 为空。 +- **原因分析**: 公会名称是识别公会的基本信息,不能为空。 +- **解决建议**: 管理员可以手动在数据文件中把该公会的name修改为临时公会名字 + +--- + +## 2. 公会ID为空 + +- **错误码**: `Err:event.check.data.guild_id` +- **问题描述**: `guild_data.guild_id` 为空。 +- **原因分析**: 公会 ID 是公会的唯一标识,缺失会导致无法正确索引。 +- **解决建议**: + + - 在控制台输入“更新公会数据” + - 打开数文件,该公会的数据中的guild_id和外层的id是否一样例如 + + ``` + 这是错误内容 + { + "aaa":{ + ... + "guild_id":"bbb" + ... + } + } + 你应该修改为如下内容 + { + "aaa":{ + ... + "guild_id":"aaa" + ... + } + } + ``` + +--- + +## 3. 公会没有成员 + +- **错误码**: `Msg:event.check.data.member_length_LMIN` +- **问题描述**: `guild_data.members` 为空或不存在。 +- **原因分析**: 一个合法的公会应当至少有一名成员。 +- **解决建议**: 这个公会在创建的时候出现了问题,你或许应该通过数据文件删除这个公会 + +--- + +## 4. 会长数量不为 1 + +- **错误码**: `Err:event.check.data.owner_length_LMAX` +- **问题描述**: 会长(`GuildRank.OWNER`)数量不等于 1。 +- **原因分析**: 会长是公会的唯一负责人,多个或无会长都是不合法的。 +- **解决建议**: 强制每个公会有且仅有一个会长成员,你应该检查数据文件中members下和这个公会会长名字对应的玩家的rank是否为owner + +--- + +## 5. 成员数量超过限制 + +- **错误码**: `Err:event.check.data.member_length_LMAX` +- **问题描述**: 成员数大于 `Config.MAX_GUILD_MEMBERS`。 +- **原因分析**: 超过系统配置的最大成员限制,可能影响性能。 +- **解决建议**: 字面意思,叫会长踢出去几个。 + +--- + +## 6. 仓库物品超过容量 + +- **错误码**: `Err:event.check.data.vault_items_LMAX` +- **问题描述**: 仓库物品数量大于 `Config.VAULT_INITIAL_SLOTS`。 +- **原因分析**: 物品数量超过配置值,可能引发异常存储或显示问题。 +- **解决建议**: 字面意思 + +--- + +## 7. 公会经验值无效 + +- **错误码**: `Err:event.check.data.exp_type_or_negative` +- **问题描述**: `guild_data.exp` 不是数字,或小于 0。 +- **原因分析**: 公会经验应为非负整数或浮点数,用于等级判定。 +- **解决建议**: 如果出现这个问题请将经验值修改为有效数字 + +--- + +## 8. 公会等级无效 + +- **错误码**: `Err:event.check.data.level_type_or_range` +- **问题描述**: `guild_data.level` 不是整数,或小于 1。 +- **原因分析**: 公会等级需为正整数,0 或负数不符合游戏设计。 +- **解决建议**: 如果出现这个问题请将公会等级修改为有效数字 + +--- + +> 若检测到多条错误,请优先修复 ID、名称、会长等核心字段,再依次清理仓库和成员问题,以保证最基本的数据可用性。 diff --git "a/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index 68f2ac07..836973de 100644 --- "a/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -1,344 +1,571 @@ -import time -from typing import Any - -from tooldelta import Player, Plugin, cfg, fmts, game_utils, plugin_entry, utils -from tooldelta.internal.launch_cli import FrameNeOmgAccessPoint - - -class WhitelistAndOpCheck(Plugin): - """负责白名单与 OP 状态校验,并对外暴露管理接口。""" - - name = "白名单&管理员检测云链联动版" - author = "猫七街" - version = (1, 1, 2) - description = "白名单与管理员状态检测,并向其他插件暴露可复用的管理 API。" - - DEFAULT_CFG = { - "检查时间(秒)": 60.0, - "白名单": { - "开启状态": False, - "踢出提示词": "请先加入白名单", - "白名单玩家": {"xuid1": "player_name1", "xuid2": "player_name2"}, - }, - "管理员检测": { - "开启状态": False, - "提示词": "你没有管理员权限", - "管理员列表": {"xuid1": "player_name1", "xuid2": "player_name2"}, - }, - } - - STD_CFG = { - "检查时间(秒)": float, - "白名单": {"开启状态": bool, "踢出提示词": str, "白名单玩家": {}}, - "管理员检测": {"开启状态": bool, "提示词": str, "管理员列表": {}}, - } - - def __init__(self, frame): - """初始化运行时状态并注册插件生命周期回调。""" - super().__init__(frame) - self.get_xuid = None - self.neomega = None - self.bot_name = "" - self._cfg = self.load_config() - - self.ListenPreload(self.on_preload) - self.ListenActive(self.on_active) - self.ListenPlayerJoin(self.on_player_join) - - @classmethod - def merge_with_default(cls, raw: Any, default: Any): - """递归合并用户配置和默认配置。""" - if isinstance(default, dict): - result = { - key: cls.merge_with_default(None, value) - for key, value in default.items() - } - if isinstance(raw, dict): - for key, value in raw.items(): - if key in result: - result[key] = cls.merge_with_default(value, result[key]) - else: - result[key] = value - return result - return raw if raw is not None else default - - def load_config(self) -> dict[str, Any]: - """读取配置文件并做结构校验,失败时退回默认值。""" - try: - raw_cfg, _ = cfg.get_plugin_config_and_version( - self.name, - {}, - self.DEFAULT_CFG, - self.version, - ) - merged_cfg = self.merge_with_default(raw_cfg, self.DEFAULT_CFG) - cfg.check_auto(self.STD_CFG, merged_cfg) - except Exception as err: - fmts.print_err(f"加载配置文件出错: {err}") - merged_cfg = self.merge_with_default({}, self.DEFAULT_CFG) - cfg.upgrade_plugin_config(self.name, merged_cfg, self.version) - return merged_cfg - - def save_cfg(self): - """把当前内存中的配置写回插件配置文件。""" - cfg.upgrade_plugin_config(self.name, self._cfg, self.version) - - def on_preload(self): - """在 preload 阶段获取 XUID 查询前置插件。""" - self.get_xuid = self.GetPluginAPI("XUID获取") - - def on_active(self): - """在插件激活后挂载控制台入口并启动周期检测。""" - self.neomega = self.require_neomega() - self.bot_name = self.neomega.get_bot_basic_info().BotName - self.frame.add_console_cmd_trigger( - ["白名单"], - None, - "在控制台修改白名单(需要玩家先登录一次服务器)", - self.console_manage_whitelist, - ) - self.frame.add_console_cmd_trigger( - ["OP操作"], - None, - "在控制台修改服务器 OP(需要玩家先登录一次服务器)", - self.console_manage_admins, - ) - self.start_periodic_check() - - def require_neomega(self): - """要求当前启动器具备 NeOmega 能力,否则直接拒绝继续运行。""" - if isinstance(self.frame.launcher, FrameNeOmgAccessPoint): - return self.frame.launcher.omega - raise ValueError("此启动框架无法使用 NeOmega API") - - def on_player_join(self, player: Player): - """玩家进服时按当前配置执行白名单和管理员状态检查。""" - if self._is_bot_player(player.name): - return - if self._cfg["白名单"]["开启状态"]: - self.enforce_whitelist(player.name, player.xuid) - if self._cfg["管理员检测"]["开启状态"]: - self.enforce_admin_state(player.name, player.xuid) - - def _is_bot_player(self, player_name: str) -> bool: - """判断给定玩家名是否就是当前机器人自己。""" - return bool(self.bot_name) and player_name == self.bot_name - - def resolve_player_xuid(self, player_name: str) -> tuple[str | None, str]: - """根据玩家名解析 XUID,失败时返回错误信息。""" - try: - player_xuid = self.get_xuid.get_xuid_by_name( - player_name, - allow_offline=True, - ) - except Exception: - return None, "玩家未加入过服务器或无法获取 XUID" - return player_xuid, "" - - def add_whitelist_player(self, player_name: str) -> tuple[bool, str]: - """把玩家加入白名单。""" - return self.add_player_mapping( - player_name, - "白名单", - "白名单玩家", - "玩家已存在白名单中", - "已添加玩家 {player_name} 到白名单", - ) - - def remove_whitelist_player(self, player_name: str) -> tuple[bool, str]: - """把玩家从白名单中移除。""" - return self.remove_player_mapping( - player_name, - "白名单", - "白名单玩家", - "玩家不存在白名单中", - "已从白名单中移除玩家 {player_name}", - ) - - def add_admin_player(self, player_name: str) -> tuple[bool, str]: - """把玩家登记为服务器管理员。""" - return self.add_player_mapping( - player_name, - "管理员检测", - "管理员列表", - "玩家已经是服务器管理员", - "已添加玩家 {player_name} 为服务器管理员", - ) - - def remove_admin_player(self, player_name: str) -> tuple[bool, str]: - """把玩家从服务器管理员名单中移除。""" - return self.remove_player_mapping( - player_name, - "管理员检测", - "管理员列表", - "玩家不是服务器管理员", - "已将玩家 {player_name} 从服务器管理员中移除", - ) - - def add_player_mapping( - self, - player_name: str, - section: str, - key: str, - duplicate_message: str, - success_message: str, - ) -> tuple[bool, str]: - """向指定映射表添加一个以 XUID 为键的玩家条目。""" - player_xuid, error = self.resolve_player_xuid(player_name) - if player_xuid is None: - return False, error - mapping = self._cfg[section][key] - if player_xuid in mapping: - return False, duplicate_message - mapping[player_xuid] = player_name - self.save_cfg() - return True, success_message.format(player_name=player_name) - - def remove_player_mapping( - self, - player_name: str, - section: str, - key: str, - missing_message: str, - success_message: str, - ) -> tuple[bool, str]: - """从指定映射表移除一个以 XUID 为键的玩家条目。""" - player_xuid, error = self.resolve_player_xuid(player_name) - if player_xuid is None: - return False, error - mapping = self._cfg[section][key] - if player_xuid not in mapping: - return False, missing_message - mapping.pop(player_xuid) - self.save_cfg() - return True, success_message.format(player_name=player_name) - - def set_whitelist_enabled(self, enabled: bool) -> tuple[bool, str]: - """切换白名单检测开关。""" - self._cfg["白名单"]["开启状态"] = enabled - self.save_cfg() - return True, f"白名单检测已{'开启' if enabled else '关闭'}" - - def set_admin_check_enabled(self, enabled: bool) -> tuple[bool, str]: - """切换管理员检测开关。""" - self._cfg["管理员检测"]["开启状态"] = enabled - self.save_cfg() - return True, f"管理员检测已{'开启' if enabled else '关闭'}" - - def set_check_interval(self, seconds: float) -> tuple[bool, str]: - """更新周期检测的轮询间隔。""" - if seconds <= 0: - return False, "检测周期必须大于 0" - self._cfg["检查时间(秒)"] = float(seconds) - self.save_cfg() - return True, f"检测周期已设置为 {seconds} 秒" - - def get_runtime_status(self) -> dict[str, int | float | bool]: - """返回给其他插件使用的当前运行状态摘要。""" - return { - "check_interval": self._cfg["检查时间(秒)"], - "whitelist_enabled": self._cfg["白名单"]["开启状态"], - "whitelist_count": len(self._cfg["白名单"]["白名单玩家"]), - "admin_check_enabled": self._cfg["管理员检测"]["开启状态"], - "admin_count": len(self._cfg["管理员检测"]["管理员列表"]), - } - - def enforce_whitelist(self, player_name: str, player_xuid: str): - """对白名单未命中的玩家执行踢出。""" - if player_xuid in self._cfg["白名单"]["白名单玩家"]: - return - self.game_ctrl.sendwocmd( - f"kick {player_xuid} {self._cfg['白名单']['踢出提示词']}" - ) - - def enforce_admin_state(self, player_name: str, player_xuid: str): - """同步服务器 OP 状态与插件配置中的管理员登记状态。""" - is_registered_admin = player_xuid in self._cfg["管理员检测"]["管理员列表"] - is_server_op = game_utils.is_op(player_name) - - if is_server_op and not is_registered_admin: - self.game_ctrl.sendwocmd(f"/say 检测到存在非法管理员:{player_name}") - self.game_ctrl.sendwocmd(f"/deop {player_name}") - self.game_ctrl.sendwocmd( - f"/tell {player_name} {self._cfg['管理员检测']['提示词']}" - ) - return - - if not is_server_op and is_registered_admin: - self.game_ctrl.sendwocmd(f"/op {player_name}") - - def console_manage_whitelist(self, _args: list[str]): - """打开控制台白名单管理菜单。""" - self.console_manage_player_mapping( - title="白名单", - add_action=self.add_whitelist_player, - remove_action=self.remove_whitelist_player, - add_prompt="请输入要添加的玩家昵称:", - remove_prompt="请输入要移除的玩家昵称:", - ) - - def console_manage_admins(self, _args: list[str]): - """打开控制台服务器管理员管理菜单。""" - self.console_manage_player_mapping( - title="服务器管理员", - add_action=self.add_admin_player, - remove_action=self.remove_admin_player, - add_prompt="请输入要添加的玩家昵称:", - remove_prompt="请输入要移除的玩家昵称:", - ) - - def console_manage_player_mapping( - self, - title: str, - add_action, - remove_action, - add_prompt: str, - remove_prompt: str, - ): - """复用同一套控制台交互来管理白名单和管理员列表。""" - option_add = f"添加{title}" - option_remove = f"移除{title}" - while True: - fmts.print_inf("选择你要进行的操作:") - fmts.print_inf(f"1. {option_add}") - fmts.print_inf(f"2. {option_remove}") - fmts.print_inf("q. 退出操作") - choice = input().strip().lower() - if choice == "q": - fmts.print_inf("已退出操作") - return - if choice == "1": - player_name = input(fmts.fmt_info(add_prompt)).strip() - ok, message = add_action(player_name) - self.print_console_result(ok, message) - return - if choice == "2": - player_name = input(fmts.fmt_info(remove_prompt)).strip() - ok, message = remove_action(player_name) - self.print_console_result(ok, message) - return - fmts.print_err("无效的选项") - - @staticmethod - def print_console_result(ok: bool, message: str): - """统一输出控制台操作结果,减少重复分支。""" - if ok: - fmts.print_suc(message) - else: - fmts.print_err(message) - - @utils.thread_func("循环检测白名单和管理员") - def start_periodic_check(self): - """按配置周期轮询在线玩家,补做白名单和管理员状态校验。""" - while True: - time.sleep(float(self._cfg["检查时间(秒)"])) - for player in self.frame.get_players().getAllPlayers(): - if self._is_bot_player(player.name): - continue - if self._cfg["白名单"]["开启状态"]: - self.enforce_whitelist(player.name, player.xuid) - if self._cfg["管理员检测"]["开启状态"]: - self.enforce_admin_state(player.name, player.xuid) - - -entry = plugin_entry(WhitelistAndOpCheck, "白名单&管理员检测云链联动版") +"""Whitelist and operator-status checker plugin.""" + +import copy +import os +import time +import threading +from typing import Any + +from tooldelta import Player, Plugin, cfg, fmts, game_utils, plugin_entry, utils + + +CONFIG_FILE_DIR = "插件配置文件" +DYNAMIC_LOAD_SETTINGS_KEY = "动态载入设置" +DYNAMIC_LOAD_ENABLED_KEY = "是否启用动态载入配置文件(仅用于本插件)" +DYNAMIC_LOAD_INTERVAL_KEY = "动态载入检测时间间隔(单位:秒)" +DYNAMIC_LOAD_DEFAULT_INTERVAL = 5 + + +class WhitelistAndOpCheck(Plugin): + """负责白名单与 OP 状态校验,并对外暴露管理接口。""" + + name = "白名单&管理员检测云链联动版" + author = "猫七街" + version = (1, 1, 4) + description = "白名单与管理员状态检测,并向其他插件暴露可复用的管理 API。" + + DEFAULT_CFG = { + DYNAMIC_LOAD_SETTINGS_KEY: { + DYNAMIC_LOAD_ENABLED_KEY: True, + DYNAMIC_LOAD_INTERVAL_KEY: DYNAMIC_LOAD_DEFAULT_INTERVAL, + }, + "检查时间(秒)": 60.0, + "白名单": { + "开启状态": False, + "踢出提示词": "请先加入白名单", + "白名单玩家": {"xuid1": "player_name1", "xuid2": "player_name2"}, + }, + "管理员检测": { + "开启状态": False, + "提示词": "你没有管理员权限", + "管理员列表": {"xuid1": "player_name1", "xuid2": "player_name2"}, + }, + } + + STD_CFG = { + DYNAMIC_LOAD_SETTINGS_KEY: { + DYNAMIC_LOAD_ENABLED_KEY: bool, + DYNAMIC_LOAD_INTERVAL_KEY: cfg.PInt, + }, + "检查时间(秒)": float, + "白名单": {"开启状态": bool, "踢出提示词": str, "白名单玩家": {}}, + "管理员检测": {"开启状态": bool, "提示词": str, "管理员列表": {}}, + } + + def __init__(self, frame): + """初始化运行时状态并注册插件生命周期回调。""" + super().__init__(frame) + self.get_xuid = None + self.bot_name = "" + self._stop_event = threading.Event() + self._config_file_state = None + self._cfg = self.load_config() + self.refresh_config_file_state() + self.config_thread = utils.createThread( + self.config_reload_task, + usage="白名单&管理员检测配置热更新任务", + ) + + self.ListenPreload(self.on_preload) + self.ListenActive(self.on_active) + self.ListenPlayerJoin(self.on_player_join) + self.ListenFrameExit(self.on_frame_exit) + + @classmethod + def merge_with_default(cls, raw: Any, default: Any): + """递归合并用户配置和默认配置。""" + if isinstance(default, dict): + result = { + key: cls.merge_with_default( + raw.get(key) if isinstance(raw, dict) else None, + value, + ) + for key, value in default.items() + } + if isinstance(raw, dict): + for key, value in raw.items(): + if key not in result: + result[key] = copy.deepcopy(value) + return result + return copy.deepcopy( + raw) if raw is not None else copy.deepcopy(default) + + @staticmethod + def trim_fixed_keys(raw: Any, default: dict[str, Any]) -> dict[str, Any]: + raw = raw if isinstance(raw, dict) else {} + return { + key: copy.deepcopy(raw.get(key, value)) + for key, value in default.items() + } + + @staticmethod + def normalize_bool(value: Any, fallback: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in ("true", "1", "yes", "y", "on", "启用", "是", "真"): + return True + if text in ("false", "0", "no", "n", "off", "禁用", "否", "假"): + return False + if isinstance(value, (int, float)) and not isinstance(value, bool): + return bool(value) + return bool(fallback) + + @staticmethod + def normalize_positive_int(value: Any, fallback: int) -> int: + if isinstance(value, bool): + return fallback + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + @staticmethod + def normalize_positive_float(value: Any, fallback: float) -> float: + if isinstance(value, bool): + return fallback + try: + result = float(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + @staticmethod + def normalize_str( + value: Any, + fallback: str, + *, + allow_empty: bool = False) -> str: + if value is None: + return fallback + text = str(value) + if text or allow_empty: + return text + return fallback + + @classmethod + def normalize_player_mapping( + cls, raw: Any, fallback: dict[str, str]) -> dict[str, str]: + source = raw if isinstance(raw, dict) else fallback + result: dict[str, str] = {} + for xuid, player_name in source.items(): + xuid_text = str(xuid).strip() + if not xuid_text: + continue + result[xuid_text] = cls.normalize_str( + player_name, "", allow_empty=True) + return result + + @classmethod + def normalize_config(cls, raw_cfg: Any) -> dict[str, Any]: + merged_cfg = cls.merge_with_default(raw_cfg, cls.DEFAULT_CFG) + normalized = cls.trim_fixed_keys(merged_cfg, cls.DEFAULT_CFG) + + dynamic_default = cls.DEFAULT_CFG[DYNAMIC_LOAD_SETTINGS_KEY] + dynamic = cls.trim_fixed_keys( + normalized.get(DYNAMIC_LOAD_SETTINGS_KEY), + dynamic_default, + ) + dynamic[DYNAMIC_LOAD_ENABLED_KEY] = cls.normalize_bool( + dynamic.get(DYNAMIC_LOAD_ENABLED_KEY), + dynamic_default[DYNAMIC_LOAD_ENABLED_KEY], + ) + dynamic[DYNAMIC_LOAD_INTERVAL_KEY] = cls.normalize_positive_int( + dynamic.get(DYNAMIC_LOAD_INTERVAL_KEY), + dynamic_default[DYNAMIC_LOAD_INTERVAL_KEY], + ) + normalized[DYNAMIC_LOAD_SETTINGS_KEY] = dynamic + + normalized["检查时间(秒)"] = cls.normalize_positive_float( + normalized.get("检查时间(秒)"), + cls.DEFAULT_CFG["检查时间(秒)"], + ) + + whitelist_default = cls.DEFAULT_CFG["白名单"] + whitelist = cls.trim_fixed_keys( + normalized.get("白名单"), whitelist_default) + whitelist["开启状态"] = cls.normalize_bool( + whitelist.get("开启状态"), + whitelist_default["开启状态"], + ) + whitelist["踢出提示词"] = cls.normalize_str( + whitelist.get("踢出提示词"), + whitelist_default["踢出提示词"], + ) + whitelist["白名单玩家"] = cls.normalize_player_mapping( + whitelist.get("白名单玩家"), + whitelist_default["白名单玩家"], + ) + normalized["白名单"] = whitelist + + admin_default = cls.DEFAULT_CFG["管理员检测"] + admin_check = cls.trim_fixed_keys( + normalized.get("管理员检测"), admin_default) + admin_check["开启状态"] = cls.normalize_bool( + admin_check.get("开启状态"), + admin_default["开启状态"], + ) + admin_check["提示词"] = cls.normalize_str( + admin_check.get("提示词"), + admin_default["提示词"], + ) + admin_check["管理员列表"] = cls.normalize_player_mapping( + admin_check.get("管理员列表"), + admin_default["管理员列表"], + ) + normalized["管理员检测"] = admin_check + + return normalized + + def load_config(self) -> dict[str, Any]: + """读取配置文件并做结构校验,失败时退回默认值。""" + try: + raw_cfg, _ = cfg.get_plugin_config_and_version( + self.name, + {}, + self.DEFAULT_CFG, + self.version, + ) + merged_cfg = self.normalize_config(raw_cfg) + cfg.check_auto(self.STD_CFG, merged_cfg) + except Exception as err: + fmts.print_err(f"加载配置文件出错: {err}") + merged_cfg = self.normalize_config({}) + cfg.check_auto(self.STD_CFG, merged_cfg) + cfg.upgrade_plugin_config(self.name, merged_cfg, self.version) + return merged_cfg + + def apply_runtime_config(self, config_items: dict[str, Any]) -> None: + """Replace runtime config with already parsed config-center data.""" + merged_cfg = self.normalize_config(config_items) + cfg.check_auto(self.STD_CFG, merged_cfg) + self._cfg = merged_cfg + self.refresh_config_file_state() + + def save_cfg(self): + """把当前内存中的配置写回插件配置文件。""" + cfg.upgrade_plugin_config(self.name, self._cfg, self.version) + self.refresh_config_file_state() + + def config_file_path(self) -> str: + return os.path.join(CONFIG_FILE_DIR, f"{self.name}.json") + + @staticmethod + def file_state(path: str) -> tuple[int, int] | None: + try: + stat = os.stat(path) + except OSError: + return None + return stat.st_mtime_ns, stat.st_size + + def refresh_config_file_state(self): + self._config_file_state = self.file_state(self.config_file_path()) + + def is_dynamic_config_reload_enabled(self) -> bool: + settings = self._cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return True + return bool(settings.get(DYNAMIC_LOAD_ENABLED_KEY, True)) + + def dynamic_config_reload_interval(self) -> int: + settings = self._cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return DYNAMIC_LOAD_DEFAULT_INTERVAL + try: + interval = int(settings.get(DYNAMIC_LOAD_INTERVAL_KEY, + DYNAMIC_LOAD_DEFAULT_INTERVAL)) + except (TypeError, ValueError): + return DYNAMIC_LOAD_DEFAULT_INTERVAL + return interval if interval > 0 else DYNAMIC_LOAD_DEFAULT_INTERVAL + + def reload_runtime_config(self, announce: bool = False): + self._cfg = self.load_config() + self.refresh_config_file_state() + if announce: + fmts.print_suc(f"{self.name} 配置文件已热更新") + + def config_reload_task(self): + while not self._stop_event.wait(self.dynamic_config_reload_interval()): + if not self.is_dynamic_config_reload_enabled(): + self.refresh_config_file_state() + continue + current_state = self.file_state(self.config_file_path()) + if current_state == self._config_file_state: + continue + try: + self.reload_runtime_config(announce=True) + except Exception as err: + self._config_file_state = current_state + fmts.print_err(f"{self.name} 配置文件热更新失败: {err}") + + def api_reload_checker_config( + self) -> tuple[bool, str, dict[str, int | float | bool]]: + try: + self.reload_runtime_config(announce=False) + except Exception as err: + return False, f"白名单&管理员检测配置重载失败: {err}", self.get_runtime_status() + return True, "白名单&管理员检测配置已重载", self.get_runtime_status() + + def on_preload(self): + """在 preload 阶段获取 XUID 查询前置插件。""" + self.get_xuid = self.GetPluginAPI("XUID获取") + + def on_active(self): + """在插件激活后挂载控制台入口并启动周期检测。""" + self.bot_name = self.resolve_bot_name() + self.frame.add_console_cmd_trigger( + ["白名单"], + None, + "在控制台修改白名单(需要玩家先登录一次服务器)", + self.console_manage_whitelist, + ) + self.start_periodic_check() + + def on_frame_exit(self, _): + self._stop_event.set() + + def resolve_bot_name(self) -> str: + """尽量用通用 ToolDelta 能力识别机器人名,避免绑定 NeOmega 接入点。""" + bot_name = getattr(self.game_ctrl, "bot_name", "") + if bot_name: + return bot_name + + get_bot_name = getattr(self.frame.launcher, "get_bot_name", None) + if callable(get_bot_name): + try: + bot_name = get_bot_name() + except Exception: + bot_name = "" + if bot_name: + return bot_name + + omega = getattr(self.frame.launcher, "omega", None) + if omega is None: + return "" + try: + return getattr(omega.get_bot_basic_info(), "BotName", "") or "" + except Exception: + return "" + + def on_player_join(self, player: Player): + """玩家进服时按当前配置执行白名单和管理员状态检查。""" + if self._is_bot_player(player.name): + return + if self._cfg["白名单"]["开启状态"]: + self.enforce_whitelist(player.name, player.xuid) + if self._cfg["管理员检测"]["开启状态"]: + self.enforce_admin_state(player.name, player.xuid) + + def _is_bot_player(self, player_name: str) -> bool: + """判断给定玩家名是否就是当前机器人自己。""" + return bool(self.bot_name) and player_name == self.bot_name + + def resolve_player_xuid(self, player_name: str) -> tuple[str | None, str]: + """根据玩家名解析 XUID,失败时返回错误信息。""" + try: + player_xuid = self.get_xuid.get_xuid_by_name( + player_name, + allow_offline=True, + ) + except Exception: + return None, "玩家未加入过服务器或无法获取 XUID" + return player_xuid, "" + + def add_whitelist_player(self, player_name: str) -> tuple[bool, str]: + """把玩家加入白名单。""" + return self.add_player_mapping( + player_name, + "白名单", + "白名单玩家", + "玩家已存在白名单中", + "已添加玩家 {player_name} 到白名单", + ) + + def remove_whitelist_player(self, player_name: str) -> tuple[bool, str]: + """把玩家从白名单中移除。""" + return self.remove_player_mapping( + player_name, + "白名单", + "白名单玩家", + "玩家不存在白名单中", + "已从白名单中移除玩家 {player_name}", + ) + + def add_admin_player(self, player_name: str) -> tuple[bool, str]: + """把玩家登记为服务器管理员。""" + return self.add_player_mapping( + player_name, + "管理员检测", + "管理员列表", + "玩家已经是服务器管理员", + "已添加玩家 {player_name} 为服务器管理员", + ) + + def remove_admin_player(self, player_name: str) -> tuple[bool, str]: + """把玩家从服务器管理员名单中移除。""" + return self.remove_player_mapping( + player_name, + "管理员检测", + "管理员列表", + "玩家不是服务器管理员", + "已将玩家 {player_name} 从服务器管理员中移除", + ) + + def add_player_mapping( + self, + player_name: str, + section: str, + key: str, + duplicate_message: str, + success_message: str, + ) -> tuple[bool, str]: + """向指定映射表添加一个以 XUID 为键的玩家条目。""" + player_xuid, error = self.resolve_player_xuid(player_name) + if player_xuid is None: + return False, error + mapping = self._cfg[section][key] + if player_xuid in mapping: + return False, duplicate_message + mapping[player_xuid] = player_name + self.save_cfg() + return True, success_message.format(player_name=player_name) + + def remove_player_mapping( + self, + player_name: str, + section: str, + key: str, + missing_message: str, + success_message: str, + ) -> tuple[bool, str]: + """从指定映射表移除一个以 XUID 为键的玩家条目。""" + player_xuid, error = self.resolve_player_xuid(player_name) + if player_xuid is None: + return False, error + mapping = self._cfg[section][key] + if player_xuid not in mapping: + return False, missing_message + mapping.pop(player_xuid) + self.save_cfg() + return True, success_message.format(player_name=player_name) + + def set_whitelist_enabled(self, enabled: bool) -> tuple[bool, str]: + """切换白名单检测开关。""" + self._cfg["白名单"]["开启状态"] = enabled + self.save_cfg() + return True, f"白名单检测已{'开启' if enabled else '关闭'}" + + def set_admin_check_enabled(self, enabled: bool) -> tuple[bool, str]: + """切换管理员检测开关。""" + self._cfg["管理员检测"]["开启状态"] = enabled + self.save_cfg() + return True, f"管理员检测已{'开启' if enabled else '关闭'}" + + def set_check_interval(self, seconds: float) -> tuple[bool, str]: + """更新周期检测的轮询间隔。""" + if seconds <= 0: + return False, "检测周期必须大于 0" + self._cfg["检查时间(秒)"] = float(seconds) + self.save_cfg() + return True, f"检测周期已设置为 {seconds} 秒" + + def get_runtime_status(self) -> dict[str, int | float | bool]: + """返回给其他插件使用的当前运行状态摘要。""" + return { + "check_interval": self._cfg["检查时间(秒)"], + "whitelist_enabled": self._cfg["白名单"]["开启状态"], + "whitelist_count": len(self._cfg["白名单"]["白名单玩家"]), + "admin_check_enabled": self._cfg["管理员检测"]["开启状态"], + "admin_count": len(self._cfg["管理员检测"]["管理员列表"]), + } + + def enforce_whitelist(self, player_name: str, player_xuid: str): + """对白名单未命中的玩家执行踢出。""" + if player_xuid in self._cfg["白名单"]["白名单玩家"]: + return + self.game_ctrl.sendwocmd( + f"kick {player_xuid} {self._cfg['白名单']['踢出提示词']}" + ) + + def enforce_admin_state(self, player_name: str, player_xuid: str): + """同步服务器 OP 状态与插件配置中的管理员登记状态。""" + is_registered_admin = player_xuid in self._cfg["管理员检测"]["管理员列表"] + is_server_op = game_utils.is_op(player_name) + + if is_server_op and not is_registered_admin: + self.game_ctrl.sendwocmd(f"/say 检测到存在非法管理员:{player_name}") + self.game_ctrl.sendwocmd(f"/deop {player_name}") + self.game_ctrl.sendwocmd( + f"/tell {player_name} {self._cfg['管理员检测']['提示词']}" + ) + return + + if not is_server_op and is_registered_admin: + self.game_ctrl.sendwocmd(f"/op {player_name}") + + def console_manage_whitelist(self, _args: list[str]): + """打开控制台白名单管理菜单。""" + self.console_manage_whitelist_mapping( + title="白名单", + add_action=self.add_whitelist_player, + remove_action=self.remove_whitelist_player, + add_prompt="请输入要添加的玩家昵称:", + remove_prompt="请输入要移除的玩家昵称:", + ) + + def console_manage_whitelist_mapping( + self, + title: str, + add_action, + remove_action, + add_prompt: str, + remove_prompt: str, + ): + """控制台白名单增删交互。""" + option_add = f"添加{title}" + option_remove = f"移除{title}" + while True: + fmts.print_inf("选择你要进行的操作:") + fmts.print_inf(f"1. {option_add}") + fmts.print_inf(f"2. {option_remove}") + fmts.print_inf("q. 退出操作") + choice = input().strip().lower() + if choice == "q": + fmts.print_inf("已退出操作") + return + if choice == "1": + player_name = input(fmts.fmt_info(add_prompt)).strip() + ok, message = add_action(player_name) + self.print_console_result(ok, message) + return + if choice == "2": + player_name = input(fmts.fmt_info(remove_prompt)).strip() + ok, message = remove_action(player_name) + self.print_console_result(ok, message) + return + fmts.print_err("无效的选项") + + @staticmethod + def print_console_result(ok: bool, message: str): + """统一输出控制台操作结果,减少重复分支。""" + if ok: + fmts.print_suc(message) + else: + fmts.print_err(message) + + @utils.thread_func("循环检测白名单和管理员") + def start_periodic_check(self): + """按配置周期轮询在线玩家,补做白名单和管理员状态校验。""" + while not self._stop_event.wait(float(self._cfg["检查时间(秒)"])): + for player in self.frame.get_players().getAllPlayers(): + if self._is_bot_player(player.name): + continue + if self._cfg["白名单"]["开启状态"]: + self.enforce_whitelist(player.name, player.xuid) + if self._cfg["管理员检测"]["开启状态"]: + self.enforce_admin_state(player.name, player.xuid) + + +entry = plugin_entry(WhitelistAndOpCheck, "白名单&管理员检测云链联动版") diff --git "a/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" "b/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" index b56563bb..7dde4311 100644 --- "a/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" +++ "b/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" @@ -1,8 +1,10 @@ { "author": "猫七街", - "version": "1.1.2", + "version": "1.1.4", "plugin-type": "classic", - "description": "白名单&管理员检测云链联动版", - "pre-plugins": {}, + "description": "白名单&管理员检测云链联动版,支持运行时配置文件热载入", + "pre-plugins": { + "XUID获取": "0.0.7" + }, "plugin-id": "白名单&管理员检测云链联动版" } diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/API\350\260\203\347\224\250\346\226\207\346\241\243.md" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/API\350\260\203\347\224\250\346\226\207\346\241\243.md" new file mode 100644 index 00000000..3842806d --- /dev/null +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/API\350\260\203\347\224\250\346\226\207\346\241\243.md" @@ -0,0 +1,501 @@ +# 群服互通云链版 Ultra API 调用指南 + +插件名称:群服互通云链版Ultra版 +插件 ID:群服互通云链版Ultra版 +API 别名:QQLinkerUltraAPI +当前文档基于插件版本:2.0.0 + +本文说明其它 ToolDelta 类式插件如何调用本插件暴露的 API。推荐优先使用带 `api_` 前缀的方法和 `add_trigger(...)`;没有 `api_` 前缀的业务方法可能随内部实现调整。 + +## 1. 获取 API 实例 + +### 1.1 声明前置依赖 + +在调用方插件的 `datas.json` 中声明依赖: + +```json +{ + "pre-plugins": { + "群服互通云链版Ultra版": ">=2.0.0" + } +} +``` + +### 1.2 在 preload 阶段获取实例 + +```python +from tooldelta import Plugin, ToolDelta, plugin_entry + + +class ExamplePlugin(Plugin): + name = "示例调用方插件" + author = "your-name" + version = (0, 0, 1) + + def __init__(self, frame: ToolDelta): + super().__init__(frame) + self.qqlinker = None + self.ListenPreload(self.on_preload) + + def on_preload(self): + self.qqlinker = self.GetPluginAPI("QQLinkerUltraAPI", (2, 0, 0)) + + +entry = plugin_entry(ExamplePlugin) +``` + +> **注意**:不要在 `__init__` 中调用 `GetPluginAPI(...)`。ToolDelta 前置 API 更稳妥的获取阶段是 preload。 + +## 2. 运行状态 API + +### 2.1 api_get_status + +```python +api_get_status() -> dict[str, Any] +``` + +返回 Ultra 的运行状态快照。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `available` | `bool` | 云链 WebSocket 当前是否可用 | +| `ws_initialized` | `bool` | WebSocket 对象是否已初始化 | +| `websocket_target` | `str` | 当前连接目标地址 | +| `manual_launch` | `bool` | 是否处于本地启动器手动连接模式 | +| `manual_launch_port` | `int` | 手动连接模式端口 | +| `reloaded` | `bool` | 当前连接是否处于重载状态 | +| `reconnect_delay` | `Optional[int]` | 下一次自动重连延迟 | +| `session_id` | `int` | 当前 WebSocket 会话编号 | +| `linked_groups` | `list[int]` | 已配置的群号列表 | +| `default_group` | `Optional[int]` | 兼容旧单群调用的默认群号 | + +示例: + +```python +status = qqlinker.api_get_status() +if not status["available"]: + self.print_war("Ultra 云链暂不可用") +``` + +### 2.2 在线玩家查询 + +| 方法 | 返回值 | 说明 | +| --- | --- | --- | +| `api_get_online_players()` | `list[str]` | 返回当前在线玩家名副本 | +| `api_is_player_online(player_name, ignore_case=False)` | `bool` | 判断玩家是否在线,`ignore_case=True` 时忽略大小写 | + +### 2.3 MC 指令执行 + +```python +api_execute_game_cmd(command: str) -> tuple[bool, str] +``` + +执行一条 MC 指令,并返回稳定的 `(是否成功, 结果文本)`。该接口复用 Ultra 原有指令执行与中文结果整理逻辑;空指令会返回失败。 + +### 2.4 游戏到群转发规则 + +| 方法 | 返回值 | 说明 | +| --- | --- | --- | +| `api_get_game_to_group_targets(enabled_only=True)` | `list[dict[str, Any]]` | 返回游戏到群转发目标和规则副本 | +| `api_should_forward_game_message(group_id, message)` | `tuple[bool, str] \| None` | 预判某条游戏消息是否会转发到指定群,并返回裁剪后的消息;群未配置时返回 `None` | + +`api_get_game_to_group_targets(...)` 返回项包含 `group_id`、`enabled`、`format`、`required_prefixes`、`blocked_prefixes`、`forward_player_events` 和 `config`。 + +### 2.5 云链重载 + +```python +api_reload_websocket() -> tuple[bool, str] +``` + +请求 Ultra 按当前配置重载云链 WebSocket 连接。调用失败时返回 `(False, 错误文本)`。 + +## 3. 群配置与权限 API + +### 3.1 群配置查询 + +| 方法 | 返回值 | 说明 | +| --- | --- | --- | +| `api_get_linked_groups()` | `list[int]` | 返回当前已配置群号,顺序与运行时一致 | +| `api_get_default_group()` | `Optional[int]` | 返回默认群号,通常是第一个配置群 | +| `api_is_group_configured(group_id)` | `bool` | 判断群号是否已配置 | +| `api_get_group_config(group_id)` | `Optional[dict[str, Any]]` | 返回指定群配置副本 | +| `api_get_group_state(group_id)` | `Optional[dict[str, list[int]]]` | 返回指定群管理员状态副本,包含 `owner`、`super_admins`、`admins` | + +`api_get_group_config(...)` 和 `api_get_group_state(...)` 返回的是副本,调用方修改返回值不会写回 Ultra 内部状态。 + +### 3.2 群权限查询 + +| 方法 | 返回值 | 说明 | +| --- | --- | --- | +| `api_get_group_admins(group_id, include_super=True)` | `list[int]` | 返回群管理员;默认包含该群 `权限设置` 中的所有者和超级管理员 | +| `api_get_group_super_admins(group_id)` | `list[int]` | 返回该群 `权限设置` 中的超级管理员,不包含所有者 | +| `api_get_group_owner(group_id)` | `Optional[int]` | 返回该群 `权限设置` 中的所有者 QQ;群未配置或所有者为 `0` 时返回 `None` | +| `api_is_group_admin(group_id, qqid)` | `bool` | 判断 QQ 是否拥有群管理权限 | +| `api_is_group_super_admin(group_id, qqid)` | `bool` | 判断 QQ 是否拥有超级管理员级权限,所有者也返回 `True` | +| `api_is_group_owner(group_id, qqid)` | `bool` | 判断 QQ 是否为该群 `权限设置` 中的所有者 | + +### 3.3 群权限写入 + +| 方法 | 返回值 | 说明 | +| --- | --- | --- | +| `api_add_group_admin(group_id, qqid, is_super=False)` | `tuple[bool, str]` | 添加普通管理员或超级管理员;不能把所有者重复添加为管理员 | +| `api_remove_group_admin(group_id, qqid, is_super=False)` | `tuple[bool, str]` | 移除普通管理员或超级管理员;不能移除所有者 | + +示例: + +```python +ok, msg = qqlinker.api_add_group_admin( + group_id=987654321, + qqid=123456789, + is_super=False, +) + +if not ok: + self.print_war(msg) +``` + +## 4. 群触发词 API + +### 4.1 api_get_group_triggers + +```python +api_get_group_triggers(group_id: int | str) -> dict[str, Any] | None +``` + +返回指定群归一化后的触发词配置。群未配置时返回 `None`。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `help` | `list[str]` | 帮助菜单触发词 | +| `admin_menu` | `list[str]` | 管理员菜单触发词 | +| `player_list` | `list[str]` | 在线玩家列表触发词 | +| `inventory_menu` | `list[str]` | 背包查询菜单触发词 | +| `menu_exit` | `list[str]` | 退出整个菜单触发词 | +| `menu_back` | `list[str]` | 返回上级菜单触发词 | +| `command_prefix` | `str` | 群内发送 MC 指令前缀 | +| `orion_ban` | `list[str]` | Orion 封禁触发词 | +| `orion_unban` | `list[str]` | Orion 解封触发词 | +| `checker_menu` | `list[str]` | 白名单与管理员检测菜单触发词 | +| `task_menu` | `list[str]` | 任务系统菜单触发词 | +| `land_menu` | `list[str]` | 领地系统菜单触发词 | +| `guild_menu` | `list[str]` | 公会系统菜单触发词;普通成员需绑定游戏账号,群管理员进入管理菜单 | +| `binding` | `list[str]` | QQ/游戏账号绑定触发词 | + +### 4.2 api_get_registered_triggers + +```python +api_get_registered_triggers() -> list[dict[str, Any]] +``` + +返回通过 `add_trigger(...)` 注册进 Ultra 的外部 QQ 触发器元信息。 + +返回项字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `triggers` | `list[str]` | 触发词列表 | +| `argument_hint` | `Optional[str]` | 参数提示 | +| `usage` | `str` | 用途说明 | +| `op_only` | `bool` | 是否仅群管理员可用 | +| `accept_group` | `bool` | 回调是否接收 `group_id` 参数 | + +## 5. 群聊触发器注册 + +### 5.1 add_trigger + +```python +add_trigger( + triggers: list[str], + argument_hint: str | None, + usage: str, + func, + args_pd=lambda _: True, + op_only: bool = False, +) +``` + +把调用方插件的 QQ 群指令挂入 Ultra 的统一分发入口。 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `triggers` | `list[str]` | 触发词列表,按前缀匹配 | +| `argument_hint` | `Optional[str]` | 参数错误时展示的格式提示 | +| `usage` | `str` | 用途说明 | +| `func` | `Callable` | 回调函数 | +| `args_pd` | `Callable[[int], bool]` | 参数个数校验函数 | +| `op_only` | `bool` | 是否仅允许群管理员调用 | + +回调签名支持两种形式: + +```python +def handler(group_id: int, qqid: int, args: list[str]): + ... + +def legacy_handler(qqid: int, args: list[str]): + ... +``` + +推荐使用带 `group_id` 的新签名,便于多群场景区分来源。 + +### 5.2 注册示例 + +```python +def on_query_binding(group_id: int, qqid: int, args: list[str]): + target_qq = int(args[0]) if args else qqid + players = qqlinker.api_get_bound_players_by_qq(target_qq) + if not players: + qqlinker.api_reply_group_member(group_id, qqid, "暂无绑定记录") + return + + lines = [ + f"- {item['player_name']} ({item['xuid']})" + for item in players + ] + qqlinker.api_send_group_msg(group_id, "绑定记录:\n" + "\n".join(lines)) + + +qqlinker.add_trigger( + triggers=["查绑定"], + argument_hint="[QQ号]", + usage="查询 QQ 绑定的游戏账号", + func=on_query_binding, + args_pd=lambda n: n in (0, 1), + op_only=False, +) +``` + +## 6. QQ 与游戏账号绑定 API + +绑定数据保存在 Ultra 数据目录下的 `QQ绑定数据.json`,结构会被自动归一化。 + +### 6.1 查询接口 + +| 方法 | 返回值 | 说明 | +| --- | --- | --- | +| `api_get_binding_data()` | `dict[str, dict[str, Any]]` | 返回完整绑定数据副本 | +| `api_get_all_bindings()` | `list[dict[str, Any]]` | 返回扁平化绑定记录列表 | +| `api_get_xuids_by_qq(qqid)` | `list[str]` | 查询 QQ 绑定的所有 XUID | +| `api_get_qqs_by_xuid(xuid)` | `list[int]` | 查询 XUID 绑定的所有 QQ | +| `api_get_player_name_by_xuid(xuid)` | `Optional[str]` | 查询 XUID 最近记录的玩家名 | +| `api_get_bound_players_by_qq(qqid)` | `list[dict[str, str]]` | 查询 QQ 绑定的玩家记录 | +| `api_get_bound_qqs_by_xuid(xuid)` | `list[dict[str, Any]]` | 查询 XUID 绑定的 QQ 记录 | +| `api_get_xuids_by_player_name(player_name, ignore_case=True)` | `list[str]` | 按玩家名查询 XUID | +| `api_get_qqs_by_player_name(player_name, ignore_case=True)` | `list[int]` | 按玩家名查询 QQ | +| `api_is_binding_enabled(group_id=None)` | `bool` | 查询绑定功能是否启用 | +| `api_is_qq_bound(qqid)` | `bool` | 判断 QQ 是否已有绑定 | +| `api_is_xuid_bound(xuid)` | `bool` | 判断 XUID 是否已有绑定 | +| `api_is_qq_bound_to_xuid(qqid, xuid)` | `bool` | 判断指定 QQ/XUID 关系是否存在 | + +### 6.2 写入接口 + +| 方法 | 返回值 | 说明 | +| --- | --- | --- | +| `api_bind_qq_to_xuid(qqid, xuid, player_name="", group_id=None)` | `tuple[bool, str]` | 创建或刷新 QQ/XUID 绑定,遵守多绑定配置 | +| `api_unbind_qq_from_xuid(qqid, xuid)` | `tuple[bool, str]` | 删除一条指定 QQ/XUID 绑定 | +| `api_unbind_all_by_qq(qqid)` | `tuple[bool, str]` | 删除某个 QQ 的全部绑定 | +| `api_unbind_all_by_xuid(xuid)` | `tuple[bool, str]` | 删除某个 XUID 的全部 QQ 绑定 | +| `api_update_xuid_player_name(xuid, player_name)` | `tuple[bool, str]` | 更新已绑定 XUID 的最近玩家名 | +| `api_start_binding_request(group_id, qqid)` | `tuple[bool, str]` | 创建验证码绑定流程 | + +### 6.3 验证码绑定流程 + +```python +ok, msg = qqlinker.api_start_binding_request( + group_id=987654321, + qqid=123456789, +) +``` + +流程说明: + +1. Ultra 向 QQ 用户私信 6 位数字验证码。 +2. 玩家在游戏聊天中发送验证码。 +3. Ultra 读取玩家 XUID 并写入绑定数据。 + +## 7. 消息发送与等待输入 API + +### 7.1 稳定发送接口 + +| 方法 | 返回值 | 说明 | +| --- | --- | --- | +| `api_send_group_msg(group_id, message, remove_cq_code=True)` | `tuple[bool, str]` | 向指定群发送消息 | +| `api_reply_group_member(group_id, qqid, message)` | `tuple[bool, str]` | 在群内 @ 指定 QQ 并回复 | +| `api_send_private_msg(qqid, message)` | `tuple[bool, str]` | 向指定 QQ 发送私信 | + +示例: + +```python +ok, msg = qqlinker.api_send_group_msg( + group_id=987654321, + message="服务器当前在线人数:12", +) +``` + +这些 `api_` 包装会捕获底层发送异常,并返回 `(False, "错误信息")`。 + +### 7.2 等待群消息 + +```python +api_wait_group_msg(qqid, timeout=60, group_id=None) -> str | None +``` + +等待某个 QQ 的下一条群消息。传入 `group_id` 时只接收指定群的下一条消息;不传 `group_id` 时保留旧单群兼容行为。 + +```python +reply = qqlinker.api_wait_group_msg( + qqid=123456789, + timeout=30, + group_id=987654321, +) +``` + +### 7.3 兼容发送方法 + +Ultra 仍保留以下旧方法: + +| 方法 | 说明 | +| --- | --- | +| `sendmsg(group, msg, do_remove_cq_code=True)` | 直接发送群消息,可能抛出底层异常 | +| `send_private_msg(qqid, msg)` | 直接发送私信,可能抛出底层异常 | + +新插件优先使用 `api_send_group_msg(...)`、`api_reply_group_member(...)` 和 `api_send_private_msg(...)`。 + +## 8. 群消息广播事件 + +Ultra 收到云链群消息后,会向 ToolDelta 框架广播内部事件。外部插件可以监听事件并返回真值来阻止 Ultra 继续处理该消息。 + +| 事件名 | 负载 | 说明 | +| --- | --- | --- | +| `群服互通/数据json` | 云链原始 `dict` | 所有来自云链的原始数据 | +| `群服互通/链接群消息` | `{"群号": int, "QQ号": int, "昵称": str, "消息": str}` | 已确认来自已配置互通群的文本消息 | + +普通命令扩展优先使用 `add_trigger(...)`;只有需要截获原始消息或阻止后续转发时,才使用内部广播事件。 + +## 9. 原始群消息监听 API + +如果不想直接依赖 ToolDelta 内部广播,也可以通过 Ultra 注册原始群消息监听器。监听器只会收到云链上报的群消息原始 `dict`;回调返回真值时,Ultra 会停止后续命令分发和群到游戏转发。 + +| 方法 | 返回值 | 说明 | +| --- | --- | --- | +| `api_register_message_listener(name, listener)` | `tuple[bool, str]` | 注册一个原始群消息监听器 | +| `api_unregister_message_listener(name)` | `tuple[bool, str]` | 注销指定名称的监听器 | +| `api_get_message_listeners()` | `list[dict[str, Any]]` | 返回已注册监听器元信息 | + +示例: + +```python +def on_raw_group_message(data: dict): + group_id = data.get("group_id") + message = data.get("message") + return False + +ok, msg = qqlinker.api_register_message_listener( + "example-listener", + on_raw_group_message, +) +``` + +## 10. Orion 联动入口 + +以下方法可复用 Ultra 已封装的 Orion 玩家封禁/解封逻辑,但它们没有 `api_` 前缀,应视为辅助入口: + +| 方法 | 返回值 | 说明 | +| --- | --- | --- | +| `orion_ban_player(target, ban_time_raw, reason)` | `tuple[bool, str]` | 按玩家名或 XUID 解析目标,并通过 Orion 写入 XUID 封禁 | +| `orion_unban_player(target)` | `tuple[bool, str]` | 按玩家名或 XUID 解析目标,并通过 Orion 解除 XUID 封禁 | + +## 11. 常见问题 + +**Q1: 多群环境下不传 `group_id` 会怎样?** + +部分旧兼容接口会使用默认群号。新插件应尽量显式传入 `group_id`,避免套用错误群配置。 + +**Q2: 什么时候用 `api_send_group_msg(...)`,什么时候用 `sendmsg(...)`?** + +新插件优先使用 `api_send_group_msg(...)`。它会处理参数校验和异常返回,调用方更容易统一处理失败。 + +**Q3: 群消息应该用广播事件还是 `add_trigger(...)`?** + +普通命令扩展用 `add_trigger(...)`。只有需要处理全部原始消息、阻止 Ultra 继续转发或接入非命令式逻辑时,才使用广播事件。 + +## 12. 快速参考 + +### 12.1 常用场景 + +| 场景 | 推荐接口 | +| --- | --- | +| 获取 Ultra API | `GetPluginAPI("QQLinkerUltraAPI", (1, 1, 10))` | +| 查看运行状态 | `api_get_status()` | +| 查询在线玩家 | `api_get_online_players()` | +| 判断玩家是否在线 | `api_is_player_online(player_name)` | +| 执行 MC 指令 | `api_execute_game_cmd(command)` | +| 查询游戏到群转发目标 | `api_get_game_to_group_targets()` | +| 预判游戏消息转发 | `api_should_forward_game_message(group_id, message)` | +| 重载云链连接 | `api_reload_websocket()` | +| 获取已配置群 | `api_get_linked_groups()` | +| 查询群配置 | `api_get_group_config(group_id)` | +| 查询群管理员 | `api_get_group_admins(group_id)` | +| 增删群管理员 | `api_add_group_admin(...)` / `api_remove_group_admin(...)` | +| 查询群触发词 | `api_get_group_triggers(group_id)` | +| 查询外部触发器 | `api_get_registered_triggers()` | +| 注册 QQ 群命令 | `add_trigger(...)` | +| 查询 QQ 绑定玩家 | `api_get_bound_players_by_qq(qqid)` | +| 发起验证码绑定 | `api_start_binding_request(group_id, qqid)` | +| 发送群消息 | `api_send_group_msg(group_id, message)` | +| @ 群成员回复 | `api_reply_group_member(group_id, qqid, message)` | +| 发送 QQ 私信 | `api_send_private_msg(qqid, message)` | +| 等待 QQ 群回复 | `api_wait_group_msg(qqid, timeout, group_id)` | +| 注册原始群消息监听 | `api_register_message_listener(name, listener)` | + +### 12.2 完整 `api_*` 索引 + +| 分类 | 接口 | +| --- | --- | +| 运行状态 | `api_get_status()` | +| 在线玩家 | `api_get_online_players()` | +| 在线玩家 | `api_is_player_online(player_name, ignore_case=False)` | +| 游戏指令 | `api_execute_game_cmd(command)` | +| 游戏到群 | `api_get_game_to_group_targets(enabled_only=True)` | +| 游戏到群 | `api_should_forward_game_message(group_id, message)` | +| 云链连接 | `api_reload_websocket()` | +| 群配置 | `api_get_linked_groups()` | +| 群配置 | `api_get_default_group()` | +| 群配置 | `api_is_group_configured(group_id)` | +| 群配置 | `api_get_group_config(group_id)` | +| 群配置 | `api_get_group_state(group_id)` | +| 群权限 | `api_get_group_admins(group_id, include_super=True)` | +| 群权限 | `api_get_group_super_admins(group_id)` | +| 群权限 | `api_get_group_owner(group_id)` | +| 群权限 | `api_is_group_admin(group_id, qqid)` | +| 群权限 | `api_is_group_super_admin(group_id, qqid)` | +| 群权限 | `api_is_group_owner(group_id, qqid)` | +| 群权限 | `api_add_group_admin(group_id, qqid, is_super=False)` | +| 群权限 | `api_remove_group_admin(group_id, qqid, is_super=False)` | +| 触发词 | `api_get_group_triggers(group_id)` | +| 触发词 | `api_get_registered_triggers()` | +| 绑定查询 | `api_get_binding_data()` | +| 绑定查询 | `api_get_all_bindings()` | +| 绑定查询 | `api_get_xuids_by_qq(qqid)` | +| 绑定查询 | `api_get_qqs_by_xuid(xuid)` | +| 绑定查询 | `api_get_player_name_by_xuid(xuid)` | +| 绑定查询 | `api_get_bound_players_by_qq(qqid)` | +| 绑定查询 | `api_get_bound_qqs_by_xuid(xuid)` | +| 绑定查询 | `api_get_xuids_by_player_name(player_name, ignore_case=True)` | +| 绑定查询 | `api_get_qqs_by_player_name(player_name, ignore_case=True)` | +| 绑定状态 | `api_is_binding_enabled(group_id=None)` | +| 绑定状态 | `api_is_qq_bound(qqid)` | +| 绑定状态 | `api_is_xuid_bound(xuid)` | +| 绑定状态 | `api_is_qq_bound_to_xuid(qqid, xuid)` | +| 绑定写入 | `api_bind_qq_to_xuid(qqid, xuid, player_name="", group_id=None)` | +| 绑定写入 | `api_unbind_qq_from_xuid(qqid, xuid)` | +| 绑定写入 | `api_unbind_all_by_qq(qqid)` | +| 绑定写入 | `api_unbind_all_by_xuid(xuid)` | +| 绑定写入 | `api_update_xuid_player_name(xuid, player_name)` | +| 绑定写入 | `api_start_binding_request(group_id, qqid)` | +| 消息发送 | `api_send_group_msg(group_id, message, remove_cq_code=True)` | +| 消息发送 | `api_reply_group_member(group_id, qqid, message)` | +| 消息发送 | `api_send_private_msg(qqid, message)` | +| 等待输入 | `api_wait_group_msg(qqid, timeout=60, group_id=None)` | +| 原始群消息监听 | `api_register_message_listener(name, listener)` | +| 原始群消息监听 | `api_unregister_message_listener(name)` | +| 原始群消息监听 | `api_get_message_listeners()` | diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/__init__.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/__init__.py" index af3194b8..4d00a5b0 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/__init__.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/__init__.py" @@ -1,191 +1,235 @@ -""" -群服互通云链版 Ultra 的入口模块。 -这个文件故意保持得比较薄: -1. 统一声明插件元信息和生命周期入口。 -2. 在构造阶段准备所有 mixin 共用的运行时状态。 -3. 把真正的配置、QQ 菜单、Orion 联动、WebSocket 运行逻辑交给拆分后的模块。 -""" - -import os -import threading - -from tooldelta import Plugin, cfg, fmts, plugin_entry - -from .config_mixin import QQLinkerConfigMixin -from .message_utils import QQMsgTrigger -from .orion_mixin import QQLinkerOrionMixin -from .qq_mixin import QQLinkerQQMixin -from .runtime_mixin import QQLinkerRuntimeMixin - - -# 入口类只负责装配 mixin 和生命周期注册,具体业务逻辑拆在各模块里。 -class QQLinker( - QQLinkerQQMixin, - QQLinkerOrionMixin, - QQLinkerRuntimeMixin, - QQLinkerConfigMixin, - Plugin, -): - """群服互通云链版 Ultra 的插件入口类。""" - - version = (1, 0, 0) - name = "群服互通云链版Ultra版" - author = "大庆油田 / 小六神" - description = "提供多群独立管理的群服互通、QQ群管理员体系和 Orion 联动封禁功能" - QQMsgTrigger = QQMsgTrigger - - @staticmethod - def console_menu_header(title: str) -> str: - """生成控制台使用的 Orion 风格标题栏。""" - return ( - "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓" - "§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" - f"§l§d❐§f 『§6群服互通云链版Ultra版§f』 §b{title}" - ) - - @staticmethod - def console_menu_footer(page_label: str, body: str) -> str: - """生成控制台使用的 Orion 风格页脚。""" - return ( - "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓 " - f"§r§7[ §b{page_label} §7] " - "§l§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" - f"§r{body}" - ) - - @staticmethod - def print_console_info(text: str): - """按统一 UI 风格输出控制台普通信息。""" - fmts.print_inf(f"§a❀ §b{text}") - - @staticmethod - def print_console_success(text: str): - """按统一 UI 风格输出控制台成功信息。""" - fmts.print_suc(f"§a❀ §b{text}") - - @staticmethod - def print_console_warn(text: str): - """按统一 UI 风格输出控制台警告信息。""" - fmts.print_war(f"§6❀ §e{text}") - - @staticmethod - def print_console_error(text: str): - """按统一 UI 风格输出控制台错误信息。""" - fmts.print_err(f"§c❀ §e{text}") - - def print_console_card( - self, - title: str, - page_label: str, - body_lines: list[str], - level: str = "info", - ): - """按 Orion 风格打印一张控制台信息卡片。""" - card = ( - self.console_menu_header(title) - + "\n" - + self.console_menu_footer( - page_label, - "\n".join( - i if i.startswith("§") else f"§a❀ §b{i}" for i in body_lines - ), - ) - ) - { - "info": fmts.print_inf, - "success": fmts.print_suc, - "warn": fmts.print_war, - "error": fmts.print_err, - }.get(level, fmts.print_inf)(card) - - def __init__(self, frame): - """ - 初始化插件的共享状态。 - 这里不直接做网络连接或重逻辑初始化,只准备后续各 mixin 需要共享的状态容器。 - 真正依赖外部插件 API 的动作,会留到 `on_def` / `on_inject` 再做。 - """ - super().__init__(frame) - self.make_data_path() - self.group_state_dir = self.format_data_path("群聊权限数据") - os.makedirs(self.group_state_dir, exist_ok=True) - - # 运行时状态集中放在入口类上,方便多个 mixin 共享同一份上下文。 - self.ws = None - self.reloaded = False - self.available = False - self.triggers: list[QQMsgTrigger] = [] - self.waitmsg_cbs = {} - self.plugin = [] - self._manual_launch = False - self._manual_launch_port = -1 - self.tps_calc = None - self.orion = None - self.whitelist_checker = None - self._ws_runner_lock = threading.Lock() - self._ws_runner_active = False - self._ws_session_id = 0 - self._ws_reconnect_delay = None - - # 配置只在启动时加载和归一化一次,后续 mixin 统一读 self.cfg。 - raw_cfg, _ = cfg.get_plugin_config_and_version( - self.name, - {}, - self.cfg_default(), - self.version, - ) - self.cfg = self.migrate_config(raw_cfg) - cfg.check_auto(self.cfg_std(), self.cfg) - cfg.upgrade_plugin_config(self.name, self.cfg, self.version) - - self.group_cfgs = {} - self.group_order = [] - self.reload_group_configs() - - self.ListenPreload(self.on_def) - self.ListenActive(self.on_inject) - self.ListenPlayerJoin(self.on_player_join) - self.ListenPlayerLeave(self.on_player_leave) - self.ListenChat(self.on_player_message) - - def on_def(self): - """ - 在 preload 阶段拿前置插件 API。 - 这些对象都可能被多个 mixin 使用,所以统一在入口层绑定一次, - 后面各模块直接读 `self.xxx`,不用重复向 ToolDelta 申请。 - """ - self.tps_calc = self.GetPluginAPI("tps计算器", (0, 0, 1), False) - self.orion = self.GetPluginAPI("Orion_System", force=False) - self.whitelist_checker = self.GetPluginAPI("白名单&管理员检测云链联动版", force=False) - - def on_inject(self): - """ - 在框架注入完成后启动主动能力。 - 这一步才真正尝试连云链,因为这时前置 API、配置和游戏控制器都已经可用。 - """ - self.print_console_card( - "群服互通 云链连接", - "准备启动", - ["正在准备云链连接"], - level="info", - ) - if not self._manual_launch: - self.connect_to_websocket() - self.init_basic_triggers() - - def init_basic_triggers(self): - """注册给控制台使用的少量入口命令。""" - self.frame.add_console_cmd_trigger( - ["QQ", "发群"], - "[群号可选] [消息]", - "在群内发消息测试", - self.on_sendmsg_test, - ) - self.frame.add_console_cmd_trigger( - ["OPQQ"], - None, - "进入QQ群管理员增删菜单", - self.on_console_add_qq_op, - ) - - -entry = plugin_entry(QQLinker, "群服互通") +""" +群服互通云链版 Ultra 的入口模块。 +这个文件故意保持得比较薄: +1. 统一声明插件元信息和生命周期入口。 +2. 在构造阶段准备所有 mixin 共用的运行时状态。 +3. 把真正的配置、QQ 菜单、Orion 联动、WebSocket 运行逻辑交给拆分后的模块。 +""" + +import threading + +from tooldelta import Plugin, cfg, fmts, plugin_entry + +from .binding_mixin import QQLinkerBindingMixin +from .config_editor_mixin import QQLinkerConfigEditorMixin +from .config_mixin import QQLinkerConfigMixin +from .message_utils import QQMsgTrigger +from .orion_mixin import QQLinkerOrionMixin +from .qq_mixin import QQLinkerQQMixin +from .runtime_mixin import QQLinkerRuntimeMixin + + +# 入口类只负责装配 mixin 和生命周期注册,具体业务逻辑拆在各模块里。 +class QQLinker( + QQLinkerConfigEditorMixin, + QQLinkerBindingMixin, + QQLinkerQQMixin, + QQLinkerOrionMixin, + QQLinkerRuntimeMixin, + QQLinkerConfigMixin, + Plugin, +): + """群服互通云链版 Ultra 的插件入口类。""" + + version = (2, 0, 0) + name = "群服互通云链版Ultra版" + author = "大庆油田 / 小六神" + description = "提供多群独立管理的群服互通、QQ群管理员体系和 Orion 联动封禁功能" + QQMsgTrigger = QQMsgTrigger + + @staticmethod + def console_menu_header(title: str) -> str: + """生成控制台使用的 Orion 风格标题栏。""" + return ( + "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓" + "§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" + f"§l§d❐§f 『§6群服互通云链版Ultra版§f』 §b{title}" + ) + + @staticmethod + def console_menu_footer(page_label: str, body: str) -> str: + """生成控制台使用的 Orion 风格页脚。""" + return ( + "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓 " + f"§r§7[ §b{page_label} §7] " + "§l§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" + f"§r{body}" + ) + + @staticmethod + def print_console_info(text: str): + """按统一 UI 风格输出控制台普通信息。""" + fmts.print_inf(f"§a❀ §b{text}") + + @staticmethod + def print_console_success(text: str): + """按统一 UI 风格输出控制台成功信息。""" + fmts.print_suc(f"§a❀ §b{text}") + + @staticmethod + def print_console_warn(text: str): + """按统一 UI 风格输出控制台警告信息。""" + fmts.print_war(f"§6❀ §e{text}") + + @staticmethod + def print_console_error(text: str): + """按统一 UI 风格输出控制台错误信息。""" + fmts.print_err(f"§c❀ §e{text}") + + def print_console_card( + self, + title: str, + page_label: str, + body_lines: list[str], + level: str = "info", + ): + """按 Orion 风格打印一张控制台信息卡片。""" + card = ( + self.console_menu_header(title) + + "\n" + + self.console_menu_footer( + page_label, + "\n".join( + i if i.startswith("§") else f"§a❀ §b{i}" for i in body_lines), + )) + { + "info": fmts.print_inf, + "success": fmts.print_suc, + "warn": fmts.print_war, + "error": fmts.print_err, + }.get(level, fmts.print_inf)(card) + + def __init__(self, frame): + """ + 初始化插件的共享状态。 + 这里不直接做网络连接或重逻辑初始化,只准备后续各 mixin 需要共享的状态容器。 + 真正依赖外部插件 API 的动作,会留到 `on_def` / `on_inject` 再做。 + """ + super().__init__(frame) + self.make_data_path() + + # 运行时状态集中放在入口类上,方便多个 mixin 共享同一份上下文。 + self.ws = None + self.reloaded = False + self.available = False + self.triggers: list[QQMsgTrigger] = [] + self.waitmsg_cbs = {} + self._message_listeners = {} + self.plugin = [] + self._manual_launch = False + self._manual_launch_port = -1 + self.tps_calc = None + self.orion = None + self.whitelist_checker = None + self.task_system = None + self.land_system = None + self.guild_system = None + self._ws_runner_lock = threading.Lock() + self._ws_runner_active = False + self._ws_session_id = 0 + self._ws_reconnect_delay = None + self._runtime_config_reload_stop = threading.Event() + self._runtime_config_reload_thread = None + + # 启动时先加载并归一化配置;后续文件变化由后台线程热应用到 self.cfg。 + raw_cfg, _ = cfg.get_plugin_config_and_version( + self.name, + {}, + self.cfg_default(), + self.version, + ) + self.cfg = self.migrate_config(raw_cfg) + cfg.check_auto(self.cfg_std(), self.cfg) + cfg.upgrade_plugin_config(self.name, self.cfg, self.version) + + self.group_cfgs = {} + self.group_order = [] + self.reload_group_configs() + self.persist_runtime_config() + self.refresh_runtime_config_file_state() + self.init_binding_state() + + self.ListenPreload(self.on_def) + self.ListenActive(self.on_inject) + self.ListenPlayerJoin(self.on_player_join) + self.ListenPlayerLeave(self.on_player_leave) + self.ListenChat(self.on_player_message) + self.ListenFrameExit(self.on_frame_exit) + + def on_def(self): + """ + 在 preload 阶段拿前置插件 API。 + 这些对象都可能被多个 mixin 使用,所以统一在入口层绑定一次, + 后面各模块直接读 `self.xxx`,不用重复向 ToolDelta 申请。 + """ + self.tps_calc = self.GetPluginAPI("tps计算器", (0, 0, 1), False) + self.orion = self.GetPluginAPI("Orion_System", force=False) + self.whitelist_checker = self.GetPluginAPI( + "白名单&管理员检测云链联动版", force=False) + self.task_system = self.GetPluginAPI("任务系统云链联动版", force=False) + self.land_system = self.GetPluginAPI("领地系统云链联动版", (0, 1, 17), False) + self.guild_system = self.GetPluginAPI( + "guild-cloud-interop", (0, 1, 7), False) + + def on_inject(self): + """ + 在框架注入完成后启动主动能力。 + 这一步才真正尝试连云链,因为这时前置 API、配置和游戏控制器都已经可用。 + """ + self.print_console_card( + "群服互通 云链连接", + "准备启动", + ["正在准备云链连接"], + level="info", + ) + if not self._manual_launch: + self.connect_to_websocket() + self.init_basic_triggers() + self.start_runtime_config_reload_task() + + def on_frame_exit(self, _): + """框架退出时清理后台任务和绑定验证码计时器。""" + self._runtime_config_reload_stop.set() + self.cleanup_binding_state() + + def start_runtime_config_reload_task(self): + """启动配置文件热更新轮询线程。""" + thread = self._runtime_config_reload_thread + if thread is not None and thread.is_alive(): + return + self._runtime_config_reload_stop.clear() + self._runtime_config_reload_thread = threading.Thread( + target=self.runtime_config_reload_loop, + name=f"{self.name}配置热更新线程", + daemon=True, + ) + self._runtime_config_reload_thread.start() + + def runtime_config_reload_loop(self): + """轮询配置文件状态,检测到外部修改时热应用。""" + while not self._runtime_config_reload_stop.wait( + self.runtime_config_reload_interval() + ): + self.check_runtime_config_file_update() + + def init_basic_triggers(self): + """注册给控制台使用的少量入口命令。""" + self.frame.add_console_cmd_trigger( + ["QQ", "发群"], + "[群号可选] [消息]", + "在群内发消息测试", + self.on_sendmsg_test, + ) + self.frame.add_console_cmd_trigger( + ["Q配置", "群服配置", "配置中心"], + None, + "进入群服互通及联动插件配置中心", + self.on_console_config_center, + ) + + +entry = plugin_entry( + QQLinker, + ["群服互通云链版Ultra版", "QQLinkerUltraAPI"], + (2, 0, 0), +) diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" new file mode 100644 index 00000000..409f55c6 --- /dev/null +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" @@ -0,0 +1,665 @@ +"""QQ account and game XUID binding support.""" + +from __future__ import annotations + +import json +import os +import secrets +import threading +import time +from copy import deepcopy +from typing import Any + + +class QQLinkerBindingMixin: + """Handles QQ-to-game binding verification and persistence.""" + + BINDING_TIMEOUT_MINUTES_DEFAULT = 10 + + def init_binding_state(self): + self.binding_data_path = self.format_data_path("QQ绑定数据.json") + self.pending_bindings: dict[str, dict[str, Any]] = {} + self.pending_binding_timers: dict[str, threading.Timer] = {} + self.pending_bindings_lock = threading.RLock() + self._ensure_binding_data() + + @staticmethod + def _binding_default_data() -> dict[str, dict[str, Any]]: + return {"qq_to_xuids": {}, "xuid_to_qqs": {}, "xuid_names": {}} + + def _ensure_binding_data(self): + data = self.read_binding_data() + self.save_binding_data(data) + + def read_binding_data(self) -> dict[str, dict[str, Any]]: + if not os.path.isfile(self.binding_data_path): + return self._binding_default_data() + try: + with open(self.binding_data_path, "r", encoding="utf-8") as file: + raw = json.load(file) + except Exception: + raw = {} + + data = self._binding_default_data() + if isinstance(raw, dict): + data["qq_to_xuids"] = self._normalize_binding_map( + raw.get("qq_to_xuids")) + data["xuid_to_qqs"] = self._normalize_binding_map( + raw.get("xuid_to_qqs")) + names = raw.get("xuid_names", {}) + if isinstance(names, dict): + data["xuid_names"] = { + str(xuid): str(name) + for xuid, name in names.items() + if str(xuid).strip() and str(name).strip() + } + return data + + @staticmethod + def _normalize_binding_map(raw: Any) -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + if not isinstance(raw, dict): + return result + for key, values in raw.items(): + skey = str(key).strip() + if not skey: + continue + if not isinstance(values, list): + values = [values] + normalized: list[str] = [] + for value in values: + svalue = str(value).strip() + if svalue and svalue not in normalized: + normalized.append(svalue) + result[skey] = normalized + return result + + def save_binding_data(self, data: dict[str, dict[str, Any]]): + with open(self.binding_data_path, "w", encoding="utf-8") as file: + json.dump(data, file, ensure_ascii=False, indent=2) + + @staticmethod + def _binding_qq_key(qqid: int | str) -> str: + text = str(qqid).strip() + if not text: + return "" + try: + value = int(text) + except ValueError: + return text + return str(value) if value > 0 else "" + + @staticmethod + def _binding_xuid_key(xuid: str) -> str: + return str(xuid).strip() + + @staticmethod + def _binding_qq_values(values: list[str]) -> list[int]: + result: list[int] = [] + for value in values: + try: + qqid = int(str(value).strip()) + except ValueError: + continue + if qqid > 0 and qqid not in result: + result.append(qqid) + return result + + def _binding_api_group_id(self, group_id: int | None = None) -> int: + if group_id is not None: + return int(group_id) + return int(self.linked_group or 0) + + def api_get_binding_data(self) -> dict[str, dict[str, Any]]: + """Return a normalized copy of all QQ/XUID binding data.""" + return deepcopy(self.read_binding_data()) + + def api_get_all_bindings(self) -> list[dict[str, Any]]: + """Return binding relations as flat records for easier iteration.""" + data = self.read_binding_data() + records: list[dict[str, Any]] = [] + for qq_key, xuids in data["qq_to_xuids"].items(): + try: + qq_value: int | str = int(qq_key) + except ValueError: + qq_value = qq_key + for xuid in xuids: + records.append( + { + "qq": qq_value, + "xuid": xuid, + "player_name": data["xuid_names"].get(xuid, ""), + } + ) + return records + + def api_get_xuids_by_qq(self, qqid: int | str) -> list[str]: + """Return all XUIDs bound to a QQ number.""" + qq_key = self._binding_qq_key(qqid) + if not qq_key: + return [] + return list(self.read_binding_data()["qq_to_xuids"].get(qq_key, [])) + + def api_get_qqs_by_xuid(self, xuid: str) -> list[int]: + """Return all QQ numbers bound to an XUID.""" + xuid_key = self._binding_xuid_key(xuid) + if not xuid_key: + return [] + values = self.read_binding_data()["xuid_to_qqs"].get(xuid_key, []) + return self._binding_qq_values(values) + + def api_get_player_name_by_xuid(self, xuid: str) -> str | None: + """Return the latest recorded player name for an XUID.""" + xuid_key = self._binding_xuid_key(xuid) + if not xuid_key: + return None + return self.read_binding_data()["xuid_names"].get(xuid_key) + + def api_get_bound_players_by_qq( + self, qqid: int | str) -> list[dict[str, str]]: + """Return player records bound to a QQ number.""" + data = self.read_binding_data() + result: list[dict[str, str]] = [] + for xuid in data["qq_to_xuids"].get(self._binding_qq_key(qqid), []): + result.append( + { + "xuid": xuid, + "player_name": data["xuid_names"].get(xuid, ""), + } + ) + return result + + def api_get_bound_qqs_by_xuid(self, xuid: str) -> list[dict[str, Any]]: + """Return QQ records bound to an XUID.""" + xuid_key = self._binding_xuid_key(xuid) + if not xuid_key: + return [] + data = self.read_binding_data() + player_name = data["xuid_names"].get(xuid_key, "") + return [ + {"qq": qqid, "xuid": xuid_key, "player_name": player_name} + for qqid in self._binding_qq_values(data["xuid_to_qqs"].get(xuid_key, [])) + ] + + def api_get_xuids_by_player_name( + self, + player_name: str, + ignore_case: bool = True, + ) -> list[str]: + """Return XUIDs whose latest recorded player name matches.""" + name = str(player_name).strip() + if not name: + return [] + data = self.read_binding_data() + if ignore_case: + name = name.lower() + return [ + xuid + for xuid, stored_name in data["xuid_names"].items() + if stored_name.lower() == name + ] + return [ + xuid + for xuid, stored_name in data["xuid_names"].items() + if stored_name == name + ] + + def api_get_qqs_by_player_name( + self, + player_name: str, + ignore_case: bool = True, + ) -> list[int]: + """Return QQ numbers bound to matching player names.""" + qqids: list[int] = [] + for xuid in self.api_get_xuids_by_player_name( + player_name, ignore_case): + for qqid in self.api_get_qqs_by_xuid(xuid): + if qqid not in qqids: + qqids.append(qqid) + return qqids + + def api_is_binding_enabled(self, group_id: int | None = None) -> bool: + """Return whether QQ/game binding is enabled in config.""" + return self._binding_enabled(self._binding_api_group_id(group_id)) + + def api_is_qq_bound(self, qqid: int | str) -> bool: + """Return whether a QQ number has any binding.""" + return bool(self.api_get_xuids_by_qq(qqid)) + + def api_is_xuid_bound(self, xuid: str) -> bool: + """Return whether an XUID has any binding.""" + return bool(self.api_get_qqs_by_xuid(xuid)) + + def api_is_qq_bound_to_xuid(self, qqid: int | str, xuid: str) -> bool: + """Return whether the exact QQ/XUID relation exists.""" + return self._binding_xuid_key(xuid) in self.api_get_xuids_by_qq(qqid) + + def api_bind_qq_to_xuid( + self, + qqid: int | str, + xuid: str, + player_name: str = "", + group_id: int | None = None, + ) -> tuple[bool, str]: + """Create or refresh a QQ/XUID binding, respecting multi-bind config.""" + qq_key = self._binding_qq_key(qqid) + xuid_key = self._binding_xuid_key(xuid) + if not qq_key or not qq_key.isdigit(): + return False, "QQ号无效" + if not xuid_key: + return False, "XUID不能为空" + name = str(player_name).strip() or xuid_key + return self._bind_qq_to_xuid( + self._binding_api_group_id(group_id), + int(qq_key), + xuid_key, + name, + ) + + def api_unbind_qq_from_xuid( + self, qqid: int | str, xuid: str) -> tuple[bool, str]: + """Remove one exact QQ/XUID binding relation.""" + qq_key = self._binding_qq_key(qqid) + xuid_key = self._binding_xuid_key(xuid) + if not qq_key or not qq_key.isdigit(): + return False, "QQ号无效" + if not xuid_key: + return False, "XUID不能为空" + data = self.read_binding_data() + if not self._remove_binding_relation(data, qq_key, xuid_key): + return False, "绑定关系不存在" + self.save_binding_data(data) + return True, "已解绑" + + def api_unbind_all_by_qq(self, qqid: int | str) -> tuple[bool, str]: + """Remove all bindings owned by one QQ number.""" + qq_key = self._binding_qq_key(qqid) + if not qq_key or not qq_key.isdigit(): + return False, "QQ号无效" + data = self.read_binding_data() + xuids = list(data["qq_to_xuids"].get(qq_key, [])) + if not xuids: + return False, "该 QQ 没有绑定记录" + for xuid in xuids: + self._remove_binding_relation(data, qq_key, xuid) + self.save_binding_data(data) + return True, f"已解绑 {len(xuids)} 个游戏ID" + + def api_unbind_all_by_xuid(self, xuid: str) -> tuple[bool, str]: + """Remove all QQ bindings owned by one XUID.""" + xuid_key = self._binding_xuid_key(xuid) + if not xuid_key: + return False, "XUID不能为空" + data = self.read_binding_data() + qqs = list(data["xuid_to_qqs"].get(xuid_key, [])) + if not qqs: + return False, "该 XUID 没有绑定记录" + for qq_key in qqs: + self._remove_binding_relation(data, qq_key, xuid_key) + self.save_binding_data(data) + return True, f"已解绑 {len(qqs)} 个QQ号" + + def api_update_xuid_player_name( + self, + xuid: str, + player_name: str, + ) -> tuple[bool, str]: + """Update the latest recorded player name for a bound XUID.""" + xuid_key = self._binding_xuid_key(xuid) + name = str(player_name).strip() + if not xuid_key: + return False, "XUID不能为空" + if not name: + return False, "玩家名不能为空" + data = self.read_binding_data() + if xuid_key not in data["xuid_to_qqs"]: + return False, "该 XUID 没有绑定记录" + data["xuid_names"][xuid_key] = name + self.save_binding_data(data) + return True, "已更新玩家名" + + def api_start_binding_request( + self, + group_id: int, + qqid: int | str, + ) -> tuple[bool, str]: + """Start the normal QQ binding verification flow for a group member.""" + qq_key = self._binding_qq_key(qqid) + if not qq_key or not qq_key.isdigit(): + return False, "QQ号无效" + try: + group_id = int(group_id) + except (TypeError, ValueError): + return False, "群号无效" + if group_id not in self.group_cfgs: + return False, "该群未配置群服互通" + try: + ok, message = self._start_binding_request( + group_id, + int(qq_key), + ) + except Exception as err: + return False, f"绑定请求创建失败: {err}" + return ok, message + + def _remove_binding_relation( + self, + data: dict[str, dict[str, Any]], + qq_key: str, + xuid: str, + ) -> bool: + changed = False + qq_xuids = data["qq_to_xuids"].get(qq_key, []) + if xuid in qq_xuids: + qq_xuids.remove(xuid) + changed = True + if qq_xuids: + data["qq_to_xuids"][qq_key] = qq_xuids + else: + data["qq_to_xuids"].pop(qq_key, None) + + xuid_qqs = data["xuid_to_qqs"].get(xuid, []) + if qq_key in xuid_qqs: + xuid_qqs.remove(qq_key) + changed = True + if xuid_qqs: + data["xuid_to_qqs"][xuid] = xuid_qqs + else: + data["xuid_to_qqs"].pop(xuid, None) + data["xuid_names"].pop(xuid, None) + return changed + + def _cleanup_pending_bindings(self): + now = time.time() + with self.pending_bindings_lock: + expired_codes = [ + code + for code, pending in self.pending_bindings.items() + if now >= float(pending.get("expire_at", 0)) + ] + for code in expired_codes: + pending = self._pop_pending_binding(code) + if pending is not None: + self._send_binding_timeout_notice(pending) + + def _new_binding_code(self) -> str: + self._cleanup_pending_bindings() + with self.pending_bindings_lock: + for _ in range(20): + code = f"{secrets.randbelow(1000000):06d}" + if code not in self.pending_bindings: + return code + raise RuntimeError("无法生成唯一绑定验证码") + + def _remove_pending_bindings_by_qq(self, group_id: int, qqid: int): + with self.pending_bindings_lock: + codes = [ + code + for code, pending in self.pending_bindings.items() + if pending.get("group_id") == group_id and pending.get("qqid") == qqid + ] + for code in codes: + self._pop_pending_binding(code) + + def _pop_pending_binding(self, code: str, cancel_timer: bool = True): + with self.pending_bindings_lock: + pending = self.pending_bindings.pop(code, None) + timer = self.pending_binding_timers.pop(code, None) + if cancel_timer and timer is not None: + timer.cancel() + return pending + + def _schedule_binding_timeout(self, code: str, timeout_seconds: int): + timer = threading.Timer( + timeout_seconds, self._handle_binding_timeout, args=(code,)) + timer.daemon = True + with self.pending_bindings_lock: + self.pending_binding_timers[code] = timer + timer.start() + + def _handle_binding_timeout(self, code: str): + pending = self._pop_pending_binding(code, cancel_timer=False) + if pending is not None: + self._send_binding_timeout_notice(pending) + + def _send_binding_timeout_notice(self, pending: dict[str, Any]): + group_id = int(pending["group_id"]) + qqid = int(pending["qqid"]) + timeout_text = self._binding_text( + group_id, + "绑定超时提示文本", + "绑定超时,请重新获取验证码绑定", + ) + try: + self._reply_to_qq(group_id, qqid, timeout_text) + except Exception as err: + self.print_console_warn(f"绑定超时提示发送失败: {err}") + + def cleanup_binding_state(self): + with self.pending_bindings_lock: + timers = list(self.pending_binding_timers.values()) + self.pending_binding_timers.clear() + self.pending_bindings.clear() + for timer in timers: + timer.cancel() + + def _binding_cfg(self) -> dict[str, Any]: + cfg = self.cfg.get("绑定设置", {}) + if isinstance(cfg, dict): + return cfg + return self.binding_default() + + def _binding_enabled(self, group_id: int) -> bool: + return bool(self._binding_cfg().get("是否开启QQ号与游戏ID绑定功能", False)) + + def _binding_text(self, group_id: int, key: str, fallback: str) -> str: + value = self._binding_cfg().get(key, fallback) + text = str(value).strip() + return text or fallback + + def _binding_reject_text(self, group_id: int) -> str: + return self._binding_text( + group_id, + "拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)", + "您已有绑定账号,请解绑后再绑定", + ) + + def _binding_timeout_minutes(self, group_id: int) -> int: + return self._normalize_positive_int( + self._binding_cfg().get( + "绑定超时时间(单位:分钟)", + self.BINDING_TIMEOUT_MINUTES_DEFAULT, + ), + self.BINDING_TIMEOUT_MINUTES_DEFAULT, + ) + + def _render_binding_text( + self, + text: str, + code: str, + timeout_minutes: int) -> str: + return ( + text + .replace("{auth_code}", code) + .replace("{time}", str(timeout_minutes)) + ) + + def _qq_has_bound_xuid(self, qqid: int) -> bool: + data = self.read_binding_data() + return bool(data["qq_to_xuids"].get(str(qqid))) + + def get_group_binding_triggers(self, group_id: int) -> list[str]: + raw = self._binding_cfg().get("绑定触发词", ["绑定"]) + return self.normalize_string_triggers(raw, ["绑定"]) + + def _start_binding_request( + self, group_id: int, qqid: int) -> tuple[bool, str]: + if not self._binding_enabled(group_id): + return False, "QQ绑定功能当前已关闭" + + cfg = self._binding_cfg() + if ( + not bool(cfg.get("是否允许单QQ号可绑定多游戏ID", False)) + and self._qq_has_bound_xuid(qqid) + ): + return False, self._binding_reject_text(group_id) + + code = self._new_binding_code() + timeout_minutes = self._binding_timeout_minutes(group_id) + self._remove_pending_bindings_by_qq(group_id, qqid) + timeout_seconds = timeout_minutes * 60 + with self.pending_bindings_lock: + self.pending_bindings[code] = { + "group_id": group_id, + "qqid": qqid, + "expire_at": time.time() + timeout_seconds, + } + self._schedule_binding_timeout(code, timeout_seconds) + + self._reply_to_qq( + group_id, + qqid, + self._render_binding_text( + self._binding_text( + group_id, + "绑定验证码群聊提示文本", + "已将验证码发送至您的私信,请在{time}分钟内在游戏中发送验证码以完成绑定。", + ), + code, + timeout_minutes, + ), + ) + self.send_private_msg( + qqid, + self._render_binding_text( + self._binding_text( + group_id, + "绑定验证码私信提示文本", + "您的绑定验证码是:{auth_code}。请在{time}分钟内在游戏中发送该验证码已完成绑定。", + ), + code, + timeout_minutes, + ), + ) + return True, "绑定验证码已发送" + + def _handle_binding_trigger( + self, + group_id: int, + qqid: int, + clean_msg: str) -> bool: + if not self._binding_enabled(group_id): + return False + if clean_msg not in self.get_group_binding_triggers(group_id): + return False + + ok, message = self._start_binding_request(group_id, qqid) + if not ok: + self._reply_to_qq(group_id, qqid, message) + return True + + def send_private_msg(self, qqid: int, msg: str): + """向指定 QQ 发送私信。""" + if self.ws is None: + raise RuntimeError("WebSocket 尚未初始化") + if not self.available: + self._print_cloud_status( + "群服互通 云链连接", + "忽略发送", + ["当前未连接云链", f"已忽略发送到 QQ {qqid} 的私信"], + level="warn", + ) + return + payload = { + "action": "send_private_msg", + "params": {"user_id": qqid, "message": msg}, + } + self.ws.send(json.dumps(payload)) + + def consume_game_binding_code(self, chat) -> bool: + msg = str(chat.msg).strip() + if len(msg) != 6 or not msg.isdigit(): + return False + + pending = self._pop_pending_binding(msg) + if pending is None: + self._cleanup_pending_bindings() + return False + + group_id = int(pending["group_id"]) + qqid = int(pending["qqid"]) + if time.time() >= float(pending.get("expire_at", 0)): + timeout_text = self._binding_text( + group_id, + "绑定超时提示文本", + "绑定超时,请重新获取验证码绑定", + ) + chat.player.show(f"§c{timeout_text}") + self._reply_to_qq(group_id, qqid, timeout_text) + return True + + if not self._binding_enabled(group_id): + chat.player.show("§cQQ绑定功能当前已关闭") + return True + + player_name = chat.player.name + xuid = str(getattr(chat.player, "xuid", "")).strip() + if not xuid: + chat.player.show("§c无法获取你的 XUID,绑定失败") + self._reply_to_qq(group_id, qqid, "绑定失败:无法获取玩家 XUID") + return True + + ok, message = self._bind_qq_to_xuid(group_id, qqid, xuid, player_name) + if not ok: + chat.player.show(f"§c{message}") + self._reply_to_qq(group_id, qqid, message) + return True + + chat.player.show("§aQQ绑定成功") + success_text = self._binding_text( + group_id, + "绑定成功提示文本", + "恭喜你绑定成功,您的游戏ID为:{player_name}。", + ) + self._reply_to_qq( + group_id, + qqid, + success_text + .replace("{player_name}", player_name) + .replace("{xuid}", xuid) + .replace("{qq}", str(qqid)), + ) + return True + + def _bind_qq_to_xuid( + self, + group_id: int, + qqid: int, + xuid: str, + player_name: str): + cfg = self._binding_cfg() + allow_multi_xuid = bool(cfg.get("是否允许单QQ号可绑定多游戏ID", False)) + allow_multi_qq = bool(cfg.get("是否允许单游戏ID可绑定多QQ号", False)) + + data = self.read_binding_data() + qq_key = str(qqid) + qq_xuids = data["qq_to_xuids"].setdefault(qq_key, []) + xuid_qqs = data["xuid_to_qqs"].setdefault(xuid, []) + + if xuid in qq_xuids and qq_key in xuid_qqs: + data["xuid_names"][xuid] = player_name + self.save_binding_data(data) + return True, "绑定关系已存在,已刷新玩家名" + + if not allow_multi_xuid and qq_xuids and xuid not in qq_xuids: + return False, self._binding_reject_text(group_id) + if not allow_multi_qq and xuid_qqs and qq_key not in xuid_qqs: + return False, "绑定失败:该游戏ID已绑定其他 QQ 号" + + if xuid not in qq_xuids: + qq_xuids.append(xuid) + if qq_key not in xuid_qqs: + xuid_qqs.append(qq_key) + data["xuid_names"][xuid] = player_name + self.save_binding_data(data) + return True, "绑定成功" diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" new file mode 100644 index 00000000..815544de --- /dev/null +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" @@ -0,0 +1,611 @@ +"""Q 群与控制台配置编辑中心。""" + +import json +import os +import shutil +import time +from typing import Any + +from tooldelta import cfg, fmts + + +class QQLinkerConfigEditorMixin: + """提供 Ultra 版和联动插件配置的菜单化编辑能力。""" + + CONFIG_MENU_TRIGGERS = ["配置中心", "配置菜单", "群服配置"] + CONFIG_FILE_DIR = "插件配置文件" + CONFIG_BACKUP_DIR = "配置文件备份" + CONFIG_BACKUP_INDEX = "backups.json" + CONFIG_EXIT = object() + CONFIG_BACK = object() + + def get_group_config_menu_triggers(self, group_id: int): + group_cfg = self.group_cfgs.get(group_id) + if not group_cfg: + return list(self.CONFIG_MENU_TRIGGERS) + raw = group_cfg["指令设置"].get("配置中心唤醒词", self.CONFIG_MENU_TRIGGERS) + return self.normalize_string_triggers(raw, self.CONFIG_MENU_TRIGGERS) + + def qq_config_center_menu(self, group_id: int, qqid: int): + """Q 群侧配置中心入口。""" + if not self._can_use_group_permission(group_id, qqid, "配置配置文件权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + self._config_center_menu( + {"mode": "qq", "group_id": group_id, "qqid": qqid}) + + def on_console_config_center(self, _args: list[str]): + """控制台侧配置中心入口。""" + self._config_center_menu({"mode": "console"}) + + def _config_group_id(self, ctx: dict[str, Any]) -> int | None: + return int(ctx["group_id"]) if ctx.get( + "mode") == "qq" and "group_id" in ctx else None + + def _config_exit_hint( + self, ctx: dict[str, Any], action: str = "退出") -> str: + return self.menu_exit_hint(self._config_group_id(ctx), action) + + def _config_back_hint( + self, ctx: dict[str, Any], action: str = "返回上级菜单") -> str: + return self.menu_back_hint(self._config_group_id(ctx), action) + + def _config_normalize_control_hints( + self, + ctx: dict[str, Any], + hints: list[str], + ) -> list[str]: + normalized: list[str] = [] + for hint in hints: + if hint == "输入 . 退出": + normalized.append(self._config_exit_hint(ctx)) + elif hint == "输入 . 取消": + normalized.append(self._config_back_hint(ctx, "取消")) + elif hint == "输入 0 返回上级": + normalized.append(self._config_back_hint(ctx, "返回上级")) + elif hint == "输入 0 返回上级菜单": + normalized.append(self._config_back_hint(ctx)) + else: + normalized.append(hint) + return normalized + + def _config_center_menu(self, ctx: dict[str, Any]): + while True: + options = [ + "模式1:整文件修改配置", + "模式2:暂未开放", + ] + choice = self._config_prompt( + ctx, + "配置中心", + options, + [f"输入 [1-{len(options)}] 选择配置模式", "输入 . 退出"], + ) + if choice is self.CONFIG_EXIT: + return + selected = self.parse_displayed_menu_choice(choice, len(options)) + if selected is None: + self._config_error(ctx, "输入有误") + continue + if selected == 1: + result = self._config_file_mode_menu(ctx) + if result is self.CONFIG_EXIT: + return + continue + if selected == 2: + self._config_error(ctx, "模式2暂未开放") + continue + + def _config_file_mode_menu(self, ctx: dict[str, Any]): + while True: + choice = self._config_prompt( + ctx, + "模式1:整文件修改", + ["修改配置文件", "还原配置文件备份"], + ["输入 [1-2] 选择操作", "输入 0 返回上级", "输入 . 退出"], + allow_back=True, + ) + if choice is self.CONFIG_EXIT: + return self.CONFIG_EXIT + if choice is self.CONFIG_BACK: + return + if choice == "1": + result = self._config_file_select_menu(ctx) + if result is self.CONFIG_EXIT: + return self.CONFIG_EXIT + continue + if choice == "2": + result = self._config_restore_backup_menu(ctx) + if result is self.CONFIG_EXIT: + return self.CONFIG_EXIT + continue + self._config_error(ctx, "输入有误") + + def _config_file_select_menu(self, ctx: dict[str, Any]): + page = 1 + while True: + files = self._discover_config_files() + if not files: + self._config_error( + ctx, f"未找到 { + self.CONFIG_FILE_DIR}/*.json 配置文件") + return + per_page = self.get_group_config_file_items_per_page( + self._config_group_id(ctx)) + total_pages, start_index, end_index = self.simple_paginate( + len(files), + per_page, + page, + ) + page = min(page, total_pages) + page_files = files[start_index - 1: end_index] + options = [item["display"] for item in page_files] + choice = self._config_prompt( + ctx, + "选择配置文件", + options, + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(options)}] 选择要整文件修改的配置", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 0 返回上级", + "输入 . 退出", + ], + allow_back=True, + ) + if choice is self.CONFIG_EXIT: + return self.CONFIG_EXIT + if choice is self.CONFIG_BACK: + return + if choice == "+": + if page < total_pages: + page += 1 + else: + self._config_error(ctx, "已经是最后一页啦~") + continue + if choice == "-": + if page > 1: + page -= 1 + else: + self._config_error(ctx, "已经是第一页啦~") + continue + if page_num := self.parse_page_jump(choice): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._config_error(ctx, f"不存在第 {page_num} 页") + continue + selected = self.parse_displayed_menu_choice( + choice, len(page_files)) + if selected is None: + self._config_error(ctx, "输入有误") + continue + result = self._edit_config_file_whole( + ctx, files[start_index + selected - 2]) + if result is self.CONFIG_EXIT: + return self.CONFIG_EXIT + + def _edit_config_file_whole( + self, ctx: dict[str, Any], item: dict[str, str]): + try: + with open(item["path"], "r", encoding="utf-8-sig") as file: + content = file.read() + except Exception as err: + self._config_error(ctx, f"读取配置文件失败: {err}") + return + try: + original_config = json.loads(content) + except json.JSONDecodeError: + original_config = None + + if ctx["mode"] == "qq": + prompt_text = self._config_whole_file_prompt_text( + ctx, + item["name"], + content, + ) + raw = self._config_input_result( + ctx, + self.qq_prompt( + ctx["group_id"], + ctx["qqid"], + prompt_text, + timeout=600), + ) + else: + self.print_console_card( + "群服互通 配置中心", + f"整文件修改 / {item['name']}", + [ + "当前配置文件内容如下,复制并修改后粘贴回控制台", + "机器人会在替换前自动备份原配置文件", + "多行 JSON 可直接粘贴,读取到完整 JSON 后会自动提交", + "输入 END 单独一行可强制提交并检查格式", + self._config_back_hint(ctx, "返回上级"), + self._config_exit_hint(ctx, "退出本次修改"), + content, + ], + level="info", + ) + raw = self._read_console_config_json_text(ctx) + + if raw is self.CONFIG_EXIT: + return self.CONFIG_EXIT + if raw is self.CONFIG_BACK: + return + + try: + parsed = json.loads(self._normalize_config_json_text(str(raw))) + except json.JSONDecodeError as err: + self._config_error(ctx, f"JSON 格式错误,未替换配置文件: {err}") + return + + if not isinstance(parsed, dict): + self._config_error(ctx, "配置文件根节点必须是 JSON 对象,未替换配置文件") + return + if not self._config_file_shape_matches(original_config, parsed): + self._config_error(ctx, "请发送完整配置文件,不能只发送配置项内容") + return + + try: + if not self._is_safe_config_path(item["path"]): + self._config_error(ctx, "配置文件路径不在允许的插件配置目录内") + return + backup = self._backup_config_file(item) + with open(item["path"], "w", encoding="utf-8") as file: + json.dump(parsed, file, ensure_ascii=False, indent=4) + file.write("\n") + except Exception as err: + self._config_error(ctx, f"替换配置文件失败: {err}") + return + + apply_msg = self._apply_runtime_config_file(item, parsed) + self._config_success( + ctx, + f"配置文件已替换,备份编号 {backup['id']}。{apply_msg}", + ) + + def _config_whole_file_prompt_text( + self, + ctx: dict[str, Any], + config_name: str, + content: str, + ) -> str: + parts = [ + self.orion_ui_border(), + f"❐ 『群服互通云链版Ultra版』 整文件修改 / {config_name}", + "❀ 请复制下面的完整 JSON,修改后作为下一条消息发送", + "❀ 机器人会在替换前自动备份原配置文件", + f"❀ {self._config_back_hint(ctx, '返回上级')}", + f"❀ {self._config_exit_hint(ctx, '退出本次修改')}", + self.orion_ui_border(), + content, + ] + return "\n".join(parts) + + def _config_restore_backup_menu(self, ctx: dict[str, Any]): + while True: + backups = self._load_config_backup_index() + backups = [item for item in backups if os.path.isfile( + item.get("backup_path", ""))] + if not backups: + self._config_error(ctx, "暂无可还原的配置文件备份") + return + backups = list(reversed(backups[-30:])) + options = [ + f"{item['id']} / {item['config_name']} / {item.get('created_at', '')}" + for item in backups + ] + choice = self._config_prompt( + ctx, + "还原配置文件备份", + options, + [ + f"输入 [1-{len(options)}] 选择要还原的备份", + "还原前也会备份当前配置文件", + "输入 0 返回上级", + "输入 . 退出", + ], + allow_back=True, + ) + if choice is self.CONFIG_EXIT: + return self.CONFIG_EXIT + if choice is self.CONFIG_BACK: + return + selected = self.parse_displayed_menu_choice(choice, len(backups)) + if selected is None: + self._config_error(ctx, "输入有误") + continue + self._restore_config_backup(ctx, backups[selected - 1]) + return + + def _restore_config_backup( + self, ctx: dict[str, Any], backup: dict[str, str]): + current_item = { + "name": backup["config_name"], + "path": backup["original_path"], + "display": backup["config_name"], + } + try: + if not self._is_safe_config_path(current_item["path"]): + self._config_error(ctx, "备份记录指向的配置文件路径不在允许目录内") + return + if not self._is_safe_backup_path(backup["backup_path"]): + self._config_error(ctx, "备份文件路径不在允许的备份目录内") + return + if os.path.isfile(current_item["path"]): + self._backup_config_file(current_item, reason="restore-before") + os.makedirs(os.path.dirname(current_item["path"]), exist_ok=True) + shutil.copy2(backup["backup_path"], current_item["path"]) + with open(current_item["path"], "r", encoding="utf-8-sig") as file: + restored = json.load(file) + except Exception as err: + self._config_error(ctx, f"还原配置文件失败: {err}") + return + apply_msg = self._apply_runtime_config_file(current_item, restored) + self._config_success(ctx, f"已还原备份 {backup['id']}。{apply_msg}") + + def _discover_config_files(self) -> list[dict[str, str]]: + cfg_dir = os.path.abspath(self.CONFIG_FILE_DIR) + if not os.path.isdir(cfg_dir): + return [] + files: list[dict[str, str]] = [] + for name in sorted(os.listdir(cfg_dir), key=str.lower): + path = os.path.join(cfg_dir, name) + if not os.path.isfile(path) or not name.lower().endswith(".json"): + continue + config_name = os.path.splitext(name)[0] + files.append( + { + "name": config_name, + "path": path, + "display": f"{config_name} ({os.path.relpath(path)})", + } + ) + return files + + def _config_backup_root(self) -> str: + path = self.format_data_path(self.CONFIG_BACKUP_DIR) + os.makedirs(path, exist_ok=True) + return path + + def _config_backup_index_path(self) -> str: + return os.path.join( + self._config_backup_root(), + self.CONFIG_BACKUP_INDEX) + + def _load_config_backup_index(self) -> list[dict[str, str]]: + path = self._config_backup_index_path() + if not os.path.isfile(path): + return [] + try: + with open(path, "r", encoding="utf-8") as file: + data = json.load(file) + except Exception: + return [] + if not isinstance(data, list): + return [] + return [item for item in data if isinstance(item, dict)] + + def _save_config_backup_index(self, backups: list[dict[str, str]]): + path = self._config_backup_index_path() + with open(path, "w", encoding="utf-8") as file: + json.dump(backups[-100:], file, ensure_ascii=False, indent=2) + file.write("\n") + + def _backup_config_file( + self, + item: dict[str, str], + reason: str = "replace-before", + ) -> dict[str, str]: + if not self._is_safe_config_path(item["path"]): + raise ValueError("配置文件路径不在允许的插件配置目录内") + created_at = time.strftime("%Y-%m-%d %H:%M:%S") + stamp = time.strftime("%Y%m%d-%H%M%S") + \ + f"-{int(time.time() * 1000) % 1000:03d}" + safe_name = self._safe_backup_name(item["name"]) + backup_id = f"{stamp}-{safe_name}" + backup_dir = os.path.join(self._config_backup_root(), safe_name) + os.makedirs(backup_dir, exist_ok=True) + backup_path = os.path.join(backup_dir, f"{backup_id}.json") + shutil.copy2(item["path"], backup_path) + backup = { + "id": backup_id, + "config_name": item["name"], + "original_path": os.path.abspath(item["path"]), + "backup_path": os.path.abspath(backup_path), + "created_at": created_at, + "reason": reason, + } + backups = self._load_config_backup_index() + backups.append(backup) + self._save_config_backup_index(backups) + return backup + + @staticmethod + def _safe_backup_name(name: str) -> str: + return "".join(ch if ch.isalnum() or ch in ("-", "_") + else "_" for ch in name) or "config" + + def _read_console_config_json_text(self, ctx: dict[str, Any]): + lines: list[str] = [] + while True: + prompt = "请输入完整配置 JSON: " if not lines else "继续输入 JSON: " + line = input(fmts.fmt_info(f"§a❀ §b{prompt}")) + text_line = line.strip() + group_id = self._config_group_id(ctx) + if self.is_menu_exit_input(text_line, group_id): + self._config_success(ctx, "已退出配置文件修改") + return self.CONFIG_EXIT + if self.is_menu_back_input(text_line, group_id): + return self.CONFIG_BACK + lowered = text_line.lower() + if lines and lowered in ("end", "提交"): + return "\n".join(lines) + lines.append(line) + text = "\n".join(lines) + try: + json.loads(self._normalize_config_json_text(text)) + return text + except json.JSONDecodeError: + continue + + @staticmethod + def _normalize_config_json_text(raw: str) -> str: + text = raw.strip() + if not text.startswith("```") or not text.endswith("```"): + return text + lines = text.splitlines() + if len(lines) < 2: + return text + if lines[0].strip().startswith("```") and lines[-1].strip() == "```": + return "\n".join(lines[1:-1]).strip() + return text + + @staticmethod + def _config_file_shape_matches( + original: Any, new_config: dict[str, Any]) -> bool: + if not isinstance(original, dict): + return True + if isinstance(original.get("配置项"), dict): + return isinstance(new_config.get("配置项"), dict) + return True + + def _is_safe_config_path(self, path: str) -> bool: + cfg_dir = os.path.abspath(self.CONFIG_FILE_DIR) + target = os.path.abspath(path) + try: + return os.path.commonpath( + [cfg_dir, target]) == cfg_dir and target.lower().endswith( + ".json") + except ValueError: + return False + + def _is_safe_backup_path(self, path: str) -> bool: + backup_root = os.path.abspath(self._config_backup_root()) + target = os.path.abspath(path) + try: + return os.path.commonpath( + [backup_root, target]) == backup_root and target.lower().endswith(".json") + except ValueError: + return False + + @staticmethod + def _extract_config_items(full_config: dict[str, Any]) -> dict[str, Any]: + config_items = full_config.get("配置项") + if isinstance(config_items, dict): + return config_items + return full_config + + def _apply_runtime_config_file( + self, item: dict[str, str], full_config: dict[str, Any]) -> str: + config_name = item["name"] + config_items = self._extract_config_items(full_config) + try: + if config_name == self.name: + message = self.apply_ultra_runtime_config(config_items) + self._runtime_config_path = item["path"] + self._runtime_config_file_state = self.runtime_config_file_state( + item["path"]) + return message + if ( + config_name == "白名单&管理员检测云链联动版" + and self.whitelist_checker is not None + and hasattr(self.whitelist_checker, "apply_runtime_config") + ): + self.whitelist_checker.apply_runtime_config(config_items) + return "白名单&管理员检测配置已动态载入" + if ( + config_name == "任务系统云链联动版" + and self.task_system is not None + and hasattr(self.task_system, "cfg") + ): + self.task_system.cfg = config_items + return "任务系统配置已动态载入" + if ( + config_name == "领地系统云链联动版" + and self.land_system is not None + and hasattr(self.land_system, "apply_runtime_config") + ): + self.land_system.apply_runtime_config(config_items) + return "领地系统配置已动态载入" + if ( + config_name in ("『Orion System』违规与作弊行为综合反制系统", "Orion System 猎户座") + and self.orion is not None + and hasattr(self.orion, "config_mgr") + ): + mgr = self.orion.config_mgr + mgr.config = config_items + cfg.check_auto(mgr.CONFIG_STD, mgr.config) + mgr.get_parsed_config() + mgr.transfer_config() + mgr.check_permission_mgr() + mgr.concise_mode() + return "Orion 配置已动态载入" + except Exception as err: + return f"配置文件已落盘,但动态载入失败: {err}。可使用备份还原或重启后查看报错" + return "该配置所属插件未接入动态载入,通常需要重启 ToolDelta 或等待插件自行重新读取" + + def _config_prompt( + self, + ctx: dict[str, Any], + subtitle: str, + options: list[str], + hints: list[str], + allow_back: bool = False, + ): + hints = self._config_normalize_control_hints(ctx, hints) + if ctx["mode"] == "qq": + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + subtitle, + options, + hints, + self._config_group_id(ctx), + ) + result = self.qq_prompt( + ctx["group_id"], ctx["qqid"], text, timeout=120) + else: + lines = [f"[ {i + 1} ] {option}" for i, + option in enumerate(options)] + lines.extend(hints) + result = self.prompt_console_input( + "群服互通 配置中心", subtitle, lines, "请输入") + if result is None: + self._config_error(ctx, "回复超时,已退出菜单") + return self.CONFIG_EXIT + result = str(result).strip() + group_id = self._config_group_id(ctx) + if self.is_menu_exit_input(result, group_id): + self._config_success(ctx, "已退出菜单") + return self.CONFIG_EXIT + if allow_back and self.is_menu_back_input(result, group_id): + return self.CONFIG_BACK + return result + + def _config_input_result(self, ctx: dict[str, Any], result: Any): + if result is None: + self._config_error(ctx, "回复超时,已退出菜单") + return self.CONFIG_EXIT + text = str(result).strip() + group_id = self._config_group_id(ctx) + if self.is_menu_exit_input(text, group_id): + self._config_success(ctx, "已退出菜单") + return self.CONFIG_EXIT + if self.is_menu_back_input(text, group_id): + return self.CONFIG_BACK + return text + + def _config_success(self, ctx: dict[str, Any], message: str): + if ctx["mode"] == "qq": + self._reply_to_qq(ctx["group_id"], ctx["qqid"], f"❀ {message}") + else: + self.print_console_success(message) + + def _config_error(self, ctx: dict[str, Any], message: str): + if ctx["mode"] == "qq": + self._reply_to_qq(ctx["group_id"], ctx["qqid"], f"❀ {message}") + else: + self.print_console_error(message) diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" index cb3740a8..8122dc76 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" @@ -1,569 +1,1629 @@ -"""配置与群权限相关的公共逻辑。 - -这一层解决两个问题: -1. 把历史配置逐步迁到现在的“多群结构”。 -2. 提供群管理员、超级管理员、触发词等基础数据访问能力。 -""" - -import inspect -import json -import os -from typing import Any -from collections.abc import Callable - -from tooldelta import cfg - -from .message_utils import QQMsgTrigger - - -# 配置迁移、群权限状态和触发词读取都收在这一层,避免散到业务逻辑里。 -class QQLinkerConfigMixin: - """配置、群状态和触发词的基础能力集合。""" - - @staticmethod - def group_default(group_id: int = 194838530): - """返回单个群聊的默认配置骨架。""" - # 单个群聊的完整默认结构,老配置迁移时也以它做兜底模板。 - return { - "群号": group_id, - "游戏到群": { - "是否启用": False, - "转发格式": "<[玩家名]> [消息]", - "仅转发以下符号开头的消息(列表为空则全部转发)": ["#"], - "屏蔽以下字符串开头的消息": [".", "。"], - "转发玩家进退提示": True, - }, - "群到游戏": { - "是否启用": True, - "转发格式": "群 <[昵称]> [消息]", - "屏蔽的QQ号": [], - "替换花里胡哨的昵称": True, - "替换花里胡哨的消息": True, - }, - "指令设置": { - "发送指令前缀": "/", - "帮助菜单唤醒词": ["help", "帮助"], - "管理员菜单唤醒词": ["管理员菜单"], - "是否允许查看玩家列表": True, - "查看玩家人数的唤醒词": ["list", "玩家列表"], - "查询背包菜单唤醒词": ["查询背包"], - "查询背包菜单每页显示的玩家数量": 10, - "QQ群封禁唤醒词": ["orban", "orion ban", "猎户封禁"], - "QQ群解封唤醒词": ["orunban", "orion unban", "猎户解封"], - "QQ群白名单&管理员检测唤醒词": ["白名单&管理员检测", "检测管理"], - "QQ群封禁/解封菜单每页显示个数": 10, - }, - } - - @classmethod - def cfg_default(cls): - """返回插件级默认配置。""" - return { - "云链设置": {"地址": "ws://127.0.0.1:3001", "校验码": ""}, - "群聊设置": [cls.group_default()], - } - - @classmethod - def cfg_std(cls): - """返回 ToolDelta 用来校验配置的数据结构定义。""" - group_std = { - "群号": cfg.PInt, - "游戏到群": { - "是否启用": bool, - "转发格式": str, - "仅转发以下符号开头的消息(列表为空则全部转发)": cfg.JsonList(str, -1), - "屏蔽以下字符串开头的消息": cfg.JsonList(str, -1), - "转发玩家进退提示": bool, - }, - "群到游戏": { - "是否启用": bool, - "转发格式": str, - "屏蔽的QQ号": cfg.JsonList(cfg.PInt, -1), - "替换花里胡哨的昵称": bool, - "替换花里胡哨的消息": bool, - }, - "指令设置": { - "发送指令前缀": str, - "帮助菜单唤醒词": cfg.JsonList(str, -1), - "管理员菜单唤醒词": cfg.JsonList(str, -1), - "是否允许查看玩家列表": bool, - "查看玩家人数的唤醒词": cfg.JsonList(str, -1), - "查询背包菜单唤醒词": cfg.JsonList(str, -1), - "查询背包菜单每页显示的玩家数量": cfg.PInt, - "QQ群封禁唤醒词": cfg.JsonList(str, -1), - "QQ群解封唤醒词": cfg.JsonList(str, -1), - "QQ群白名单&管理员检测唤醒词": cfg.JsonList(str, -1), - "QQ群封禁/解封菜单每页显示个数": cfg.PInt, - }, - } - return { - "云链设置": {"地址": str, "校验码": str}, - "群聊设置": cfg.JsonList(group_std, -1), - } - - @staticmethod - def normalize_int_list(values: Any) -> list[int]: - """把来源不可信的列表规整成去重后的正整数列表。""" - if not isinstance(values, list): - return [] - result: list[int] = [] - for value in values: - try: - ivalue = int(value) - except (TypeError, ValueError): - continue - if ivalue > 0 and ivalue not in result: - result.append(ivalue) - return result - - @classmethod - def merge_with_default(cls, raw: Any, default: Any): - """递归合并旧配置和默认值。 - - 这里不会粗暴覆盖未知字段,目的是在升级时尽量保留用户已经写进配置里的内容。 - """ - if isinstance(default, dict): - result = { - key: cls.merge_with_default(None, value) - for key, value in default.items() - } - if isinstance(raw, dict): - for key, value in raw.items(): - if key in result: - result[key] = cls.merge_with_default(value, result[key]) - else: - result[key] = value - return result - if isinstance(default, list): - return list(raw) if isinstance(raw, list) else list(default) - return raw if raw is not None else default - - def migrate_group_config(self, raw_group: Any): - """把单个群聊配置迁到当前结构。 - - 这个方法主要处理字段补全、类型纠正和历史字段兼容, - 这样业务层就可以假设拿到的 group_cfg 是完整且结构稳定的。 - """ - if not isinstance(raw_group, dict): - return None - try: - group_id = int(raw_group.get("群号", 0)) - except (TypeError, ValueError): - return None - if group_id <= 0: - return None - group_cfg = self.group_default(group_id) - # 老版本配置是按几个子区块散开的,这里逐段合并到统一结构里。 - old_g2q = raw_group.get("游戏到群", {}) - old_q2g = raw_group.get("群到游戏", {}) - old_cmd = raw_group.get("指令设置", {}) - self._merge_game_to_group_cfg(group_cfg, old_g2q) - self._merge_group_to_game_cfg(group_cfg, old_q2g) - self._merge_command_cfg(group_cfg, old_cmd) - return group_cfg - - def _merge_game_to_group_cfg(self, group_cfg: dict[str, Any], old_g2q: Any): - """把旧版“游戏到群”配置段合并进当前群配置。""" - if not isinstance(old_g2q, dict): - return - game_to_group = group_cfg["游戏到群"] - game_to_group["是否启用"] = bool(old_g2q.get("是否启用", game_to_group["是否启用"])) - game_to_group["转发格式"] = str(old_g2q.get("转发格式", game_to_group["转发格式"])) - game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"] = self._clean_string_list( - old_g2q.get( - "仅转发以下符号开头的消息(列表为空则全部转发)", - game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"], - ), - game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"], - ) - game_to_group["屏蔽以下字符串开头的消息"] = self._clean_string_list( - old_g2q.get( - "屏蔽以下字符串开头的消息", - game_to_group["屏蔽以下字符串开头的消息"], - ), - game_to_group["屏蔽以下字符串开头的消息"], - ) - game_to_group["转发玩家进退提示"] = bool( - old_g2q.get("转发玩家进退提示", game_to_group["转发玩家进退提示"]) - ) - - def _merge_group_to_game_cfg(self, group_cfg: dict[str, Any], old_q2g: Any): - """把旧版“群到游戏”配置段合并进当前群配置。""" - if not isinstance(old_q2g, dict): - return - group_to_game = group_cfg["群到游戏"] - group_to_game["是否启用"] = bool(old_q2g.get("是否启用", group_to_game["是否启用"])) - group_to_game["转发格式"] = str(old_q2g.get("转发格式", group_to_game["转发格式"])) - group_to_game["屏蔽的QQ号"] = self.normalize_int_list(old_q2g.get("屏蔽的QQ号", [])) - group_to_game["替换花里胡哨的昵称"] = bool( - old_q2g.get("替换花里胡哨的昵称", group_to_game["替换花里胡哨的昵称"]) - ) - group_to_game["替换花里胡哨的消息"] = bool( - old_q2g.get("替换花里胡哨的消息", group_to_game["替换花里胡哨的消息"]) - ) - - def _merge_command_cfg(self, group_cfg: dict[str, Any], old_cmd: Any): - """把旧版指令相关配置合并进当前群配置。""" - if not isinstance(old_cmd, dict): - return - command_cfg = group_cfg["指令设置"] - command_cfg["发送指令前缀"] = ( - str(old_cmd.get("发送指令前缀", command_cfg["发送指令前缀"])).strip() - or command_cfg["发送指令前缀"] - ) - command_cfg["管理员菜单唤醒词"] = self._clean_string_list( - old_cmd.get("管理员菜单唤醒词", command_cfg["管理员菜单唤醒词"]), - command_cfg["管理员菜单唤醒词"], - ) - command_cfg["帮助菜单唤醒词"] = self._clean_string_list( - old_cmd.get("帮助菜单唤醒词", command_cfg["帮助菜单唤醒词"]), - command_cfg["帮助菜单唤醒词"], - ) - command_cfg["是否允许查看玩家列表"] = bool( - old_cmd.get("是否允许查看玩家列表", command_cfg["是否允许查看玩家列表"]) - ) - command_cfg["查看玩家人数的唤醒词"] = self._clean_string_list( - old_cmd.get("查看玩家人数的唤醒词", command_cfg["查看玩家人数的唤醒词"]), - command_cfg["查看玩家人数的唤醒词"], - ) - command_cfg["查询背包菜单唤醒词"] = self._clean_string_list( - old_cmd.get("查询背包菜单唤醒词", command_cfg["查询背包菜单唤醒词"]), - command_cfg["查询背包菜单唤醒词"], - ) - command_cfg["查询背包菜单每页显示的玩家数量"] = self._normalize_positive_int( - old_cmd.get( - "查询背包菜单每页显示的玩家数量", - command_cfg["查询背包菜单每页显示的玩家数量"], - ), - command_cfg["查询背包菜单每页显示的玩家数量"], - ) - command_cfg["QQ群封禁唤醒词"] = self._clean_string_list( - old_cmd.get("QQ群封禁唤醒词", command_cfg["QQ群封禁唤醒词"]), - command_cfg["QQ群封禁唤醒词"], - ) - command_cfg["QQ群解封唤醒词"] = self._clean_string_list( - old_cmd.get("QQ群解封唤醒词", command_cfg["QQ群解封唤醒词"]), - command_cfg["QQ群解封唤醒词"], - ) - command_cfg["QQ群白名单&管理员检测唤醒词"] = self._clean_string_list( - old_cmd.get( - "QQ群白名单&管理员检测唤醒词", - command_cfg["QQ群白名单&管理员检测唤醒词"], - ), - command_cfg["QQ群白名单&管理员检测唤醒词"], - ) - command_cfg["QQ群封禁/解封菜单每页显示个数"] = self._normalize_positive_int( - old_cmd.get( - "QQ群封禁/解封菜单每页显示个数", - command_cfg["QQ群封禁/解封菜单每页显示个数"], - ), - command_cfg["QQ群封禁/解封菜单每页显示个数"], - ) - - @staticmethod - def _clean_string_list(raw: Any, fallback: list[str]): - """清理字符串列表,失败时回退到默认值。""" - if isinstance(raw, list): - cleaned = [str(item) for item in raw if isinstance(item, str)] - if cleaned: - return cleaned - return fallback - - @staticmethod - def _normalize_positive_int(value: Any, fallback: int): - """把不可信的数字输入规范成正整数。""" - try: - return max(1, int(value)) - except (TypeError, ValueError): - return fallback - - def migrate_config(self, raw_cfg: Any): - """把整个插件配置迁到最新版本。 - - 历史上这个插件经历过“单群结构”和“多群结构”两个阶段, - 所以这里除了常规默认值补齐,还要兼容最早那一版只有 `消息转发设置` 的写法。 - """ - new_cfg = self.cfg_default() - if not isinstance(raw_cfg, dict): - return new_cfg - raw_cfg = self.merge_with_default(raw_cfg, new_cfg) - - cloud_cfg = raw_cfg.get("云链设置", {}) - if isinstance(cloud_cfg, dict): - new_cfg["云链设置"]["地址"] = str( - cloud_cfg.get("地址", new_cfg["云链设置"]["地址"]) +"""配置与群权限相关的公共逻辑。 + +这一层解决两个问题: +1. 把历史配置逐步迁到现在的“多群结构”。 +2. 提供群管理员、超级管理员、触发词等基础数据访问能力。 +""" + +import inspect +import json +import os +from copy import deepcopy +from typing import Any +from collections.abc import Callable + +from tooldelta import cfg + +from .message_utils import QQMsgTrigger + + +# 配置迁移、群权限状态和触发词读取都收在这一层,避免散到业务逻辑里。 +class QQLinkerConfigMixin: + """配置、群状态和触发词的基础能力集合。""" + + MENU_EXIT_TRIGGERS_DEFAULT = [".", "。", "q"] + MENU_BACK_TRIGGERS_DEFAULT = ["!", "!"] + CONFIG_FILE_DIR = "插件配置文件" + RUNTIME_CONFIG_RELOAD_INTERVAL = 5 + DYNAMIC_LOAD_SETTINGS_KEY = "动态载入设置" + DYNAMIC_LOAD_ENABLED_KEY = "是否启用动态载入配置文件(仅用于本插件)" + DYNAMIC_LOAD_INTERVAL_KEY = "动态载入检测时间间隔(单位:秒)" + OWNER_QQ_DEFAULT = 1234567890 + OWNER_QQ_UNSET = 0 + PERMISSION_SETTINGS_KEY = "权限设置" + LEGACY_GROUP_STATE_DIR_NAME = "群聊权限数据" + + @staticmethod + def binding_default(): + """返回全局 QQ 与游戏 ID 绑定配置。""" + return { + "是否开启QQ号与游戏ID绑定功能": False, + "是否允许单QQ号可绑定多游戏ID": False, + "是否允许单游戏ID可绑定多QQ号": False, + "绑定触发词": ["绑定"], + "绑定超时时间(单位:分钟)": 10, + "绑定验证码群聊提示文本": "已将验证码发送至您的私信,请在{time}分钟内在游戏中发送验证码以完成绑定。", + "绑定验证码私信提示文本": "您的绑定验证码是:{auth_code}。请在{time}分钟内在游戏中发送该验证码已完成绑定。", + "拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)": "您已有绑定账号,请解绑后再绑定", + "绑定超时提示文本": "绑定超时,请重新获取验证码绑定", + "绑定成功提示文本": "恭喜你绑定成功,您的游戏ID为:{player_name}。", + } + + @classmethod + def permission_default(cls): + """返回单个群聊的权限配置。""" + return { + "所有者QQ号": cls.OWNER_QQ_DEFAULT, + "超级管理员QQ号": [cls.OWNER_QQ_DEFAULT], + "普通管理员QQ号": [cls.OWNER_QQ_DEFAULT], + "各功能权限设置": { + "查看玩家人数权限": { + "是否允许普通成员使用": True, + "是否允许普通管理员使用": True, + "是否允许超级管理员使用": True, + }, + "发送指令权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": True, + "是否允许超级管理员使用": True, + }, + "查询背包权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": True, + "是否允许超级管理员使用": True, + }, + "封禁/解封玩家权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": True, + "是否允许超级管理员使用": True, + }, + "白名单&管理员检测权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": False, + "是否允许超级管理员使用": True, + }, + "领地系统权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": True, + "是否允许超级管理员使用": True, + }, + "公会系统权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": True, + "是否允许超级管理员使用": True, + }, + "任务系统权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": True, + "是否允许超级管理员使用": True, + }, + "配置配置文件权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": False, + "是否允许超级管理员使用": True, + }, + "QQ普通管理员菜单权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": False, + "是否允许超级管理员使用": True, + }, + "QQ超级管理员菜单权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": False, + "是否允许超级管理员使用": False, + }, + }, + } + + @staticmethod + def group_default(group_id: int = 194838530): + """返回单个群聊的默认配置骨架。""" + # 单个群聊的完整默认结构,老配置迁移时也以它做兜底模板。 + return { + "群号": group_id, + "游戏到群": { + "是否启用": False, + "转发格式": "<[玩家名]> [消息]", + "仅转发以下符号开头的消息(列表为空则全部转发)": ["#"], + "屏蔽以下字符串开头的消息": [".", "。"], + "转发玩家进退提示": True, + }, + "群到游戏": { + "是否启用": True, + "转发格式": "群 <[昵称]> [消息]", + "仅转发以下符号开头的消息(列表为空则全部转发)": [], + "屏蔽的QQ号": [], + "替换花里胡哨的昵称": True, + "替换花里胡哨的消息": True, + }, + QQLinkerConfigMixin.PERMISSION_SETTINGS_KEY: QQLinkerConfigMixin.permission_default(), + "指令设置": { + "发送指令前缀": "/", + "帮助菜单唤醒词": ["help", "帮助"], + "帮助菜单非管理功能每页显示数量": 10, + "帮助菜单管理功能每页显示数量": 10, + "命令触发词帮助菜单每页显示数量": 10, + "配置文件整文件修改模式每页显示数量": 10, + "管理员菜单唤醒词": ["管理员菜单"], + "配置中心唤醒词": ["配置中心", "配置菜单", "群服配置"], + "退出整个菜单触发词": [".", "。", "q"], + "返回上一级菜单触发词": ["!", "!"], + "是否允许查看玩家列表": True, + "查看玩家人数的唤醒词": ["list", "玩家列表"], + "查询背包菜单唤醒词": ["查询背包"], + "查询背包菜单每页显示的玩家数量": 10, + "QQ群封禁唤醒词": ["orban", "orion ban", "猎户封禁"], + "QQ群解封唤醒词": ["orunban", "orion unban", "猎户解封"], + "QQ群白名单&管理员检测唤醒词": ["白名单&管理员检测", "检测管理"], + "任务系统菜单唤醒词": ["任务系统"], + "任务系统每页显示玩家数量": 10, + "任务系统每页显示任务数量": 10, + "领地系统菜单唤醒词": ["领地系统云链联动版", "领地系统", "领地管理"], + "领地系统每页显示领地数量": 10, + "公会系统管理菜单唤醒词": ["公会系统"], + "QQ群封禁/解封菜单每页显示个数": 10, + }, + } + + @classmethod + def cfg_default(cls): + """返回插件级默认配置。""" + return { + cls.DYNAMIC_LOAD_SETTINGS_KEY: { + cls.DYNAMIC_LOAD_ENABLED_KEY: True, + cls.DYNAMIC_LOAD_INTERVAL_KEY: cls.RUNTIME_CONFIG_RELOAD_INTERVAL, + }, + "云链设置": { + "地址": "ws://127.0.0.1:3001", + "校验码": ""}, + "绑定设置": cls.binding_default(), + "群聊设置": [ + cls.group_default()], + } + + @classmethod + def cfg_std(cls): + """返回 ToolDelta 用来校验配置的数据结构定义。""" + binding_std = { + "是否开启QQ号与游戏ID绑定功能": bool, + "是否允许单QQ号可绑定多游戏ID": bool, + "是否允许单游戏ID可绑定多QQ号": bool, + "绑定触发词": cfg.JsonList(str, -1), + "绑定超时时间(单位:分钟)": cfg.PInt, + "绑定验证码群聊提示文本": str, + "绑定验证码私信提示文本": str, + "拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)": str, + "绑定超时提示文本": str, + "绑定成功提示文本": str, + } + permission_item_std = { + "是否允许普通成员使用": bool, + "是否允许普通管理员使用": bool, + "是否允许超级管理员使用": bool, + } + permission_std = { + "所有者QQ号": int, + "超级管理员QQ号": cfg.JsonList(cfg.PInt, -1), + "普通管理员QQ号": cfg.JsonList(cfg.PInt, -1), + "各功能权限设置": { + "查看玩家人数权限": permission_item_std, + "发送指令权限": permission_item_std, + "查询背包权限": permission_item_std, + "封禁/解封玩家权限": permission_item_std, + "白名单&管理员检测权限": permission_item_std, + "领地系统权限": permission_item_std, + "公会系统权限": permission_item_std, + "任务系统权限": permission_item_std, + "配置配置文件权限": permission_item_std, + "QQ普通管理员菜单权限": permission_item_std, + "QQ超级管理员菜单权限": permission_item_std, + }, + } + group_std = { + "群号": cfg.PInt, + "游戏到群": { + "是否启用": bool, + "转发格式": str, + "仅转发以下符号开头的消息(列表为空则全部转发)": cfg.JsonList(str, -1), + "屏蔽以下字符串开头的消息": cfg.JsonList(str, -1), + "转发玩家进退提示": bool, + }, + "群到游戏": { + "是否启用": bool, + "转发格式": str, + "仅转发以下符号开头的消息(列表为空则全部转发)": cfg.JsonList(str, -1), + "屏蔽的QQ号": cfg.JsonList(cfg.PInt, -1), + "替换花里胡哨的昵称": bool, + "替换花里胡哨的消息": bool, + }, + cls.PERMISSION_SETTINGS_KEY: permission_std, + "指令设置": { + "发送指令前缀": str, + "帮助菜单唤醒词": cfg.JsonList(str, -1), + "帮助菜单非管理功能每页显示数量": cfg.PInt, + "帮助菜单管理功能每页显示数量": cfg.PInt, + "命令触发词帮助菜单每页显示数量": cfg.PInt, + "配置文件整文件修改模式每页显示数量": cfg.PInt, + "管理员菜单唤醒词": cfg.JsonList(str, -1), + "配置中心唤醒词": cfg.JsonList(str, -1), + "退出整个菜单触发词": cfg.JsonList(str, -1), + "返回上一级菜单触发词": cfg.JsonList(str, -1), + "是否允许查看玩家列表": bool, + "查看玩家人数的唤醒词": cfg.JsonList(str, -1), + "查询背包菜单唤醒词": cfg.JsonList(str, -1), + "查询背包菜单每页显示的玩家数量": cfg.PInt, + "QQ群封禁唤醒词": cfg.JsonList(str, -1), + "QQ群解封唤醒词": cfg.JsonList(str, -1), + "QQ群白名单&管理员检测唤醒词": cfg.JsonList(str, -1), + "任务系统菜单唤醒词": cfg.JsonList(str, -1), + "任务系统每页显示玩家数量": cfg.PInt, + "任务系统每页显示任务数量": cfg.PInt, + "领地系统菜单唤醒词": cfg.JsonList(str, -1), + "领地系统每页显示领地数量": cfg.PInt, + "公会系统管理菜单唤醒词": cfg.JsonList(str, -1), + "QQ群封禁/解封菜单每页显示个数": cfg.PInt, + }, + } + return { + cls.DYNAMIC_LOAD_SETTINGS_KEY: { + cls.DYNAMIC_LOAD_ENABLED_KEY: bool, + cls.DYNAMIC_LOAD_INTERVAL_KEY: cfg.PInt, + }, + "云链设置": {"地址": str, "校验码": str}, + "绑定设置": binding_std, + "群聊设置": cfg.JsonList(group_std, -1), + } + + @staticmethod + def normalize_int_list(values: Any) -> list[int]: + """把来源不可信的列表规整成去重后的正整数列表。""" + if not isinstance(values, list): + return [] + result: list[int] = [] + for value in values: + try: + ivalue = int(value) + except (TypeError, ValueError): + continue + if ivalue > 0 and ivalue not in result: + result.append(ivalue) + return result + + @classmethod + def normalize_owner_qq(cls, value: Any) -> int: + """把配置中的所有者 QQ 规整成单个整数 QQ 号。""" + text = str(value).strip() if value is not None else "" + if text in ("", "00000000"): + return cls.OWNER_QQ_DEFAULT + try: + qqid = int(text) + except (TypeError, ValueError): + return cls.OWNER_QQ_DEFAULT + if qqid == cls.OWNER_QQ_UNSET: + return cls.OWNER_QQ_DEFAULT + return qqid if qqid > 0 else cls.OWNER_QQ_DEFAULT + + @staticmethod + def _normalize_bool(value: Any, fallback: bool) -> bool: + if isinstance(value, bool): + return value + return fallback + + @classmethod + def merge_with_default(cls, raw: Any, default: Any): + """递归合并旧配置和默认值。 + + 这里不会粗暴覆盖未知字段,目的是在升级时尽量保留用户已经写进配置里的内容。 + """ + if isinstance(default, dict): + result = { + key: cls.merge_with_default(None, value) + for key, value in default.items() + } + if isinstance(raw, dict): + for key, value in raw.items(): + if key in result: + result[key] = cls.merge_with_default( + value, result[key]) + else: + result[key] = value + return result + if isinstance(default, list): + return list(raw) if isinstance(raw, list) else list(default) + return raw if raw is not None else default + + def migrate_group_config(self, raw_group: Any): + """把单个群聊配置迁到当前结构。 + + 这个方法主要处理字段补全、类型纠正和历史字段兼容, + 这样业务层就可以假设拿到的 group_cfg 是完整且结构稳定的。 + """ + if not isinstance(raw_group, dict): + return None + try: + group_id = int(raw_group.get("群号", 0)) + except (TypeError, ValueError): + return None + if group_id <= 0: + return None + group_cfg = self.group_default(group_id) + # 老版本配置是按几个子区块散开的,这里逐段合并到统一结构里。 + old_g2q = raw_group.get("游戏到群", {}) + old_q2g = raw_group.get("群到游戏", {}) + old_permissions = raw_group.get(self.PERMISSION_SETTINGS_KEY, {}) + old_cmd = raw_group.get("指令设置", {}) + self._merge_game_to_group_cfg(group_cfg, old_g2q) + self._merge_group_to_game_cfg(group_cfg, old_q2g) + self._merge_permission_cfg(group_cfg, old_permissions) + self._merge_command_cfg(group_cfg, old_cmd) + return group_cfg + + def _merge_binding_cfg( + self, binding_cfg: dict[str, Any], old_binding: Any): + """把 QQ 与游戏 ID 绑定设置合并进全局配置。""" + if not isinstance(old_binding, dict): + return + binding_cfg["是否开启QQ号与游戏ID绑定功能"] = bool( + old_binding.get( + "是否开启QQ号与游戏ID绑定功能", + binding_cfg["是否开启QQ号与游戏ID绑定功能"], + ) + ) + binding_cfg["是否允许单QQ号可绑定多游戏ID"] = bool( + old_binding.get( + "是否允许单QQ号可绑定多游戏ID", + binding_cfg["是否允许单QQ号可绑定多游戏ID"], + ) + ) + binding_cfg["是否允许单游戏ID可绑定多QQ号"] = bool( + old_binding.get( + "是否允许单游戏ID可绑定多QQ号", + binding_cfg["是否允许单游戏ID可绑定多QQ号"], + ) + ) + binding_cfg["绑定触发词"] = self._clean_string_list( + old_binding.get("绑定触发词", binding_cfg["绑定触发词"]), + binding_cfg["绑定触发词"], + ) + binding_cfg["绑定超时时间(单位:分钟)"] = self._normalize_positive_int( + old_binding.get( + "绑定超时时间(单位:分钟)", + binding_cfg["绑定超时时间(单位:分钟)"], + ), + binding_cfg["绑定超时时间(单位:分钟)"], + ) + old_send_text = str( + old_binding.get( + "绑定验证码群聊提示文本", + binding_cfg["绑定验证码群聊提示文本"], + ) + ).strip() + if old_send_text: + binding_cfg["绑定验证码群聊提示文本"] = old_send_text + binding_cfg["绑定验证码私信提示文本"] = ( + str( + old_binding.get( + "绑定验证码私信提示文本", + binding_cfg["绑定验证码私信提示文本"], + ) + ).strip() + or binding_cfg["绑定验证码私信提示文本"] + ) + binding_cfg["拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)"] = ( + str( + old_binding.get( + "拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)", + binding_cfg["拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)"], + ) + ).strip() + or binding_cfg["拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)"] + ) + binding_cfg["绑定超时提示文本"] = ( + str( + old_binding.get( + "绑定超时提示文本", + binding_cfg["绑定超时提示文本"], + ) + ).strip() + or binding_cfg["绑定超时提示文本"] + ) + binding_cfg["绑定成功提示文本"] = ( + str(old_binding.get("绑定成功提示文本", binding_cfg["绑定成功提示文本"])).strip() + or binding_cfg["绑定成功提示文本"] + ) + + def _merge_game_to_group_cfg( + self, group_cfg: dict[str, Any], old_g2q: Any): + """把旧版“游戏到群”配置段合并进当前群配置。""" + if not isinstance(old_g2q, dict): + return + game_to_group = group_cfg["游戏到群"] + game_to_group["是否启用"] = bool( + old_g2q.get("是否启用", game_to_group["是否启用"])) + game_to_group["转发格式"] = str(old_g2q.get("转发格式", game_to_group["转发格式"])) + game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"] = self._clean_string_list( + old_g2q.get( + "仅转发以下符号开头的消息(列表为空则全部转发)", + game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"], + ), + game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"], + allow_empty=True, + ) + game_to_group["屏蔽以下字符串开头的消息"] = self._clean_string_list( + old_g2q.get( + "屏蔽以下字符串开头的消息", + game_to_group["屏蔽以下字符串开头的消息"], + ), + game_to_group["屏蔽以下字符串开头的消息"], + ) + game_to_group["转发玩家进退提示"] = bool( + old_g2q.get("转发玩家进退提示", game_to_group["转发玩家进退提示"]) + ) + + def _merge_group_to_game_cfg( + self, group_cfg: dict[str, Any], old_q2g: Any): + """把旧版“群到游戏”配置段合并进当前群配置。""" + if not isinstance(old_q2g, dict): + return + group_to_game = group_cfg["群到游戏"] + group_to_game["是否启用"] = bool( + old_q2g.get("是否启用", group_to_game["是否启用"])) + group_to_game["转发格式"] = str(old_q2g.get("转发格式", group_to_game["转发格式"])) + group_to_game["仅转发以下符号开头的消息(列表为空则全部转发)"] = self._clean_string_list( + old_q2g.get( + "仅转发以下符号开头的消息(列表为空则全部转发)", + group_to_game["仅转发以下符号开头的消息(列表为空则全部转发)"], + ), + group_to_game["仅转发以下符号开头的消息(列表为空则全部转发)"], + allow_empty=True, + ) + group_to_game["屏蔽的QQ号"] = self.normalize_int_list( + old_q2g.get("屏蔽的QQ号", [])) + group_to_game["替换花里胡哨的昵称"] = bool( + old_q2g.get("替换花里胡哨的昵称", group_to_game["替换花里胡哨的昵称"]) + ) + group_to_game["替换花里胡哨的消息"] = bool( + old_q2g.get("替换花里胡哨的消息", group_to_game["替换花里胡哨的消息"]) + ) + + def _legacy_group_state_dir(self) -> str: + return self.format_data_path(self.LEGACY_GROUP_STATE_DIR_NAME) + + def _read_legacy_group_state_file(self, path: str) -> dict[str, list[int]]: + if not os.path.isfile(path): + return {"admins": [], "super_admins": []} + try: + with open(path, "r", encoding="utf-8") as file: + data = json.load(file) + except Exception: + data = {} + if not isinstance(data, dict): + data = {} + return { + "admins": self.normalize_int_list( + data.get( + "admins", [])), "super_admins": self.normalize_int_list( + data.get( + "super_admins", [])), } + + def _merge_permission_item( + self, next_item: dict[str, bool], raw_item: Any): + if not isinstance(raw_item, dict): + return + for key, fallback in list(next_item.items()): + next_item[key] = self._normalize_bool(raw_item.get(key), fallback) + + def _merge_permission_cfg( + self, + group_cfg: dict[str, Any], + old_permissions: Any, + ): + """把权限配置合并进当前群配置。管理员状态只来自配置本身。""" + permission_cfg = group_cfg[self.PERMISSION_SETTINGS_KEY] + + if isinstance(old_permissions, dict): + permission_cfg["所有者QQ号"] = self.normalize_owner_qq( + old_permissions.get("所有者QQ号", permission_cfg["所有者QQ号"]) + ) + permission_cfg["超级管理员QQ号"] = self.normalize_int_list( + old_permissions.get("超级管理员QQ号", permission_cfg["超级管理员QQ号"]) + ) + permission_cfg["普通管理员QQ号"] = self.normalize_int_list( + old_permissions.get("普通管理员QQ号", permission_cfg["普通管理员QQ号"]) + ) + feature_permissions = old_permissions.get("各功能权限设置", {}) + if isinstance(feature_permissions, dict): + for key, next_item in permission_cfg["各功能权限设置"].items(): + self._merge_permission_item( + next_item, feature_permissions.get(key)) + + owner_qq = self.normalize_owner_qq(permission_cfg.get("所有者QQ号")) + permission_cfg["所有者QQ号"] = owner_qq + permission_cfg["超级管理员QQ号"] = self.normalize_int_list( + permission_cfg.get("超级管理员QQ号", []) + ) + permission_cfg["普通管理员QQ号"] = self.normalize_int_list( + permission_cfg.get("普通管理员QQ号", []) + ) + + def _merge_command_cfg(self, group_cfg: dict[str, Any], old_cmd: Any): + """把旧版指令相关配置合并进当前群配置。""" + if not isinstance(old_cmd, dict): + return + command_cfg = group_cfg["指令设置"] + command_cfg["发送指令前缀"] = ( + str(old_cmd.get("发送指令前缀", command_cfg["发送指令前缀"])).strip() + or command_cfg["发送指令前缀"] + ) + command_cfg["管理员菜单唤醒词"] = self._clean_string_list( + old_cmd.get("管理员菜单唤醒词", command_cfg["管理员菜单唤醒词"]), + command_cfg["管理员菜单唤醒词"], + ) + command_cfg["帮助菜单唤醒词"] = self._clean_string_list( + old_cmd.get("帮助菜单唤醒词", command_cfg["帮助菜单唤醒词"]), + command_cfg["帮助菜单唤醒词"], + ) + command_cfg["帮助菜单非管理功能每页显示数量"] = self._normalize_positive_int( + old_cmd.get( + "帮助菜单非管理功能每页显示数量", + command_cfg["帮助菜单非管理功能每页显示数量"], + ), + command_cfg["帮助菜单非管理功能每页显示数量"], + ) + command_cfg["帮助菜单管理功能每页显示数量"] = self._normalize_positive_int( + old_cmd.get( + "帮助菜单管理功能每页显示数量", + command_cfg["帮助菜单管理功能每页显示数量"], + ), + command_cfg["帮助菜单管理功能每页显示数量"], + ) + command_cfg["命令触发词帮助菜单每页显示数量"] = self._normalize_positive_int( + old_cmd.get( + "命令触发词帮助菜单每页显示数量", + command_cfg["命令触发词帮助菜单每页显示数量"], + ), + command_cfg["命令触发词帮助菜单每页显示数量"], + ) + command_cfg["配置文件整文件修改模式每页显示数量"] = self._normalize_positive_int( + old_cmd.get( + "配置文件整文件修改模式每页显示数量", + command_cfg["配置文件整文件修改模式每页显示数量"], + ), + command_cfg["配置文件整文件修改模式每页显示数量"], + ) + command_cfg["配置中心唤醒词"] = self._clean_string_list( + old_cmd.get("配置中心唤醒词", command_cfg["配置中心唤醒词"]), + command_cfg["配置中心唤醒词"], + ) + command_cfg["退出整个菜单触发词"] = self._clean_string_list( + old_cmd.get("退出整个菜单触发词", command_cfg["退出整个菜单触发词"]), + command_cfg["退出整个菜单触发词"], + ) + command_cfg["返回上一级菜单触发词"] = self._clean_string_list( + old_cmd.get("返回上一级菜单触发词", command_cfg["返回上一级菜单触发词"]), + command_cfg["返回上一级菜单触发词"], + ) + command_cfg["是否允许查看玩家列表"] = bool( + old_cmd.get("是否允许查看玩家列表", command_cfg["是否允许查看玩家列表"]) + ) + command_cfg["查看玩家人数的唤醒词"] = self._clean_string_list( + old_cmd.get("查看玩家人数的唤醒词", command_cfg["查看玩家人数的唤醒词"]), + command_cfg["查看玩家人数的唤醒词"], + ) + command_cfg["查询背包菜单唤醒词"] = self._clean_string_list( + old_cmd.get("查询背包菜单唤醒词", command_cfg["查询背包菜单唤醒词"]), + command_cfg["查询背包菜单唤醒词"], + ) + command_cfg["查询背包菜单每页显示的玩家数量"] = self._normalize_positive_int( + old_cmd.get( + "查询背包菜单每页显示的玩家数量", + command_cfg["查询背包菜单每页显示的玩家数量"], + ), + command_cfg["查询背包菜单每页显示的玩家数量"], + ) + command_cfg["QQ群封禁唤醒词"] = self._clean_string_list( + old_cmd.get("QQ群封禁唤醒词", command_cfg["QQ群封禁唤醒词"]), + command_cfg["QQ群封禁唤醒词"], + ) + command_cfg["QQ群解封唤醒词"] = self._clean_string_list( + old_cmd.get("QQ群解封唤醒词", command_cfg["QQ群解封唤醒词"]), + command_cfg["QQ群解封唤醒词"], + ) + command_cfg["QQ群白名单&管理员检测唤醒词"] = self._clean_string_list( + old_cmd.get( + "QQ群白名单&管理员检测唤醒词", + command_cfg["QQ群白名单&管理员检测唤醒词"], + ), + command_cfg["QQ群白名单&管理员检测唤醒词"], + ) + command_cfg["任务系统菜单唤醒词"] = self._clean_string_list( + old_cmd.get("任务系统菜单唤醒词", command_cfg["任务系统菜单唤醒词"]), + command_cfg["任务系统菜单唤醒词"], + ) + command_cfg["任务系统每页显示玩家数量"] = self._normalize_positive_int( + old_cmd.get( + "任务系统每页显示玩家数量", + command_cfg["任务系统每页显示玩家数量"], + ), + command_cfg["任务系统每页显示玩家数量"], + ) + command_cfg["任务系统每页显示任务数量"] = self._normalize_positive_int( + old_cmd.get( + "任务系统每页显示任务数量", + command_cfg["任务系统每页显示任务数量"], + ), + command_cfg["任务系统每页显示任务数量"], + ) + command_cfg["领地系统菜单唤醒词"] = self._clean_string_list( + old_cmd.get("领地系统菜单唤醒词", command_cfg["领地系统菜单唤醒词"]), + command_cfg["领地系统菜单唤醒词"], + ) + command_cfg["领地系统每页显示领地数量"] = self._normalize_positive_int( + old_cmd.get( + "领地系统每页显示领地数量", + command_cfg["领地系统每页显示领地数量"], + ), + command_cfg["领地系统每页显示领地数量"], + ) + command_cfg["公会系统管理菜单唤醒词"] = self._clean_string_list( + old_cmd.get( + "公会系统管理菜单唤醒词", + command_cfg["公会系统管理菜单唤醒词"], + ), + command_cfg["公会系统管理菜单唤醒词"], + ) + command_cfg["QQ群封禁/解封菜单每页显示个数"] = self._normalize_positive_int( + old_cmd.get( + "QQ群封禁/解封菜单每页显示个数", + command_cfg["QQ群封禁/解封菜单每页显示个数"], + ), + command_cfg["QQ群封禁/解封菜单每页显示个数"], + ) + + @staticmethod + def _clean_string_list( + raw: Any, + fallback: list[str], + allow_empty: bool = False): + """清理字符串列表,失败时回退到默认值。""" + if isinstance(raw, list): + cleaned = [str(item) for item in raw if isinstance(item, str)] + if cleaned or allow_empty: + return cleaned + return fallback + + @staticmethod + def _normalize_positive_int(value: Any, fallback: int): + """把不可信的数字输入规范成正整数。""" + try: + return max(1, int(value)) + except (TypeError, ValueError): + return fallback + + def _legacy_group_state_files(self) -> list[tuple[int, str]]: + legacy_dir = self._legacy_group_state_dir() + try: + names = os.listdir(legacy_dir) + except OSError: + return [] + files: list[tuple[int, str]] = [] + for name in names: + stem, ext = os.path.splitext(name) + if ext.lower() != ".json": + continue + try: + group_id = int(stem) + except ValueError: + continue + if group_id > 0: + files.append((group_id, os.path.join(legacy_dir, name))) + return files + + @staticmethod + def _append_unique_ints(target: list[int], values: list[int]) -> bool: + changed = False + for value in values: + if value not in target: + target.append(value) + changed = True + return changed + + def migrate_legacy_group_admin_data(self, next_cfg: dict[str, Any]) -> int: + """把旧插件数据目录里的群管理员状态合并进主配置。""" + group_list = next_cfg.get("群聊设置") + if not isinstance(group_list, list): + group_list = [] + next_cfg["群聊设置"] = group_list + + groups_by_id: dict[int, dict[str, Any]] = {} + for group_cfg in group_list: + if not isinstance(group_cfg, dict): + continue + try: + group_id = int(group_cfg.get("群号", 0)) + except (TypeError, ValueError): + continue + if group_id > 0: + groups_by_id[group_id] = group_cfg + + migrated_files = 0 + for group_id, path in self._legacy_group_state_files(): + legacy_state = self._read_legacy_group_state_file(path) + if not legacy_state["admins"] and not legacy_state["super_admins"]: + continue + group_cfg = groups_by_id.get(group_id) + if group_cfg is None: + group_cfg = self.group_default(group_id) + group_list.append(group_cfg) + groups_by_id[group_id] = group_cfg + permission_cfg = group_cfg.setdefault( + self.PERMISSION_SETTINGS_KEY, + self.permission_default(), + ) + if not isinstance(permission_cfg, dict): + permission_cfg = self.permission_default() + group_cfg[self.PERMISSION_SETTINGS_KEY] = permission_cfg + + owner_qq = self.normalize_owner_qq(permission_cfg.get("所有者QQ号")) + super_admins = self.normalize_int_list( + permission_cfg.get("超级管理员QQ号", []) + ) + admins = self.normalize_int_list( + permission_cfg.get("普通管理员QQ号", [])) + self._append_unique_ints( + super_admins, legacy_state["super_admins"]) + self._append_unique_ints(admins, legacy_state["admins"]) + permission_cfg["所有者QQ号"] = owner_qq + permission_cfg["超级管理员QQ号"] = super_admins + permission_cfg["普通管理员QQ号"] = admins + self.delete_migrated_legacy_group_admin_file(path) + migrated_files += 1 + + return migrated_files + + def delete_migrated_legacy_group_admin_file(self, path: str) -> bool: + """迁移完单个旧群管理员数据文件后立即删除。""" + try: + os.remove(path) + except FileNotFoundError: + return True + except OSError as err: + if hasattr(self, "print_console_warn"): + self.print_console_warn(f"旧版群管理员数据文件删除失败: {path}: {err}") + return False + return True + + def migrate_config(self, raw_cfg: Any): + """把整个插件配置迁到最新版本。 + + 历史上这个插件经历过“单群结构”和“多群结构”两个阶段, + 所以这里除了常规默认值补齐,还要兼容最早那一版只有 `消息转发设置` 的写法。 + """ + new_cfg = self.cfg_default() + if not isinstance(raw_cfg, dict): + self.migrate_legacy_group_admin_data(new_cfg) + return new_cfg + original_cfg = raw_cfg + has_top_level_binding = isinstance(original_cfg.get("绑定设置"), dict) + raw_cfg = self.merge_with_default(raw_cfg, new_cfg) + + dynamic_load_cfg = raw_cfg.get(self.DYNAMIC_LOAD_SETTINGS_KEY, {}) + if isinstance(dynamic_load_cfg, dict): + dynamic_settings = new_cfg[self.DYNAMIC_LOAD_SETTINGS_KEY] + dynamic_settings[self.DYNAMIC_LOAD_ENABLED_KEY] = bool( + dynamic_load_cfg.get( + self.DYNAMIC_LOAD_ENABLED_KEY, + dynamic_settings[self.DYNAMIC_LOAD_ENABLED_KEY], + ) ) - validate_code = cloud_cfg.get("校验码", "") - new_cfg["云链设置"]["校验码"] = ( - "" if validate_code is None else str(validate_code) + default_interval = dynamic_settings[self.DYNAMIC_LOAD_INTERVAL_KEY] + dynamic_settings[self.DYNAMIC_LOAD_INTERVAL_KEY] = self._normalize_positive_int( + dynamic_load_cfg.get( + self.DYNAMIC_LOAD_INTERVAL_KEY, + default_interval, + ), + default_interval, ) - - group_cfgs: list[dict[str, Any]] = [] - if isinstance(raw_cfg.get("群聊设置"), list): - for raw_group in raw_cfg["群聊设置"]: - migrated = self.migrate_group_config(raw_group) - if migrated is not None: - group_cfgs.append(migrated) - elif isinstance(raw_cfg.get("消息转发设置"), dict): - # 兼容最早的单群结构,迁完之后统一走“群聊设置”列表。 - old_msg_cfg = raw_cfg["消息转发设置"] - try: - old_group_id = int(old_msg_cfg.get("链接的群聊", 194838530)) - except (TypeError, ValueError): - old_group_id = 194838530 - migrated_group = self.group_default(old_group_id) - if isinstance(old_msg_cfg.get("游戏到群"), dict): - migrated_group["游戏到群"] = self.migrate_group_config( - { - "群号": old_group_id, - "游戏到群": old_msg_cfg["游戏到群"], - } - )["游戏到群"] - if isinstance(old_msg_cfg.get("群到游戏"), dict): - migrated_group["群到游戏"] = self.migrate_group_config( - { - "群号": old_group_id, - "群到游戏": old_msg_cfg["群到游戏"], - } - )["群到游戏"] - if isinstance(raw_cfg.get("指令设置"), dict): - migrated_group["指令设置"]["是否允许查看玩家列表"] = bool( - raw_cfg["指令设置"].get( - "是否允许查看玩家列表", - migrated_group["指令设置"]["是否允许查看玩家列表"], - ) - ) - group_cfgs.append(migrated_group) - - if group_cfgs: - dedup: dict[int, dict[str, Any]] = {} - for group_cfg in group_cfgs: - dedup[group_cfg["群号"]] = group_cfg - new_cfg["群聊设置"] = list(dedup.values()) - - return new_cfg - - def reload_group_configs(self): - """把配置中的群信息展开成运行时缓存。 - - 后续消息分发会频繁按群号查配置,所以这里同时保留: - - `group_cfgs`: 适合按群号直接读取 - - `group_order`: 适合顺序遍历和显示菜单 - """ - self.group_cfgs.clear() - self.group_order.clear() - # 运行时同时保留 dict 和顺序列表,后面做群路由会更直接。 - for group_cfg in self.cfg["群聊设置"]: - group_id = int(group_cfg["群号"]) - self.group_cfgs[group_id] = group_cfg - self.group_order.append(group_id) - for group_id in self.group_order: - self.ensure_group_state(group_id) - - @property - def linked_group(self) -> int | None: - """返回兼容旧插件调用时使用的默认群号。""" - # 给还按“单群互通”接口调用的旧插件留一个兼容入口。 - return self.group_order[0] if self.group_order else None - - def group_state_path(self, group_id: int): - """返回某个群的权限状态文件路径。""" - return os.path.join(self.group_state_dir, f"{group_id}.json") - - def read_group_state(self, group_id: int): - """读取单个群的管理员状态。 - - 就算文件损坏,也会兜底回空状态,避免因为单个群配置异常拖垮整个插件。 - """ - path = self.group_state_path(group_id) - if not os.path.isfile(path): - return {"admins": [], "super_admins": []} - try: - with open(path, "r", encoding="utf-8") as file: - data = json.load(file) - except Exception: - data = {} - return { - "admins": self.normalize_int_list(data.get("admins", [])), - "super_admins": self.normalize_int_list(data.get("super_admins", [])), - } - - def save_group_state(self, group_id: int, state: dict[str, list[int]]): - """保存并顺手归一化群权限状态。""" - path = self.group_state_path(group_id) - normalized = { - "admins": self.normalize_int_list(state.get("admins", [])), - "super_admins": self.normalize_int_list(state.get("super_admins", [])), - } - with open(path, "w", encoding="utf-8") as file: - json.dump(normalized, file, ensure_ascii=False, indent=2) - - def ensure_group_state(self, group_id: int): - """确保群权限文件一定存在,而且结构可读。""" - path = self.group_state_path(group_id) - if os.path.isfile(path): - state = self.read_group_state(group_id) - self.save_group_state(group_id, state) - return - self.save_group_state(group_id, {"admins": [], "super_admins": []}) - - def is_group_super_admin(self, group_id: int, qqid: int): - """判断某个 QQ 是否是指定群的超级管理员。""" - return qqid in self.read_group_state(group_id)["super_admins"] - - def is_group_admin(self, group_id: int, qqid: int): - """判断某个 QQ 是否拥有群内管理权限。 - - 普通管理员和超级管理员在大多数执行权限上是并列的,所以这里统一封装成一个入口。 - """ - state = self.read_group_state(group_id) - return qqid in state["super_admins"] or qqid in state["admins"] - - def is_qq_op(self, qqid: int, group_id: int | None = None): - """兼容旧命名,判断某个 QQ 是否拥有管理员权限。""" - if group_id is not None: - return self.is_group_admin(group_id, qqid) - return any(self.is_group_admin(gid, qqid) for gid in self.group_order) - - def add_group_role(self, group_id: int, qqid: int, is_super: bool): - """给群成员授予管理员或超级管理员身份。""" - state = self.read_group_state(group_id) - if is_super: - if qqid in state["super_admins"]: - return False, "该 QQ 已经是本群超级管理员" - if qqid in state["admins"]: - state["admins"].remove(qqid) - state["super_admins"].append(qqid) - self.save_group_state(group_id, state) - return True, "已添加为本群超级管理员" - if qqid in state["super_admins"]: - return False, "该 QQ 已经是本群超级管理员,无需再添加为管理员" - if qqid in state["admins"]: - return False, "该 QQ 已经是本群管理员" - state["admins"].append(qqid) - self.save_group_state(group_id, state) - return True, "已添加为本群管理员" - - def remove_group_role(self, group_id: int, qqid: int, is_super: bool): - """移除群成员的管理员或超级管理员身份。""" - state = self.read_group_state(group_id) - if is_super: - if qqid not in state["super_admins"]: - return False, "该 QQ 不是本群超级管理员" - state["super_admins"].remove(qqid) - self.save_group_state(group_id, state) - return True, "已移除本群超级管理员" - if qqid not in state["admins"]: - return False, "该 QQ 不是本群普通管理员" - state["admins"].remove(qqid) - self.save_group_state(group_id, state) - return True, "已移除本群普通管理员" - - def get_group_player_list_triggers(self, group_id: int): - """读取某个群的玩家列表触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get("查看玩家人数的唤醒词", ["list", "玩家列表"]) - return self.normalize_string_triggers(raw, ["list", "玩家列表"]) - - def get_group_inventory_menu_triggers(self, group_id: int): - """读取某个群的背包查询触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get("查询背包菜单唤醒词", ["查询背包"]) - return self.normalize_string_triggers(raw, ["查询背包"]) - - def get_group_inventory_items_per_page(self, group_id: int): - """读取背包查询菜单每页条数。""" - group_cfg = self.group_cfgs.get(group_id) - if group_cfg is not None: - try: - return max( - 1, - int(group_cfg["指令设置"]["查询背包菜单每页显示的玩家数量"]), - ) - except (KeyError, TypeError, ValueError): - return 10 - return 10 - - def get_group_help_triggers(self, group_id: int): - """读取某个群的帮助菜单触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get("帮助菜单唤醒词", ["help", "帮助"]) - return self.normalize_string_triggers(raw, ["help", "帮助"]) - - def get_group_admin_menu_triggers(self, group_id: int): - """读取某个群的管理员菜单触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get("管理员菜单唤醒词", ["管理员菜单"]) - return self.normalize_string_triggers(raw, ["管理员菜单"]) - - def get_group_cmd_prefix(self, group_id: int): - """读取群内执行 MC 指令时使用的命令前缀。""" - group_cfg = self.group_cfgs[group_id] - prefix = str(group_cfg["指令设置"].get("发送指令前缀", "/")).strip() - return prefix or "/" - - def get_group_orion_ban_triggers(self, group_id: int): - """读取某个群的 Orion 封禁触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get( - "QQ群封禁唤醒词", - ["orban", "orion ban", "猎户封禁"], - ) - return self.normalize_string_triggers(raw, ["orban", "orion ban", "猎户封禁"]) - - def get_group_orion_unban_triggers(self, group_id: int): - """读取某个群的 Orion 解封触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get( - "QQ群解封唤醒词", - ["orunban", "orion unban", "猎户解封"], - ) - return self.normalize_string_triggers(raw, ["orunban", "orion unban", "猎户解封"]) - - def get_group_checker_menu_triggers(self, group_id: int): - """读取某个群的白名单联动菜单触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get( - "QQ群白名单&管理员检测唤醒词", - ["白名单&管理员检测", "检测管理"], - ) - return self.normalize_string_triggers(raw, ["白名单&管理员检测", "检测管理"]) - - @staticmethod - def normalize_string_triggers(raw: Any, fallback: list[str]): - """把触发词列表清洗成无空值、无重复的稳定序列。""" - triggers: list[str] = [] - if isinstance(raw, list): - for item in raw: - if not isinstance(item, str): - continue - text = item.strip() - if text and text not in triggers: - triggers.append(text) - return triggers or fallback - - def add_trigger( - self, - triggers: list[str], - argument_hint: str | None, - usage: str, - func: Callable[..., Any], - args_pd: Callable[[int], bool] = lambda _: True, - op_only: bool = False, - ): - """把外部插件注册的 QQ 触发规则挂入统一分发入口。""" - # 允许外部插件把自己的 QQ 指令挂进互通插件的统一分发入口。 - if not inspect.isroutine(func) and not callable(func): - raise TypeError("func 必须是可调用对象") - self.triggers.append( - QQMsgTrigger(triggers, argument_hint, usage, func, args_pd, op_only) - ) - - def set_manual_launch(self, port: int): - """切换到“本地启动器负责拉起云链”的模式。""" - self._manual_launch = True - self._manual_launch_port = port - - def manual_launch(self): - """给本地启动器调用的显式连接入口。""" - self.connect_to_websocket() + + cloud_cfg = raw_cfg.get("云链设置", {}) + if isinstance(cloud_cfg, dict): + new_cfg["云链设置"]["地址"] = str( + cloud_cfg.get("地址", new_cfg["云链设置"]["地址"]) + ) + validate_code = cloud_cfg.get("校验码", "") + new_cfg["云链设置"]["校验码"] = ( + "" if validate_code is None else str(validate_code) + ) + + if has_top_level_binding: + self._merge_binding_cfg(new_cfg["绑定设置"], original_cfg["绑定设置"]) + + group_cfgs: list[dict[str, Any]] = [] + if isinstance(raw_cfg.get("群聊设置"), list): + for raw_group in raw_cfg["群聊设置"]: + if ( + not has_top_level_binding + and isinstance(raw_group, dict) + and isinstance(raw_group.get("绑定设置"), dict) + ): + self._merge_binding_cfg(new_cfg["绑定设置"], raw_group["绑定设置"]) + migrated = self.migrate_group_config(raw_group) + if migrated is not None: + group_cfgs.append(migrated) + elif isinstance(raw_cfg.get("消息转发设置"), dict): + # 兼容最早的单群结构,迁完之后统一走“群聊设置”列表。 + old_msg_cfg = raw_cfg["消息转发设置"] + try: + old_group_id = int(old_msg_cfg.get("链接的群聊", 194838530)) + except (TypeError, ValueError): + old_group_id = 194838530 + migrated_group = self.group_default(old_group_id) + if isinstance(old_msg_cfg.get("游戏到群"), dict): + migrated_group["游戏到群"] = self.migrate_group_config( + { + "群号": old_group_id, + "游戏到群": old_msg_cfg["游戏到群"], + } + )["游戏到群"] + if isinstance(old_msg_cfg.get("群到游戏"), dict): + migrated_group["群到游戏"] = self.migrate_group_config( + { + "群号": old_group_id, + "群到游戏": old_msg_cfg["群到游戏"], + } + )["群到游戏"] + if isinstance(raw_cfg.get("指令设置"), dict): + migrated_group["指令设置"]["是否允许查看玩家列表"] = bool( + raw_cfg["指令设置"].get( + "是否允许查看玩家列表", + migrated_group["指令设置"]["是否允许查看玩家列表"], + ) + ) + self._merge_permission_cfg(migrated_group, {}) + group_cfgs.append(migrated_group) + + if group_cfgs: + dedup: dict[int, dict[str, Any]] = {} + for group_cfg in group_cfgs: + dedup[group_cfg["群号"]] = group_cfg + new_cfg["群聊设置"] = list(dedup.values()) + + self.migrate_legacy_group_admin_data(new_cfg) + return new_cfg + + def reload_group_configs(self): + """把配置中的群信息展开成运行时缓存。 + + 后续消息分发会频繁按群号查配置,所以这里同时保留: + - `group_cfgs`: 适合按群号直接读取 + - `group_order`: 适合顺序遍历和显示菜单 + """ + self.group_cfgs.clear() + self.group_order.clear() + # 运行时同时保留 dict 和顺序列表,后面做群路由会更直接。 + for group_cfg in self.cfg["群聊设置"]: + group_id = int(group_cfg["群号"]) + self.group_cfgs[group_id] = group_cfg + self.group_order.append(group_id) + for group_id in self.group_order: + self.ensure_group_state(group_id) + + def persist_runtime_config(self): + """把当前 Ultra 配置写回 ToolDelta 配置文件。""" + cfg.check_auto(self.cfg_std(), self.cfg) + cfg.upgrade_plugin_config(self.name, self.cfg, self.version) + self.refresh_runtime_config_file_state() + + def runtime_config_path(self) -> str: + """返回 ToolDelta 生成的本插件配置文件路径。""" + return os.path.join(self.CONFIG_FILE_DIR, f"{self.name}.json") + + @staticmethod + def runtime_config_file_state(path: str) -> tuple[int, int] | None: + """返回配置文件状态,用于判断外部修改。""" + try: + stat = os.stat(path) + except OSError: + return None + return stat.st_mtime_ns, stat.st_size + + def refresh_runtime_config_file_state(self) -> None: + """记录当前配置文件状态,避免刚启动就重复热载一次。""" + path = getattr( + self, + "_runtime_config_path", + None) or self.runtime_config_path() + self._runtime_config_path = path + self._runtime_config_file_state = self.runtime_config_file_state(path) + + def is_runtime_config_reload_enabled(self) -> bool: + """返回是否启用本插件配置文件动态载入。""" + settings = getattr( + self, "cfg", {}).get( + self.DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return True + return bool(settings.get(self.DYNAMIC_LOAD_ENABLED_KEY, True)) + + def runtime_config_reload_interval(self) -> int: + """返回动态载入检测间隔秒数。""" + settings = getattr( + self, "cfg", {}).get( + self.DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return self.RUNTIME_CONFIG_RELOAD_INTERVAL + return self._normalize_positive_int( + settings.get(self.DYNAMIC_LOAD_INTERVAL_KEY, + self.RUNTIME_CONFIG_RELOAD_INTERVAL), + self.RUNTIME_CONFIG_RELOAD_INTERVAL, + ) + + def apply_ultra_runtime_config(self, raw_config: Any) -> str: + """把 Ultra 配置应用到当前运行时,无需重载插件。""" + old_cloud_cfg = deepcopy(getattr(self, "cfg", {}).get("云链设置", {})) + migrated = self.migrate_config(raw_config) + cfg.check_auto(self.cfg_std(), migrated) + self.cfg = migrated + self.reload_group_configs() + self.persist_runtime_config() + if old_cloud_cfg != self.cfg.get("云链设置", {}): + self.reload_websocket_connection() + return "Ultra 配置已动态载入,云链连接正在按新配置重连" + return "Ultra 配置已动态载入" + + def check_runtime_config_file_update(self) -> bool: + """检查本插件配置文件是否被修改,变化时立即热应用。""" + if not self.is_runtime_config_reload_enabled(): + return False + + path = getattr( + self, + "_runtime_config_path", + None) or self.runtime_config_path() + self._runtime_config_path = path + current_state = self.runtime_config_file_state(path) + if current_state is None or current_state == getattr( + self, + "_runtime_config_file_state", + None, + ): + return False + + try: + with open(path, "r", encoding="utf-8-sig") as file: + full_config = json.load(file) + raw_config = self._extract_config_items(full_config) + message = self.apply_ultra_runtime_config(raw_config) + self.refresh_runtime_config_file_state() + if hasattr(self, "print_console_success"): + self.print_console_success(message) + return True + except Exception as err: + self._runtime_config_file_state = current_state + if hasattr(self, "print_console_error"): + self.print_console_error(f"Ultra 配置热更新失败: {err}") + return False + + @staticmethod + def _extract_config_items(full_config: Any) -> Any: + if isinstance( + full_config, + dict) and isinstance( + full_config.get("配置项"), + dict): + return full_config["配置项"] + return full_config + + @property + def linked_group(self) -> int | None: + """返回兼容旧插件调用时使用的默认群号。""" + # 给还按“单群互通”接口调用的旧插件留一个兼容入口。 + return self.group_order[0] if self.group_order else None + + def _permission_cfg_for_group( + self, group_id: int) -> dict[str, Any] | None: + group_cfg = getattr(self, "group_cfgs", {}).get(group_id) + if not isinstance(group_cfg, dict): + return None + permission_cfg = group_cfg.get(self.PERMISSION_SETTINGS_KEY) + return permission_cfg if isinstance(permission_cfg, dict) else None + + def read_group_state(self, group_id: int): + """从主配置读取单个群的管理员状态。""" + permission_cfg = self._permission_cfg_for_group(group_id) + if permission_cfg is None: + return {"admins": [], "super_admins": []} + return { + "admins": self.normalize_int_list( + permission_cfg.get( + "普通管理员QQ号", [])), "super_admins": self.normalize_int_list( + permission_cfg.get( + "超级管理员QQ号", [])), } + + def get_group_owner_qq(self, group_id: int) -> int | None: + """读取群配置中的所有者 QQ。""" + permission_cfg = self._permission_cfg_for_group(group_id) + if permission_cfg is None: + return None + owner_qq = self.normalize_owner_qq(permission_cfg.get("所有者QQ号")) + if owner_qq == self.OWNER_QQ_UNSET: + return None + return owner_qq + + def save_group_state(self, group_id: int, state: dict[str, list[int]]): + """把群权限状态保存到主配置文件。""" + permission_cfg = self._permission_cfg_for_group(group_id) + if permission_cfg is None: + return + owner_qq = self.get_group_owner_qq(group_id) + normalized = { + "admins": self.normalize_int_list( + state.get( + "admins", [])), "super_admins": self.normalize_int_list( + state.get( + "super_admins", [])), } + permission_cfg["普通管理员QQ号"] = normalized["admins"] + permission_cfg["超级管理员QQ号"] = normalized["super_admins"] + self.persist_runtime_config() + + def ensure_group_state(self, group_id: int): + """确保群权限配置结构可读,并移除重复/非法管理员项。""" + permission_cfg = self._permission_cfg_for_group(group_id) + if permission_cfg is None: + return + state = self.read_group_state(group_id) + permission_cfg["超级管理员QQ号"] = state["super_admins"] + permission_cfg["普通管理员QQ号"] = state["admins"] + + def is_group_owner(self, group_id: int, qqid: int): + """判断某个 QQ 是否是指定群的所有者。""" + return self.get_group_owner_qq(group_id) == qqid + + def is_group_super_admin(self, group_id: int, qqid: int): + """判断某个 QQ 是否拥有超级管理员级权限。""" + if self.is_group_owner(group_id, qqid): + return True + return qqid in self.read_group_state(group_id)["super_admins"] + + def is_group_admin(self, group_id: int, qqid: int): + """判断某个 QQ 是否拥有群内管理权限。 + + 所有者、超级管理员和普通管理员都可以执行普通管理功能。 + """ + if self.is_group_owner(group_id, qqid): + return True + state = self.read_group_state(group_id) + return qqid in state["super_admins"] or qqid in state["admins"] + + def has_group_permission( + self, + group_id: int, + qqid: int, + permission_name: str) -> bool: + """按群配置中的“各功能权限设置”判断某个 QQ 是否可用指定功能。""" + if self.is_group_owner(group_id, qqid): + return True + permission_cfg = self._permission_cfg_for_group(group_id) + if permission_cfg is None: + return False + feature_permissions = permission_cfg.get("各功能权限设置", {}) + if not isinstance(feature_permissions, dict): + return False + item = feature_permissions.get(permission_name) + if not isinstance(item, dict): + return False + state = self.read_group_state(group_id) + if qqid in state["super_admins"]: + return bool(item.get("是否允许超级管理员使用", False)) + if qqid in state["admins"]: + return bool(item.get("是否允许普通管理员使用", False)) + return bool(item.get("是否允许普通成员使用", False)) + + def is_qq_op(self, qqid: int, group_id: int | None = None): + """兼容旧命名,判断某个 QQ 是否拥有管理员权限。""" + if group_id is not None: + return self.is_group_admin(group_id, qqid) + return any(self.is_group_admin(gid, qqid) for gid in self.group_order) + + def add_group_role(self, group_id: int, qqid: int, is_super: bool): + """给群成员授予管理员或超级管理员身份。""" + if self.is_group_owner(group_id, qqid): + return False, "该 QQ 是本群所有者,已拥有最高权限" + state = self.read_group_state(group_id) + if is_super: + if qqid in state["super_admins"]: + return False, "该 QQ 已经是本群超级管理员" + if qqid in state["admins"]: + state["admins"].remove(qqid) + state["super_admins"].append(qqid) + self.save_group_state(group_id, state) + return True, "已添加为本群超级管理员" + if qqid in state["super_admins"]: + return False, "该 QQ 已经是本群超级管理员,无需再添加为管理员" + if qqid in state["admins"]: + return False, "该 QQ 已经是本群管理员" + state["admins"].append(qqid) + self.save_group_state(group_id, state) + return True, "已添加为本群管理员" + + def remove_group_role(self, group_id: int, qqid: int, is_super: bool): + """移除群成员的管理员或超级管理员身份。""" + if self.is_group_owner(group_id, qqid): + return False, "不能在管理员菜单中移除本群所有者" + state = self.read_group_state(group_id) + if is_super: + if qqid not in state["super_admins"]: + return False, "该 QQ 不是本群超级管理员" + state["super_admins"].remove(qqid) + self.save_group_state(group_id, state) + return True, "已移除本群超级管理员" + if qqid not in state["admins"]: + return False, "该 QQ 不是本群普通管理员" + state["admins"].remove(qqid) + self.save_group_state(group_id, state) + return True, "已移除本群普通管理员" + + @staticmethod + def _api_int_value(value: Any): + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return None + + def api_get_linked_groups(self) -> list[int]: + """Return configured linked QQ group IDs in runtime order.""" + return list(self.group_order) + + def api_get_default_group(self) -> int | None: + """Return the first configured group ID for old single-group callers.""" + return self.linked_group + + def api_is_group_configured(self, group_id: int | str) -> bool: + """Return whether a QQ group is configured in Ultra.""" + gid = self._api_int_value(group_id) + return gid in self.group_cfgs if gid is not None else False + + def api_get_group_config(self, group_id: int | + str) -> dict[str, Any] | None: + """Return a copy of one group's runtime config.""" + gid = self._api_int_value(group_id) + if gid is None or gid not in self.group_cfgs: + return None + return deepcopy(self.group_cfgs[gid]) + + def api_get_group_state(self, group_id: int | + str) -> dict[str, list[int]] | None: + """Return a copy of one group's admin state.""" + gid = self._api_int_value(group_id) + if gid is None or gid not in self.group_cfgs: + return None + state = self.read_group_state(gid) + owner_qq = self.get_group_owner_qq(gid) + return { + "admins": list(state["admins"]), + "super_admins": list(state["super_admins"]), + "owner": [] if owner_qq is None else [owner_qq], + } + + def api_get_group_admins( + self, + group_id: int | str, + include_super: bool = True, + ) -> list[int]: + """Return normal group admins, optionally including super admins.""" + state = self.api_get_group_state(group_id) + if state is None: + return [] + admins = list(state["admins"]) + if include_super: + for qqid in state["super_admins"]: + if qqid not in admins: + admins.append(qqid) + for qqid in state.get("owner", []): + if qqid not in admins: + admins.append(qqid) + return admins + + def api_get_group_super_admins(self, group_id: int | str) -> list[int]: + """Return super admins for one configured group.""" + state = self.api_get_group_state(group_id) + return [] if state is None else list(state["super_admins"]) + + def api_is_group_admin(self, group_id: int | str, qqid: int | str) -> bool: + """Return whether a QQ number has admin permission in a group.""" + gid = self._api_int_value(group_id) + qid = self._api_int_value(qqid) + if gid is None or qid is None or gid not in self.group_cfgs: + return False + return self.is_group_admin(gid, qid) + + def api_is_group_super_admin( + self, + group_id: int | str, + qqid: int | str) -> bool: + """Return whether a QQ number is a super admin in a group.""" + gid = self._api_int_value(group_id) + qid = self._api_int_value(qqid) + if gid is None or qid is None or gid not in self.group_cfgs: + return False + return self.is_group_super_admin(gid, qid) + + def api_get_group_owner(self, group_id: int | str) -> int | None: + """Return the configured owner QQ for one group.""" + gid = self._api_int_value(group_id) + if gid is None or gid not in self.group_cfgs: + return None + return self.get_group_owner_qq(gid) + + def api_is_group_owner(self, group_id: int | str, qqid: int | str) -> bool: + """Return whether a QQ number is the configured owner in a group.""" + gid = self._api_int_value(group_id) + qid = self._api_int_value(qqid) + if gid is None or qid is None or gid not in self.group_cfgs: + return False + return self.is_group_owner(gid, qid) + + def api_add_group_admin( + self, + group_id: int | str, + qqid: int | str, + is_super: bool = False, + ) -> tuple[bool, str]: + """Grant normal-admin or super-admin permission to a QQ number.""" + gid = self._api_int_value(group_id) + qid = self._api_int_value(qqid) + if gid is None or gid not in self.group_cfgs: + return False, "群号无效或未配置" + if qid is None or qid <= 0: + return False, "QQ号无效" + return self.add_group_role(gid, qid, bool(is_super)) + + def api_remove_group_admin( + self, + group_id: int | str, + qqid: int | str, + is_super: bool = False, + ) -> tuple[bool, str]: + """Remove normal-admin or super-admin permission from a QQ number.""" + gid = self._api_int_value(group_id) + qid = self._api_int_value(qqid) + if gid is None or gid not in self.group_cfgs: + return False, "群号无效或未配置" + if qid is None or qid <= 0: + return False, "QQ号无效" + return self.remove_group_role(gid, qid, bool(is_super)) + + def api_get_group_triggers( + self, group_id: int | str) -> dict[str, Any] | None: + """Return normalized trigger words and menu controls for one group.""" + gid = self._api_int_value(group_id) + if gid is None or gid not in self.group_cfgs: + return None + triggers = { + "help": self.get_group_help_triggers(gid), + "admin_menu": self.get_group_admin_menu_triggers(gid), + "player_list": self.get_group_player_list_triggers(gid), + "inventory_menu": self.get_group_inventory_menu_triggers(gid), + "menu_exit": self.get_group_menu_exit_triggers(gid), + "menu_back": self.get_group_menu_back_triggers(gid), + "command_prefix": self.get_group_cmd_prefix(gid), + "orion_ban": self.get_group_orion_ban_triggers(gid), + "orion_unban": self.get_group_orion_unban_triggers(gid), + "checker_menu": self.get_group_checker_menu_triggers(gid), + "task_menu": self.get_group_task_menu_triggers(gid), + "land_menu": self.get_group_land_menu_triggers(gid), + "guild_menu": self.get_group_guild_menu_triggers(gid), + } + if hasattr(self, "get_group_binding_triggers"): + triggers["binding"] = self.get_group_binding_triggers(gid) + return triggers + + def api_get_registered_triggers(self) -> list[dict[str, Any]]: + """Return external QQ triggers registered through add_trigger(...).""" + return [ + { + "triggers": list(trigger.triggers), + "argument_hint": trigger.argument_hint, + "usage": trigger.usage, + "op_only": bool(trigger.op_only), + "accept_group": bool(trigger.accept_group), + } + for trigger in self.triggers + ] + + def get_group_player_list_triggers(self, group_id: int): + """读取某个群的玩家列表触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get("查看玩家人数的唤醒词", ["list", "玩家列表"]) + return self.normalize_string_triggers(raw, ["list", "玩家列表"]) + + def get_group_inventory_menu_triggers(self, group_id: int): + """读取某个群的背包查询触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get("查询背包菜单唤醒词", ["查询背包"]) + return self.normalize_string_triggers(raw, ["查询背包"]) + + def get_group_inventory_items_per_page(self, group_id: int): + """读取背包查询菜单每页条数。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["查询背包菜单每页显示的玩家数量"]), + ) + except (KeyError, TypeError, ValueError): + return 10 + return 10 + + def get_group_help_non_admin_items_per_page(self, group_id: int): + """读取帮助菜单非管理功能每页条数。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["帮助菜单非管理功能每页显示数量"]), + ) + except (KeyError, TypeError, ValueError): + return 10 + return 10 + + def get_group_help_admin_items_per_page(self, group_id: int): + """读取帮助菜单管理功能每页条数。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["帮助菜单管理功能每页显示数量"]), + ) + except (KeyError, TypeError, ValueError): + return 10 + return 10 + + def get_group_command_help_items_per_page(self, group_id: int): + """读取命令触发词帮助菜单每页条数。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["命令触发词帮助菜单每页显示数量"]), + ) + except (KeyError, TypeError, ValueError): + return 10 + return 10 + + def get_group_config_file_items_per_page( + self, group_id: int | None = None): + """读取配置文件整文件修改模式选择菜单每页条数。""" + group_cfg = self.group_cfgs.get( + group_id) if group_id is not None else None + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["配置文件整文件修改模式每页显示数量"]), + ) + except (KeyError, TypeError, ValueError): + return 10 + return 10 + + def get_group_task_player_items_per_page(self, group_id: int): + """读取任务系统选择玩家菜单每页条数。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["任务系统每页显示玩家数量"]), + ) + except (KeyError, TypeError, ValueError): + return self.get_group_inventory_items_per_page(group_id) + return 10 + + def get_group_task_items_per_page(self, group_id: int): + """读取任务系统选择任务菜单每页条数。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["任务系统每页显示任务数量"]), + ) + except (KeyError, TypeError, ValueError): + return self.get_group_inventory_items_per_page(group_id) + return 10 + + def get_group_land_items_per_page(self, group_id: int): + """读取领地系统云链联动版菜单每页条数。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["领地系统每页显示领地数量"]), + ) + except (KeyError, TypeError, ValueError): + return self.get_group_inventory_items_per_page(group_id) + return 10 + + def get_group_help_triggers(self, group_id: int): + """读取某个群的帮助菜单触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get("帮助菜单唤醒词", ["help", "帮助"]) + return self.normalize_string_triggers(raw, ["help", "帮助"]) + + def get_group_admin_menu_triggers(self, group_id: int): + """读取某个群的管理员菜单触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get("管理员菜单唤醒词", ["管理员菜单"]) + return self.normalize_string_triggers(raw, ["管理员菜单"]) + + def get_group_menu_exit_triggers(self, group_id: int | None = None): + """读取菜单内“退出整个菜单”的触发词。""" + if group_id is not None and group_id in self.group_cfgs: + raw = self.group_cfgs[group_id]["指令设置"].get( + "退出整个菜单触发词", + self.MENU_EXIT_TRIGGERS_DEFAULT, + ) + return self.normalize_string_triggers( + raw, self.MENU_EXIT_TRIGGERS_DEFAULT) + return list(self.MENU_EXIT_TRIGGERS_DEFAULT) + + def get_group_menu_back_triggers(self, group_id: int | None = None): + """读取菜单内“返回上一级”的触发词。""" + if group_id is not None and group_id in self.group_cfgs: + raw = self.group_cfgs[group_id]["指令设置"].get( + "返回上一级菜单触发词", + self.MENU_BACK_TRIGGERS_DEFAULT, + ) + return self.normalize_string_triggers( + raw, self.MENU_BACK_TRIGGERS_DEFAULT) + return list(self.MENU_BACK_TRIGGERS_DEFAULT) + + def is_menu_exit_input(self, user_input: str, group_id: int | None = None): + """判断一条输入是否要求退出整个交互菜单。""" + text = str(user_input).strip() + return any( + text.lower() == trigger.lower() + for trigger in self.get_group_menu_exit_triggers(group_id) + ) + + def is_menu_back_input(self, user_input: str, group_id: int | None = None): + """判断一条输入是否要求返回上一级菜单。""" + text = str(user_input).strip() + return any( + text.lower() == trigger.lower() + for trigger in self.get_group_menu_back_triggers(group_id) + ) + + def menu_exit_hint(self, group_id: int | None = None, action: str = "退出"): + return f"输入 {' / '.join(self.get_group_menu_exit_triggers(group_id))} {action}" + + def menu_back_hint( + self, + group_id: int | None = None, + action: str = "返回上级菜单"): + return f"输入 {' / '.join(self.get_group_menu_back_triggers(group_id))} {action}" + + def normalize_menu_control_hints( + self, + hints: list[str], + group_id: int | None = None, + ) -> list[str]: + """把旧菜单里的硬编码退出/返回提示替换成当前配置中的触发词。""" + normalized: list[str] = [] + for hint in hints: + if hint == "输入 . 退出": + normalized.append(self.menu_exit_hint(group_id)) + elif hint == "输入 . 取消": + normalized.append(self.menu_back_hint(group_id, "取消")) + elif hint == "输入 0 返回上级": + normalized.append(self.menu_back_hint(group_id, "返回上级")) + elif hint == "输入 0 返回上级菜单": + normalized.append(self.menu_back_hint(group_id)) + elif hint == "输入 q 退出菜单": + normalized.append(self.menu_exit_hint(group_id, "退出菜单")) + else: + normalized.append(hint) + return normalized + + def get_group_cmd_prefix(self, group_id: int): + """读取群内执行 MC 指令时使用的命令前缀。""" + group_cfg = self.group_cfgs[group_id] + prefix = str(group_cfg["指令设置"].get("发送指令前缀", "/")).strip() + return prefix or "/" + + def get_group_orion_ban_triggers(self, group_id: int): + """读取某个群的 Orion 封禁触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get( + "QQ群封禁唤醒词", + ["orban", "orion ban", "猎户封禁"], + ) + return self.normalize_string_triggers( + raw, ["orban", "orion ban", "猎户封禁"]) + + def get_group_orion_unban_triggers(self, group_id: int): + """读取某个群的 Orion 解封触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get( + "QQ群解封唤醒词", + ["orunban", "orion unban", "猎户解封"], + ) + return self.normalize_string_triggers( + raw, ["orunban", "orion unban", "猎户解封"]) + + def get_group_checker_menu_triggers(self, group_id: int): + """读取某个群的白名单联动菜单触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get( + "QQ群白名单&管理员检测唤醒词", + ["白名单&管理员检测", "检测管理"], + ) + return self.normalize_string_triggers(raw, ["白名单&管理员检测", "检测管理"]) + + def get_group_task_menu_triggers(self, group_id: int): + """读取某个群的任务系统菜单触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get("任务系统菜单唤醒词", ["任务系统"]) + return self.normalize_string_triggers(raw, ["任务系统"]) + + def get_group_land_menu_triggers(self, group_id: int): + """读取某个群的领地系统云链联动版菜单触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get("领地系统菜单唤醒词", ["领地系统云链联动版", "领地系统", "领地管理"]) + return self.normalize_string_triggers( + raw, ["领地系统云链联动版", "领地系统", "领地管理"]) + + def get_group_guild_menu_triggers(self, group_id: int): + """读取某个群的公会系统管理菜单触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get("公会系统管理菜单唤醒词", ["公会系统"]) + return self.normalize_string_triggers(raw, ["公会系统"]) + + @staticmethod + def normalize_string_triggers(raw: Any, fallback: list[str]): + """把触发词列表清洗成无空值、无重复的稳定序列。""" + triggers: list[str] = [] + if isinstance(raw, list): + for item in raw: + if not isinstance(item, str): + continue + text = item.strip() + if text and text not in triggers: + triggers.append(text) + return triggers or fallback + + def add_trigger( + self, + triggers: list[str], + argument_hint: str | None, + usage: str, + func: Callable[..., Any], + args_pd: Callable[[int], bool] = lambda _: True, + op_only: bool = False, + ): + """把外部插件注册的 QQ 触发规则挂入统一分发入口。""" + # 允许外部插件把自己的 QQ 指令挂进互通插件的统一分发入口。 + if not inspect.isroutine(func) and not callable(func): + raise TypeError("func 必须是可调用对象") + self.triggers.append( + QQMsgTrigger( + triggers, + argument_hint, + usage, + func, + args_pd, + op_only)) + + def set_manual_launch(self, port: int): + """切换到“本地启动器负责拉起云链”的模式。""" + self._manual_launch = True + self._manual_launch_port = port + + def manual_launch(self): + """给本地启动器调用的显式连接入口。""" + self.connect_to_websocket() diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/datas.json" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/datas.json" index 0914dff9..2ce2b896 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/datas.json" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/datas.json" @@ -1,13 +1,9 @@ { "author": "大庆油田&SuperScript&小六神", - "version": "1.0.0", - "description": "群服互通云链版Ultra版。提供多群独立管理的群服互通、QQ群管理员体系、白名单&管理员检测联动、QQ背包查询,以及 Orion 联动封禁功能。\n支持多个链接群聊;每个群单独保存管理员和超级管理员;支持每群独立设置帮助菜单唤醒词、管理员菜单唤醒词、查看玩家列表唤醒词、查询背包唤醒词、发送指令前缀、各类菜单分页数量;支持在群聊和控制台管理群管理员;支持在群聊中管理服务器白名单/管理员、设置检测周期,并通过 Orion API 封禁与解封玩家。", + "version": "2.0.0", + "description": "群服互通云链版Ultra版。提供多群独立管理的群服互通、QQ群管理员体系、QQ与游戏ID绑定、白名单&管理员检测联动、QQ背包查询、领地系统云链联动版群内管理、公会系统云链联动版群内普通/管理菜单,以及 Orion 联动封禁功能。\n支持多个链接群聊;每个群单独在配置文件中保存所有者、超级管理员和普通管理员,并会自动迁移旧版插件数据文件夹中的群管理员数据;支持每群独立设置帮助菜单唤醒词、管理员菜单唤醒词、查看玩家列表唤醒词、查询背包唤醒词、领地系统云链联动版菜单唤醒词、公会系统菜单唤醒词、发送指令前缀、各类菜单分页数量;支持所有者在群聊中管理超级管理员和普通管理员,支持超级管理员管理普通管理员;支持普通群成员在绑定游戏账号后使用公会系统普通菜单;支持在群聊中管理服务器白名单/管理员、领地系统云链联动版、公会系统云链联动版、设置检测周期,并通过 Orion API 封禁与解封玩家;对外提供 QQLinkerUltraAPI 绑定查询、群配置查询、群权限管理、触发词查询、运行状态查询、在线玩家查询、MC 指令执行、游戏到群转发预判、云链重载、原始群消息监听和消息发送接口。", "limit_launcher": null, - "pre-plugins": { - "tpscalculator": "0.0.1", - "『Orion System』违规与作弊行为综合反制系统": "0.4.2", - "白名单&管理员检测云链联动版": "1.1.2" - }, + "pre-plugins": {}, "plugin-type": "classic", - "plugin-id": "群服互通Ultra" -} \ No newline at end of file + "plugin-id": "群服互通云链版Ultra版" +} diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/orion_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/orion_mixin.py" index b741d26e..3ee48ff9 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/orion_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/orion_mixin.py" @@ -1,998 +1,940 @@ -import os -import re - -from tooldelta import fmts, utils - - -# 和 Orion_System 的联动全收在这里,主入口不用再关心封禁细节。 -class QQLinkerOrionMixin: - """负责 Orion_System 菜单、封禁与解封逻辑。""" - - @staticmethod - def console_menu_header(title: str) -> str: - """生成控制台使用的 Orion 风格标题栏。""" - return ( - "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓" - "§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" - f"§l§d❐§f 『§6群服互通云链版Ultra版§f』 §b{title}" - ) - - @staticmethod - def console_menu_footer(page_label: str, body: str) -> str: - """生成控制台使用的 Orion 风格页脚。""" - return ( - "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓 " - f"§r§7[ §b{page_label} §7] " - "§l§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" - f"§r{body}" - ) - - def prompt_console_input( - self, - title: str, - page_label: str, - body_lines: list[str], - prompt: str, - ) -> str: - """按 Orion 卡片样式展示控制台交互步骤,并读取输入。""" - self.print_console_card(title, page_label, body_lines, level="info") - return input(fmts.fmt_info(f"§a❀ §b{prompt}")).strip() - - @staticmethod - def print_console_info(text: str): - """按统一 UI 风格输出控制台普通信息。""" - fmts.print_inf(f"§a❀ §b{text}") - - @staticmethod - def print_console_success(text: str): - """按统一 UI 风格输出控制台成功信息。""" - fmts.print_suc(f"§a❀ §b{text}") - - @staticmethod - def print_console_warn(text: str): - """按统一 UI 风格输出控制台警告信息。""" - fmts.print_war(f"§6❀ §e{text}") - - @staticmethod - def print_console_error(text: str): - """按统一 UI 风格输出控制台错误信息。""" - fmts.print_err(f"§c❀ §e{text}") - - def print_console_card( - self, - title: str, - page_label: str, - body_lines: list[str], - level: str = "info", - ): - """按 Orion 风格打印一张控制台信息卡片。""" - card = ( - self.console_menu_header(title) - + "\n" - + self.console_menu_footer( - page_label, - "\n".join( - i if i.startswith("§") else f"§a❀ §b{i}" for i in body_lines - ), - ) - ) - { - "info": fmts.print_inf, - "success": fmts.print_suc, - "warn": fmts.print_war, - "error": fmts.print_err, - }.get(level, fmts.print_inf)(card) - - def require_orion(self): - """返回可用的 Orion 实例,不可用时抛出明确异常。""" - if self.orion is None: - raise RuntimeError("Orion_System 插件不可用") - return self.orion - - def reply_to_qq(self, group_id: int, qqid: int, text: str): - """向指定群成员回复一条文本消息。""" - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {text}", - do_remove_cq_code=False, - ) - - def reply_result(self, group_id: int, qqid: int, ok: bool, msg: str): - """把统一格式的成功/失败结果回发到群里。""" - prefix = "😄" if ok else "😭" - self.reply_to_qq(group_id, qqid, f"{prefix} {msg}") - - def on_qq_orion_ban(self, group_id: int, sender: int, args: list[str]): - """群聊侧的 Orion 封禁入口。 - - 支持两种调用方式: - - 直接给参数:适合熟悉命令的管理人员 - - 不给参数:转到交互式菜单 - """ - if not self.is_group_admin(group_id, sender): - self.reply_to_qq(group_id, sender, "你没有权限执行此指令") - return - if args == []: - self.qq_orion_ban_menu(group_id, sender) - return - target = args[0] - ban_time_raw = args[1] - reason = " ".join(args[2:]).strip() or "群聊管理员封禁" - ok, msg = self.orion_ban_player(target, ban_time_raw, reason) - self.reply_result(group_id, sender, ok, msg) - - def on_qq_orion_unban(self, group_id: int, sender: int, args: list[str]): - """群聊侧的 Orion 解封入口。""" - if not self.is_group_admin(group_id, sender): - self.reply_to_qq(group_id, sender, "你没有权限执行此指令") - return - if args == []: - self.qq_orion_unban_menu(group_id, sender) - return - ok, msg = self.orion_unban_player(args[0]) - self.reply_result(group_id, sender, ok, msg) - - def qq_prompt(self, group_id: int, qqid: int, text: str, timeout: int = 60): - """发送一段提示文本,并等待同群同 QQ 的下一条回复。""" - self.sendmsg(group_id, f"[CQ:at,qq={qqid}] {text}", do_remove_cq_code=False) - resp = self.waitMsg(qqid, timeout=timeout, group_id=group_id) - if isinstance(resp, str): - return resp.strip() - return None - - @staticmethod - def orion_ui_border(): - """返回 Orion 菜单统一使用的装饰边框。""" - return "✧✦〓〓〓〓〓〓〓〓〓〓〓✦✧" - - def orion_ui_menu(self, subtitle: str, options: list[str], hints: list[str]): - """生成 Orion 风格的普通菜单文本。""" - parts = [self.orion_ui_border(), f"❐ 『Orion System 猎户座』 {subtitle}"] - parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(options)]) - parts.append(self.orion_ui_border()) - parts.extend([f"❀ {hint}" for hint in hints]) - return "\n".join(parts) - - def plugin_ui_menu( - self, - system_name: str, - subtitle: str, - options: list[str], - hints: list[str], - ): - """生成插件内部复用的菜单文本。""" - parts = [self.orion_ui_border(), f"❐ 『{system_name}』 {subtitle}"] - parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(options)]) - parts.append(self.orion_ui_border()) - parts.extend([f"❀ {hint}" for hint in hints]) - return "\n".join(parts) - - def orion_ui_list( - self, - subtitle: str, - items: list[str], - page: int, - total_pages: int, - select_hint: str, - search_hint: str | None = None, - ): - """生成带分页和搜索提示的列表菜单文本。""" - parts = [self.orion_ui_border(), f"❐ 『Orion System 猎户座』 {subtitle}"] - parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(items)]) - parts.append(self.orion_ui_border()) - parts.append(f"❀ 当前第 {page}/{total_pages} 页") - parts.append(f"❀ 输入 {select_hint}") - if search_hint: - parts.append(f"❀ 输入 {search_hint}") - parts.append("❀ 输入 - 转到上一页") - parts.append("❀ 输入 + 转到下一页") - parts.append("❀ 输入 正整数+页 转到对应页") - parts.append("❀ 输入 . 退出") - return "\n".join(parts) - - def get_orion_items_per_page(self, group_id: int): - """读取 Orion 菜单每页条数配置。""" - group_cfg = self.group_cfgs.get(group_id) - if group_cfg is not None: - try: - return max( - 1, - int(group_cfg["指令设置"]["QQ群封禁/解封菜单每页显示个数"]), - ) - except (KeyError, TypeError, ValueError): - return 10 - return 10 - - def orion_xuid_status_text(self, xuid: str): - """读取某个 xuid 在 Orion 中的封禁状态摘要。""" - orion = self.require_orion() - path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" - if not os.path.exists(path): - return "未封禁" - try: - data = orion.utils.disk_read_need_exists(path) - except Exception: - return "状态异常" - return f"封禁至: {data.get('ban_end_real_time', '未知')}" - - def orion_device_status_text(self, device_id: str): - """读取某个设备号在 Orion 中的封禁状态摘要。""" - orion = self.require_orion() - path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" - if not os.path.exists(path): - return "未封禁" - try: - data = orion.utils.disk_read_need_exists(path) - except Exception: - return "状态异常" - return f"封禁至: {data.get('ban_end_real_time', '未知')}" - - @staticmethod - def format_device_history(player_data: dict[str, list[str]]): - """把设备号关联历史压缩成适合列表展示的单行文本。""" - outputs: list[str] = [] - for xuid, names in list(player_data.items())[:3]: - if isinstance(names, list) and names: - outputs.append(f"{xuid}:{'/'.join(names[-2:])}") - else: - outputs.append(f"{xuid}:[]") - if len(player_data) > 3: - outputs.append("...") - return "; ".join(outputs) - - def build_online_xuid_data(self): - """构建当前在线玩家的 xuid 映射。""" - orion = self.require_orion() - result: dict[str, str] = {} - for player_name in self.game_ctrl.allplayers.copy(): - try: - xuid = orion.xuid_getter.get_xuid_by_name(player_name) - except Exception: - continue - result[xuid] = player_name - return result - - def build_historical_xuid_data(self): - """读取历史玩家名称到 xuid 的映射数据。""" - path = os.path.join("插件数据文件", "前置-玩家XUID获取", "xuids.json") - try: - return self.orion.utils.disk_read_need_exists(path) if self.orion else {} - except Exception: - return {} - - def build_device_history_data(self): - """读取 Orion 设备号与玩家历史的映射数据。""" - orion = self.require_orion() - path = f"{orion.data_path}/{orion.config_mgr.player_data_file}" - try: - data = orion.utils.disk_read_need_exists(path) - except Exception: - return {} - return data if isinstance(data, dict) else {} - - def _show_paginated_orion_menu( - self, - group_id: int, - qqid: int, - title: str, - matched_items: list[dict[str, object]], - page: int, - per_page: int, - select_hint: str, - search_hint: str | None, - ): - """渲染一页列表菜单,并返回用户输入与分页边界。""" - total_pages, start_index, end_index = self.orion.utils.paginate( - len(matched_items), - per_page, - page, - ) - output_lines = [ - item["display"] - for item in matched_items[start_index - 1 : end_index] - ] - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] " - + self.orion_ui_list( - title, - output_lines, - page, - total_pages, - select_hint.format(start_index=start_index, end_index=end_index), - search_hint, - ), - do_remove_cq_code=False, - ) - user_input = self.waitMsg(qqid, timeout=60, group_id=group_id) - return user_input, total_pages, start_index, end_index - - def _handle_paginated_orion_input( - self, - group_id: int, - qqid: int, - user_input: str | None, - page: int, - total_pages: int, - allow_search: bool, - search: str, - ): - """统一处理分页菜单中的翻页、退出、搜索和选择输入。""" - if user_input is None: - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 回复超时! 已退出菜单", - do_remove_cq_code=False, - ) - return "exit", None - - user_input = user_input.strip() - if user_input.lower() in ("q", ".", "。"): - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 已退出菜单", - do_remove_cq_code=False, - ) - return "exit", None - - if user_input == "+": - if page < total_pages: - return "page", page + 1 - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 已经是最后一页啦~", - do_remove_cq_code=False, - ) - return "retry", None - - if user_input == "-": - if page > 1: - return "page", page - 1 - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 已经是第一页啦~", - do_remove_cq_code=False, - ) - return "retry", None - - if match := re.fullmatch(r"^([1-9]\d*)页$", user_input): - page_num = int(match.group(1)) - if 1 <= page_num <= total_pages: - return "page", page_num - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 不存在第 {page_num} 页!请重新输入!", - do_remove_cq_code=False, - ) - return "retry", None - - choice = utils.try_int(user_input) - if choice is not None: - return "choice", choice - - if allow_search: - return "search", user_input.replace("\\", "") - - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 您的输入有误", - do_remove_cq_code=False, - ) - return "retry", search - - def _select_paginated_orion_items( - self, - group_id: int, - qqid: int, - title: str, - build_matches, - empty_message: str, - select_hint: str, - search_hint: str | None, - allow_search: bool = True, - ): - """执行一套通用的分页选择流程。""" - search = "" - page = 1 - per_page = self.get_orion_items_per_page(group_id) - while True: - matched_items = build_matches(search) - if not matched_items: - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {empty_message}", - do_remove_cq_code=False, - ) - return None - - user_input, total_pages, start_index, end_index = ( - self._show_paginated_orion_menu( - group_id, - qqid, - title, - matched_items, - page, - per_page, - select_hint, - search_hint, - ) - ) - action, value = self._handle_paginated_orion_input( - group_id, - qqid, - user_input, - page, - total_pages, - allow_search, - search, - ) - if action == "exit": - return None - if action == "page": - page = value - continue - if action == "search": - search = value - page = 1 - continue - if action == "retry": - continue - if action == "choice" and value in range(start_index, end_index + 1): - return matched_items[value - 1] - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 您的输入有误", - do_remove_cq_code=False, - ) - - def qq_select_orion_xuid( - self, - group_id: int, - qqid: int, - title: str, - xuid_data: dict[str, str], - allow_search: bool = True, - ): - """从 xuid 列表里分页选择目标玩家。 - - 返回 `(xuid, 玩家名)`,失败或退出时返回 `(None, None)`。 - """ - selected = self._select_paginated_orion_items( - group_id, - qqid, - title, - lambda search: [ - { - "value": (xuid, name), - "display": ( - f"{xuid} - {name}" - f" - {self.orion_xuid_status_text(xuid)}" - ), - } - for xuid, name in xuid_data.items() - if search == "" or search in xuid or search in name - ], - "找不到您输入的 xuid 或玩家名称", - "[{start_index}-{end_index}] 之间的数字以选择 对应玩家", - "xuid、玩家名称或玩家部分名称 可尝试搜索", - allow_search=allow_search, - ) - if selected is None: - return None, None - return selected["value"] - - def qq_select_orion_device( - self, - group_id: int, - qqid: int, - title: str, - device_data: dict[str, dict[str, list[str]]], - ): - """从设备号列表里分页选择目标设备。""" - selected = self._select_paginated_orion_items( - group_id, - qqid, - title, - lambda search: [ - { - "value": (device_id, player_info), - "display": ( - f"{device_id} - {self.format_device_history(player_info)}" - f" - {self.orion_device_status_text(device_id)}" - ), - } - for device_id, player_info in device_data.items() - if search == "" - or search in device_id - or search in self.format_device_history(player_info) - ], - "找不到您输入的设备号或玩家名称", - "[{start_index}-{end_index}] 之间的数字以选择 对应设备号", - "设备号、玩家名称或玩家部分名称 可尝试搜索", - ) - if selected is None: - return None, None - return selected["value"] - - def qq_get_orion_ban_time(self, group_id: int, qqid: int): - """统一处理群里输入的封禁时长。""" - prompt = self.orion_ui_menu( - "封禁时间输入", - [], - [ - "输入 -1 表示永久封禁", - "输入 正整数 表示封禁秒数", - "输入 0年0月5日6时7分8秒 表示对应时长", - "输入 . 退出", - ], - ) - user_input = self.qq_prompt(group_id, qqid, prompt, timeout=120) - if user_input is None: - self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - if user_input.lower() in ("q", ".", "。"): - self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - ban_time = self.orion.utils.ban_time_format(user_input) if self.orion else 0 - if ban_time == 0: - self.reply_to_qq(group_id, qqid, "❀ 您输入的封禁时间有误") - return None - return ban_time - - def qq_get_orion_reason(self, group_id: int, qqid: int): - """获取封禁原因,允许直接回车走默认文案。""" - user_input = self.qq_prompt( - group_id, - qqid, - self.orion_ui_menu( - "封禁原因输入", - [], - [ - "请输入封禁原因", - "直接回车使用默认原因“群聊管理员封禁”", - "输入 . 退出", - ], - ), - timeout=120, - ) - if user_input is None: - self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - if user_input.lower() in ("q", ".", "。"): - self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - return user_input or "群聊管理员封禁" - - def _select_orion_ban_target(self, group_id: int, qqid: int, choice: str): - """根据封禁模式选择具体目标对象。""" - if choice == "1": - xuid, player_name = self.qq_select_orion_xuid( - group_id, - qqid, - "在线玩家封禁", - self.build_online_xuid_data(), - ) - if not xuid or not player_name: - return None - return { - "kind": "xuid", - "payload": (xuid, player_name), - "online_only": True, - } - if choice == "2": - xuid, player_name = self.qq_select_orion_xuid( - group_id, - qqid, - "历史玩家封禁", - self.build_historical_xuid_data(), - ) - if not xuid or not player_name: - return None - return { - "kind": "xuid", - "payload": (xuid, player_name), - "online_only": False, - } - if choice == "3": - device_id, player_info = self.qq_select_orion_device( - group_id, - qqid, - "设备号封禁", - self.build_device_history_data(), - ) - if not device_id or not player_info: - return None - return { - "kind": "device", - "payload": (device_id, player_info), - } - self.reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return None - - def _apply_selected_orion_ban(self, group_id: int, qqid: int, ban_target: dict): - """把封禁时间和封禁原因的通用交互流程复用到所有封禁模式。""" - ban_time = self.qq_get_orion_ban_time(group_id, qqid) - if ban_time is None: - return - reason = self.qq_get_orion_reason(group_id, qqid) - if reason is None: - return - - if ban_target["kind"] == "xuid": - xuid, player_name = ban_target["payload"] - ok, msg = self.apply_orion_xuid_ban( - xuid, - player_name, - ban_time, - reason, - online_only=ban_target.get("online_only", False), - ) - else: - device_id, player_info = ban_target["payload"] - ok, msg = self.apply_orion_device_ban( - device_id, - player_info, - ban_time, - reason, - ) - self.reply_result(group_id, qqid, ok, msg) - - def qq_orion_ban_menu(self, group_id: int, qqid: int): - """交互式 Orion 封禁菜单。""" - if self.orion is None: - self.reply_to_qq(group_id, qqid, "未检测到 Orion_System 插件") - return - choice = self.qq_prompt( - group_id, - qqid, - self.orion_ui_menu( - "封禁管理系统", - ["根据在线玩家名称和xuid封禁", "根据历史玩家名称和xuid封禁", "根据设备号封禁"], - ["输入 [1-3] 之间的数字以选择 封禁模式", "输入 . 退出"], - ), - timeout=120, - ) - if choice is None: - self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if choice.lower() in ("q", ".", "。"): - self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - ban_target = self._select_orion_ban_target(group_id, qqid, choice) - if ban_target is None: - return - self._apply_selected_orion_ban(group_id, qqid, ban_target) - - def qq_orion_unban_menu(self, group_id: int, qqid: int): - """交互式 Orion 解封菜单。""" - if self.orion is None: - self.reply_to_qq(group_id, qqid, "未检测到 Orion_System 插件") - return - choice = self.qq_prompt( - group_id, - qqid, - self.orion_ui_menu( - "解封管理系统", - ["根据玩家名称和xuid解封", "根据设备号解封"], - ["输入 [1-2] 之间的数字以选择 解封模式", "输入 . 退出"], - ), - timeout=120, - ) - if choice is None: - self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if choice.lower() in ("q", ".", "。"): - self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if choice == "1": - xuid_dir = f"{self.orion.data_path}/{self.orion.config_mgr.xuid_dir}" - xuid_data: dict[str, str] = {} - if os.path.isdir(xuid_dir): - for xuid_json in os.listdir(xuid_dir): - xuid = xuid_json.replace(".json", "") - try: - xuid_data[xuid] = self.orion.xuid_getter.get_name_by_xuid( - xuid, - True, - ) - except Exception: - xuid_data[xuid] = xuid - xuid, player_name = self.qq_select_orion_xuid( - group_id, - qqid, - "xuid 解封", - xuid_data, - allow_search=True, - ) - if not xuid or not player_name: - return - ok, msg = self.apply_orion_xuid_unban(xuid, player_name) - self.reply_result(group_id, qqid, ok, msg) - return - if choice == "2": - device_dir = f"{self.orion.data_path}/{self.orion.config_mgr.device_id_dir}" - player_data = self.build_device_history_data() - device_data: dict[str, dict[str, list[str]]] = {} - if os.path.isdir(device_dir): - for device_json in os.listdir(device_dir): - device_id = device_json.replace(".json", "") - device_data[device_id] = player_data.get(device_id, {}) - device_id, player_info = self.qq_select_orion_device( - group_id, - qqid, - "设备号解封", - device_data, - ) - if not device_id or player_info is None: - return - ok, msg = self.apply_orion_device_unban(device_id, player_info) - self.reply_result(group_id, qqid, ok, msg) - return - self.reply_to_qq(group_id, qqid, "❀ 您的输入有误") - - def resolve_orion_target(self, target: str): - """把群里的输入尽量解析成稳定的 `(xuid, 玩家名)` 组合。""" - if self.orion is None: - return None, None, "未检测到 Orion_System 插件" - if not hasattr(self.orion, "xuid_getter"): - return None, None, "Orion 插件尚未完成初始化" - - # 先按玩家名解析,失败再把输入当 xuid 处理,兼容群里两种用法。 - try: - xuid = self.orion.xuid_getter.get_xuid_by_name(target, True) - try: - player_name = self.orion.xuid_getter.get_name_by_xuid(xuid, True) - except Exception: - player_name = target - except Exception: - xuid = target - try: - player_name = self.orion.xuid_getter.get_name_by_xuid(xuid, True) - except Exception: - player_name = target - - if not isinstance(xuid, str) or not xuid: - return None, None, "无法解析玩家名称或 xuid" - return xuid, player_name, None - - def orion_ban_player(self, target: str, ban_time_raw: str, reason: str): - """非交互式封禁入口,给命令行式调用使用。""" - xuid, player_name, error = self.resolve_orion_target(target) - if error: - return False, error - orion = self.require_orion() - ban_time = orion.utils.ban_time_format(ban_time_raw) - return self.apply_orion_xuid_ban(xuid, player_name, ban_time, reason) - - def orion_unban_player(self, target: str): - """非交互式解封入口,供命令式调用复用。""" - xuid, player_name, error = self.resolve_orion_target(target) - if error: - return False, error - return self.apply_orion_xuid_unban(xuid, player_name) - - def apply_orion_xuid_ban( - self, - xuid: str, - player_name: str, - ban_time: int | str, - reason: str, - online_only: bool = False, - ): - """写入 xuid 封禁记录,并在需要时踢出对应在线玩家。""" - orion = self.require_orion() - # 实际封禁前会先过一遍在线状态和 Orion 自己的白名单检查。 - if online_only and player_name not in self.game_ctrl.allplayers: - return False, f"玩家 {player_name} 当前不在线" - if orion.utils.in_whitelist(player_name): - return False, f"玩家 {player_name} 位于 Orion 反制白名单内" - timestamp_now, date_now = orion.utils.now() - path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" - with orion.lock_ban_xuid: - ban_data = orion.utils.disk_read(path) - timestamp_end, date_end = orion.utils.calculate_ban_end_time( - ban_data, - ban_time, - timestamp_now, - ) - if timestamp_end is False or date_end is False: - return False, f"玩家 {player_name} 已经是永久封禁" - orion.utils.disk_write( - path, - { - "xuid": xuid, - "name": player_name, - "ban_start_real_time": date_now, - "ban_start_timestamp": timestamp_now, - "ban_end_real_time": date_end, - "ban_end_timestamp": timestamp_end, - "ban_reason": reason, - }, - ) - if player_name in self.game_ctrl.allplayers: - orion.utils.kick(player_name, f"由于{reason},您被系统封禁至:{date_end}") - return True, f"已通过 Orion 封禁 {player_name} (xuid:{xuid}) 至 {date_end}" - - def apply_orion_device_ban( - self, - device_id: str, - player_info: dict[str, list[str]], - ban_time: int | str, - reason: str, - ): - """写入设备号封禁记录,并处理命中该设备号的在线玩家。""" - orion = self.require_orion() - # 设备号封禁会顺手踢掉当前在线、且命中过这个设备号的玩家。 - timestamp_now, date_now = orion.utils.now() - path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" - with orion.lock_ban_device_id: - ban_data = orion.utils.disk_read(path) - timestamp_end, date_end = orion.utils.calculate_ban_end_time( - ban_data, - ban_time, - timestamp_now, - ) - if timestamp_end is False or date_end is False: - return False, f"设备号 {device_id} 已经是永久封禁" - orion.utils.disk_write( - path, - { - "device_id": device_id, - "xuid_and_player": player_info, - "ban_start_real_time": date_now, - "ban_start_timestamp": timestamp_now, - "ban_end_real_time": date_end, - "ban_end_timestamp": timestamp_end, - "ban_reason": reason, - }, - ) - for _xuid, names in player_info.items(): - kick_name = None - if isinstance(names, list): - for name in reversed(names): - if name in self.game_ctrl.allplayers: - kick_name = name - break - if kick_name: - orion.utils.kick(kick_name, f"由于{reason},您被系统封禁至:{date_end}") - return True, f"已通过 Orion 封禁设备号 {device_id} 至 {date_end}" - - def apply_orion_xuid_unban(self, xuid: str, player_name: str): - """删除 Orion 中某个 xuid 的封禁记录。""" - orion = self.require_orion() - path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" - if not os.path.exists(path): - return False, f"玩家 {player_name} 当前不在 Orion 的 xuid 封禁列表中" - os.remove(path) - return True, f"已通过 Orion 解封 {player_name} (xuid:{xuid})" - - def apply_orion_device_unban( - self, - device_id: str, - player_info: dict[str, list[str]], - ): - """删除 Orion 中某个设备号的封禁记录。""" - orion = self.require_orion() - path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" - if not os.path.exists(path): - return False, f"设备号 {device_id} 当前不在 Orion 的设备号封禁列表中" - os.remove(path) - return True, f"已通过 Orion 解封设备号 {device_id}" - - def _prompt_console_group_id(self): - """在控制台里选择要操作的目标群。""" - while True: - group_input = input( - fmts.fmt_info("§a❀ §b请输入群序号,输入 q 退出: ") - ).strip().lower() - if group_input == "q": - self.print_console_error("已退出QQ群管理员管理菜单") - return None - group_index = utils.try_int(group_input) - if group_index is None or group_index not in range( - 1, - len(self.group_order) + 1, - ): - self.print_console_error("群序号无效") - continue - return self.group_order[group_index - 1] - - def _prompt_console_remove_flag(self): - """在控制台里选择是添加还是删除管理员。""" - while True: - action_input = self.prompt_console_input( - "群服互通 控制台管理", - "选择操作", - ["输入 1 添加管理员", "输入 2 删除管理员", "输入 q 退出菜单"], - "请输入操作类型 (1=添加, 2=删除, q=退出): ", - ).lower() - if action_input == "q": - self.print_console_error("已退出QQ群管理员管理菜单") - return None - if action_input in ("1", "2"): - return action_input == "2" - self.print_console_error("操作类型无效") - - def _prompt_console_super_flag(self): - """在控制台里选择普通管理员还是超级管理员。""" - while True: - role_input = self.prompt_console_input( - "群服互通 控制台管理", - "选择角色", - ["输入 1 普通管理员", "输入 2 超级管理员", "输入 q 退出菜单"], - "请输入角色类型 (1=普通管理员, 2=超级管理员, q=退出): ", - ).lower() - if role_input == "q": - self.print_console_error("已退出QQ群管理员管理菜单") - return None - if role_input in ("1", "2"): - return role_input == "2" - self.print_console_error("角色类型无效") - - def _prompt_console_qqid(self, is_remove: bool): - """在控制台里读取要增删的 QQ 号。""" - while True: - qq_input = self.prompt_console_input( - "群服互通 控制台管理", - "输入 QQ", - [ - f"请输入要{'删除' if is_remove else '添加'}的 QQ 号", - "输入 q 退出菜单", - ], - f"请输入要{'删除' if is_remove else '添加'}的QQ号,输入 q 退出: ", - ).lower() - if qq_input == "q": - self.print_console_error("已退出QQ群管理员管理菜单") - return None - qqid = utils.try_int(qq_input) - if qqid is None or qqid <= 0: - self.print_console_error("QQ号无效") - continue - return qqid - - def on_console_add_qq_op(self, _args: list[str]): - """控制台侧管理群管理员,主要给服主离线处理配置时使用。""" - if not self.group_order: - self.print_console_error("当前没有配置任何群聊") - return - summary_lines = [] - for index, group_id in enumerate(self.group_order, start=1): - state = self.read_group_state(group_id) - summary_lines.append( - f"[ {index} ] 群 {group_id}" - f" - 管理员:{len(state['admins'])}" - f" / 超级管理员:{len(state['super_admins'])}" - ) - summary_lines.extend( - [ - "输入群序号继续操作", - "输入 q 退出菜单", - ] - ) - self.print_console_card( - "群服互通 控制台管理", - "OPQQ", - summary_lines, - level="info", - ) - target_group = self._prompt_console_group_id() - if target_group is None: - return - is_remove = self._prompt_console_remove_flag() - if is_remove is None: - return - is_super = self._prompt_console_super_flag() - if is_super is None: - return - qqid = self._prompt_console_qqid(is_remove) - if qqid is None: - return - - if is_remove: - ok, msg = self.remove_group_role(target_group, qqid, is_super=is_super) - else: - ok, msg = self.add_group_role(target_group, qqid, is_super=is_super) - if ok: - self.print_console_success(f"群 {target_group}: {msg}") - else: - self.print_console_error(f"群 {target_group}: {msg}") +"""Orion System integration helpers for Ultra.""" + +import os +import re + +from tooldelta import fmts, utils + + +# 和 Orion_System 的联动全收在这里,主入口不用再关心封禁细节。 +class QQLinkerOrionMixin: + """负责 Orion_System 菜单、封禁与解封逻辑。""" + + @staticmethod + def console_menu_header(title: str) -> str: + """生成控制台使用的 Orion 风格标题栏。""" + return ( + "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓" + "§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" + f"§l§d❐§f 『§6群服互通云链版Ultra版§f』 §b{title}" + ) + + @staticmethod + def console_menu_footer(page_label: str, body: str) -> str: + """生成控制台使用的 Orion 风格页脚。""" + return ( + "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓 " + f"§r§7[ §b{page_label} §7] " + "§l§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" + f"§r{body}" + ) + + def prompt_console_input( + self, + title: str, + page_label: str, + body_lines: list[str], + prompt: str, + ) -> str: + """按 Orion 卡片样式展示控制台交互步骤,并读取输入。""" + self.print_console_card(title, page_label, body_lines, level="info") + return input(fmts.fmt_info(f"§a❀ §b{prompt}")).strip() + + @staticmethod + def print_console_info(text: str): + """按统一 UI 风格输出控制台普通信息。""" + fmts.print_inf(f"§a❀ §b{text}") + + @staticmethod + def print_console_success(text: str): + """按统一 UI 风格输出控制台成功信息。""" + fmts.print_suc(f"§a❀ §b{text}") + + @staticmethod + def print_console_warn(text: str): + """按统一 UI 风格输出控制台警告信息。""" + fmts.print_war(f"§6❀ §e{text}") + + @staticmethod + def print_console_error(text: str): + """按统一 UI 风格输出控制台错误信息。""" + fmts.print_err(f"§c❀ §e{text}") + + def print_console_card( + self, + title: str, + page_label: str, + body_lines: list[str], + level: str = "info", + ): + """按 Orion 风格打印一张控制台信息卡片。""" + card = ( + self.console_menu_header(title) + + "\n" + + self.console_menu_footer( + page_label, + "\n".join( + i if i.startswith("§") else f"§a❀ §b{i}" for i in body_lines), + )) + { + "info": fmts.print_inf, + "success": fmts.print_suc, + "warn": fmts.print_war, + "error": fmts.print_err, + }.get(level, fmts.print_inf)(card) + + def require_orion(self): + """返回可用的 Orion 实例,不可用时抛出明确异常。""" + if self.orion is None: + raise RuntimeError("相关插件未安装:Orion_System") + enabled = self._extract_plugin_enabled_flag(self.orion) + if not enabled and enabled is not None: + raise RuntimeError("相关插件未启用") + return self.orion + + def reply_to_qq(self, group_id: int, qqid: int, text: str): + """向指定群成员回复一条文本消息。""" + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False, + ) + + def reply_result(self, group_id: int, qqid: int, ok: bool, msg: str): + """把统一格式的成功/失败结果回发到群里。""" + prefix = "😄" if ok else "😭" + self.reply_to_qq(group_id, qqid, f"{prefix} {msg}") + + def on_qq_orion_ban(self, group_id: int, sender: int, args: list[str]): + """群聊侧的 Orion 封禁入口。 + + 支持两种调用方式: + - 直接给参数:适合熟悉命令的管理人员 + - 不给参数:转到交互式菜单 + """ + if not self._can_use_group_permission(group_id, sender, "封禁/解封玩家权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if self.orion is None: + self.reply_to_qq(group_id, sender, "相关插件未安装:Orion_System") + return + if not self.ensure_linked_plugin_enabled(self.orion, group_id, sender): + return + if args == []: + self.qq_orion_ban_menu(group_id, sender) + return + target = args[0] + ban_time_raw = args[1] + reason = " ".join(args[2:]).strip() or "群聊管理员封禁" + ok, msg = self.orion_ban_player(target, ban_time_raw, reason) + self.reply_result(group_id, sender, ok, msg) + + def on_qq_orion_unban(self, group_id: int, sender: int, args: list[str]): + """群聊侧的 Orion 解封入口。""" + if not self._can_use_group_permission(group_id, sender, "封禁/解封玩家权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if self.orion is None: + self.reply_to_qq(group_id, sender, "相关插件未安装:Orion_System") + return + if not self.ensure_linked_plugin_enabled(self.orion, group_id, sender): + return + if args == []: + self.qq_orion_unban_menu(group_id, sender) + return + ok, msg = self.orion_unban_player(args[0]) + self.reply_result(group_id, sender, ok, msg) + + def qq_prompt( + self, + group_id: int, + qqid: int, + text: str, + timeout: int = 60): + """发送一段提示文本,并等待同群同 QQ 的下一条回复。""" + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False) + resp = self.waitMsg(qqid, timeout=timeout, group_id=group_id) + if isinstance(resp, str): + return resp.strip() + return None + + @staticmethod + def orion_ui_border(): + """返回 Orion 菜单统一使用的装饰边框。""" + return "✧✦〓〓〓〓〓〓〓〓〓〓〓✦✧" + + def orion_ui_menu( + self, + subtitle: str, + options: list[str], + hints: list[str], + group_id: int | None = None, + ): + """生成 Orion 风格的普通菜单文本。""" + hints = self.normalize_menu_control_hints(hints, group_id) + parts = [self.orion_ui_border(), f"❐ 『Orion System 猎户座』 {subtitle}"] + parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(options)]) + parts.append(self.orion_ui_border()) + parts.extend([f"❀ {hint}" for hint in hints]) + return "\n".join(parts) + + def plugin_ui_menu( + self, + system_name: str, + subtitle: str, + options: list[str], + hints: list[str], + group_id: int | None = None, + ): + """生成插件内部复用的菜单文本。""" + hints = self.normalize_menu_control_hints(hints, group_id) + parts = [self.orion_ui_border(), f"❐ 『{system_name}』 {subtitle}"] + parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(options)]) + parts.append(self.orion_ui_border()) + parts.extend([f"❀ {hint}" for hint in hints]) + return "\n".join(parts) + + def orion_ui_list( + self, + subtitle: str, + items: list[str], + page: int, + total_pages: int, + select_hint: str, + search_hint: str | None = None, + group_id: int | None = None, + ): + """生成带分页和搜索提示的列表菜单文本。""" + parts = [self.orion_ui_border(), f"❐ 『Orion System 猎户座』 {subtitle}"] + parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(items)]) + parts.append(self.orion_ui_border()) + parts.append(f"❀ 当前第 {page}/{total_pages} 页") + parts.append(f"❀ 输入 {select_hint}") + if search_hint: + parts.append(f"❀ 输入 {search_hint}") + parts.append("❀ 输入 - 转到上一页") + parts.append("❀ 输入 + 转到下一页") + parts.append("❀ 输入 正整数+页 转到对应页") + parts.append(f"❀ {self.menu_exit_hint(group_id)}") + return "\n".join(parts) + + def get_orion_items_per_page(self, group_id: int): + """读取 Orion 菜单每页条数配置。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["QQ群封禁/解封菜单每页显示个数"]), + ) + except (KeyError, TypeError, ValueError): + return 10 + return 10 + + def orion_xuid_status_text(self, xuid: str): + """读取某个 xuid 在 Orion 中的封禁状态摘要。""" + orion = self.require_orion() + path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" + if not os.path.exists(path): + return "未封禁" + try: + data = orion.utils.disk_read_need_exists(path) + except Exception: + return "状态异常" + return f"封禁至: {data.get('ban_end_real_time', '未知')}" + + def orion_device_status_text(self, device_id: str): + """读取某个设备号在 Orion 中的封禁状态摘要。""" + orion = self.require_orion() + path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" + if not os.path.exists(path): + return "未封禁" + try: + data = orion.utils.disk_read_need_exists(path) + except Exception: + return "状态异常" + return f"封禁至: {data.get('ban_end_real_time', '未知')}" + + @staticmethod + def format_device_history(player_data: dict[str, list[str]]): + """把设备号关联历史压缩成适合列表展示的单行文本。""" + outputs: list[str] = [] + for xuid, names in list(player_data.items())[:3]: + if isinstance(names, list) and names: + outputs.append(f"{xuid}:{'/'.join(names[-2:])}") + else: + outputs.append(f"{xuid}:[]") + if len(player_data) > 3: + outputs.append("...") + return "; ".join(outputs) + + def build_online_xuid_data(self): + """构建当前在线玩家的 xuid 映射。""" + orion = self.require_orion() + result: dict[str, str] = {} + for player_name in self.game_ctrl.allplayers.copy(): + try: + xuid = orion.xuid_getter.get_xuid_by_name(player_name) + except Exception: + continue + result[xuid] = player_name + return result + + def build_historical_xuid_data(self): + """读取历史玩家名称到 xuid 的映射数据。""" + path = os.path.join("插件数据文件", "前置-玩家XUID获取", "xuids.json") + try: + return self.orion.utils.disk_read_need_exists( + path) if self.orion else {} + except Exception: + return {} + + def build_device_history_data(self): + """读取 Orion 设备号与玩家历史的映射数据。""" + orion = self.require_orion() + path = f"{orion.data_path}/{orion.config_mgr.player_data_file}" + try: + data = orion.utils.disk_read_need_exists(path) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + def _show_paginated_orion_menu( + self, + group_id: int, + qqid: int, + title: str, + matched_items: list[dict[str, object]], + page: int, + per_page: int, + select_hint: str, + search_hint: str | None, + ): + """渲染一页列表菜单,并返回用户输入与分页边界。""" + total_pages, start_index, end_index = self.orion.utils.paginate( + len(matched_items), + per_page, + page, + ) + output_lines = [ + item["display"] + for item in matched_items[start_index - 1: end_index] + ] + displayed_count = len(output_lines) + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] " + + self.orion_ui_list( + title, + output_lines, + page, + total_pages, + select_hint.format( + start_index=start_index, + end_index=end_index, + displayed_count=displayed_count, + ), + search_hint, + group_id, + ), + do_remove_cq_code=False, + ) + user_input = self.waitMsg(qqid, timeout=60, group_id=group_id) + return user_input, total_pages, start_index, end_index + + def _handle_paginated_orion_input( + self, + group_id: int, + qqid: int, + user_input: str | None, + page: int, + total_pages: int, + allow_search: bool, + search: str, + ): + """统一处理分页菜单中的翻页、退出、搜索和选择输入。""" + if user_input is None: + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 回复超时! 已退出菜单", + do_remove_cq_code=False, + ) + return "exit", None + + user_input = user_input.strip() + if self.is_menu_exit_input(user_input, group_id): + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 已退出菜单", + do_remove_cq_code=False, + ) + return "exit", None + + if user_input == "+": + if page < total_pages: + return "page", page + 1 + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 已经是最后一页啦~", + do_remove_cq_code=False, + ) + return "retry", None + + if user_input == "-": + if page > 1: + return "page", page - 1 + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 已经是第一页啦~", + do_remove_cq_code=False, + ) + return "retry", None + + if match := re.fullmatch(r"^([1-9]\d*)页$", user_input): + page_num = int(match.group(1)) + if 1 <= page_num <= total_pages: + return "page", page_num + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 不存在第 {page_num} 页!请重新输入!", + do_remove_cq_code=False, + ) + return "retry", None + + choice = utils.try_int(user_input) + if choice is not None: + return "choice", choice + + if allow_search: + return "search", user_input.replace("\\", "") + + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 您的输入有误", + do_remove_cq_code=False, + ) + return "retry", search + + def _select_paginated_orion_items( + self, + group_id: int, + qqid: int, + title: str, + build_matches, + empty_message: str, + select_hint: str, + search_hint: str | None, + allow_search: bool = True, + ): + """执行一套通用的分页选择流程。""" + search = "" + page = 1 + per_page = self.get_orion_items_per_page(group_id) + while True: + matched_items = build_matches(search) + if not matched_items: + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {empty_message}", + do_remove_cq_code=False, + ) + return None + + user_input, total_pages, start_index, end_index = ( + self._show_paginated_orion_menu( + group_id, + qqid, + title, + matched_items, + page, + per_page, + select_hint, + search_hint, + ) + ) + action, value = self._handle_paginated_orion_input( + group_id, + qqid, + user_input, + page, + total_pages, + allow_search, + search, + ) + if action == "exit": + return None + if action == "page": + page = value + continue + if action == "search": + search = value + page = 1 + continue + if action == "retry": + continue + displayed_count = end_index - start_index + 1 + if action == "choice" and value in range(1, displayed_count + 1): + return matched_items[start_index + value - 2] + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 您的输入有误", + do_remove_cq_code=False, + ) + + def qq_select_orion_xuid( + self, + group_id: int, + qqid: int, + title: str, + xuid_data: dict[str, str], + allow_search: bool = True, + ): + """从 xuid 列表里分页选择目标玩家。 + + 返回 `(xuid, 玩家名)`,失败或退出时返回 `(None, None)`。 + """ + selected = self._select_paginated_orion_items( + group_id, + qqid, + title, + lambda search: [ + { + "value": (xuid, name), + "display": ( + f"{xuid} - {name}" + f" - {self.orion_xuid_status_text(xuid)}" + ), + } + for xuid, name in xuid_data.items() + if search == "" or search in xuid or search in name + ], + "找不到您输入的 xuid 或玩家名称", + "[1-{displayed_count}] 之间的数字以选择 对应玩家", + "xuid、玩家名称或玩家部分名称 可尝试搜索", + allow_search=allow_search, + ) + if selected is None: + return None, None + return selected["value"] + + def qq_select_orion_device( + self, + group_id: int, + qqid: int, + title: str, + device_data: dict[str, dict[str, list[str]]], + ): + """从设备号列表里分页选择目标设备。""" + selected = self._select_paginated_orion_items( + group_id, + qqid, + title, + lambda search: [ + { + "value": (device_id, player_info), + "display": ( + f"{device_id} - {self.format_device_history(player_info)}" + f" - {self.orion_device_status_text(device_id)}" + ), + } + for device_id, player_info in device_data.items() + if search == "" + or search in device_id + or search in self.format_device_history(player_info) + ], + "找不到您输入的设备号或玩家名称", + "[1-{displayed_count}] 之间的数字以选择 对应设备号", + "设备号、玩家名称或玩家部分名称 可尝试搜索", + ) + if selected is None: + return None, None + return selected["value"] + + def qq_get_orion_ban_time(self, group_id: int, qqid: int): + """统一处理群里输入的封禁时长。""" + prompt = self.orion_ui_menu( + "封禁时间输入", + [], + [ + "输入 -1 表示永久封禁", + "输入 正整数 表示封禁秒数", + "输入 0年0月5日6时7分8秒 表示对应时长", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, prompt, timeout=120) + if user_input is None: + self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + if self.is_menu_exit_input(user_input, group_id): + self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + ban_time = self.orion.utils.ban_time_format( + user_input) if self.orion else 0 + if ban_time == 0: + self.reply_to_qq(group_id, qqid, "❀ 您输入的封禁时间有误") + return None + return ban_time + + def qq_get_orion_reason(self, group_id: int, qqid: int): + """获取封禁原因,允许直接回车走默认文案。""" + user_input = self.qq_prompt( + group_id, + qqid, + self.orion_ui_menu( + "封禁原因输入", + [], + [ + "请输入封禁原因", + "直接回车使用默认原因“群聊管理员封禁”", + "输入 . 退出", + ], + group_id, + ), + timeout=120, + ) + if user_input is None: + self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + if self.is_menu_exit_input(user_input, group_id): + self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + return user_input or "群聊管理员封禁" + + def _select_orion_ban_target(self, group_id: int, qqid: int, choice: str): + """根据封禁模式选择具体目标对象。""" + if choice == "1": + xuid, player_name = self.qq_select_orion_xuid( + group_id, + qqid, + "在线玩家封禁", + self.build_online_xuid_data(), + ) + if not xuid or not player_name: + return None + return { + "kind": "xuid", + "payload": (xuid, player_name), + "online_only": True, + } + if choice == "2": + xuid, player_name = self.qq_select_orion_xuid( + group_id, + qqid, + "历史玩家封禁", + self.build_historical_xuid_data(), + ) + if not xuid or not player_name: + return None + return { + "kind": "xuid", + "payload": (xuid, player_name), + "online_only": False, + } + if choice == "3": + device_id, player_info = self.qq_select_orion_device( + group_id, + qqid, + "设备号封禁", + self.build_device_history_data(), + ) + if not device_id or not player_info: + return None + return { + "kind": "device", + "payload": (device_id, player_info), + } + self.reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return None + + def _apply_selected_orion_ban( + self, + group_id: int, + qqid: int, + ban_target: dict): + """把封禁时间和封禁原因的通用交互流程复用到所有封禁模式。""" + ban_time = self.qq_get_orion_ban_time(group_id, qqid) + if ban_time is None: + return + reason = self.qq_get_orion_reason(group_id, qqid) + if reason is None: + return + + if ban_target["kind"] == "xuid": + xuid, player_name = ban_target["payload"] + ok, msg = self.apply_orion_xuid_ban( + xuid, + player_name, + ban_time, + reason, + online_only=ban_target.get("online_only", False), + ) + else: + device_id, player_info = ban_target["payload"] + ok, msg = self.apply_orion_device_ban( + device_id, + player_info, + ban_time, + reason, + ) + self.reply_result(group_id, qqid, ok, msg) + + def qq_orion_ban_menu(self, group_id: int, qqid: int): + """交互式 Orion 封禁菜单。""" + if not self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if self.orion is None: + self.reply_to_qq(group_id, qqid, "相关插件未安装:Orion_System") + return + if not self.ensure_linked_plugin_enabled(self.orion, group_id, qqid): + return + choice = self.qq_prompt( + group_id, + qqid, + self.orion_ui_menu( + "封禁管理系统", + ["根据在线玩家名称和xuid封禁", "根据历史玩家名称和xuid封禁", "根据设备号封禁"], + ["输入 [1-3] 之间的数字以选择 封禁模式", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if choice is None: + self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self.is_menu_exit_input(choice, group_id): + self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + ban_target = self._select_orion_ban_target(group_id, qqid, choice) + if ban_target is None: + return + self._apply_selected_orion_ban(group_id, qqid, ban_target) + + def qq_orion_unban_menu(self, group_id: int, qqid: int): + """交互式 Orion 解封菜单。""" + if not self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if self.orion is None: + self.reply_to_qq(group_id, qqid, "相关插件未安装:Orion_System") + return + if not self.ensure_linked_plugin_enabled(self.orion, group_id, qqid): + return + choice = self.qq_prompt( + group_id, + qqid, + self.orion_ui_menu( + "解封管理系统", + ["根据玩家名称和xuid解封", "根据设备号解封"], + ["输入 [1-2] 之间的数字以选择 解封模式", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if choice is None: + self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self.is_menu_exit_input(choice, group_id): + self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if choice == "1": + xuid_dir = f"{self.orion.data_path}/{self.orion.config_mgr.xuid_dir}" + xuid_data: dict[str, str] = {} + if os.path.isdir(xuid_dir): + for xuid_json in os.listdir(xuid_dir): + xuid = xuid_json.replace(".json", "") + try: + xuid_data[xuid] = self.orion.xuid_getter.get_name_by_xuid( + xuid, True, ) + except Exception: + xuid_data[xuid] = xuid + xuid, player_name = self.qq_select_orion_xuid( + group_id, + qqid, + "xuid 解封", + xuid_data, + allow_search=True, + ) + if not xuid or not player_name: + return + ok, msg = self.apply_orion_xuid_unban(xuid, player_name) + self.reply_result(group_id, qqid, ok, msg) + return + if choice == "2": + device_dir = f"{ + self.orion.data_path}/{ + self.orion.config_mgr.device_id_dir}" + player_data = self.build_device_history_data() + device_data: dict[str, dict[str, list[str]]] = {} + if os.path.isdir(device_dir): + for device_json in os.listdir(device_dir): + device_id = device_json.replace(".json", "") + device_data[device_id] = player_data.get(device_id, {}) + device_id, player_info = self.qq_select_orion_device( + group_id, + qqid, + "设备号解封", + device_data, + ) + if not device_id or player_info is None: + return + ok, msg = self.apply_orion_device_unban(device_id, player_info) + self.reply_result(group_id, qqid, ok, msg) + return + self.reply_to_qq(group_id, qqid, "❀ 您的输入有误") + + def resolve_orion_target(self, target: str): + """把群里的输入尽量解析成稳定的 `(xuid, 玩家名)` 组合。""" + if self.orion is None: + return None, None, "相关插件未安装:Orion_System" + if not hasattr(self.orion, "xuid_getter"): + return None, None, "Orion 插件尚未完成初始化" + + # 先按玩家名解析,失败再把输入当 xuid 处理,兼容群里两种用法。 + try: + xuid = self.orion.xuid_getter.get_xuid_by_name(target, True) + try: + player_name = self.orion.xuid_getter.get_name_by_xuid( + xuid, True) + except Exception: + player_name = target + except Exception: + xuid = target + try: + player_name = self.orion.xuid_getter.get_name_by_xuid( + xuid, True) + except Exception: + player_name = target + + if not isinstance(xuid, str) or not xuid: + return None, None, "无法解析玩家名称或 xuid" + return xuid, player_name, None + + def orion_ban_player(self, target: str, ban_time_raw: str, reason: str): + """非交互式封禁入口,给命令行式调用使用。""" + xuid, player_name, error = self.resolve_orion_target(target) + if error: + return False, error + orion = self.require_orion() + ban_time = orion.utils.ban_time_format(ban_time_raw) + return self.apply_orion_xuid_ban(xuid, player_name, ban_time, reason) + + def orion_unban_player(self, target: str): + """非交互式解封入口,供命令式调用复用。""" + xuid, player_name, error = self.resolve_orion_target(target) + if error: + return False, error + return self.apply_orion_xuid_unban(xuid, player_name) + + def apply_orion_xuid_ban( + self, + xuid: str, + player_name: str, + ban_time: int | str, + reason: str, + online_only: bool = False, + ): + """写入 xuid 封禁记录,并在需要时踢出对应在线玩家。""" + orion = self.require_orion() + # 实际封禁前会先过一遍在线状态和 Orion 自己的白名单检查。 + if online_only and player_name not in self.game_ctrl.allplayers: + return False, f"玩家 {player_name} 当前不在线" + if orion.utils.in_whitelist(player_name): + return False, f"玩家 {player_name} 位于 Orion 反制白名单内" + timestamp_now, date_now = orion.utils.now() + path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" + with orion.lock_ban_xuid: + ban_data = orion.utils.disk_read(path) + timestamp_end, date_end = orion.utils.calculate_ban_end_time( + ban_data, + ban_time, + timestamp_now, + ) + if not timestamp_end and timestamp_end is not None: + return False, f"玩家 {player_name} 已经是永久封禁" + orion.utils.disk_write( + path, + { + "xuid": xuid, + "name": player_name, + "ban_start_real_time": date_now, + "ban_start_timestamp": timestamp_now, + "ban_end_real_time": date_end, + "ban_end_timestamp": timestamp_end, + "ban_reason": reason, + }, + ) + if player_name in self.game_ctrl.allplayers: + orion.utils.kick(player_name, f"由于{reason},您被系统封禁至:{date_end}") + return True, f"已通过 Orion 封禁 {player_name} (xuid:{xuid}) 至 {date_end}" + + def apply_orion_device_ban( + self, + device_id: str, + player_info: dict[str, list[str]], + ban_time: int | str, + reason: str, + ): + """写入设备号封禁记录,并处理命中该设备号的在线玩家。""" + orion = self.require_orion() + # 设备号封禁会顺手踢掉当前在线、且命中过这个设备号的玩家。 + timestamp_now, date_now = orion.utils.now() + path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" + with orion.lock_ban_device_id: + ban_data = orion.utils.disk_read(path) + timestamp_end, date_end = orion.utils.calculate_ban_end_time( + ban_data, + ban_time, + timestamp_now, + ) + if not timestamp_end and timestamp_end is not None: + return False, f"设备号 {device_id} 已经是永久封禁" + orion.utils.disk_write( + path, + { + "device_id": device_id, + "xuid_and_player": player_info, + "ban_start_real_time": date_now, + "ban_start_timestamp": timestamp_now, + "ban_end_real_time": date_end, + "ban_end_timestamp": timestamp_end, + "ban_reason": reason, + }, + ) + for _xuid, names in player_info.items(): + kick_name = None + if isinstance(names, list): + for name in reversed(names): + if name in self.game_ctrl.allplayers: + kick_name = name + break + if kick_name: + orion.utils.kick(kick_name, f"由于{reason},您被系统封禁至:{date_end}") + return True, f"已通过 Orion 封禁设备号 {device_id} 至 {date_end}" + + def apply_orion_xuid_unban(self, xuid: str, player_name: str): + """删除 Orion 中某个 xuid 的封禁记录。""" + orion = self.require_orion() + path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" + if not os.path.exists(path): + return False, f"玩家 {player_name} 当前不在 Orion 的 xuid 封禁列表中" + os.remove(path) + return True, f"已通过 Orion 解封 {player_name} (xuid:{xuid})" + + def apply_orion_device_unban( + self, + device_id: str, + player_info: dict[str, list[str]], + ): + """删除 Orion 中某个设备号的封禁记录。""" + orion = self.require_orion() + path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" + if not os.path.exists(path): + return False, f"设备号 {device_id} 当前不在 Orion 的设备号封禁列表中" + os.remove(path) + return True, f"已通过 Orion 解封设备号 {device_id}" diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" index a5aee918..939a4676 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" @@ -1,565 +1,3893 @@ -import re -from typing import Any - -from tooldelta import utils - -try: - from tooldelta.utils.mc_translator import translate -except ImportError: - translate = None - - -# 日常群聊交互放在这一层:帮助、背包查询、管理员菜单、联动检查菜单。 -class QQLinkerQQMixin: - """负责群聊菜单、查询类功能以及白名单联动命令。""" - - @utils.thread_func("群服执行指令并获取返回") - def on_qq_execute_cmd(self, group_id: int, qqid: int, cmd: list[str]): - """在群里执行 Minecraft 指令,并把执行结果回发到群里。""" - if not self.is_group_admin(group_id, qqid): - self.sendmsg(group_id, "你没有权限执行此指令") - return - res = self.execute_cmd_and_get_zhcn_cb(" ".join(cmd)) - self.sendmsg(group_id, res) - - def _reply_to_qq(self, group_id: int, qqid: int, text: str): - """向指定群成员回复一条文本消息。""" - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {text}", - do_remove_cq_code=False, - ) - - def _reply_result(self, group_id: int, qqid: int, ok: bool, msg: str): - """统一回发成功/失败结果。""" - self._reply_to_qq(group_id, qqid, f"{'😄' if ok else '😭'} {msg}") - - def on_qq_help(self, group_id: int, sender: int, _): - """根据当前群配置和权限状态动态生成帮助菜单。""" - options: list[str] = [] - options.append( - f"{'/'.join(self.get_group_help_triggers(group_id))} - 查看群服互通帮助菜单" - ) - if self.is_group_super_admin(group_id, sender): - options.append( - f"{' / '.join(self.get_group_admin_menu_triggers(group_id))}" - " - 打开普通管理员管理菜单" - ) - options.append( - f"{self.get_group_cmd_prefix(group_id)}[指令] - 向租赁服发送指令" - + ("(本群管理员与超级管理员可用,无需额外配置 QQ 号)") - ) - if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"]: - options.append( - f"{' / '.join(self.get_group_player_list_triggers(group_id))} - 查看玩家列表" - ) - options.append( - f"{' / '.join(self.get_group_inventory_menu_triggers(group_id))} - 查询在线玩家背包" - ) - options.append( - f"{' / '.join(self.get_group_orion_ban_triggers(group_id))} - Orion QQ 封禁菜单" - ) - options.append( - f"{' / '.join(self.get_group_orion_unban_triggers(group_id))}" - " - Orion QQ 解封菜单" - ) - options.append( - f"{' / '.join(self.get_group_checker_menu_triggers(group_id))}" - " - 白名单&管理员检测云链联动版 管理菜单" - ) - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - "帮助菜单", - options, - ["输入 . 退出"], - ) - self._reply_to_qq(group_id, sender, text) - - def on_qq_player_list(self, group_id: int, _sender: int, _): - """把在线玩家列表和可用 TPS 信息发到群里。""" - group_cfg = self.group_cfgs[group_id] - if not group_cfg["指令设置"]["是否允许查看玩家列表"]: - self.sendmsg(group_id, "当前群未启用玩家列表查询") - return - players = [f"{i + 1}.{j}" for i, j in enumerate(self.game_ctrl.allplayers)] - fmt_msg = ( - f"在线玩家有 {len(players)} 人:\n " - + "\n ".join(players) - + ( - f"\n当前 TPS: {round(self.tps_calc.get_tps(), 1)}/20" - if self.tps_calc - else "" - ) - ) - self.sendmsg(group_id, fmt_msg) - - def qq_inventory_menu(self, group_id: int, qqid: int): - """分页展示在线玩家,并允许在群里进一步查询某个人的背包。""" - online_names = list(self.game_ctrl.allplayers) - if not online_names: - self._reply_to_qq(group_id, qqid, "当前没有在线玩家") - return - page = 1 - per_page = self.get_group_inventory_items_per_page(group_id) - while True: - # 这里保留一个循环式菜单,避免每次翻页都重新走一遍入口指令。 - total_pages, start_index, end_index = ( - utils.paginate(len(online_names), per_page, page) - if hasattr(utils, "paginate") - else self.simple_paginate(len(online_names), per_page, page) - ) - page_names = online_names[start_index - 1 : end_index] - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - "查询背包", - page_names, - [ - f"当前第 {page}/{total_pages} 页", - f"输入 [{start_index}-{end_index}] 之间的数字以选择 对应玩家", - "输入 - 转到上一页", - "输入 + 转到下一页", - "输入 正整数+页 转到对应页", - "输入 . 退出", - ], - ) - self.sendmsg(group_id, f"[CQ:at,qq={qqid}] {text}", do_remove_cq_code=False) - user_input = self.waitMsg(qqid, timeout=120, group_id=group_id) - if user_input is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - user_input = user_input.strip() - if user_input.lower() in ("q", ".", "。"): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if user_input == "+": - # 菜单保持在同一条交互链里,翻页只改当前页码。 - if page < total_pages: - page += 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") - continue - if user_input == "-": - if page > 1: - page -= 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") - continue - if match := re.fullmatch(r"^([1-9]\d*)页$", user_input): - page_num = int(match.group(1)) - if 1 <= page_num <= total_pages: - page = page_num - else: - self._reply_to_qq( - group_id, - qqid, - f"❀ 不存在第 {page_num} 页!请重新输入!", - ) - continue - choice = utils.try_int(user_input) - if choice is None or choice not in range(start_index, end_index + 1): - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - continue - self.show_player_inventory(group_id, qqid, online_names[choice - 1]) - return - - @staticmethod - def simple_paginate(total_len: int, per_page: int, page: int): - """在缺少 utils.paginate 时使用的本地分页兜底实现。""" - total_pages = max(1, (total_len + per_page - 1) // per_page) - page = max(1, min(page, total_pages)) - start_index = (page - 1) * per_page + 1 - end_index = min(page * per_page, total_len) - return total_pages, start_index, end_index - - def show_player_inventory(self, group_id: int, qqid: int, player_name: str): - """把背包槽位整理成适合群聊阅读的文本。""" - player_obj = self.game_ctrl.players.getPlayerByName(player_name) - if player_obj is None: - self._reply_to_qq(group_id, qqid, f"玩家 {player_name} 当前已不在线") - return - try: - inventory = player_obj.queryInventory() - except Exception as err: - self._reply_to_qq(group_id, qqid, f"查询背包失败: {err}") - return - items: list[str] = [] - for idx, slot in enumerate(inventory.slots): - if slot is None: - continue - # 这里把原始槽位信息尽量整理成适合群内阅读的文本。 - item_id = getattr(slot, "id", "未知ID") - display_name = self.translate_item_name(item_id) - stack_size = getattr(slot, "stackSize", 0) - aux = getattr(slot, "aux", 0) - line = f"槽位 {idx}: {display_name} x{stack_size}" - if display_name != item_id: - line += f" ({item_id})" - if aux not in (None, -1, 0): - line += f" (数据值:{aux})" - custom_name = self.get_item_custom_name(slot) - if custom_name: - line += f" (命名:{custom_name})" - ench_text = self.get_item_enchantments_text(slot) - if ench_text: - line += f" (附魔:{ench_text})" - items.append(line) - if not items: - items = ["该玩家背包为空"] - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - f"背包查询 - {player_name}", - items, - ["输入 . 退出"], - ) - self.sendmsg(group_id, f"[CQ:at,qq={qqid}] {text}", do_remove_cq_code=False) - - @staticmethod - def translate_item_name(item_id: str): - """尽量把物品 ID 翻译成中文显示名,失败时退回原始 ID。""" - if not isinstance(item_id, str) or item_id == "": - return "未知物品" - item_tail = item_id.split(":")[-1] - if translate is None: - return item_id - for key in (f"item.{item_tail}.name", f"tile.{item_tail}.name"): - try: - translated = translate(key) - except Exception: - continue - if isinstance(translated, str) and translated and translated != key: - return translated - return item_id - - @staticmethod - def get_item_custom_name(slot: Any): - """尝试从物品槽位对象里提取自定义名称。""" - for attr in ("customName", "custom_name", "name"): - value = getattr(slot, attr, None) - if ( - isinstance(value, str) - and value.strip() - and value.strip() != getattr(slot, "id", "") - ): - return value.strip() - return "" - - @staticmethod - def get_item_enchantments_text(slot: Any): - """把槽位上的附魔信息整理成单行文字。""" - enchants = getattr(slot, "enchantments", None) - if not isinstance(enchants, list) or not enchants: - return "" - outputs: list[str] = [] - for enchant in enchants: - if enchant is None: - continue - name = getattr(enchant, "name", None) - level = getattr(enchant, "level", None) - if isinstance(name, str) and name.strip(): - if isinstance(level, int): - outputs.append(f"{name.strip()} {level}") - else: - outputs.append(name.strip()) - else: - etype = getattr(enchant, "type", None) - if etype is not None: - if isinstance(level, int): - outputs.append(f"ID{etype} {level}") - else: - outputs.append(f"ID{etype}") - return "、".join(outputs) - - def on_qq_add_admin(self, group_id: int, sender: int, args: list[str]): - """给当前群添加普通管理员。""" - if not self.is_group_super_admin(group_id, sender): - self._reply_to_qq(group_id, sender, "只有本群超级管理员可以添加管理员") - return - try: - qqid = int(args[0]) - except (TypeError, ValueError): - self._reply_to_qq(group_id, sender, "QQ号格式有误") - return - _ok, msg = self.add_group_role(group_id, qqid, is_super=False) - self._reply_to_qq(group_id, sender, msg) - - def qq_admin_menu(self, group_id: int, qqid: int): - """给群超级管理员使用的普通管理员管理菜单。""" - if not self.is_group_super_admin(group_id, qqid): - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] 只有本群超级管理员可以打开管理员菜单", - do_remove_cq_code=False, - ) - return - # 普通管理员的增删统一走一个小菜单,避免群里散落多条半交互命令。 - choice = self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - "普通管理员 管理菜单", - ["添加普通管理员", "删除普通管理员"], - ["输入 [1-2] 之间的数字以选择 对应功能", "输入 . 退出"], - ), - timeout=120, - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if choice.lower() in ("q", ".", "。"): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if choice not in ("1", "2"): - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - qq_text = self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - "普通管理员 管理菜单", - [], - [f"请输入要{'添加' if choice == '1' else '删除'}的 QQ 号", "输入 . 退出"], - ), - timeout=120, - ) - if qq_text is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if qq_text.lower() in ("q", ".", "。"): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - qqid_target = utils.try_int(qq_text) - if qqid_target is None or qqid_target <= 0: - self._reply_to_qq(group_id, qqid, "❀ QQ号格式有误") - return - if choice == "1": - ok, msg = self.add_group_role(group_id, qqid_target, is_super=False) - else: - ok, msg = self.remove_group_role(group_id, qqid_target, is_super=False) - self._reply_result(group_id, qqid, ok, msg) - - def ensure_whitelist_checker(self, group_id: int, sender: int): - """检查白名单联动插件是否可用,避免菜单点进去后才报空引用。""" - if self.whitelist_checker is None: - self._reply_to_qq(group_id, sender, "未检测到插件 白名单&管理员检测云链联动版") - return False - return True - - def on_qq_whitelist_add(self, group_id: int, sender: int, args: list[str]): - """通过群命令把玩家加入白名单。""" - if not self.ensure_whitelist_checker(group_id, sender): - return - ok, msg = self.whitelist_checker.add_whitelist_player(args[0]) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_whitelist_remove(self, group_id: int, sender: int, args: list[str]): - """通过群命令把玩家移出白名单。""" - if not self.ensure_whitelist_checker(group_id, sender): - return - ok, msg = self.whitelist_checker.remove_whitelist_player(args[0]) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_server_admin_add(self, group_id: int, sender: int, args: list[str]): - """通过群命令把玩家登记为服务器管理员。""" - if not self.ensure_whitelist_checker(group_id, sender): - return - ok, msg = self.whitelist_checker.add_admin_player(args[0]) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_server_admin_remove(self, group_id: int, sender: int, args: list[str]): - """通过群命令把玩家从服务器管理员名单中移除。""" - if not self.ensure_whitelist_checker(group_id, sender): - return - ok, msg = self.whitelist_checker.remove_admin_player(args[0]) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_whitelist_toggle(self, group_id: int, sender: int, args: list[str]): - """通过群命令切换白名单检测开关。""" - if not self.ensure_whitelist_checker(group_id, sender): - return - action = args[0].strip() - if action not in ("开启", "关闭", "on", "off"): - self._reply_to_qq(group_id, sender, "参数错误,格式:白名单检测 [开启/关闭]") - return - ok, msg = self.whitelist_checker.set_whitelist_enabled(action in ("开启", "on")) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_admin_check_toggle(self, group_id: int, sender: int, args: list[str]): - """通过群命令切换管理员检测开关。""" - if not self.ensure_whitelist_checker(group_id, sender): - return - action = args[0].strip() - if action not in ("开启", "关闭", "on", "off"): - self._reply_to_qq(group_id, sender, "参数错误,格式:管理员检测 [开启/关闭]") - return - ok, msg = self.whitelist_checker.set_admin_check_enabled(action in ("开启", "on")) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_check_interval(self, group_id: int, sender: int, args: list[str]): - """通过群命令修改联动插件的轮询周期。""" - if not self.ensure_whitelist_checker(group_id, sender): - return - try: - seconds = float(args[0]) - except (TypeError, ValueError): - self._reply_to_qq(group_id, sender, "参数错误,格式:检测周期 [秒数]") - return - ok, msg = self.whitelist_checker.set_check_interval(seconds) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_check_status(self, group_id: int, sender: int, _args: list[str]): - """把联动插件当前状态摘要发回群里。""" - if not self.ensure_whitelist_checker(group_id, sender): - return - status = self.whitelist_checker.get_runtime_status() - output = ( - f"[CQ:at,qq={sender}] 白名单&管理员检测云链联动版状态:\n" - f"检测周期:{status['check_interval']} 秒\n" - f"白名单检测:{'开启' if status['whitelist_enabled'] else '关闭'}\n" - f"白名单人数:{status['whitelist_count']}\n" - f"管理员检测:{'开启' if status['admin_check_enabled'] else '关闭'}\n" - f"管理员人数:{status['admin_count']}" - ) - self.sendmsg(group_id, output, do_remove_cq_code=False) - - def _qq_checker_prompt( - self, - group_id: int, - qqid: int, - subtitle: str, - options: list[str], - hints: list[str], - ): - """统一构造白名单联动菜单提示并等待回复。""" - return self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "白名单&管理员检测云链联动版", - subtitle, - options, - hints, - ), - timeout=120, - ) - - def _qq_checker_handle_player_action(self, group_id: int, qqid: int, choice: str): - """处理添加/移除白名单与服务器管理员这四类玩家操作。""" - title_map = { - "1": "白名单 添加玩家", - "2": "白名单 移除玩家", - "3": "管理员 添加玩家", - "4": "管理员 移除玩家", - } - handler_map = { - "1": self.on_qq_whitelist_add, - "2": self.on_qq_whitelist_remove, - "3": self.on_qq_server_admin_add, - "4": self.on_qq_server_admin_remove, - } - player_name = self._qq_checker_prompt( - group_id, - qqid, - title_map[choice], - [], - ["请输入玩家名称", "输入 . 退出"], - ) - if player_name is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if player_name.lower() in ("q", ".", "。"): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - handler_map[choice](group_id, qqid, [player_name]) - - def _qq_checker_handle_toggle_action(self, group_id: int, qqid: int, choice: str): - """处理白名单检测和管理员检测的开关菜单。""" - subtitle = "白名单检测 设置" if choice == "5" else "管理员检测 设置" - handler = ( - self.on_qq_whitelist_toggle - if choice == "5" - else self.on_qq_admin_check_toggle - ) - action = self._qq_checker_prompt( - group_id, - qqid, - subtitle, - ["开启", "关闭"], - ["输入 [1-2] 之间的数字以选择 对应操作", "输入 . 退出"], - ) - if action is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if action.lower() in ("q", ".", "。"): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - action_arg = {"1": ["开启"], "2": ["关闭"]}.get(action) - if action_arg is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - handler(group_id, qqid, action_arg) - - def _qq_checker_handle_interval_action(self, group_id: int, qqid: int): - """处理检测周期设置菜单。""" - seconds = self._qq_checker_prompt( - group_id, - qqid, - "检测周期 设置", - [], - ["请输入检测周期秒数", "输入 . 退出"], - ) - if seconds is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if seconds.lower() in ("q", ".", "。"): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - self.on_qq_check_interval(group_id, qqid, [seconds]) - - def qq_checker_menu(self, group_id: int, qqid: int): - """在群里打开白名单与管理员检测联动菜单。""" - if not self.ensure_whitelist_checker(group_id, qqid): - return - # 这个菜单本质上是把白名单插件暴露出来的 API 做了一层群聊版操作面板。 - # 这样权限仍然统一归群服互通管理,而不是把原插件的控制台能力原样暴露出来。 - choice = self._qq_checker_prompt( +"""QQ group menu and command handlers for Ultra.""" + +import re +import time +from typing import Any + +from tooldelta import utils + +try: + from tooldelta.utils.mc_translator import translate +except ImportError: + translate = None + + +# 日常群聊交互放在这一层:帮助、背包查询、管理员菜单、联动检查菜单。 +class QQLinkerQQMixin: + """负责群聊菜单、查询类功能以及白名单联动命令。""" + + @utils.thread_func("群服执行指令并获取返回") + def on_qq_execute_cmd(self, group_id: int, qqid: int, cmd: list[str]): + """在群里执行 Minecraft 指令,并把执行结果回发到群里。""" + if not self._can_use_group_permission(group_id, qqid, "发送指令权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + res = self.execute_cmd_and_get_zhcn_cb(" ".join(cmd)) + self.sendmsg(group_id, res) + + def _reply_to_qq(self, group_id: int, qqid: int, text: str): + """向指定群成员回复一条文本消息。""" + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False, + ) + + def _reply_result(self, group_id: int, qqid: int, ok: bool, msg: str): + """统一回发成功/失败结果。""" + self._reply_to_qq(group_id, qqid, f"{'😄' if ok else '😭'} {msg}") + + def _can_use_group_permission( + self, + group_id: int, + qqid: int, + permission_name: str, + ) -> bool: + if hasattr(self, "_has_group_permission"): + return self._has_group_permission(group_id, qqid, permission_name) + return self.has_group_permission(group_id, qqid, permission_name) + + def _can_use_any_group_permission( + self, + group_id: int, + qqid: int, + permission_names: tuple[str, ...], + ) -> bool: + return any( + self._can_use_group_permission(group_id, qqid, permission_name) + for permission_name in permission_names + ) + + def _reply_menu_permission_denied(self, group_id: int, qqid: int): + if hasattr(self, "_reply_permission_denied"): + self._reply_permission_denied(group_id, qqid) + return + self._reply_to_qq(group_id, qqid, "你没有权限执行此指令") + + def _ensure_group_permission( + self, + group_id: int, + qqid: int, + permission_name: str, + ) -> bool: + if self._can_use_group_permission(group_id, qqid, permission_name): + return True + self._reply_menu_permission_denied(group_id, qqid) + return False + + def _append_permission_action( + self, + group_id: int, + qqid: int, + options: list[str], + actions: list[Any], + permission_name: str, + label: str, + action, + ): + if self._can_use_group_permission(group_id, qqid, permission_name): + options.append(label) + actions.append(action) + + def _has_help_admin_actions(self, group_id: int, qqid: int) -> bool: + permission_names = [ + "QQ普通管理员菜单权限", + "QQ超级管理员菜单权限", + "发送指令权限", + "配置配置文件权限", + "查询背包权限", + "封禁/解封玩家权限", + "白名单&管理员检测权限", + "领地系统权限", + "公会系统权限", + ] + if self.task_system is not None: + permission_names.append("任务系统权限") + return self._can_use_any_group_permission( + group_id, + qqid, + tuple(permission_names), + ) + + @staticmethod + def _extract_plugin_enabled_flag(plugin: Any): + """读取联动插件明确暴露的整体启用状态;缺少该项时返回 None。""" + for method_name in ("_plugin_enabled", "is_plugin_enabled"): + method = getattr(plugin, method_name, None) + if callable(method): + try: + return bool(method()) + except Exception: + return None + + enabled = getattr(plugin, "enabled", None) + if isinstance(enabled, bool): + return enabled + + for method_name in ("api_get_runtime_status", "get_runtime_status"): + method = getattr(plugin, method_name, None) + if not callable(method): + continue + try: + status = method() + except Exception: + continue + if isinstance(status, tuple) and len(status) >= 3: + status = status[2] + if isinstance(status, dict): + for key in ("enabled", "是否启用", "是否启用插件"): + if isinstance(status.get(key), bool): + return status[key] + + for attr_name in ("config", "cfg", "_cfg"): + config = getattr(plugin, attr_name, None) + if not isinstance(config, dict): + continue + for key in ("是否启用", "是否启用插件"): + if isinstance(config.get(key), bool): + return config[key] + base_config = config.get("基础配置") + if isinstance(base_config, dict): + for key in ("是否启用", "是否启用插件"): + if isinstance(base_config.get(key), bool): + return base_config[key] + + return None + + def ensure_linked_plugin_enabled( + self, + plugin: Any, + group_id: int, + sender: int) -> bool: + """联动插件明确配置为禁用时,不进入群内管理菜单。""" + enabled = self._extract_plugin_enabled_flag(plugin) + if not enabled and enabled is not None: + self._reply_to_qq(group_id, sender, "相关插件未启用") + return False + return True + + def on_qq_help(self, group_id: int, sender: int, _): + """打开可交互的群帮助主菜单。""" + self.qq_help_main_menu(group_id, sender) + + def _is_menu_exit(self, user_input: str, group_id: int | None = None): + return self.is_menu_exit_input(user_input, group_id) + + def _is_menu_back(self, user_input: str, group_id: int | None = None): + return self.is_menu_back_input(user_input, group_id) + + def _prompt_help_menu( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + hints: list[str], + allow_back: bool = False, + ): + choice = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + subtitle, + options, + hints, + group_id), + timeout=120, + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + choice = choice.strip() + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if allow_back and self._is_menu_back(choice, group_id): + return "back" + return choice + + def _parse_help_choice( + self, + group_id: int, + qqid: int, + choice: str, + count: int): + selected = self.parse_displayed_menu_choice(choice, count) + if selected is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return selected + + def _prompt_paginated_help_actions( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + actions: list[Any], + per_page: int, + ): + if len(options) != len(actions): + self._reply_to_qq(group_id, qqid, "❀ 菜单配置错误") + return None + if not options: + self._reply_to_qq(group_id, qqid, "当前没有可用功能") + return None + page = 1 + while True: + total_pages, start_index, end_index = ( + utils.paginate(len(options), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(options), per_page, page) + ) + page_options = options[start_index - 1: end_index] + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + subtitle, + page_options, + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(page_options)}] 之间的数字以选择 对应功能", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 0 返回上级菜单", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + user_input = user_input.strip() + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if self._is_menu_back(user_input, group_id): + return "back" + if user_input == "+": + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") + continue + selected = self._parse_help_choice( + group_id, + qqid, + user_input, + len(page_options), + ) + if selected is None: + continue + result = actions[start_index + selected - 2]() + return result if result == "back" else None + + def qq_help_main_menu(self, group_id: int, qqid: int): + """群帮助主菜单,负责路由到各个二级菜单。""" + options = ["非管理功能"] + handlers = [self.qq_help_basic_menu] + if self._has_help_admin_actions(group_id, qqid): + options.append("管理功能") + handlers.append(self.qq_help_admin_menu) + options.append("命令说明") + handlers.append(self.qq_help_show_all_reference) + while True: + choice = self._prompt_help_menu( + group_id, + qqid, + "帮助主菜单", + options, + [f"输入 [1-{len(options)}] 之间的数字以选择 对应菜单", "输入 . 退出"], + ) + if choice is None: + return + selected = self._parse_help_choice( + group_id, qqid, choice, len(options)) + if selected is None: + continue + if handlers[selected - 1](group_id, qqid) == "back": + continue + return + + def qq_help_basic_menu(self, group_id: int, qqid: int): + """非管理功能二级菜单。""" + options = [] + actions = [] + if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"] and ( + self._can_use_group_permission(group_id, qqid, "查看玩家人数权限") + ): + options.append("查看在线玩家列表") + actions.append(lambda: self.on_qq_player_list(group_id, qqid, [])) + options.append("公会系统菜单") + actions.append(lambda: self.qq_guild_player_menu(group_id, qqid)) + options.append("查看非管理功能触发词") + actions.append( + lambda: self.qq_help_show_basic_reference( + group_id, qqid)) + return self._prompt_paginated_help_actions( + group_id, + qqid, + "非管理功能", + options, + actions, + self.get_group_help_non_admin_items_per_page(group_id), + ) + + def qq_help_admin_menu(self, group_id: int, qqid: int): + """管理功能二级菜单。""" + options = [] + actions = [] + if self._can_use_any_group_permission( + group_id, + qqid, + ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), + ): + options.append("QQ群管理员管理菜单") + actions.append(lambda: self.qq_admin_menu(group_id, qqid)) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "发送指令权限", + "发送 Minecraft 指令", + lambda: self.qq_help_execute_command_prompt(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "配置配置文件权限", + "配置中心", + lambda: self.qq_config_center_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "查询背包权限", + "查询在线玩家背包", + lambda: self.qq_inventory_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "封禁/解封玩家权限", + "Orion QQ 封禁菜单", + lambda: self.qq_orion_ban_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "封禁/解封玩家权限", + "Orion QQ 解封菜单", + lambda: self.qq_orion_unban_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "白名单&管理员检测权限", + "白名单&管理员检测管理菜单", + lambda: self.qq_checker_menu(group_id, qqid), + ) + if self.task_system is not None: + self._append_permission_action( + group_id, + qqid, + options, + actions, + "任务系统权限", + "任务系统管理菜单", + lambda: self.qq_task_system_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "领地系统权限", + "领地系统云链联动版管理菜单", + lambda: self.qq_land_system_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "公会系统权限", + "公会系统管理菜单", + lambda: self.qq_guild_system_menu(group_id, qqid), + ) + options.append("查看管理功能触发词") + actions.append( + lambda: self.qq_help_show_admin_reference( + group_id, qqid)) + return self._prompt_paginated_help_actions( + group_id, + qqid, + "管理功能", + options, + actions, + self.get_group_help_admin_items_per_page(group_id), + ) + + def qq_help_integration_menu(self, group_id: int, qqid: int): + """联动系统二级菜单。""" + while True: + options = [] + actions = [] + self._append_permission_action( + group_id, + qqid, + options, + actions, + "封禁/解封玩家权限", + "Orion QQ 封禁菜单", + lambda: self.qq_orion_ban_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "封禁/解封玩家权限", + "Orion QQ 解封菜单", + lambda: self.qq_orion_unban_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "白名单&管理员检测权限", + "白名单&管理员检测管理菜单", + lambda: self.qq_checker_menu(group_id, qqid), + ) + if self.task_system is not None: + self._append_permission_action( + group_id, + qqid, + options, + actions, + "任务系统权限", + "任务系统管理菜单", + lambda: self.qq_task_system_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "领地系统权限", + "领地系统云链联动版管理菜单", + lambda: self.qq_land_system_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "公会系统权限", + "公会系统管理菜单", + lambda: self.qq_guild_system_menu(group_id, qqid), + ) + if not options: + self._reply_to_qq(group_id, qqid, "当前没有可用功能") + return None + choice = self._prompt_help_menu( + group_id, + qqid, + "联动系统", + options, + [ + f"输入 [1-{len(options)}] 之间的数字以选择 对应功能", + "输入 0 返回上级菜单", + "输入 . 退出", + ], + allow_back=True, + ) + if choice is None: + return None + if choice == "back": + return "back" + selected = self._parse_help_choice( + group_id, qqid, choice, len(options)) + if selected is None: + continue + actions[selected - 1]() + return None + + def qq_help_show_all_reference(self, group_id: int, qqid: int): + """直接分页展示本群当前可用的全部命令触发词。""" + lines = self.qq_help_build_all_reference_lines(group_id, qqid) + if not lines: + self._reply_to_qq(group_id, qqid, "当前没有可展示的命令触发词") + return None + page = 1 + per_page = self.get_group_command_help_items_per_page(group_id) + while True: + total_pages, start_index, end_index = ( + utils.paginate(len(lines), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(lines), per_page, page) + ) + page_lines = lines[start_index - 1: end_index] + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + "命令说明", + page_lines, + [ + f"当前第 {page}/{total_pages} 页", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 0 返回上级菜单", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if self._is_menu_back(user_input, group_id): + return "back" + if user_input == "+": + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") + continue + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + + def qq_help_build_all_reference_lines(self, group_id: int, qqid: int): + """按运行时触发分发入口整理全部命令触发词说明。""" + cmd_prefix = self.get_group_cmd_prefix(group_id) + lines = [ + f"{' / '.join(self.get_group_help_triggers(group_id))} - 打开帮助菜单", + ] + if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"] and ( + self._can_use_group_permission(group_id, qqid, "查看玩家人数权限") + ): + lines.append( + f"{' / '.join(self.get_group_player_list_triggers(group_id))} - 查看玩家列表" + ) + lines.append( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统菜单(普通成员需绑定游戏账号;管理员进入管理菜单)" + ) + if self._can_use_group_permission(group_id, qqid, "查询背包权限"): + lines.append( + f"{' / '.join(self.get_group_inventory_menu_triggers(group_id))} - 查询在线玩家背包" + ) + if self._can_use_group_permission(group_id, qqid, "发送指令权限"): + lines.append(f"{cmd_prefix}[指令] - 向租赁服发送指令") + if self._can_use_any_group_permission( + group_id, + qqid, + ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), + ): + lines.append( + f"{' / '.join(self.get_group_admin_menu_triggers(group_id))} - QQ群管理员管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "配置配置文件权限"): + lines.append( + f"{' / '.join(self.get_group_config_menu_triggers(group_id))} - 配置中心" + ) + if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + lines.extend( + [ + ( + f"{' / '.join(self.get_group_orion_ban_triggers(group_id))} " + "[玩家名/xuid] [封禁时间] [原因可选] - Orion QQ 封禁" + ), + ( + f"{' / '.join(self.get_group_orion_unban_triggers(group_id))} " + "[玩家名/xuid] - Orion QQ 解封" + ), + ] + ) + if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): + lines.append( + f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - 白名单&管理员检测管理菜单" + ) + if self.task_system is not None and self._can_use_group_permission( + group_id, + qqid, + "任务系统权限", + ): + lines.append( + f"{' / '.join(self.get_group_task_menu_triggers(group_id))} - 任务系统管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "领地系统权限"): + lines.append( + f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - 领地系统云链联动版管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "公会系统权限"): + lines.append( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统管理菜单(管理员)" + ) + for trigger in self.triggers: + if trigger.op_only and not self.is_group_admin(group_id, qqid): + continue + trigger_text = " / ".join(trigger.triggers) + argument_hint = f" { + trigger.argument_hint}" if trigger.argument_hint else "" + permission_hint = "(管理员)" if trigger.op_only else "" + lines.append( + f"外部|{trigger_text}{argument_hint} - {trigger.usage}{permission_hint}" + ) + return lines + + def qq_help_reference_menu(self, group_id: int, qqid: int): + """命令说明二级菜单。""" + while True: + options = ["非管理功能触发词"] + actions = [ + lambda: self.qq_help_show_basic_reference( + group_id, qqid)] + if self._has_help_admin_actions(group_id, qqid): + options.append("管理功能触发词") + actions.append( + lambda: self.qq_help_show_admin_reference(group_id, qqid)) + choice = self._prompt_help_menu( + group_id, + qqid, + "命令说明", + options, + [ + f"输入 [1-{len(options)}] 之间的数字以查看 对应说明", + "输入 0 返回上级菜单", + "输入 . 退出", + ], + allow_back=True, + ) + if choice is None: + return None + if choice == "back": + return "back" + selected = self._parse_help_choice( + group_id, qqid, choice, len(options)) + if selected is None: + continue + actions[selected - 1]() + return None + + def qq_help_execute_command_prompt(self, group_id: int, qqid: int): + """通过帮助菜单交互式发送一条 Minecraft 指令。""" + if not self._can_use_group_permission(group_id, qqid, "发送指令权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + cmd_prefix = self.get_group_cmd_prefix(group_id) + command = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "发送 Minecraft 指令", + ["在下一条消息中输入要发送到租赁服的指令"], + [f"可以省略或保留前缀 {cmd_prefix}", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if command is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + command = command.strip() + if self._is_menu_exit(command, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if command.startswith(cmd_prefix): + command = command.removeprefix(cmd_prefix).strip() + if not command: + self._reply_to_qq(group_id, qqid, f"参数错误,格式:{cmd_prefix}[指令]") + return + self.on_qq_execute_cmd(group_id, qqid, command.split()) + + def qq_help_show_basic_reference(self, group_id: int, qqid: int): + options = [ + f"{' / '.join(self.get_group_help_triggers(group_id))} - 打开帮助菜单", + ] + if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"] and ( + self._can_use_group_permission(group_id, qqid, "查看玩家人数权限") + ): + options.append( + f"{' / '.join(self.get_group_player_list_triggers(group_id))} - 查看玩家列表" + ) + options.append( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统菜单(需绑定游戏账号)" + ) + self._reply_to_qq( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "非管理功能触发词", + options, + ["输入帮助菜单唤醒词可重新打开菜单"], + group_id, + ), + ) + + def qq_help_show_admin_reference(self, group_id: int, qqid: int): + cmd_prefix = self.get_group_cmd_prefix(group_id) + options = [] + if self._can_use_group_permission(group_id, qqid, "发送指令权限"): + options.append(f"{cmd_prefix}[指令] - 向租赁服发送指令") + if self._can_use_any_group_permission( + group_id, + qqid, + ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), + ): + options.append( + f"{' / '.join(self.get_group_admin_menu_triggers(group_id))} - QQ群管理员管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "配置配置文件权限"): + options.append( + f"{' / '.join(self.get_group_config_menu_triggers(group_id))} - 配置中心" + ) + if self._can_use_group_permission(group_id, qqid, "查询背包权限"): + options.append( + f"{' / '.join(self.get_group_inventory_menu_triggers(group_id))} - 查询在线玩家背包" + ) + if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + options.extend( + [ + f"{' / '.join(self.get_group_orion_ban_triggers(group_id))} - Orion QQ 封禁菜单", + f"{' / '.join(self.get_group_orion_unban_triggers(group_id))} - Orion QQ 解封菜单", + ] + ) + if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): + options.append( + f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - 白名单&管理员检测管理菜单" + ) + if self.task_system is not None and self._can_use_group_permission( + group_id, + qqid, + "任务系统权限", + ): + options.append( + f"{' / '.join(self.get_group_task_menu_triggers(group_id))} - 任务系统管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "领地系统权限"): + options.append( + f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - 领地系统云链联动版管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "公会系统权限"): + options.append( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统管理菜单(管理员)" + ) + if not options: + self._reply_to_qq(group_id, qqid, "当前没有可展示的管理功能触发词") + return + self._reply_to_qq( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "管理功能触发词", + options, + ["这些功能需要本群所有者、超级管理员或普通管理员权限"], + group_id, + ), + ) + + def qq_help_show_integration_reference(self, group_id: int, qqid: int): + options = [] + if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + options.extend( + [ + f"{' / '.join(self.get_group_orion_ban_triggers(group_id))} - Orion QQ 封禁菜单", + f"{' / '.join(self.get_group_orion_unban_triggers(group_id))} - Orion QQ 解封菜单", + ] + ) + if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): + options.append( + f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - 白名单&管理员检测管理菜单" + ) + if self.task_system is not None and self._can_use_group_permission( + group_id, + qqid, + "任务系统权限", + ): + options.append( + f"{' / '.join(self.get_group_task_menu_triggers(group_id))} - 任务系统管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "领地系统权限"): + options.append( + f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - 领地系统云链联动版管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "公会系统权限"): + options.append( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统菜单(普通成员需绑定;管理员进入管理菜单)" + ) + if not options: + self._reply_to_qq(group_id, qqid, "当前没有可展示的联动系统触发词") + return + self._reply_to_qq( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "联动系统触发词", + options, + ["公会系统普通菜单需要 QQ 绑定游戏账号,管理菜单需要本群管理员权限"], + group_id, + ), + ) + + def on_qq_player_list(self, group_id: int, _sender: int, _): + """把在线玩家列表和可用 TPS 信息发到群里。""" + if not self._can_use_group_permission(group_id, _sender, "查看玩家人数权限"): + self._reply_menu_permission_denied(group_id, _sender) + return + group_cfg = self.group_cfgs[group_id] + if not group_cfg["指令设置"]["是否允许查看玩家列表"]: + self.sendmsg(group_id, "当前群未启用玩家列表查询") + return + players = [f"{i + 1}.{j}" for i, + j in enumerate(self.game_ctrl.allplayers)] + fmt_msg = ( + f"在线玩家有 {len(players)} 人:\n " + + "\n ".join(players) + + ( + f"\n当前 TPS: {round(self.tps_calc.get_tps(), 1)}/20" + if self.tps_calc + else "" + ) + ) + self.sendmsg(group_id, fmt_msg) + + def qq_inventory_menu(self, group_id: int, qqid: int): + """分页展示在线玩家,并允许在群里进一步查询某个人的背包。""" + if not self._can_use_group_permission(group_id, qqid, "查询背包权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + online_names = list(self.game_ctrl.allplayers) + if not online_names: + self._reply_to_qq(group_id, qqid, "当前没有在线玩家") + return + page = 1 + per_page = self.get_group_inventory_items_per_page(group_id) + while True: + # 这里保留一个循环式菜单,避免每次翻页都重新走一遍入口指令。 + total_pages, start_index, end_index = ( + utils.paginate(len(online_names), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(online_names), per_page, page) + ) + page_names = online_names[start_index - 1: end_index] + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + "查询背包", + page_names, + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(page_names)}] 之间的数字以选择 对应玩家", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 . 退出", + ], + group_id, + ) + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False) + user_input = self.waitMsg(qqid, timeout=120, group_id=group_id) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + user_input = user_input.strip() + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if user_input == "+": + # 菜单保持在同一条交互链里,翻页只改当前页码。 + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq( + group_id, + qqid, + f"❀ 不存在第 {page_num} 页!请重新输入!", + ) + continue + choice = self.parse_displayed_menu_choice( + user_input, len(page_names)) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + continue + self.show_player_inventory(group_id, qqid, page_names[choice - 1]) + return + + @staticmethod + def simple_paginate(total_len: int, per_page: int, page: int): + """在缺少 utils.paginate 时使用的本地分页兜底实现。""" + total_pages = max(1, (total_len + per_page - 1) // per_page) + page = max(1, min(page, total_pages)) + start_index = (page - 1) * per_page + 1 + end_index = min(page * per_page, total_len) + return total_pages, start_index, end_index + + @staticmethod + def parse_displayed_menu_choice(user_input: str, displayed_count: int): + """Parse a choice using the numbers shown on the current menu page.""" + choice = utils.try_int(user_input.strip().strip("[]")) + if choice is None or choice not in range(1, displayed_count + 1): + return None + return choice + + @staticmethod + def parse_page_jump(user_input: str): + """Parse page jumps only when the input explicitly ends with a page suffix.""" + text = user_input.strip() + if len(text) < 2 or text[-1] not in ("页", "頁", "椤"): + return None + return utils.try_int(text[:-1]) + + def show_player_inventory( + self, + group_id: int, + qqid: int, + player_name: str): + """把背包槽位整理成适合群聊阅读的文本。""" + player_obj = self.game_ctrl.players.getPlayerByName(player_name) + if player_obj is None: + self._reply_to_qq(group_id, qqid, f"玩家 {player_name} 当前已不在线") + return + try: + inventory = player_obj.queryInventory() + except Exception as err: + self._reply_to_qq(group_id, qqid, f"查询背包失败: {err}") + return + items: list[str] = [] + for idx, slot in enumerate(inventory.slots): + if slot is None: + continue + # 这里把原始槽位信息尽量整理成适合群内阅读的文本。 + item_id = getattr(slot, "id", "未知ID") + display_name = self.translate_item_name(item_id) + stack_size = getattr(slot, "stackSize", 0) + aux = getattr(slot, "aux", 0) + line = f"槽位 {idx}: {display_name} x{stack_size}" + if display_name != item_id: + line += f" ({item_id})" + if aux not in (None, -1, 0): + line += f" (数据值:{aux})" + custom_name = self.get_item_custom_name(slot) + if custom_name: + line += f" (命名:{custom_name})" + ench_text = self.get_item_enchantments_text(slot) + if ench_text: + line += f" (附魔:{ench_text})" + items.append(line) + if not items: + items = ["该玩家背包为空"] + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"背包查询 - {player_name}", + items, + ["输入 . 退出"], + group_id, + ) + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False) + + def ensure_task_system(self, group_id: int, sender: int): + if self.task_system is None: + self._reply_to_qq(group_id, sender, "相关插件未安装:任务系统云链联动版") + return False + return self.ensure_linked_plugin_enabled( + self.task_system, group_id, sender) + + @staticmethod + def _format_task_time(timestamp: int): + try: + return time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(timestamp)) + except Exception: + return str(timestamp) + + @staticmethod + def _format_task_label(task_info: dict[str, Any]): + show_name = str(task_info.get("show_name", "")).strip() + tag_name = str(task_info.get("tag_name", "")).strip() + if not show_name or show_name == tag_name: + return tag_name or show_name or "<未知任务>" + return f"{show_name} ({tag_name})" + + def _format_task_menu_item(self, task_info: dict[str, Any], status: str): + label = self._format_task_label(task_info) + lines = [f"{status} {label}"] + description = str(task_info.get("description", "")).strip() + if description: + lines.append(f" {description}") + if "finished_time" in task_info: + try: + finished_time = self._format_task_time( + int(task_info["finished_time"])) + except Exception: + finished_time = str(task_info.get("finished_time", "未知时间")) + lines.append(f" 完成时间:{finished_time}") + return "\n".join(lines) + + def _format_task_progress_text( + self, progress: dict[str, Any], group_id: int): + in_progress = progress.get("in_progress", []) + completed = progress.get("completed", []) + options = [] + if in_progress: + for task_info in in_progress: + options.append(self._format_task_menu_item(task_info, "未完成")) + else: + options.append("未完成任务:无") + if completed: + for task_info in completed: + options.append(self._format_task_menu_item(task_info, "已完成")) + else: + options.append("已完成任务:无") + player_name = progress.get("player_name", "<未知玩家>") + return self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"任务进度 - {player_name}", + options, + [ + f"未完成任务:{len(in_progress)} 个", + f"已完成任务:{len(completed)} 个", + "输入 . 退出", + ], + group_id, + ) + + def qq_task_system_menu(self, group_id: int, qqid: int): + if not self._can_use_group_permission(group_id, qqid, "任务系统权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if not self.ensure_task_system(group_id, qqid): + return + choice = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "任务系统菜单", + ["查看在线玩家任务进度", "给在线玩家下发任务", "直接完成在线玩家任务"], + ["输入 [1-3] 之间的数字以选择 对应功能", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if choice == "1": + player_name = self.qq_select_online_player( + group_id, qqid, "查看任务进度") + if player_name is None: + return + self.on_qq_task_progress(group_id, qqid, [player_name]) + return + if choice == "2": + player_name = self.qq_select_online_player(group_id, qqid, "下发任务") + if player_name is None: + return + quest_tag = self.qq_select_task_from_catalog(group_id, qqid) + if quest_tag is None: + return + self.on_qq_task_add(group_id, qqid, [player_name, quest_tag]) + return + if choice == "3": + player_name = self.qq_select_online_player(group_id, qqid, "完成任务") + if player_name is None: + return + quest_tag = self.qq_select_in_progress_task( + group_id, qqid, player_name) + if quest_tag is None: + return + self.on_qq_task_finish(group_id, qqid, [player_name, quest_tag]) + return + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + + def qq_select_online_player(self, group_id: int, qqid: int, subtitle: str): + online_names = list(self.game_ctrl.allplayers) + if not online_names: + self._reply_to_qq(group_id, qqid, "当前没有在线玩家") + return None + page = 1 + per_page = self.get_group_task_player_items_per_page(group_id) + while True: + total_pages, start_index, end_index = ( + utils.paginate(len(online_names), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(online_names), per_page, page) + ) + page_names = online_names[start_index - 1: end_index] + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"任务系统 - {subtitle}", + page_names, + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(page_names)}] 之间的数字以选择 对应玩家", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") + return None + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if user_input == "+": + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") + continue + choice = self.parse_displayed_menu_choice( + user_input, len(page_names)) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + continue + return page_names[choice - 1] + + def qq_select_task_from_catalog(self, group_id: int, qqid: int): + quests = self.task_system.list_available_quests() + if not quests: + self._reply_to_qq(group_id, qqid, "任务系统中暂无可用任务") + return None + page = 1 + per_page = self.get_group_task_items_per_page(group_id) + while True: + total_pages, start_index, end_index = ( + utils.paginate(len(quests), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(quests), per_page, page) + ) + page_quests = quests[start_index - 1: end_index] + options = [] + for quest_info in page_quests: + label = self._format_task_label(quest_info) + description = str(quest_info.get("description", "")).strip() + options.append( + label if not description else f"{label}\n {description}") + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + "任务系统 - 选择任务", + options, + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(page_quests)}] 之间的数字以选择 对应任务", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") + return None + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if user_input == "+": + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") + continue + choice = self.parse_displayed_menu_choice( + user_input, len(page_quests)) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + continue + return page_quests[choice - 1]["tag_name"] + + def qq_select_in_progress_task( + self, + group_id: int, + qqid: int, + player_name: str): + ok, result = self.task_system.get_online_player_task_progress( + player_name) + if not ok: + self._reply_to_qq(group_id, qqid, str(result)) + return None + in_progress = result.get("in_progress", []) + if not in_progress: + self._reply_to_qq(group_id, qqid, f"玩家 {player_name} 当前没有进行中的任务") + return None + options = [] + for task_info in in_progress: + label = self._format_task_label(task_info) + description = str(task_info.get("description", "")).strip() + options.append( + label if not description else f"{label}\n {description}") + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"任务系统 - 完成任务 - {player_name}", + options, + [f"输入 [1-{len(options)}] 之间的数字以选择 对应任务", "输入 . 退出"], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") + return None + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + choice = self.parse_displayed_menu_choice(user_input, len(options)) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return None + return in_progress[choice - 1]["tag_name"] + + def on_qq_task_progress(self, group_id: int, sender: int, args: list[str]): + if not self._can_use_group_permission(group_id, sender, "任务系统权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_task_system(group_id, sender): + return + ok, result = self.task_system.get_online_player_task_progress(args[0]) + if not ok: + self._reply_to_qq(group_id, sender, str(result)) + return + self._reply_to_qq( + group_id, + sender, + self._format_task_progress_text(result, group_id), + ) + + def on_qq_task_add(self, group_id: int, sender: int, args: list[str]): + if not self._can_use_group_permission(group_id, sender, "任务系统权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_task_system(group_id, sender): + return + player_name = args[0] + quest_query = " ".join(args[1:]).strip() + ok, msg = self.task_system.add_quest_to_online_player( + player_name, quest_query) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_task_finish(self, group_id: int, sender: int, args: list[str]): + if not self._can_use_group_permission(group_id, sender, "任务系统权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_task_system(group_id, sender): + return + player_name = args[0] + quest_query = " ".join(args[1:]).strip() + ok, msg = self.task_system.finish_quest_for_online_player( + player_name, quest_query + ) + self._reply_result(group_id, sender, ok, msg) + + def ensure_guild_system(self, group_id: int, sender: int): + """检查公会系统云链联动版 API 是否可用。""" + if self.guild_system is None: + self._reply_to_qq(group_id, sender, "相关插件未安装:公会系统云链联动版") + return False + return self.ensure_linked_plugin_enabled( + self.guild_system, group_id, sender) + + @staticmethod + def _guild_actor(group_id: int, qqid: int): + return f"QQ群{group_id}:{qqid}" + + def _qq_guild_prompt( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + hints: list[str], + ): + return self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "公会系统云链联动版", + subtitle, + options, + hints, + group_id, + ), + timeout=120, + ) + + def _qq_guild_prompt_text( + self, + group_id: int, + qqid: int, + subtitle: str, + prompt: str, + allow_empty: bool = False, + ): + value = self._qq_guild_prompt( + group_id, qqid, subtitle, [], [ + prompt, "输入 . 退出"]) + if value is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + value = value.strip() + if self._is_menu_exit(value, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if not allow_empty and not value: + self._reply_to_qq(group_id, qqid, "❀ 输入不能为空") + return None + return value + + def _qq_guild_prompt_optional_query( + self, + group_id: int, + qqid: int, + subtitle: str, + prompt: str, + ): + value = self._qq_guild_prompt_text( + group_id, qqid, subtitle, prompt, allow_empty=True) + if value is None: + return None + if value in ("", "全部", "全服", "all", "*", "-"): + return "" + return value + + def _qq_guild_prompt_int( + self, + group_id: int, + qqid: int, + subtitle: str, + prompt: str, + minimum: int | None = None, + default: int | None = None, + ): + hints = [prompt, "输入 . 退出"] + if default is not None: + hints.insert(1, f"输入 默认 使用 {default}") + value = self._qq_guild_prompt(group_id, qqid, subtitle, [], hints) + if value is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + value = value.strip() + if self._is_menu_exit(value, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if default is not None and value in ("", "默认", "default"): + return default + parsed = utils.try_int(value) + if parsed is None: + self._reply_to_qq(group_id, qqid, "❀ 请输入整数") + return None + if minimum is not None and parsed < minimum: + self._reply_to_qq(group_id, qqid, f"❀ 数值不能小于 {minimum}") + return None + return parsed + + def _qq_guild_confirm( + self, + group_id: int, + qqid: int, + subtitle: str, + detail: str): + choice = self._qq_guild_prompt( + group_id, + qqid, + subtitle, + [detail], + ["请输入 确认 继续执行", "输入 . 取消"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已取消") + return False + choice = choice.strip() + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已取消") + return False + if choice != "确认": + self._reply_to_qq(group_id, qqid, "❀ 已取消") + return False + return True + + def _reply_guild_api_result(self, group_id: int, qqid: int, result): + if not isinstance(result, tuple) or not result: + self._reply_to_qq(group_id, qqid, "公会系统接口返回异常") + return + ok = bool(result[0]) + msg = str( + result[1]) if len(result) >= 2 else ( + "操作成功" if ok else "操作失败") + if len(result) >= 3 and isinstance(result[2], str) and result[2]: + msg = f"{msg}\n{result[2]}" + self._reply_result(group_id, qqid, ok, msg) + + @staticmethod + def _format_guild_base(base: dict[str, Any] | None): + if not base: + return "未设置" + return f"{ + base.get( + 'dimension', + 0)} ({ + base.get( + 'x', + 0):.1f}, { + base.get( + 'y', + 0):.1f}, { + base.get( + 'z', + 0):.1f})" + + @staticmethod + def _format_guild_line(item: dict[str, Any], index: int): + frozen = " 冻结" if item.get("frozen") else "" + return ( + f"{index}. {item.get('name', '<未知>')} Lv.{item.get('level', 0)} " + f"成员 {item.get('member_count', 0)}/{item.get('max_members', 0)} " + f"会长 {item.get('owner', '<未知>')}{frozen}" + ) + + def _format_guild_summary(self, guild: dict[str, Any], group_id: int): + effects = guild.get("purchased_effects", {}) + effect_text = "无" + if isinstance(effects, dict) and effects: + effect_text = "、".join( + f"{key}:{level}" for key, + level in effects.items()) + return self.plugin_ui_menu( + "公会系统云链联动版", + f"公会信息 - {guild.get('name', '<未知>')}", + [ + f"ID:{guild.get('guild_id', '<未知>')}", + f"会长:{guild.get('owner', '<未知>')}", + f"等级/经验:{guild.get('level', 0)} / {guild.get('exp', 0)}", + f"成员:{guild.get('member_count', 0)}/{guild.get('max_members', 0)}", + f"仓库:{guild.get('vault_count', 0)}/{guild.get('vault_capacity', 0)}", + f"资金:{guild.get('funds', 0)}", + f"据点:{self._format_guild_base(guild.get('base'))}", + f"据点锁定:{'是' if guild.get('base_locked') else '否'}", + f"冻结:{'是' if guild.get('frozen') else '否'}", + f"冻结原因:{guild.get('frozen_reason') or '无'}", + f"活跃/完成任务:{guild.get('active_tasks', 0)} / {guild.get('completed_tasks', 0)}", + f"总贡献:{guild.get('total_contribution', 0)}", + f"效果:{effect_text}", + f"公告:{guild.get('announcement') or '无'}", + ], + ["输入 . 退出"], + group_id, + ) + + def _reply_guild_lines( + self, + group_id: int, + qqid: int, + title: str, + lines: list[str], + hints: list[str] | None = None, + ): + self._reply_to_qq( + group_id, + qqid, + self.plugin_ui_menu( + "公会系统云链联动版", + title, + lines or ["暂无数据"], + hints or ["输入 . 退出"], + group_id, + ), + ) + + def _qq_guild_run_menu( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + actions: list[Any], + allow_back: bool = False, + ): + if len(options) != len(actions): + self._reply_to_qq(group_id, qqid, "❀ 菜单配置错误") + return None + hints = [f"输入 [1-{len(options)}] 之间的数字以选择 对应功能"] + if allow_back: + hints.append("输入 0 返回上级菜单") + hints.append("输入 . 退出") + choice = self._qq_guild_prompt( + group_id, + qqid, + subtitle, + options, + hints, + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + choice = choice.strip() + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if allow_back and self._is_menu_back(choice, group_id): + return "back" + selected = self.parse_displayed_menu_choice(choice, len(options)) + if selected is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return None + return actions[selected - 1]() + + def qq_guild_entry_menu(self, group_id: int, qqid: int): + """公会系统统一入口:群管理员进管理菜单,普通成员进绑定账号菜单。""" + if self._can_use_group_permission(group_id, qqid, "公会系统权限"): + self.qq_guild_system_menu(group_id, qqid) + return + self.qq_guild_player_menu(group_id, qqid) + + def _qq_guild_select_bound_player(self, group_id: int, qqid: int): + """选择一个 QQ 已绑定的游戏账号,返回玩家名。""" + bound_players = self.api_get_bound_players_by_qq(qqid) + usable_players = [ + item + for item in bound_players + if str(item.get("player_name", "")).strip() + ] + if not usable_players: + bind_hint = " / ".join(self.get_group_binding_triggers(group_id)) + if not self.api_is_binding_enabled(group_id): + self._reply_to_qq( + group_id, + qqid, + "请先绑定游戏账号后再使用公会菜单;当前群的 QQ 绑定功能未开启,请联系管理员。", + ) + return None + self._reply_to_qq( + group_id, + qqid, + f"请先绑定游戏账号后再使用公会菜单。绑定触发词:{bind_hint}", + ) + return None + if len(usable_players) == 1: + return str(usable_players[0].get("player_name", "")).strip() + + options = [ + f"{item.get('player_name', '<未知玩家>')} ({item.get('xuid', '<未知XUID>')})" + for item in usable_players + ] + choice = self._qq_guild_prompt( + group_id, + qqid, + "选择绑定账号", + options, + [f"输入 [1-{len(options)}] 之间的数字以选择 游戏账号", "输入 . 退出"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + selected = self.parse_displayed_menu_choice(choice, len(options)) + if selected is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return None + return str(usable_players[selected - 1].get("player_name", "")).strip() + + def _qq_guild_player_state( + self, + group_id: int, + qqid: int, + player_name: str): + result = self.guild_system.api_get_player_guild_menu_state(player_name) + if not isinstance(result, tuple) or len(result) < 3: + self._reply_to_qq(group_id, qqid, "公会系统接口返回异常") + return None + ok, msg, state = result + if not ok or not isinstance(state, dict): + self._reply_result(group_id, qqid, bool(ok), str(msg)) + return None + return state + + def qq_guild_player_menu(self, group_id: int, qqid: int): + """普通群成员可使用的公会菜单,要求 QQ 已绑定游戏账号。""" + if not self.ensure_guild_system(group_id, qqid): + return + player_name = self._qq_guild_select_bound_player(group_id, qqid) + if not player_name: + return + while True: + state = self._qq_guild_player_state(group_id, qqid, player_name) + if state is None: + return + if state.get("in_guild"): + guild = state.get("guild") if isinstance( + state.get("guild"), dict) else {} + member = state.get("member") if isinstance( + state.get("member"), dict) else {} + permissions = set(state.get("permissions") or []) + is_owner = bool(state.get("is_owner")) + is_frozen = bool(state.get("is_frozen")) + subtitle = ( + f"玩家菜单 - {player_name} | {guild.get('name', '<未知公会>')} " + f"({member.get('rank_name', member.get('rank', '成员'))})" + ) + options = [ + "我的公会信息", + "查看成员列表", + "查看公会日志", + "查看公会公告", + "查看公会排行", + "查看贡献排行", + ] + actions = [ + lambda: self.qq_guild_player_show_self( + group_id, qqid, player_name), + lambda: self.qq_guild_player_show_members( + group_id, qqid, player_name), + lambda: self.qq_guild_player_show_logs( + group_id, qqid, player_name), + lambda: self.qq_guild_player_show_announcement( + group_id, qqid, player_name), + lambda: self.qq_guild_show_rankings(group_id, qqid), + lambda: self.qq_guild_show_donation_rankings( + group_id, qqid),] + if not is_frozen: + if "announce" in permissions: + options.append("设置公会公告") + actions.append( + lambda: self.qq_guild_player_set_announcement( + group_id, qqid, player_name)) + if "vault" in permissions: + options.append("查看公会仓库") + actions.append(lambda: self.qq_guild_player_show_vault( + group_id, qqid, player_name)) + options.append("查看公会任务") + actions.append(lambda: self.qq_guild_player_show_tasks( + group_id, qqid, player_name)) + options.append("参与公会任务") + actions.append(lambda: self.qq_guild_player_join_task( + group_id, qqid, player_name)) + if "return_base" in permissions: + options.append("返回公会据点") + actions.append( + lambda: self.qq_guild_player_return_base( + group_id, qqid, player_name)) + if not is_owner: + options.append("退出公会") + actions.append(lambda: self.qq_guild_player_leave( + group_id, qqid, player_name)) + if is_owner and not is_frozen: + options.append("解散我的公会") + actions.append(lambda: self.qq_guild_player_disband( + group_id, qqid, player_name)) + else: + subtitle = f"玩家菜单 - {player_name} | 未加入公会" + options = ["查看公会列表", "申请加入公会", "查看公会排行", "查看贡献排行"] + actions = [ + lambda: self.qq_guild_list( + group_id, qqid), lambda: self.qq_guild_player_request_join( + group_id, qqid, player_name), lambda: self.qq_guild_show_rankings( + group_id, qqid), lambda: self.qq_guild_show_donation_rankings( + group_id, qqid), ] + result = self._qq_guild_run_menu( + group_id, qqid, subtitle, options, actions) + if result == "back": + continue + return + + def qq_guild_player_show_self( + self, + group_id: int, + qqid: int, + player_name: str): + state = self._qq_guild_player_state(group_id, qqid, player_name) + if state is None: + return + guild = state.get("guild") if isinstance( + state.get("guild"), dict) else {} + member = state.get("member") if isinstance( + state.get("member"), dict) else {} + if not guild: + self._reply_to_qq(group_id, qqid, f"{player_name} 暂未加入公会") + return + self._reply_guild_lines( + group_id, + qqid, + f"我的公会 - {player_name}", + [ + f"公会:{guild.get('name', '<未知>')} ({guild.get('guild_id', '<未知>')})", + f"职位:{member.get('rank_name', member.get('rank', '<未知>'))}", + f"贡献:{member.get('contribution', 0)}", + f"会长:{guild.get('owner', '<未知>')}", + f"等级/经验:{guild.get('level', 0)} / {guild.get('exp', 0)}", + f"成员:{guild.get('member_count', 0)}/{guild.get('max_members', 0)}", + f"仓库:{guild.get('vault_count', 0)}/{guild.get('vault_capacity', 0)}", + f"据点:{self._format_guild_base(guild.get('base'))}", + f"冻结:{'是' if guild.get('frozen') else '否'}", + f"公告:{guild.get('announcement') or '无'}", + ], + ) + + def qq_guild_player_show_members( + self, + group_id: int, + qqid: int, + player_name: str): + state = self._qq_guild_player_state(group_id, qqid, player_name) + if state is None: + return + guild = state.get("guild") if isinstance( + state.get("guild"), dict) else {} + guild_id = str(guild.get("guild_id", "")).strip() + if not guild_id: + self._reply_to_qq(group_id, qqid, "你尚未加入任何公会") + return + ok, msg, data = self.guild_system.api_get_guild(guild_id) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return + members = data.get("members", []) + lines = [] + for index, member in enumerate(members[:30], start=1): + rank = member.get("rank_name", member.get("rank", "成员")) + name = member.get("name", "<未知>") + contribution = member.get("contribution", 0) + lines.append(f"{index}. {rank} {name} 贡献 {contribution}") + self._reply_guild_lines( + group_id, qqid, f"{ + data.get( + 'name', guild_id)} 成员", lines or ["暂无成员"], [ + f"共 { + len(members)} 名成员"]) + + def qq_guild_player_show_logs( + self, + group_id: int, + qqid: int, + player_name: str): + ok, msg, data = self.guild_system.api_get_own_guild_logs( + player_name, 20) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return + lines = [str(item) for item in data.get("logs", [])[-20:]] + audit_logs = data.get("audit_logs", []) + if audit_logs: + lines.append("--- 审计日志 ---") + for item in audit_logs[-10:]: + action = item.get("action", "<未知>") + actor = item.get("actor", "") + target = item.get("target", "") + detail = item.get("detail", "") + lines.append(f"{action} {actor} -> {target} {detail}".strip()) + self._reply_guild_lines( + group_id, qqid, f"{player_name} 的公会日志", lines or ["暂无日志"]) + + def qq_guild_player_show_announcement( + self, group_id: int, qqid: int, player_name: str): + state = self._qq_guild_player_state(group_id, qqid, player_name) + if state is None: + return + guild = state.get("guild") if isinstance( + state.get("guild"), dict) else {} + if not guild: + self._reply_to_qq(group_id, qqid, "你尚未加入任何公会") + return + self._reply_guild_lines( + group_id, + qqid, + f"{guild.get('name', '<未知公会>')} 公告", + [guild.get("announcement") or "当前没有公告"], + ) + + def qq_guild_player_set_announcement( + self, group_id: int, qqid: int, player_name: str): + text = self._qq_guild_prompt_text( + group_id, qqid, "设置公会公告", "请输入新的公告内容(不超过200字符)") + if text is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_announcement_as_player( + player_name, text), ) + + def qq_guild_player_show_vault( + self, + group_id: int, + qqid: int, + player_name: str): + ok, msg, data = self.guild_system.api_get_own_guild_vault(player_name) + if not ok or data is None: + self._reply_result(group_id, qqid, False, msg) + return + lines = [] + for index, item in enumerate(data[:30], start=1): + item_index = item.get("index", index) + item_id = item.get("item_id", "<未知>") + count = item.get("count", 0) + price = item.get("price", 0) + seller = item.get("seller", "<未知>") + lines.append(f"{item_index}. {item_id} x{count} 价格 {price} 卖家 {seller}") + self._reply_guild_lines(group_id, qqid, msg, lines or ["仓库为空"]) + + def qq_guild_player_show_tasks( + self, + group_id: int, + qqid: int, + player_name: str): + ok, msg, data = self.guild_system.api_get_own_guild_tasks(player_name) + if not ok or data is None: + self._reply_result(group_id, qqid, False, msg) + return + lines = [] + for index, task in enumerate(data[:30], start=1): + status = "已完成" if task.get("completed") else f"进行中 {task.get( + 'current_count', 0)} /{task.get('target_count', 0)} " + joined = " 已参与" if player_name in task.get( + "participants", []) else "" + lines.append( + f"{index}. { + task.get( + 'name', + '<未知任务>')} [{status}{joined}] 奖励 { + task.get( + 'reward_contribution', + 0)}贡献/{ + task.get( + 'reward_exp', + 0)}经验") + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无任务"]) + + def qq_guild_player_join_task( + self, + group_id: int, + qqid: int, + player_name: str): + ok, msg, data = self.guild_system.api_get_own_guild_tasks(player_name) + if not ok or data is None: + self._reply_result(group_id, qqid, False, msg) + return + active_tasks = [task for task in data if not task.get("completed")] + if not active_tasks: + self._reply_to_qq(group_id, qqid, "暂无可参与的任务") + return + options = [ + f"{task.get('name', '<未知任务>')} ({task.get('current_count', 0)}/{task.get('target_count', 0)})" + for task in active_tasks[:20] + ] + choice = self._qq_guild_prompt( + group_id, + qqid, + "参与公会任务", + options, + [f"输入 [1-{len(options)}] 之间的数字以选择 任务", "输入 . 退出"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + selected = self.parse_displayed_menu_choice(choice, len(options)) + if selected is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + task = active_tasks[selected - 1] + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_join_guild_task_as_player( + player_name, + task.get("task_id") or task.get("name", ""), + ), + ) + + def qq_guild_player_return_base( + self, + group_id: int, + qqid: int, + player_name: str): + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_return_to_guild_base_as_player(player_name), + ) + + def qq_guild_player_request_join( + self, + group_id: int, + qqid: int, + player_name: str): + guild = self._qq_guild_prompt_text( + group_id, qqid, "申请加入公会", "请输入公会名称或ID") + if guild is None: + return + reason = self._qq_guild_prompt_text( + group_id, + qqid, + "申请加入公会", + "请输入申请理由,可输入 无 跳过", + allow_empty=True, + ) + if reason is None: + return + if reason == "无": + reason = "" + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_request_join_guild_as_player( + player_name, guild, reason), + ) + + def qq_guild_player_leave( + self, + group_id: int, + qqid: int, + player_name: str): + if not self._qq_guild_confirm( + group_id, qqid, "退出公会", f"{player_name} 将退出当前公会"): + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_leave_guild_as_player(player_name), + ) + + def qq_guild_player_disband( + self, + group_id: int, + qqid: int, + player_name: str): + if not self._qq_guild_confirm( + group_id, + qqid, + "解散公会", + f"{player_name} 将解散自己担任会长的公会"): + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_disband_owned_guild_as_player(player_name), + ) + + def qq_guild_system_menu(self, group_id: int, qqid: int): + """在群里打开公会系统云链联动版管理菜单。""" + if not self._can_use_group_permission(group_id, qqid, "公会系统权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if not self.ensure_guild_system(group_id, qqid): + return + while True: + result = self._qq_guild_run_menu( + group_id, + qqid, + "管理菜单", + ["查询与排行", "公会与成员管理", "仓库与效果管理", "任务与据点管理", "数据维护与活动"], + [ + lambda: self.qq_guild_query_menu(group_id, qqid), + lambda: self.qq_guild_member_manage_menu(group_id, qqid), + lambda: self.qq_guild_vault_effect_menu(group_id, qqid), + lambda: self.qq_guild_task_base_menu(group_id, qqid), + lambda: self.qq_guild_data_activity_menu(group_id, qqid), + ], + ) + if result == "back": + continue + return + + def qq_guild_query_menu(self, group_id: int, qqid: int): + if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): + return + if not self.ensure_guild_system(group_id, qqid): + return + self._qq_guild_run_menu( + group_id, + qqid, + "查询与排行", + [ + "查看公会列表", + "查看公会信息", + "查看公会成员", + "查看公会仓库", + "查看公会日志", + "查询玩家公会记录", + "查看系统统计", + "查看公会排行", + "查看贡献排行", + "查看异常交易", + ], + [ + lambda: self.qq_guild_list(group_id, qqid), + lambda: self.qq_guild_show_info(group_id, qqid), + lambda: self.qq_guild_show_members(group_id, qqid), + lambda: self.qq_guild_show_vault(group_id, qqid), + lambda: self.qq_guild_show_logs(group_id, qqid), + lambda: self.qq_guild_show_player_record(group_id, qqid), + lambda: self.qq_guild_show_statistics(group_id, qqid), + lambda: self.qq_guild_show_rankings(group_id, qqid), + lambda: self.qq_guild_show_donation_rankings(group_id, qqid), + lambda: self.qq_guild_show_abnormal_trades(group_id, qqid), + ], + allow_back=True, + ) + + def qq_guild_member_manage_menu(self, group_id: int, qqid: int): + if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): + return + if not self.ensure_guild_system(group_id, qqid): + return + self._qq_guild_run_menu( + group_id, + qqid, + "公会与成员管理", + [ + "强制解散公会", + "修改公会名称", + "设置公会等级", + "设置公会经验", + "转让公会会长", + "强制玩家加入公会", + "强制玩家退出公会", + "冻结公会", + "解冻公会", + "调整公会资金", + "设置公会资金", + "调整成员贡献", + "设置成员贡献", + "清空公会贡献", + "发送全服公会公告", + ], + [ + lambda: self.qq_guild_force_disband(group_id, qqid), + lambda: self.qq_guild_rename(group_id, qqid), + lambda: self.qq_guild_set_level(group_id, qqid), + lambda: self.qq_guild_set_exp(group_id, qqid), + lambda: self.qq_guild_transfer_owner(group_id, qqid), + lambda: self.qq_guild_force_join(group_id, qqid), + lambda: self.qq_guild_force_leave(group_id, qqid), + lambda: self.qq_guild_set_frozen(group_id, qqid, True), + lambda: self.qq_guild_set_frozen(group_id, qqid, False), + lambda: self.qq_guild_add_funds(group_id, qqid), + lambda: self.qq_guild_set_funds(group_id, qqid), + lambda: self.qq_guild_add_member_contribution(group_id, qqid), + lambda: self.qq_guild_set_member_contribution(group_id, qqid), + lambda: self.qq_guild_reset_contributions(group_id, qqid), + lambda: self.qq_guild_broadcast_announcement(group_id, qqid), + ], + allow_back=True, + ) + + def qq_guild_vault_effect_menu(self, group_id: int, qqid: int): + if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): + return + if not self.ensure_guild_system(group_id, qqid): + return + self._qq_guild_run_menu( + group_id, + qqid, + "仓库与效果管理", + [ + "备份公会仓库", + "清空公会仓库", + "删除仓库物品", + "回滚仓库备份", + "导出仓库数据", + "重置市场价格", + "清空公会效果", + "设置公会效果", + ], + [ + lambda: self.qq_guild_backup_vault(group_id, qqid), + lambda: self.qq_guild_clear_vault(group_id, qqid), + lambda: self.qq_guild_delete_vault_item(group_id, qqid), + lambda: self.qq_guild_rollback_vault(group_id, qqid), + lambda: self.qq_guild_export_vault(group_id, qqid), + lambda: self.qq_guild_reset_market_prices(group_id, qqid), + lambda: self.qq_guild_clear_effects(group_id, qqid), + lambda: self.qq_guild_set_effect(group_id, qqid), + ], + allow_back=True, + ) + + def qq_guild_task_base_menu(self, group_id: int, qqid: int): + if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): + return + if not self.ensure_guild_system(group_id, qqid): + return + self._qq_guild_run_menu( + group_id, + qqid, + "任务与据点管理", + [ + "刷新公会任务", + "创建全服任务", + "删除公会任务", + "重置任务进度", + "强制完成任务", + "传送玩家到公会据点", + "删除公会据点", + "设置公会据点", + "锁定公会据点", + "解锁公会据点", + ], + [ + lambda: self.qq_guild_refresh_tasks(group_id, qqid), + lambda: self.qq_guild_create_global_task(group_id, qqid), + lambda: self.qq_guild_delete_task(group_id, qqid), + lambda: self.qq_guild_reset_task(group_id, qqid), + lambda: self.qq_guild_complete_task(group_id, qqid), + lambda: self.qq_guild_teleport_base(group_id, qqid), + lambda: self.qq_guild_delete_base(group_id, qqid), + lambda: self.qq_guild_set_base(group_id, qqid), + lambda: self.qq_guild_set_base_locked(group_id, qqid, True), + lambda: self.qq_guild_set_base_locked(group_id, qqid, False), + ], + allow_back=True, + ) + + def qq_guild_data_activity_menu(self, group_id: int, qqid: int): + if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): + return + if not self.ensure_guild_system(group_id, qqid): + return + self._qq_guild_run_menu( + group_id, qqid, "数据维护与活动", + ["重载公会配置", "保存公会数据", "备份公会数据", "修复公会数据", "查看活动状态", "开启双倍经验", + "开启双倍贡献", "开启公会争霸", "停止活动", "结算排行奖励",], + [lambda: self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_reload_guild_config()), + lambda: self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_save_guild_data()), + lambda: self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_backup_guild_data()), + lambda: self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_repair_guild_data( + self._guild_actor(group_id, qqid))), + lambda: self.qq_guild_show_activity_status(group_id, qqid), + lambda: self.qq_guild_start_activity( + group_id, qqid, "exp", 2.0), + lambda: self.qq_guild_start_activity( + group_id, qqid, "contribution", 2.0), + lambda: self.qq_guild_start_activity( + group_id, qqid, "contest", 1.0), + lambda: self.qq_guild_stop_activity(group_id, qqid), + lambda: self.qq_guild_settle_rewards(group_id, qqid),], + allow_back=True,) + + def qq_guild_list(self, group_id: int, qqid: int): + ok, msg, guilds = self.guild_system.api_list_guilds() + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + lines = [self._format_guild_line(item, index) + for index, item in enumerate(guilds[:20], start=1)] + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无公会"]) + + def qq_guild_show_info(self, group_id: int, qqid: int): + query = self._qq_guild_prompt_text( + group_id, qqid, "查看公会信息", "请输入公会名称或ID") + if query is None: + return + ok, msg, data = self.guild_system.api_get_guild(query) + self._reply_to_qq(group_id, qqid, self._format_guild_summary( + data, group_id) if ok and data else msg) + + def qq_guild_show_members(self, group_id: int, qqid: int): + query = self._qq_guild_prompt_text( + group_id, qqid, "查看公会成员", "请输入公会名称或ID") + if query is None: + return + ok, msg, data = self.guild_system.api_get_guild(query) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return + members = data.get("members", []) + lines = [] + for index, member in enumerate(members[:30], start=1): + rank = member.get("rank_name", member.get("rank", "成员")) + name = member.get("name", "<未知>") + contribution = member.get("contribution", 0) + lines.append(f"{index}. {rank} {name} 贡献 {contribution}") + self._reply_guild_lines( + group_id, qqid, f"{ + data.get( + 'name', query)} 成员", lines or ["暂无成员"], [ + f"共 { + len(members)} 名成员"]) + + def qq_guild_show_vault(self, group_id: int, qqid: int): + query = self._qq_guild_prompt_text( + group_id, qqid, "查看公会仓库", "请输入公会名称或ID") + if query is None: + return + ok, msg, data = self.guild_system.api_get_guild_vault(query) + if not ok or data is None: + self._reply_result(group_id, qqid, False, msg) + return + lines = [] + for index, item in enumerate(data[:30], start=1): + item_index = item.get("index", index) + item_id = item.get("item_id", "<未知>") + count = item.get("count", 0) + price = item.get("price", 0) + seller = item.get("seller", "<未知>") + lines.append(f"{item_index}. {item_id} x{count} 价格 {price} 卖家 {seller}") + self._reply_guild_lines(group_id, qqid, msg, lines or ["仓库为空"]) + + def qq_guild_show_logs(self, group_id: int, qqid: int): + query = self._qq_guild_prompt_text( + group_id, qqid, "查看公会日志", "请输入公会名称或ID") + if query is None: + return + limit = self._qq_guild_prompt_int( + group_id, qqid, "查看公会日志", "请输入日志数量", minimum=1, default=10) + if limit is None: + return + ok, msg, data = self.guild_system.api_get_guild_logs(query, limit) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return + logs = [str(item) for item in data.get("logs", [])[-limit:]] + self._reply_guild_lines( + group_id, + qqid, + f"{query} 日志", + logs or ["暂无日志"]) + + def qq_guild_show_player_record(self, group_id: int, qqid: int): + player_name = self._qq_guild_prompt_text( + group_id, qqid, "查询玩家公会记录", "请输入玩家名") + if player_name is None: + return + ok, msg, data = self.guild_system.api_get_player_record(player_name) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return + member = data.get("member", {}) + guild = data.get("guild", {}) + self._reply_guild_lines( + group_id, + qqid, + f"玩家记录 - {member.get('name', player_name)}", + [ + f"所属公会:{guild.get('name', '<未知>')}", + f"职位:{member.get('rank_name', member.get('rank', '<未知>'))}", + f"贡献:{member.get('contribution', 0)}", + f"相关审计:{len(data.get('audit_logs', []))} 条", + f"仓库交易:{len(data.get('vault_trade_logs', []))} 条", + ], + ) + + def qq_guild_show_statistics(self, group_id: int, qqid: int): + ok, msg, data = self.guild_system.api_get_guild_statistics() + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + self._reply_guild_lines( + group_id, + qqid, + "公会系统统计", + [ + f"公会:{data.get('guild_count', 0)}", + f"成员:{data.get('member_count', 0)}", + f"仓库物品:{data.get('vault_item_count', 0)}", + f"任务:{data.get('task_count', 0)}", + f"活跃任务:{data.get('active_task_count', 0)}", + f"冻结公会:{data.get('frozen_guild_count', 0)}", + ], + ) + + def qq_guild_show_rankings(self, group_id: int, qqid: int): + sort_choice = self._qq_guild_prompt( + group_id, + qqid, + "查看公会排行", + ["等级", "成员", "贡献", "活跃"], + ["输入 [1-4] 之间的数字以选择 排行类型", "输入 . 退出"], + ) + if sort_choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(sort_choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + sort_map = { + "1": "level", + "2": "members", + "3": "contribution", + "4": "activity"} + sort_by = sort_map.get(sort_choice.strip()) + if sort_by is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + ok, msg, data = self.guild_system.api_get_guild_rankings(sort_by, 10) + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + lines = [ + f"{ + item.get( + 'rank', index)}. { + item.get( + 'name', '<未知>')} 分值 { + item.get( + 'score', 0)} 会长 { + item.get( + 'owner', '<未知>')}" for index, item in enumerate( + data, start=1)] + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无排行"]) + + def qq_guild_show_donation_rankings(self, group_id: int, qqid: int): + query = self._qq_guild_prompt_optional_query( + group_id, qqid, "查看贡献排行", "请输入公会名/ID,输入 全部 查看全服") + if query is None: + return + ok, msg, data = self.guild_system.api_get_donation_rankings( + query or None, 10) + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + lines = [ + f"{index}. { + item.get( + 'player_name', + '<未知>')} { + item.get( + 'guild_name', + '<未知>')} 贡献 { + item.get( + 'contribution', + 0)}" for index, + item in enumerate( + data, + start=1)] + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无排行"]) + + def qq_guild_show_abnormal_trades(self, group_id: int, qqid: int): + query = self._qq_guild_prompt_optional_query( + group_id, qqid, "查看异常交易", "请输入公会名/ID,输入 全部 查看全服") + if query is None: + return + ok, msg, data = self.guild_system.api_get_abnormal_trades( + query or None) + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + lines = [] + for item in data[:20]: + guild_name = item.get("guild_name", "<未知>") + item_id = item.get("item_id", "<未知>") + count = item.get("count", 0) + price = item.get("price", 0) + ratio = item.get("ratio", 0) + lines.append(f"{guild_name} {item_id} x{count} 价格 {price} 倍率 {ratio}") + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无异常交易"]) + + def _qq_guild_prompt_guild(self, group_id: int, qqid: int, subtitle: str): + return self._qq_guild_prompt_text( + group_id, qqid, subtitle, "请输入公会名称或ID") + + def qq_guild_force_disband(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "强制解散公会") + if guild is None: + return + if not self._qq_guild_confirm( + group_id, qqid, "强制解散公会", f"即将解散公会:{guild}"): + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_force_disband_guild( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_rename(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "修改公会名称") + if guild is None: + return + new_name = self._qq_guild_prompt_text( + group_id, qqid, "修改公会名称", "请输入新公会名") + if new_name is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_rename_guild( + guild, new_name, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_level(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会等级") + if guild is None: + return + level = self._qq_guild_prompt_int( + group_id, qqid, "设置公会等级", "请输入等级", minimum=1) + if level is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_level( + guild, level, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_exp(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会经验") + if guild is None: + return + exp = self._qq_guild_prompt_int( + group_id, qqid, "设置公会经验", "请输入经验值", minimum=0) + if exp is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_exp( + guild, exp, self._guild_actor( + group_id, qqid))) + + def qq_guild_transfer_owner(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "转让公会会长") + if guild is None: + return + player = self._qq_guild_prompt_text( + group_id, qqid, "转让公会会长", "请输入新会长玩家名") + if player is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_transfer_guild_owner( + guild, player, self._guild_actor( + group_id, qqid))) + + def qq_guild_force_join(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "强制玩家加入公会") + if guild is None: + return + player = self._qq_guild_prompt_text( + group_id, qqid, "强制玩家加入公会", "请输入玩家名") + if player is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_force_join_guild( + guild, player, self._guild_actor( + group_id, qqid))) + + def qq_guild_force_leave(self, group_id: int, qqid: int): + player = self._qq_guild_prompt_text( + group_id, qqid, "强制玩家退出公会", "请输入玩家名") + if player is None: + return + if not self._qq_guild_confirm( + group_id, + qqid, + "强制玩家退出公会", + f"即将移出玩家:{player}"): + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_force_leave_guild( + player, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_frozen(self, group_id: int, qqid: int, frozen: bool): + title = "冻结公会" if frozen else "解冻公会" + guild = self._qq_guild_prompt_guild(group_id, qqid, title) + if guild is None: + return + reason = "" + if frozen: + reason = self._qq_guild_prompt_text( + group_id, qqid, title, "请输入冻结原因,可输入 无", allow_empty=True) + if reason is None: + return + if reason == "无": + reason = "" + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_frozen( + guild, frozen, reason, self._guild_actor( + group_id, qqid))) + + def qq_guild_add_funds(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "调整公会资金") + if guild is None: + return + amount = self._qq_guild_prompt_int( + group_id, qqid, "调整公会资金", "请输入调整数量,可为负数") + if amount is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_add_guild_funds( + guild, amount, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_funds(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会资金") + if guild is None: + return + amount = self._qq_guild_prompt_int( + group_id, qqid, "设置公会资金", "请输入资金余额", minimum=0) + if amount is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_funds( + guild, amount, self._guild_actor( + group_id, qqid))) + + def qq_guild_add_member_contribution(self, group_id: int, qqid: int): + player = self._qq_guild_prompt_text(group_id, qqid, "调整成员贡献", "请输入玩家名") + if player is None: + return + amount = self._qq_guild_prompt_int( + group_id, qqid, "调整成员贡献", "请输入调整数量,可为负数") + if amount is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_add_member_contribution( + player, amount, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_member_contribution(self, group_id: int, qqid: int): + player = self._qq_guild_prompt_text(group_id, qqid, "设置成员贡献", "请输入玩家名") + if player is None: + return + amount = self._qq_guild_prompt_int( + group_id, qqid, "设置成员贡献", "请输入贡献值", minimum=0) + if amount is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_member_contribution( + player, amount, self._guild_actor( + group_id, qqid))) + + def qq_guild_reset_contributions(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "清空公会贡献") + if guild is None: + return + if not self._qq_guild_confirm( + group_id, qqid, "清空公会贡献", f"即将清空公会贡献:{guild}"): + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_reset_guild_contributions( + guild, + self._guild_actor( + group_id, + qqid))) + + def qq_guild_broadcast_announcement(self, group_id: int, qqid: int): + message = self._qq_guild_prompt_text( + group_id, qqid, "发送全服公会公告", "请输入公告内容") + if message is None: + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_broadcast_guild_announcement( + message, + self._guild_actor( + group_id, + qqid))) + + def qq_guild_backup_vault(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "备份公会仓库") + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_backup_guild_vault( + guild, "qq", self._guild_actor( + group_id, qqid))) + + def qq_guild_clear_vault(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "清空公会仓库") + if guild is None: + return + if not self._qq_guild_confirm( + group_id, qqid, "清空公会仓库", f"即将清空仓库:{guild}"): + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_clear_guild_vault( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_delete_vault_item(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "删除仓库物品") + if guild is None: + return + index = self._qq_guild_prompt_int( + group_id, qqid, "删除仓库物品", "请输入仓库序号", minimum=1) + if index is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_delete_guild_vault_item( + guild, index, self._guild_actor( + group_id, qqid))) + + def qq_guild_rollback_vault(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "回滚仓库备份") + if guild is None: + return + index = self._qq_guild_prompt_int( + group_id, qqid, "回滚仓库备份", "请输入备份序号", minimum=1, default=1) + if index is None: + return + if not self._qq_guild_confirm( + group_id, + qqid, + "回滚仓库备份", + f"即将回滚 {guild} 的仓库备份 {index}"): + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_rollback_guild_vault( + guild, index, self._guild_actor( + group_id, qqid))) + + def qq_guild_export_vault(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "导出仓库数据") + if guild is None: + return + ok, msg, data = self.guild_system.api_export_guild_vault(guild) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return + items = data.get("vault_items", []) + trade_logs = data.get("vault_trade_logs", []) + self._reply_guild_lines( group_id, qqid, - "管理系统", - [ - "添加玩家到白名单", - "从白名单中移除玩家", - "添加服务器管理员", - "移除服务器管理员", - "开启/关闭 白名单检测", - "开启/关闭 管理员检测", - "设置检测周期", - "查看当前状态", - ], - ["输入 [1-8] 之间的数字以选择 对应功能", "输入 . 退出"], + msg, + [f"仓库物品:{len(items)} 件", f"交易日志:{len(trade_logs)} 条"], ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if choice.lower() in ("q", ".", "。"): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - - if choice in ("1", "2", "3", "4"): - self._qq_checker_handle_player_action(group_id, qqid, choice) - return - - if choice in ("5", "6"): - self._qq_checker_handle_toggle_action(group_id, qqid, choice) - return - - if choice == "7": - self._qq_checker_handle_interval_action(group_id, qqid) - return - - if choice == "8": - self.on_qq_check_status(group_id, qqid, []) - return - - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + + def qq_guild_reset_market_prices(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "重置市场价格") + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_reset_market_prices( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_clear_effects(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "清空公会效果") + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_clear_guild_effects( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_effect(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会效果") + if guild is None: + return + effect = self._qq_guild_prompt_text( + group_id, qqid, "设置公会效果", "请输入效果ID或名称") + if effect is None: + return + level = self._qq_guild_prompt_int( + group_id, qqid, "设置公会效果", "请输入效果等级,0 表示移除", minimum=0) + if level is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_effect( + guild, effect, level, self._guild_actor( + group_id, qqid))) + + def qq_guild_refresh_tasks(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "刷新公会任务") + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_refresh_guild_tasks( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_create_global_task(self, group_id: int, qqid: int): + name = self._qq_guild_prompt_text(group_id, qqid, "创建全服任务", "请输入任务名称") + if name is None: + return + task_type = self._qq_guild_prompt_text( + group_id, qqid, "创建全服任务", "请输入任务类型,如 trade/collect/kill/build") + if task_type is None: + return + target = self._qq_guild_prompt_text( + group_id, qqid, "创建全服任务", "请输入任务目标") + if target is None: + return + target_count = self._qq_guild_prompt_int( + group_id, qqid, "创建全服任务", "请输入目标数量", minimum=1) + if target_count is None: + return + reward_exp = self._qq_guild_prompt_int( + group_id, qqid, "创建全服任务", "请输入经验奖励", minimum=0, default=0) + if reward_exp is None: + return + reward_contribution = self._qq_guild_prompt_int( + group_id, qqid, "创建全服任务", "请输入贡献奖励", minimum=0, default=0) + if reward_contribution is None: + return + description = self._qq_guild_prompt_text( + group_id, qqid, "创建全服任务", "请输入任务描述,可输入 默认", allow_empty=True) + if description is None: + return + if description == "默认": + description = name + deadline = self._qq_guild_prompt_int( + group_id, qqid, "创建全服任务", "请输入截止秒数,0 表示无限期", minimum=0, default=0) + if deadline is None: + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_create_global_task( + name, + task_type, + target, + target_count, + reward_exp, + reward_contribution, + description, + deadline, + self._guild_actor(group_id, qqid), + ), + ) + + def qq_guild_delete_task(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "删除公会任务") + if guild is None: + return + task = self._qq_guild_prompt_text( + group_id, qqid, "删除公会任务", "请输入任务ID或名称") + if task is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_delete_guild_task( + guild, task, self._guild_actor( + group_id, qqid))) + + def qq_guild_reset_task(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "重置任务进度") + if guild is None: + return + task = self._qq_guild_prompt_text( + group_id, qqid, "重置任务进度", "请输入任务ID或名称") + if task is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_reset_guild_task_progress( + guild, task, self._guild_actor( + group_id, qqid))) + + def qq_guild_complete_task(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "强制完成任务") + if guild is None: + return + task = self._qq_guild_prompt_text( + group_id, qqid, "强制完成任务", "请输入任务ID或名称") + if task is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_force_complete_guild_task( + guild, task, self._guild_actor( + group_id, qqid))) + + def qq_guild_teleport_base(self, group_id: int, qqid: int): + player = self._qq_guild_prompt_text( + group_id, qqid, "传送玩家到公会据点", "请输入玩家名") + if player is None: + return + guild = self._qq_guild_prompt_optional_query( + group_id, qqid, "传送玩家到公会据点", "请输入公会名/ID,输入 全部 使用玩家所在公会") + if guild is None: + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_teleport_player_to_guild_base( + player, + guild or None)) + + def qq_guild_delete_base(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "删除公会据点") + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_delete_guild_base( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_base(self, group_id: int, qqid: int): + guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会据点") + if guild is None: + return + dimension = self._qq_guild_prompt_int( + group_id, qqid, "设置公会据点", "请输入维度ID") + if dimension is None: + return + x = self._qq_guild_prompt_text(group_id, qqid, "设置公会据点", "请输入 x 坐标") + if x is None: + return + y = self._qq_guild_prompt_text(group_id, qqid, "设置公会据点", "请输入 y 坐标") + if y is None: + return + z = self._qq_guild_prompt_text(group_id, qqid, "设置公会据点", "请输入 z 坐标") + if z is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_base( + guild, dimension, x, y, z, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_base_locked(self, group_id: int, qqid: int, locked: bool): + title = "锁定公会据点" if locked else "解锁公会据点" + guild = self._qq_guild_prompt_guild(group_id, qqid, title) + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_base_locked( + guild, locked, self._guild_actor( + group_id, qqid))) + + def qq_guild_show_activity_status(self, group_id: int, qqid: int): + ok, msg, data = self.guild_system.api_get_guild_activity_status() + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + lines = [] + for key, event in data.items(): + lines.append( + f"{key}: 倍率 { + event.get( + 'multiplier', + 1)} 剩余 { + event.get( + 'remaining_seconds', + 0)} 秒 发起 { + event.get( + 'actor', + '<未知>')}") + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无活动"]) + + def qq_guild_start_activity( + self, + group_id: int, + qqid: int, + activity: str, + multiplier: float): + duration = self._qq_guild_prompt_int( + group_id, qqid, "开启公会活动", "请输入持续秒数", minimum=1, default=3600) + if duration is None: + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_start_guild_activity( + activity, + duration, + multiplier, + self._guild_actor( + group_id, + qqid))) + + def qq_guild_stop_activity(self, group_id: int, qqid: int): + choice = self._qq_guild_prompt( + group_id, + qqid, + "停止活动", + ["双倍经验", "双倍贡献", "公会争霸"], + ["输入 [1-3] 之间的数字以选择 活动", "输入 . 退出"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + activity = { + "1": "exp", + "2": "contribution", + "3": "contest"}.get( + choice.strip()) + if activity is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + self._reply_guild_api_result( + group_id, qqid, + self.guild_system.api_stop_guild_activity(activity)) + + def qq_guild_settle_rewards(self, group_id: int, qqid: int): + sort_by = self._qq_guild_prompt_text( + group_id, qqid, "结算排行奖励", + "请输入排行类型:level/members/contribution/activity") + if sort_by is None: + return + top = self._qq_guild_prompt_int( + group_id, qqid, "结算排行奖励", "请输入结算名次", minimum=1, default=3) + if top is None: + return + reward_exp = self._qq_guild_prompt_int( + group_id, qqid, "结算排行奖励", "请输入经验奖励", minimum=0, default=0) + if reward_exp is None: + return + reward_funds = self._qq_guild_prompt_int( + group_id, qqid, "结算排行奖励", "请输入资金奖励", minimum=0, default=0) + if reward_funds is None: + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_settle_guild_ranking_rewards( + sort_by, + top, + reward_exp, + reward_funds, + self._guild_actor( + group_id, + qqid))) + + def ensure_land_system(self, group_id: int, sender: int): + """检查领地系统云链联动版 API 是否可用。""" + if self.land_system is None: + self._reply_to_qq(group_id, sender, "相关插件未安装:领地系统云链联动版") + return False + return self.ensure_linked_plugin_enabled( + self.land_system, group_id, sender) + + def _format_land_summary(self, land: dict[str, Any], group_id: int): + center = land.get("center", (0, 0, 0)) + admins = "、".join(land.get("admins", [])) or "无" + members = "、".join(land.get("members", [])) or "无" + return self.plugin_ui_menu( + "领地系统云链联动版", + f"领地信息 - {land.get('name', '<未知>')}", + [ + f"领主:{land.get('owner', '<未知>')}", + f"中心:{center[0]}, {center[1]}, {center[2]}", + f"范围:{land.get('range_text', '<未知>')}", + f"管理员:{admins}", + f"成员:{members}", + ], + ["输入 . 退出"], + group_id, + ) + + def _qq_land_prompt( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + hints: list[str], + ): + return self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "领地系统云链联动版", + subtitle, + options, + hints, + group_id, + ), + timeout=120, + ) + + def qq_land_system_menu(self, group_id: int, qqid: int): + """在群里打开领地系统云链联动版管理菜单。""" + if not self._can_use_group_permission(group_id, qqid, "领地系统权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if not self.ensure_land_system(group_id, qqid): + return + choice = self._qq_land_prompt( + group_id, + qqid, + "管理菜单", + [ + "查看领地列表", + "查看领地信息", + "新增玩家领地", + "删除玩家领地", + "添加领地成员", + "移除领地成员", + "添加领地管理员", + "移除领地管理员", + "修改领地所有者", + "修改领地中心点", + "修改领地范围", + ], + ["输入 [1-11] 之间的数字以选择 对应功能", "输入 . 退出"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + handler_map = { + "1": self.qq_land_list, + "2": self.qq_land_show_info, + "3": self.qq_land_add, + "4": self.qq_land_delete, + "5": lambda gid, + qid: self.qq_land_member_action( + gid, + qid, + is_add=True, + rank="member"), + "6": lambda gid, + qid: self.qq_land_member_action( + gid, + qid, + is_add=False, + rank="member"), + "7": lambda gid, + qid: self.qq_land_member_action( + gid, + qid, + is_add=True, + rank="admin"), + "8": lambda gid, + qid: self.qq_land_member_action( + gid, + qid, + is_add=False, + rank="admin"), + "9": self.qq_land_transfer_owner, + "10": self.qq_land_update_center, + "11": self.qq_land_update_range, + } + handler = handler_map.get(choice) + if handler is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + handler(group_id, qqid) + + def qq_land_list(self, group_id: int, qqid: int): + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + if not self.ensure_land_system(group_id, qqid): + return + ok, msg, lands = self.land_system.api_list_lands() + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + if not lands: + self._reply_to_qq(group_id, qqid, "暂无任何领地") + return + page = 1 + per_page = self.get_group_land_items_per_page(group_id) + while True: + total_pages, start_index, end_index = ( + utils.paginate(len(lands), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(lands), per_page, page) + ) + page_lands = lands[start_index - 1: end_index] + text = self.plugin_ui_menu( + "领地系统云链联动版", + "领地列表", + [ + f"{land['name']} - 领主: {land['owner']}, {land['range_text']}" + for land in page_lands + ], + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(page_lands)}] 之间的数字查看详情", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if user_input == "+": + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") + continue + choice = self.parse_displayed_menu_choice( + user_input, len(page_lands)) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + continue + self._reply_to_qq( + group_id, + qqid, + self._format_land_summary(page_lands[choice - 1], group_id), + ) + return + + def _qq_land_prompt_text( + self, + group_id: int, + qqid: int, + subtitle: str, + prompt: str): + value = self._qq_land_prompt( + group_id, qqid, subtitle, [], [ + prompt, "输入 . 退出"]) + if value is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + if self._is_menu_exit(value, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + return value + + @staticmethod + def _parse_land_pos(raw: str): + parts = raw.replace(",", " ").replace(",", " ").split() + if len(parts) != 3: + return None + try: + return (float(parts[0]), float(parts[1]), float(parts[2])) + except ValueError: + return None + + @staticmethod + def _parse_land_size(raw: str): + parts = raw.replace(",", " ").replace(",", " ").split() + if len(parts) != 3: + return None + try: + size = (int(parts[0]), int(parts[1]), int(parts[2])) + except ValueError: + return None + if any(value <= 0 for value in size): + return None + return size + + def qq_land_show_info(self, group_id: int, qqid: int): + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + query = self._qq_land_prompt_text(group_id, qqid, "查看领地信息", "请输入领地名称") + if query is None: + return + ok, msg, land = self.land_system.api_get_land(query) + self._reply_to_qq( + group_id, + qqid, + self._format_land_summary(land, group_id) if ok else msg, + ) + + def qq_land_add(self, group_id: int, qqid: int): + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + owner = self._qq_land_prompt_text( + group_id, qqid, "新增玩家领地", "请输入领地主人玩家名") + if owner is None: + return + name = self._qq_land_prompt_text(group_id, qqid, "新增玩家领地", "请输入领地名称") + if name is None: + return + center_text = self._qq_land_prompt_text( + group_id, qqid, "新增玩家领地", "请输入领地中心坐标,格式:x y z") + if center_text is None: + return + center = self._parse_land_pos(center_text) + if center is None: + self._reply_to_qq(group_id, qqid, "❀ 坐标格式有误") + return + shape_choice = self._qq_land_prompt( + group_id, + qqid, + "新增玩家领地", + ["圆形领地", "方形领地"], + ["输入 [1-2] 之间的数字以选择 领地类型", "输入 . 退出"], + ) + if shape_choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(shape_choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if shape_choice == "1": + radius_text = self._qq_land_prompt_text( + group_id, qqid, "新增圆形领地", "请输入领地半径") + if radius_text is None: + return + try: + radius = int(radius_text) + except ValueError: + self._reply_to_qq(group_id, qqid, "❀ 半径必须为整数") + return + ok, msg, _land = self.land_system.api_add_land( + owner, name, center, "圆形", radius=radius) + elif shape_choice == "2": + size_text = self._qq_land_prompt_text( + group_id, qqid, "新增方形领地", "请输入 长 高 宽") + if size_text is None: + return + size = self._parse_land_size(size_text) + if size is None: + self._reply_to_qq(group_id, qqid, "❀ 方形尺寸格式有误") + return + ok, msg, _land = self.land_system.api_add_land( + owner, name, center, "方形", size=size) + else: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + self._reply_result(group_id, qqid, ok, msg) + + def qq_land_delete(self, group_id: int, qqid: int): + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + query = self._qq_land_prompt_text(group_id, qqid, "删除玩家领地", "请输入领地名称") + if query is None: + return + ok, msg, _data = self.land_system.api_delete_land(query) + self._reply_result(group_id, qqid, ok, msg) + + def qq_land_member_action( + self, + group_id: int, + qqid: int, + is_add: bool, + rank: str): + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + title = "添加" if is_add else "移除" + role = "管理员" if rank == "admin" else "成员" + query = self._qq_land_prompt_text( + group_id, qqid, f"{title}领地{role}", "请输入领地名称") + if query is None: + return + player_name = self._qq_land_prompt_text( + group_id, qqid, f"{title}领地{role}", "请输入玩家名称") + if player_name is None: + return + if is_add: + ok, msg, _land = self.land_system.api_add_member( + query, player_name, rank=rank) + elif rank == "admin": + ok, msg, _land = self.land_system.api_set_member_rank( + query, player_name, "member") + else: + ok, msg, _land = self.land_system.api_remove_member( + query, player_name) + self._reply_result(group_id, qqid, ok, msg) + + def qq_land_transfer_owner(self, group_id: int, qqid: int): + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + query = self._qq_land_prompt_text(group_id, qqid, "修改领地所有者", "请输入领地名称") + if query is None: + return + owner = self._qq_land_prompt_text( + group_id, qqid, "修改领地所有者", "请输入新所有者玩家名") + if owner is None: + return + ok, msg, _land = self.land_system.api_transfer_owner(query, owner) + self._reply_result(group_id, qqid, ok, msg) + + def qq_land_update_center(self, group_id: int, qqid: int): + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + query = self._qq_land_prompt_text(group_id, qqid, "修改领地中心点", "请输入领地名称") + if query is None: + return + center_text = self._qq_land_prompt_text( + group_id, qqid, "修改领地中心点", "请输入新中心坐标,格式:x y z") + if center_text is None: + return + center = self._parse_land_pos(center_text) + if center is None: + self._reply_to_qq(group_id, qqid, "❀ 坐标格式有误") + return + ok, msg, _land = self.land_system.api_update_land_center(query, center) + self._reply_result(group_id, qqid, ok, msg) + + def qq_land_update_range(self, group_id: int, qqid: int): + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + query = self._qq_land_prompt_text(group_id, qqid, "修改领地范围", "请输入领地名称") + if query is None: + return + ok, msg, land = self.land_system.api_get_land(query) + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + if land.get("shape") == "方形": + size_text = self._qq_land_prompt_text( + group_id, qqid, "修改方形领地范围", "请输入新的 长 高 宽") + if size_text is None: + return + size = self._parse_land_size(size_text) + if size is None: + self._reply_to_qq(group_id, qqid, "❀ 方形尺寸格式有误") + return + ok, msg, _land = self.land_system.api_update_land_range( + query, size=size) + else: + radius_text = self._qq_land_prompt_text( + group_id, qqid, "修改圆形领地范围", "请输入新的半径") + if radius_text is None: + return + try: + radius = int(radius_text) + except ValueError: + self._reply_to_qq(group_id, qqid, "❀ 半径必须为整数") + return + ok, msg, _land = self.land_system.api_update_land_range( + query, radius=radius) + self._reply_result(group_id, qqid, ok, msg) + + @staticmethod + def translate_item_name(item_id: str): + """尽量把物品 ID 翻译成中文显示名,失败时退回原始 ID。""" + if not isinstance(item_id, str) or item_id == "": + return "未知物品" + item_tail = item_id.split(":")[-1] + if translate is None: + return item_id + for key in (f"item.{item_tail}.name", f"tile.{item_tail}.name"): + try: + translated = translate(key) + except Exception: + continue + if isinstance( + translated, + str) and translated and translated != key: + return translated + return item_id + + @staticmethod + def get_item_custom_name(slot: Any): + """尝试从物品槽位对象里提取自定义名称。""" + for attr in ("customName", "custom_name", "name"): + value = getattr(slot, attr, None) + if ( + isinstance(value, str) + and value.strip() + and value.strip() != getattr(slot, "id", "") + ): + return value.strip() + return "" + + @staticmethod + def get_item_enchantments_text(slot: Any): + """把槽位上的附魔信息整理成单行文字。""" + enchants = getattr(slot, "enchantments", None) + if not isinstance(enchants, list) or not enchants: + return "" + outputs: list[str] = [] + for enchant in enchants: + if enchant is None: + continue + name = getattr(enchant, "name", None) + level = getattr(enchant, "level", None) + if isinstance(name, str) and name.strip(): + if isinstance(level, int): + outputs.append(f"{name.strip()} {level}") + else: + outputs.append(name.strip()) + else: + etype = getattr(enchant, "type", None) + if etype is not None: + if isinstance(level, int): + outputs.append(f"ID{etype} {level}") + else: + outputs.append(f"ID{etype}") + return "、".join(outputs) + + def on_qq_add_admin(self, group_id: int, sender: int, args: list[str]): + """给当前群添加普通管理员。""" + if not self._can_use_group_permission(group_id, sender, "QQ普通管理员菜单权限"): + self._reply_menu_permission_denied(group_id, sender) + return + try: + qqid = int(args[0]) + except (TypeError, ValueError): + self._reply_to_qq(group_id, sender, "QQ号格式有误") + return + _ok, msg = self.add_group_role(group_id, qqid, is_super=False) + self._reply_to_qq(group_id, sender, msg) + + def qq_admin_menu(self, group_id: int, qqid: int): + """QQ群管理员菜单。 + + 可管理的层级由“权限设置”中的管理员菜单权限决定。 + """ + options = [] + actions = [] + if self._can_use_group_permission(group_id, qqid, "QQ普通管理员菜单权限"): + options.append("普通管理员管理") + actions.append(lambda: self.qq_admin_role_menu( + group_id, qqid, is_super=False)) + if self._can_use_group_permission(group_id, qqid, "QQ超级管理员菜单权限"): + options.append("超级管理员管理") + actions.append(lambda: self.qq_admin_role_menu( + group_id, qqid, is_super=True)) + if not options: + self._reply_menu_permission_denied(group_id, qqid) + return + + if len(options) == 1: + actions[0]() + return + + choice = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "QQ群管理员 管理菜单", + options, + [f"输入 [1-{len(options)}] 之间的数字以选择 对应功能", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + choice = choice.strip() + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + selected = self.parse_displayed_menu_choice(choice, len(options)) + if selected is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + actions[selected - 1]() + + def qq_admin_role_menu(self, group_id: int, qqid: int, is_super: bool): + """增删指定层级的群管理员。""" + permission_name = "QQ超级管理员菜单权限" if is_super else "QQ普通管理员菜单权限" + if not self._can_use_group_permission(group_id, qqid, permission_name): + self._reply_menu_permission_denied(group_id, qqid) + return + role_name = "超级管理员" if is_super else "普通管理员" + choice = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"{role_name} 管理菜单", + [f"添加{role_name}", f"删除{role_name}"], + ["输入 [1-2] 之间的数字以选择 对应功能", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if choice not in ("1", "2"): + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + qq_text = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"{role_name} 管理菜单", + [], + [f"请输入要{'添加' if choice == '1' else '删除'}的 QQ 号", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if qq_text is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(qq_text, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + qqid_target = utils.try_int(qq_text) + if qqid_target is None or qqid_target <= 0: + self._reply_to_qq(group_id, qqid, "❀ QQ号格式有误") + return + if choice == "1": + ok, msg = self.add_group_role( + group_id, qqid_target, is_super=is_super) + else: + ok, msg = self.remove_group_role( + group_id, qqid_target, is_super=is_super) + self._reply_result(group_id, qqid, ok, msg) + + def ensure_whitelist_checker(self, group_id: int, sender: int): + """检查白名单联动插件是否可用,避免菜单点进去后才报空引用。""" + if self.whitelist_checker is None: + self._reply_to_qq(group_id, sender, "相关插件未安装:白名单&管理员检测云链联动版") + return False + return self.ensure_linked_plugin_enabled( + self.whitelist_checker, group_id, sender) + + def on_qq_whitelist_add(self, group_id: int, sender: int, args: list[str]): + """通过群命令把玩家加入白名单。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + ok, msg = self.whitelist_checker.add_whitelist_player(args[0]) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_whitelist_remove( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令把玩家移出白名单。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + ok, msg = self.whitelist_checker.remove_whitelist_player(args[0]) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_server_admin_add( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令把玩家登记为服务器管理员。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + ok, msg = self.whitelist_checker.add_admin_player(args[0]) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_server_admin_remove( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令把玩家从服务器管理员名单中移除。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + ok, msg = self.whitelist_checker.remove_admin_player(args[0]) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_whitelist_toggle( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令切换白名单检测开关。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + action = args[0].strip() + if action not in ("开启", "关闭", "on", "off"): + self._reply_to_qq(group_id, sender, "参数错误,格式:白名单检测 [开启/关闭]") + return + ok, msg = self.whitelist_checker.set_whitelist_enabled( + action in ("开启", "on")) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_admin_check_toggle( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令切换管理员检测开关。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + action = args[0].strip() + if action not in ("开启", "关闭", "on", "off"): + self._reply_to_qq(group_id, sender, "参数错误,格式:管理员检测 [开启/关闭]") + return + ok, msg = self.whitelist_checker.set_admin_check_enabled( + action in ("开启", "on")) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_check_interval( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令修改联动插件的轮询周期。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + try: + seconds = float(args[0]) + except (TypeError, ValueError): + self._reply_to_qq(group_id, sender, "参数错误,格式:检测周期 [秒数]") + return + ok, msg = self.whitelist_checker.set_check_interval(seconds) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_check_status(self, group_id: int, sender: int, _args: list[str]): + """把联动插件当前状态摘要发回群里。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + status = self.whitelist_checker.get_runtime_status() + output = ( + f"[CQ:at,qq={sender}] 白名单&管理员检测云链联动版状态:\n" + f"检测周期:{status['check_interval']} 秒\n" + f"白名单检测:{'开启' if status['whitelist_enabled'] else '关闭'}\n" + f"白名单人数:{status['whitelist_count']}\n" + f"管理员检测:{'开启' if status['admin_check_enabled'] else '关闭'}\n" + f"管理员人数:{status['admin_count']}" + ) + self.sendmsg(group_id, output, do_remove_cq_code=False) + + def _qq_checker_prompt( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + hints: list[str], + ): + """统一构造白名单联动菜单提示并等待回复。""" + return self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "白名单&管理员检测云链联动版", + subtitle, + options, + hints, + group_id, + ), + timeout=120, + ) + + def _qq_checker_handle_player_action( + self, group_id: int, qqid: int, choice: str): + """处理添加/移除白名单与服务器管理员这四类玩家操作。""" + title_map = { + "1": "白名单 添加玩家", + "2": "白名单 移除玩家", + "3": "管理员 添加玩家", + "4": "管理员 移除玩家", + } + handler_map = { + "1": self.on_qq_whitelist_add, + "2": self.on_qq_whitelist_remove, + "3": self.on_qq_server_admin_add, + "4": self.on_qq_server_admin_remove, + } + player_name = self._qq_checker_prompt( + group_id, + qqid, + title_map[choice], + [], + ["请输入玩家名称", "输入 . 退出"], + ) + if player_name is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(player_name, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + handler_map[choice](group_id, qqid, [player_name]) + + def _qq_checker_handle_toggle_action( + self, group_id: int, qqid: int, choice: str): + """处理白名单检测和管理员检测的开关菜单。""" + subtitle = "白名单检测 设置" if choice == "5" else "管理员检测 设置" + handler = ( + self.on_qq_whitelist_toggle + if choice == "5" + else self.on_qq_admin_check_toggle + ) + action = self._qq_checker_prompt( + group_id, + qqid, + subtitle, + ["开启", "关闭"], + ["输入 [1-2] 之间的数字以选择 对应操作", "输入 . 退出"], + ) + if action is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(action, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + action_arg = {"1": ["开启"], "2": ["关闭"]}.get(action) + if action_arg is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + handler(group_id, qqid, action_arg) + + def _qq_checker_handle_interval_action(self, group_id: int, qqid: int): + """处理检测周期设置菜单。""" + seconds = self._qq_checker_prompt( + group_id, + qqid, + "检测周期 设置", + [], + ["请输入检测周期秒数", "输入 . 退出"], + ) + if seconds is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(seconds, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + self.on_qq_check_interval(group_id, qqid, [seconds]) + + def qq_checker_menu(self, group_id: int, qqid: int): + """在群里打开白名单与管理员检测联动菜单。""" + if not self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if not self.ensure_whitelist_checker(group_id, qqid): + return + # 这个菜单本质上是把白名单插件暴露出来的 API 做了一层群聊版操作面板。 + # 这样权限仍然统一归群服互通管理,而不是把原插件的控制台能力原样暴露出来。 + choice = self._qq_checker_prompt( + group_id, + qqid, + "管理系统", + [ + "添加玩家到白名单", + "从白名单中移除玩家", + "添加服务器管理员", + "移除服务器管理员", + "开启/关闭 白名单检测", + "开启/关闭 管理员检测", + "设置检测周期", + "查看当前状态", + ], + ["输入 [1-8] 之间的数字以选择 对应功能", "输入 . 退出"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + + if choice in ("1", "2", "3", "4"): + self._qq_checker_handle_player_action(group_id, qqid, choice) + return + + if choice in ("5", "6"): + self._qq_checker_handle_toggle_action(group_id, qqid, choice) + return + + if choice == "7": + self._qq_checker_handle_interval_action(group_id, qqid) + return + + if choice == "8": + self.on_qq_check_status(group_id, qqid, []) + return + + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" index 2790a17e..365e10de 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" @@ -1,601 +1,1008 @@ -import json -import time -from typing import Any - -try: - import websocket -except ImportError: - from . import websocket - -from tooldelta import Chat, InternalBroadcast, Player, utils - -from .message_utils import ( - EASTER_EGG_QQIDS, - remove_color, - remove_cq_code, - replace_cq, -) - -try: - from tooldelta.utils.mc_translator import translate -except ImportError: - translate = None - - -# 运行时层只管消息流转:执行指令、WebSocket、广播、群服互通分发。 -class QQLinkerRuntimeMixin: - """负责云链运行时、消息分发与 WebSocket 生命周期。""" - - def _start_ws_session(self): - """注册一个新的 WebSocket 会话编号,并清空上次重连状态。""" - self._ws_session_id += 1 - self.reloaded = False - self._ws_reconnect_delay = None - return self._ws_session_id - - def _is_current_ws_session(self, ws_obj, session_id: int): - """判断回调是否来自当前仍然有效的 WebSocket 会话。""" - return session_id == self._ws_session_id and ws_obj is self.ws - - def _print_cloud_status( - self, - title: str, - page_label: str, - lines: list[str], - level: str = "info", - ): - """按统一的控制台卡片样式输出云链连接状态。""" - self.print_console_card(title, page_label, lines, level=level) - - def execute_cmd_and_get_zhcn_cb(self, cmd: str): - """执行 MC 指令,并把原始返回整理成适合群聊展示的文本。""" - try: - result = self.game_ctrl.sendwscmd_with_resp(cmd, 10) - if len(result.OutputMessages) == 0: - return ["😅 指令执行失败", "😄 指令执行成功"][bool(result.SuccessCount)] - if result.OutputMessages[0].Message in ( - "commands.generic.syntax", - "commands.generic.unknown", - ): - return f'😅 未知的 MC 指令, 可能是指令格式有误: "{cmd}"' - if translate is not None: - output_text = "\n".join( - translate(i.Message, i.Parameters) for i in result.OutputMessages - ) - else: - output_text = "\n".join(i.Message for i in result.OutputMessages) - if result.SuccessCount: - return "😄 指令执行成功,执行结果:\n" + output_text - return "😭 指令执行失败,原因:\n" + output_text - except IndexError as exec_err: - import traceback - - traceback.print_exc() - return f"执行出现问题: {exec_err}" - except TimeoutError: - return "😭 超时:指令获取结果返回超时" - - def iter_game_to_group_targets(self): - """遍历当前启用了“游戏到群”转发的群。""" - for group_id in self.group_order: - group_cfg = self.group_cfgs[group_id] - if group_cfg["游戏到群"]["是否启用"]: - yield group_id, group_cfg - - @staticmethod - def should_forward_game_message(msg: str, group_cfg: dict[str, Any]): - """根据群配置判断一条游戏消息是否要转发,以及转发时应裁掉哪些前缀。""" - trans_chars = group_cfg["游戏到群"]["仅转发以下符号开头的消息(列表为空则全部转发)"] - block_prefixs = group_cfg["游戏到群"]["屏蔽以下字符串开头的消息"] - if trans_chars: - for prefix in trans_chars: - if msg.startswith(prefix): - return True, msg[len(prefix) :] - return False, msg - if block_prefixs: - for prefix in block_prefixs: - if msg.startswith(prefix): - return False, msg - return True, msg - - @utils.thread_func("云链群服连接进程") - def connect_to_websocket(self): - """按当前配置或本地桥接参数建立到云链的连接。""" - with self._ws_runner_lock: - if self._ws_runner_active: - self._print_cloud_status( - "群服互通 云链连接", - "运行中", - ["云链连接线程已在运行", "本次重复连接请求已忽略"], - level="warn", - ) - return - self._ws_runner_active = True - - try: - while True: - target = self._get_websocket_target() - header = None - validate_code = self.cfg["云链设置"]["校验码"].strip() - if validate_code: - header = {"Authorization": f"Bearer {validate_code}"} - self._print_cloud_status( - "群服互通 云链连接", - "连接中", - ["正在尝试连接云链", f"目标地址: {target}"], - level="info", - ) - session_id = self._start_ws_session() - ws_app = websocket.WebSocketApp( - target, - header, - on_message=lambda a, b, sid=session_id: self.on_ws_message( - a, b, sid - ) - and None, - on_error=lambda a, b, sid=session_id: self.on_ws_error(a, b, sid), - on_close=lambda a, b, c, sid=session_id: self.on_ws_close( - a, b, c, sid - ), - ) - ws_app.on_open = lambda ws_obj, sid=session_id: self.on_ws_open( - ws_obj, sid - ) - self.ws = ws_app - self.available = False - ws_app.run_forever() - - delay = self._ws_reconnect_delay - if delay is None: - break - time.sleep(delay) - finally: - with self._ws_runner_lock: - self._ws_runner_active = False - - def _get_websocket_target(self): - """返回当前应连接的 WebSocket 地址。""" - if self._manual_launch: - return f"ws://127.0.0.1:{self._manual_launch_port}" - return self.cfg["云链设置"]["地址"] - - @utils.thread_func("云链群服消息广播进程") - def broadcast(self, data): - """把原始群消息广播给主动注册的其他插件。""" - for plugin_name in self.plugin: - self.GetPluginAPI(plugin_name).QQLinker_message(data) - - def on_ws_open(self, _ws, session_id: int): - """在 WebSocket 建立后标记连接可用。""" - if not self._is_current_ws_session(_ws, session_id): - return - self.available = True - self._print_cloud_status( - "群服互通 云链连接", - "已连接", - ["已成功连接到群服互通云链版Ultra版", f"当前地址: {self._get_websocket_target()}"], - level="success", - ) - - @utils.thread_func("群服互通消息接收线程") - def on_ws_message(self, _ws, message, session_id: int): - """处理来自云链的群消息,并按配置分发到不同入口。""" - if not self._is_current_ws_session(_ws, session_id): - return - data = json.loads(message) - if self._stop_when_data_broadcast_handled(data): - return - - payload = self._build_group_message_payload(data) - if payload is None: - return - - group_id, group_cfg, msg, user_id, nickname = payload - if self._consume_waiting_reply(group_id, user_id, msg): - return - if self._stop_when_group_broadcast_handled(group_id, user_id, nickname, msg): - return - if self.execute_triggers(group_id, user_id, msg): - return - self._forward_group_message_to_game(group_cfg, user_id, nickname, msg) - - def _stop_when_data_broadcast_handled(self, data: dict[str, Any]) -> bool: - """把原始数据广播给框架,其它插件声明已处理时立即停止后续流程。""" - bc_recv = self.BroadcastEvent(InternalBroadcast("群服互通/数据json", data)) - if any(bc_recv): - return True - if data.get("post_type") != "message" or data.get("message_type") != "group": - return True - self.broadcast(data) - return False - - def _build_group_message_payload(self, data: dict[str, Any]): - """把云链原始消息整理成后续逻辑统一使用的结构。""" - group_id = data.get("group_id") - if group_id not in self.group_cfgs: - return None - group_cfg = self.group_cfgs[group_id] - msg = self._extract_text_message(data["message"]) - user_id = int(data["sender"]["user_id"]) - nickname = data["sender"]["card"] or data["sender"]["nickname"] - return group_id, group_cfg, msg, user_id, nickname - - @staticmethod - def _extract_text_message(msg: Any) -> str: - """从云链消息结构里提取可处理的纯文本。""" - if isinstance(msg, list): - msg_rawdict = msg[0] - msg_type = msg_rawdict["type"] - msg_data = msg_rawdict["data"] - if msg_type != "text": - return "" - return msg_data["text"] - if not isinstance(msg, str): - raise ValueError(f"键 'message' 值不是字符串类型, 而是 {msg}") - return msg - - def _consume_waiting_reply(self, group_id: int, user_id: int, msg: str) -> bool: - """把当前消息投递给等待输入的菜单回调。""" - wait_key = (group_id, user_id) - if wait_key in self.waitmsg_cbs: - self.waitmsg_cbs[wait_key](msg) - return True - if user_id in self.waitmsg_cbs: - self.waitmsg_cbs[user_id](msg) - return True - return False - - def _stop_when_group_broadcast_handled( - self, - group_id: int, - user_id: int, - nickname: str, - msg: str, - ) -> bool: - """把群消息广播给框架层,其它插件声明已处理时立即停止。""" - bc_recv = self.BroadcastEvent( - InternalBroadcast( - "群服互通/链接群消息", - {"群号": group_id, "QQ号": user_id, "昵称": nickname, "消息": msg}, - ), - ) - return any(bc_recv) - - def _forward_group_message_to_game( - self, - group_cfg: dict[str, Any], - user_id: int, - nickname: str, - msg: str, - ): - """把普通群消息按当前群配置转发到游戏内。""" - if not group_cfg["群到游戏"]["是否启用"]: - return - if user_id in group_cfg["群到游戏"]["屏蔽的QQ号"]: - return - - if group_cfg["群到游戏"]["替换花里胡哨的昵称"]: - nickname = remove_color(nickname) - if group_cfg["群到游戏"]["替换花里胡哨的消息"]: - msg = remove_color(msg) - self.game_ctrl.say_to( - "@a", - utils.simple_fmt( - {"[昵称]": nickname, "[消息]": replace_cq(msg)}, - group_cfg["群到游戏"]["转发格式"], - ), - ) - - def on_ws_error(self, _ws, error, session_id: int): - """处理 WebSocket 错误并按配置尝试重连。""" - if not self._is_current_ws_session(_ws, session_id): - return - if not isinstance(error, Exception): - # 某些 WebSocket 实现会在连接仍然可用时回调空字符串/None。 - # 这类“空错误”没有实际诊断价值,也不代表连接真的断开。 - if error is None or (isinstance(error, str) and error.strip() == ""): - return - self._print_cloud_status( - "群服互通 云链连接", - "停止", - [f"连接线程已结束: {error}", "收到非异常错误对象,已停止重连"], - level="info", - ) - self.reloaded = True - self._ws_reconnect_delay = None - return - self.available = False - self._ws_reconnect_delay = 15 - self._print_cloud_status( - "群服互通 云链连接", - "异常", - [f"连接失败: {error}", "15 秒后尝试重连"], - level="error", - ) - - def waitMsg(self, qqid: int, timeout=60, group_id: int | None = None) -> str | None: - """等待某个 QQ 在指定群里的下一条回复。 - 带 `group_id` 时只收同群回复,不带时保留对旧插件的兼容行为。 - """ - getter, setter = utils.create_result_cb(str) - key: int | tuple[int, int] = qqid if group_id is None else (group_id, qqid) - self.waitmsg_cbs[key] = setter - result = getter(timeout) - if key in self.waitmsg_cbs: - del self.waitmsg_cbs[key] - return result - - def on_ws_close(self, _ws, _, _2, session_id: int): - """连接关闭时按当前状态决定是否自动重连。""" - if not self._is_current_ws_session(_ws, session_id): - return - self.available = False - if self.reloaded: - return - if self._ws_reconnect_delay is None: - self._ws_reconnect_delay = 10 - self._print_cloud_status( - "群服互通 云链连接", - "关闭", - ["连接已关闭", "10 秒后尝试重连"], - level="error", - ) - - def on_player_join(self, playerf: Player): - """把玩家加入事件转发到所有启用了游戏到群的群。""" - player = playerf.name - if not self.ws: - return - for group_id, group_cfg in self.iter_game_to_group_targets(): - if group_cfg["游戏到群"]["转发玩家进退提示"]: - self.sendmsg(group_id, f"{player} 加入了游戏") - - def on_player_leave(self, playerf: Player): - """把玩家离开事件转发到所有启用了游戏到群的群。""" - player = playerf.name - if not self.ws: - return - for group_id, group_cfg in self.iter_game_to_group_targets(): - if group_cfg["游戏到群"]["转发玩家进退提示"]: - self.sendmsg(group_id, f"{player} 退出了游戏") - - def on_player_message(self, chat: Chat): - """按各群配置把游戏聊天消息转发到对应群聊。""" - player = chat.player.name - msg = chat.msg - if not self.ws: - return - for group_id, group_cfg in self.iter_game_to_group_targets(): - can_send, filtered_msg = self.should_forward_game_message(msg, group_cfg) - if not can_send: - continue - self.sendmsg( - group_id, - utils.simple_fmt( - {"[玩家名]": player, "[消息]": remove_cq_code(filtered_msg)}, - group_cfg["游戏到群"]["转发格式"], - ), - ) - - def execute_triggers(self, group_id: int, qqid: int, msg: str): - """对一条群消息做内置命令和外挂命令的统一分发。""" - clean_msg = msg.strip() - if self._handle_exact_trigger(group_id, qqid, clean_msg): - return True - if self._handle_prefixed_command(group_id, qqid, clean_msg): - return True - if self._handle_group_orion_triggers(group_id, qqid, clean_msg): - return True - return self._handle_external_trigger(group_id, qqid, msg) - - def _reply_to_qq(self, group_id: int, qqid: int, text: str): - """向指定 QQ 回复一条消息。""" - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {text}", - do_remove_cq_code=False, - ) - - def _handle_exact_trigger(self, group_id: int, qqid: int, clean_msg: str) -> bool: - """处理帮助、管理员菜单、背包查询等完全匹配型触发词。""" - if clean_msg in self.get_group_help_triggers(group_id): - self.on_qq_help(group_id, qqid, []) - return True - if clean_msg in self.get_group_admin_menu_triggers(group_id): - self.qq_admin_menu(group_id, qqid) - return True - if clean_msg in self.get_group_player_list_triggers(group_id): - self.on_qq_player_list(group_id, qqid, []) - return True - if clean_msg in self.get_group_inventory_menu_triggers(group_id): - return self._run_admin_only_action( - group_id, - qqid, - lambda: self.qq_inventory_menu(group_id, qqid), - ) - if clean_msg in self.get_group_checker_menu_triggers(group_id): - return self._run_admin_only_action( - group_id, - qqid, - lambda: self.qq_checker_menu(group_id, qqid), - ) - return False - - def _handle_prefixed_command( - self, - group_id: int, - qqid: int, - clean_msg: str, - ) -> bool: - """处理带统一前缀的群内执行指令入口。""" - cmd_prefix = self.get_group_cmd_prefix(group_id) - if not clean_msg.startswith(cmd_prefix): - return False - - args = clean_msg.removeprefix(cmd_prefix).strip().split() - if not self.is_group_admin(group_id, qqid): - self._reply_to_qq(group_id, qqid, "你没有权限执行此指令") - return True - if len(args) == 0: - self._reply_to_qq(group_id, qqid, f"参数错误,格式:{cmd_prefix}[指令]") - return True - - self.on_qq_execute_cmd(group_id, qqid, args) - return True - - def _handle_group_orion_triggers( - self, - group_id: int, - qqid: int, - clean_msg: str, - ) -> bool: - """处理 Orion 封禁/解封相关的前缀命令。""" - if self._handle_orion_trigger( - group_id, - qqid, - clean_msg, - self.get_group_orion_ban_triggers(group_id), - self.on_qq_orion_ban, - "[玩家名/xuid] [封禁时间] [原因可选]", - lambda args: len(args) == 0 or len(args) >= 2, - ): - return True - return self._handle_orion_trigger( - group_id, - qqid, - clean_msg, - self.get_group_orion_unban_triggers(group_id), - self.on_qq_orion_unban, - "[玩家名/xuid]", - lambda args: len(args) in (0, 1), - ) - - def _handle_orion_trigger( - self, - group_id: int, - qqid: int, - clean_msg: str, - triggers: list[str], - handler, - args_hint: str, - args_validator, - ) -> bool: - """处理一组 Orion 触发词。""" - for trigger in triggers: - if not clean_msg.startswith(trigger): - continue - args = clean_msg.removeprefix(trigger).strip().split() - if not self.is_group_admin(group_id, qqid): - self._reply_to_qq(group_id, qqid, "你没有权限执行此指令") - return True - if not args_validator(args): - self._reply_to_qq(group_id, qqid, f"参数错误,格式:{trigger} {args_hint}") - return True - handler(group_id, qqid, args) - return True - return False - - def _handle_external_trigger(self, group_id: int, qqid: int, msg: str) -> bool: - """处理外部插件注册进来的自定义触发词。""" - for trigger in self.triggers: - matched = trigger.match(msg) - if not matched: - continue - - if trigger.op_only and not self.is_group_admin(group_id, qqid): - self._reply_permission_denied(group_id, qqid) - return True - - args = msg.removeprefix(matched).strip().split() - if not trigger.args_pd(len(args)): - self._reply_trigger_arg_error( - group_id, - qqid, - matched, - trigger.argument_hint, - ) - return True - - if trigger.accept_group: - trigger.func(group_id, qqid, args) - else: - trigger.func(qqid, args) - return True - return False - - def _reply_permission_denied(self, group_id: int, qqid: int): - """统一处理没有管理权限时的回复。""" - if easter_egg := EASTER_EGG_QQIDS.get(qqid): - _name, nickname = easter_egg - self._reply_to_qq(group_id, qqid, f"你没有权限执行此指令,即使你是 {nickname}..") - return - self._reply_to_qq(group_id, qqid, "你没有权限执行此指令") - - def _reply_trigger_arg_error( - self, - group_id: int, - qqid: int, - trigger: str, - argument_hint: str | None, - ): - """统一处理外部触发器参数不足时的回复。""" - suffix = f" {argument_hint}" if argument_hint else "" - self._reply_to_qq(group_id, qqid, f"参数错误,格式:{trigger}{suffix}") - - def _run_admin_only_action(self, group_id: int, qqid: int, action) -> bool: - """执行仅群管理员可用的动作。""" - if not self.is_group_admin(group_id, qqid): - self._reply_to_qq(group_id, qqid, "你没有权限执行此指令") - return True - action() - return True - - def on_sendmsg_test(self, args: list[str]): - """供控制台快速验证群消息发送链路是否正常。""" - if not self.ws: - self.print_console_error("还没有连接到群服互通云链版Ultra版") - return - if not args: - self.print_console_error("请输入要发送的消息") - return - target_group = None - if len(args) >= 2: - maybe_gid = utils.try_int(args[0]) - if maybe_gid in self.group_cfgs: - target_group = maybe_gid - args = args[1:] - if target_group is not None: - self.sendmsg(target_group, " ".join(args)) - return - for group_id in self.group_order: - self.sendmsg(group_id, " ".join(args)) - - def sendmsg(self, group: int, msg: str, do_remove_cq_code=True): - """向目标群发消息。 - 这里顺手处理了两件事: - - 在还没连上云链时直接忽略发送,避免抛异常 - - at 消息后面补换行,让群里显示更自然 - """ - if self.ws is None: - raise RuntimeError("WebSocket 尚未初始化") - if not self.available: - self._print_cloud_status( - "群服互通 云链连接", - "忽略发送", - ["当前未连接云链", f"已忽略发送到群 {group} 的消息"], - level="warn", - ) - return - if msg.startswith("[CQ:at,qq="): - cq_end = msg.find("]") - if cq_end != -1: - head = msg[: cq_end + 1] - tail = msg[cq_end + 1 :].lstrip() - msg = head if tail == "" else head + "\n" + tail - if do_remove_cq_code: - msg = remove_cq_code(msg) - payload = { - "action": "send_group_msg", - "params": {"group_id": group, "message": msg}, - } - self.ws.send(json.dumps(payload)) +"""Runtime websocket and message forwarding logic for Ultra.""" + +import json +import time +from copy import deepcopy +from typing import Any + +try: + import websocket +except ImportError: + from . import websocket + +from tooldelta import Chat, InternalBroadcast, Player, utils + +from .message_utils import ( + EASTER_EGG_QQIDS, + remove_color, + remove_cq_code, + replace_cq, +) + +try: + from tooldelta.utils.mc_translator import translate +except ImportError: + translate = None + + +# 运行时层只管消息流转:执行指令、WebSocket、广播、群服互通分发。 +class QQLinkerRuntimeMixin: + """负责云链运行时、消息分发与 WebSocket 生命周期。""" + + def _start_ws_session(self): + """注册一个新的 WebSocket 会话编号,并清空上次重连状态。""" + self._ws_session_id += 1 + self.reloaded = False + self._ws_reconnect_delay = None + return self._ws_session_id + + def _is_current_ws_session(self, ws_obj, session_id: int): + """判断回调是否来自当前仍然有效的 WebSocket 会话。""" + return session_id == self._ws_session_id and ws_obj is self.ws + + def _print_cloud_status( + self, + title: str, + page_label: str, + lines: list[str], + level: str = "info", + ): + """按统一的控制台卡片样式输出云链连接状态。""" + self.print_console_card(title, page_label, lines, level=level) + + def execute_cmd_and_get_zhcn_cb(self, cmd: str): + """执行 MC 指令,并把原始返回整理成适合群聊展示的文本。""" + try: + result = self.game_ctrl.sendwscmd_with_resp(cmd, 10) + if len(result.OutputMessages) == 0: + return ["😅 指令执行失败", "😄 指令执行成功"][bool(result.SuccessCount)] + if result.OutputMessages[0].Message in ( + "commands.generic.syntax", + "commands.generic.unknown", + ): + return f'😅 未知的 MC 指令, 可能是指令格式有误: "{cmd}"' + if translate is not None: + output_text = "\n".join( + translate( + i.Message, + i.Parameters) for i in result.OutputMessages) + else: + output_text = "\n".join( + i.Message for i in result.OutputMessages) + if result.SuccessCount: + return "😄 指令执行成功,执行结果:\n" + output_text + return "😭 指令执行失败,原因:\n" + output_text + except IndexError as exec_err: + import traceback + + traceback.print_exc() + return f"执行出现问题: {exec_err}" + except TimeoutError: + return "😭 超时:指令获取结果返回超时" + + def iter_game_to_group_targets(self): + """遍历当前启用了“游戏到群”转发的群。""" + for group_id in self.group_order: + group_cfg = self.group_cfgs[group_id] + if group_cfg["游戏到群"]["是否启用"]: + yield group_id, group_cfg + + @staticmethod + def should_forward_game_message(msg: str, group_cfg: dict[str, Any]): + """根据群配置判断一条游戏消息是否要转发,以及转发时应裁掉哪些前缀。""" + trans_chars = group_cfg["游戏到群"]["仅转发以下符号开头的消息(列表为空则全部转发)"] + block_prefixs = group_cfg["游戏到群"]["屏蔽以下字符串开头的消息"] + if trans_chars: + for prefix in trans_chars: + if msg.startswith(prefix): + return True, msg[len(prefix):] + return False, msg + if block_prefixs: + for prefix in block_prefixs: + if msg.startswith(prefix): + return False, msg + return True, msg + + @utils.thread_func("云链群服连接进程") + def connect_to_websocket(self): + """按当前配置或本地桥接参数建立到云链的连接。""" + with self._ws_runner_lock: + if self._ws_runner_active: + self._print_cloud_status( + "群服互通 云链连接", + "运行中", + ["云链连接线程已在运行", "本次重复连接请求已忽略"], + level="warn", + ) + return + self._ws_runner_active = True + + try: + while True: + target = self._get_websocket_target() + header = None + validate_code = self.cfg["云链设置"]["校验码"].strip() + if validate_code: + header = {"Authorization": f"Bearer {validate_code}"} + self._print_cloud_status( + "群服互通 云链连接", + "连接中", + ["正在尝试连接云链", f"目标地址: {target}"], + level="info", + ) + session_id = self._start_ws_session() + ws_app = websocket.WebSocketApp( + target, header, on_message=lambda a, b, sid=session_id: self.on_ws_message( + a, b, sid) and None, on_error=lambda a, b, sid=session_id: self.on_ws_error( + a, b, sid), on_close=lambda a, b, c, sid=session_id: self.on_ws_close( + a, b, c, sid), ) + ws_app.on_open = lambda ws_obj, sid=session_id: self.on_ws_open( + ws_obj, sid) + self.ws = ws_app + self.available = False + ws_app.run_forever() + + delay = self._ws_reconnect_delay + if delay is None: + break + time.sleep(delay) + finally: + with self._ws_runner_lock: + self._ws_runner_active = False + + def _get_websocket_target(self): + """返回当前应连接的 WebSocket 地址。""" + if self._manual_launch: + return f"ws://127.0.0.1:{self._manual_launch_port}" + return self.cfg["云链设置"]["地址"] + + def api_get_status(self) -> dict[str, Any]: + """Return a compact runtime status snapshot for external plugins.""" + try: + websocket_target = self._get_websocket_target() + except Exception: + websocket_target = "" + return { + "available": bool(self.available), + "ws_initialized": self.ws is not None, + "websocket_target": websocket_target, + "manual_launch": bool(self._manual_launch), + "manual_launch_port": int(self._manual_launch_port), + "reloaded": bool(self.reloaded), + "reconnect_delay": self._ws_reconnect_delay, + "session_id": int(self._ws_session_id), + "linked_groups": list(self.group_order), + "default_group": self.linked_group, + } + + def api_get_online_players(self) -> list[str]: + """Return a copy of current online player names.""" + game_ctrl = getattr(self, "game_ctrl", None) + if game_ctrl is None: + return [] + raw_players = getattr(game_ctrl, "allplayers", []) + try: + players = list(raw_players) + except TypeError: + return [] + result: list[str] = [] + for player in players: + name = getattr(player, "name", player) + name = str(name).strip() + if name: + result.append(name) + return result + + def api_is_player_online( + self, + player_name: str, + ignore_case: bool = False, + ) -> bool: + """Return whether a player name is currently online.""" + name = str(player_name).strip() + if not name: + return False + players = self.api_get_online_players() + if ignore_case: + name = name.lower() + return any(player.lower() == name for player in players) + return name in players + + def api_execute_game_cmd(self, command: str) -> tuple[bool, str]: + """Execute an MC command and return a stable result tuple.""" + cmd = str(command).strip() + if not cmd: + return False, "MC指令不能为空" + if getattr(self, "game_ctrl", None) is None: + return False, "游戏控制器不可用" + try: + result = self.execute_cmd_and_get_zhcn_cb(cmd) + except Exception as err: + return False, f"MC指令执行失败: {err}" + message = "\n".join(result) if isinstance( + result, list) else str(result) + fail_markers = ( + "指令执行失败", + "未知的 MC 指令", + "执行出现问题", + "超时", + ) + return not any(marker in message for marker in fail_markers), message + + def api_get_game_to_group_targets( + self, + enabled_only: bool = True, + ) -> list[dict[str, Any]]: + """Return game-to-group forwarding rules for configured groups.""" + targets: list[dict[str, Any]] = [] + for group_id in self.group_order: + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is None: + continue + game_to_group = group_cfg["游戏到群"] + enabled = bool(game_to_group["是否启用"]) + if enabled_only and not enabled: + continue + targets.append( + { + "group_id": group_id, + "enabled": enabled, + "format": str(game_to_group["转发格式"]), + "required_prefixes": list( + game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"] + ), + "blocked_prefixes": list(game_to_group["屏蔽以下字符串开头的消息"]), + "forward_player_events": bool(game_to_group["转发玩家进退提示"]), + "config": deepcopy(group_cfg), + } + ) + return targets + + def api_should_forward_game_message( + self, + group_id: int | str, + message: str, + ) -> tuple[bool, str] | None: + """Preview whether a game chat message would be forwarded to a group.""" + try: + gid = int(str(group_id).strip()) + except (TypeError, ValueError): + return None + group_cfg = self.group_cfgs.get(gid) + if group_cfg is None: + return None + msg = str(message) + if not group_cfg["游戏到群"]["是否启用"]: + return False, msg + return self.should_forward_game_message(msg, group_cfg) + + def reload_websocket_connection(self): + """让云链连接按当前配置重新建立。""" + self.reloaded = True + self._ws_reconnect_delay = None + self.available = False + self._ws_session_id += 1 + ws_obj = self.ws + if ws_obj is not None: + try: + ws_obj.close() + except Exception as err: + self._print_cloud_status( + "群服互通 云链连接", + "重载", + [f"关闭旧连接失败: {err}", "将继续尝试使用新配置连接"], + level="warn", + ) + self.ws = None + if not self._manual_launch: + self.connect_to_websocket() + + def api_reload_websocket(self) -> tuple[bool, str]: + """Request the cloud WebSocket connection to reload.""" + try: + self.reload_websocket_connection() + except Exception as err: + return False, f"云链重载失败: {err}" + return True, "已请求云链重载" + + def _get_message_listener_store(self): + """Return the raw group message listener registry.""" + if not hasattr(self, "_message_listeners") or not isinstance( + self._message_listeners, dict + ): + self._message_listeners = {} + return self._message_listeners + + def api_register_message_listener( + self, name: str, listener) -> tuple[bool, str]: + """Register a raw group message listener callback.""" + listener_name = str(name).strip() + if not listener_name: + return False, "监听器名称不能为空" + if not callable(listener): + return False, "监听器必须是可调用对象" + listeners = self._get_message_listener_store() + if listener_name in listeners: + return False, "监听器已存在" + listeners[listener_name] = listener + return True, "已注册原始群消息监听器" + + def api_unregister_message_listener(self, name: str) -> tuple[bool, str]: + """Unregister a raw group message listener callback.""" + listener_name = str(name).strip() + if not listener_name: + return False, "监听器名称不能为空" + listeners = self._get_message_listener_store() + if listener_name not in listeners: + return False, "监听器不存在" + del listeners[listener_name] + return True, "已注销原始群消息监听器" + + def api_get_message_listeners(self) -> list[dict[str, Any]]: + """Return metadata for registered raw group message listeners.""" + return [ + {"name": name, "callable": callable(listener)} + for name, listener in self._get_message_listener_store().items() + ] + + def _stop_when_message_listener_handled( + self, data: dict[str, Any]) -> bool: + """Run registered raw message listeners; truthy return stops processing.""" + for name, listener in list(self._get_message_listener_store().items()): + try: + if listener(deepcopy(data)): + return True + except Exception as err: + if hasattr(self, "_print_cloud_status"): + self._print_cloud_status( + "群服互通 原始消息监听", + "监听器异常", + [f"{name}: {err}"], + level="warn", + ) + return False + + @utils.thread_func("云链群服消息广播进程") + def broadcast(self, data): + """把原始群消息广播给主动注册的其他插件。""" + for plugin_name in self.plugin: + self.GetPluginAPI(plugin_name).QQLinker_message(data) + + def on_ws_open(self, _ws, session_id: int): + """在 WebSocket 建立后标记连接可用。""" + if not self._is_current_ws_session(_ws, session_id): + return + self.available = True + self._print_cloud_status( + "群服互通 云链连接", + "已连接", + ["已成功连接到群服互通云链版Ultra版", f"当前地址: {self._get_websocket_target()}"], + level="success", + ) + + @utils.thread_func("群服互通消息接收线程") + def on_ws_message(self, _ws, message, session_id: int): + """处理来自云链的群消息,并按配置分发到不同入口。""" + if not self._is_current_ws_session(_ws, session_id): + return + data = json.loads(message) + if self._stop_when_data_broadcast_handled(data): + return + + payload = self._build_group_message_payload(data) + if payload is None: + return + + group_id, group_cfg, msg, user_id, nickname = payload + if self._consume_waiting_reply(group_id, user_id, msg): + return + if self._stop_when_group_broadcast_handled( + group_id, user_id, nickname, msg): + return + if self.execute_triggers(group_id, user_id, msg): + return + self._forward_group_message_to_game(group_cfg, user_id, nickname, msg) + + def _stop_when_data_broadcast_handled(self, data: dict[str, Any]) -> bool: + """把原始数据广播给框架,其它插件声明已处理时立即停止后续流程。""" + bc_recv = self.BroadcastEvent(InternalBroadcast("群服互通/数据json", data)) + if any(bc_recv): + return True + if data.get("post_type") != "message" or data.get( + "message_type") != "group": + return True + if self._stop_when_message_listener_handled(data): + return True + self.broadcast(data) + return False + + def _build_group_message_payload(self, data: dict[str, Any]): + """把云链原始消息整理成后续逻辑统一使用的结构。""" + group_id = data.get("group_id") + if group_id not in self.group_cfgs: + return None + group_cfg = self.group_cfgs[group_id] + msg = self._extract_text_message(data["message"]) + user_id = int(data["sender"]["user_id"]) + nickname = data["sender"]["card"] or data["sender"]["nickname"] + return group_id, group_cfg, msg, user_id, nickname + + @staticmethod + def _extract_text_message(msg: Any) -> str: + """从云链消息结构里提取可处理的纯文本。""" + if isinstance(msg, list): + msg_rawdict = msg[0] + msg_type = msg_rawdict["type"] + msg_data = msg_rawdict["data"] + if msg_type != "text": + return "" + return msg_data["text"] + if not isinstance(msg, str): + raise ValueError(f"键 'message' 值不是字符串类型, 而是 {msg}") + return msg + + def _consume_waiting_reply( + self, + group_id: int, + user_id: int, + msg: str) -> bool: + """把当前消息投递给等待输入的菜单回调。""" + wait_key = (group_id, user_id) + cb = self.waitmsg_cbs.pop(wait_key, None) + if cb is not None: + cb(msg) + return True + cb = self.waitmsg_cbs.pop(user_id, None) + if cb is not None: + cb(msg) + return True + return False + + def _stop_when_group_broadcast_handled( + self, + group_id: int, + user_id: int, + nickname: str, + msg: str, + ) -> bool: + """把群消息广播给框架层,其它插件声明已处理时立即停止。""" + bc_recv = self.BroadcastEvent( + InternalBroadcast( + "群服互通/链接群消息", + {"群号": group_id, "QQ号": user_id, "昵称": nickname, "消息": msg}, + ), + ) + return any(bc_recv) + + def _forward_group_message_to_game( + self, + group_cfg: dict[str, Any], + user_id: int, + nickname: str, + msg: str, + ): + """把普通群消息按当前群配置转发到游戏内。""" + if not group_cfg["群到游戏"]["是否启用"]: + return + if user_id in group_cfg["群到游戏"]["屏蔽的QQ号"]: + return + trans_chars = group_cfg["群到游戏"]["仅转发以下符号开头的消息(列表为空则全部转发)"] + if trans_chars: + matched_prefix = None + for prefix in trans_chars: + if msg.startswith(prefix): + matched_prefix = prefix + break + if matched_prefix is None: + return + msg = msg[len(matched_prefix):] + + if group_cfg["群到游戏"]["替换花里胡哨的昵称"]: + nickname = remove_color(nickname) + if group_cfg["群到游戏"]["替换花里胡哨的消息"]: + msg = remove_color(msg) + self.game_ctrl.say_to( + "@a", + utils.simple_fmt( + {"[昵称]": nickname, "[消息]": replace_cq(msg)}, + group_cfg["群到游戏"]["转发格式"], + ), + ) + + def on_ws_error(self, _ws, error, session_id: int): + """处理 WebSocket 错误并按配置尝试重连。""" + if not self._is_current_ws_session(_ws, session_id): + return + if not isinstance(error, Exception): + # 某些 WebSocket 实现会在连接仍然可用时回调空字符串/None。 + # 这类“空错误”没有实际诊断价值,也不代表连接真的断开。 + if error is None or (isinstance(error, str) + and error.strip() == ""): + return + self._print_cloud_status( + "群服互通 云链连接", + "停止", + [f"连接线程已结束: {error}", "收到非异常错误对象,已停止重连"], + level="info", + ) + self.reloaded = True + self._ws_reconnect_delay = None + return + self.available = False + self._ws_reconnect_delay = 15 + self._print_cloud_status( + "群服互通 云链连接", + "异常", + [f"连接失败: {error}", "15 秒后尝试重连"], + level="error", + ) + + def waitMsg( + self, + qqid: int, + timeout=60, + group_id: int | None = None) -> str | None: + """等待某个 QQ 在指定群里的下一条回复。 + 带 `group_id` 时只收同群回复,不带时保留对旧插件的兼容行为。 + """ + getter, setter = utils.create_result_cb(str) + key: int | tuple[int, int] = qqid if group_id is None else ( + group_id, qqid) + self.waitmsg_cbs[key] = setter + try: + return getter(timeout) + finally: + if self.waitmsg_cbs.get(key) is setter: + del self.waitmsg_cbs[key] + + def api_wait_group_msg( + self, + qqid: int | str, + timeout: int = 60, + group_id: int | str | None = None, + ) -> str | None: + """Wait for one QQ member's next group message.""" + try: + qid = int(str(qqid).strip()) + except (TypeError, ValueError): + return None + if qid <= 0: + return None + gid = None + if group_id is not None: + try: + gid = int(str(group_id).strip()) + except (TypeError, ValueError): + return None + try: + wait_seconds = max(0, int(timeout)) + except (TypeError, ValueError): + wait_seconds = 60 + return self.waitMsg(qid, wait_seconds, gid) + + def on_ws_close(self, _ws, _, _2, session_id: int): + """连接关闭时按当前状态决定是否自动重连。""" + if not self._is_current_ws_session(_ws, session_id): + return + self.available = False + if self.reloaded: + return + if self._ws_reconnect_delay is None: + self._ws_reconnect_delay = 10 + self._print_cloud_status( + "群服互通 云链连接", + "关闭", + ["连接已关闭", "10 秒后尝试重连"], + level="error", + ) + + def on_player_join(self, playerf: Player): + """把玩家加入事件转发到所有启用了游戏到群的群。""" + player = playerf.name + if not self.ws: + return + for group_id, group_cfg in self.iter_game_to_group_targets(): + if group_cfg["游戏到群"]["转发玩家进退提示"]: + self.sendmsg(group_id, f"{player} 加入了游戏") + + def on_player_leave(self, playerf: Player): + """把玩家离开事件转发到所有启用了游戏到群的群。""" + player = playerf.name + if not self.ws: + return + for group_id, group_cfg in self.iter_game_to_group_targets(): + if group_cfg["游戏到群"]["转发玩家进退提示"]: + self.sendmsg(group_id, f"{player} 退出了游戏") + + def on_player_message(self, chat: Chat): + """按各群配置把游戏聊天消息转发到对应群聊。""" + if self.consume_game_binding_code(chat): + return True + + player = chat.player.name + msg = chat.msg + if not self.ws: + return + for group_id, group_cfg in self.iter_game_to_group_targets(): + can_send, filtered_msg = self.should_forward_game_message( + msg, group_cfg) + if not can_send: + continue + self.sendmsg( + group_id, + utils.simple_fmt( + {"[玩家名]": player, "[消息]": remove_cq_code(filtered_msg)}, + group_cfg["游戏到群"]["转发格式"], + ), + ) + + def execute_triggers(self, group_id: int, qqid: int, msg: str): + """对一条群消息做内置命令和外挂命令的统一分发。""" + clean_msg = msg.strip() + if self._handle_exact_trigger(group_id, qqid, clean_msg): + return True + if self._handle_prefixed_command(group_id, qqid, clean_msg): + return True + if self._handle_group_orion_triggers(group_id, qqid, clean_msg): + return True + return self._handle_external_trigger(group_id, qqid, msg) + + def _reply_to_qq(self, group_id: int, qqid: int, text: str): + """向指定 QQ 回复一条消息。""" + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False, + ) + + def _handle_exact_trigger( + self, + group_id: int, + qqid: int, + clean_msg: str) -> bool: + """处理帮助、管理员菜单、背包查询等完全匹配型触发词。""" + if self._handle_binding_trigger(group_id, qqid, clean_msg): + return True + if clean_msg in self.get_group_help_triggers(group_id): + self.on_qq_help(group_id, qqid, []) + return True + if clean_msg in self.get_group_admin_menu_triggers(group_id): + if not self._has_any_group_permission( + group_id, + qqid, + ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), + ): + self._reply_permission_denied(group_id, qqid) + return True + self.qq_admin_menu(group_id, qqid) + return True + if clean_msg in self.get_group_config_menu_triggers(group_id): + return self._run_permission_action( + group_id, + qqid, + "配置配置文件权限", + lambda: self.qq_config_center_menu(group_id, qqid), + ) + if clean_msg in self.get_group_player_list_triggers(group_id): + if not self._has_group_permission(group_id, qqid, "查看玩家人数权限"): + self._reply_permission_denied(group_id, qqid) + return True + self.on_qq_player_list(group_id, qqid, []) + return True + if clean_msg in self.get_group_inventory_menu_triggers(group_id): + return self._run_permission_action( + group_id, + qqid, + "查询背包权限", + lambda: self.qq_inventory_menu(group_id, qqid), + ) + if clean_msg in self.get_group_checker_menu_triggers(group_id): + return self._run_permission_action( + group_id, + qqid, + "白名单&管理员检测权限", + lambda: self.qq_checker_menu(group_id, qqid), + ) + if clean_msg in self.get_group_task_menu_triggers(group_id): + return self._run_permission_action( + group_id, + qqid, + "任务系统权限", + lambda: self.qq_task_system_menu(group_id, qqid), + ) + if clean_msg in self.get_group_land_menu_triggers(group_id): + return self._run_permission_action( + group_id, + qqid, + "领地系统权限", + lambda: self.qq_land_system_menu(group_id, qqid), + ) + if clean_msg in self.get_group_guild_menu_triggers(group_id): + self.qq_guild_entry_menu(group_id, qqid) + return True + return False + + def _handle_prefixed_command( + self, + group_id: int, + qqid: int, + clean_msg: str, + ) -> bool: + """处理带统一前缀的群内执行指令入口。""" + cmd_prefix = self.get_group_cmd_prefix(group_id) + if not clean_msg.startswith(cmd_prefix): + return False + + args = clean_msg.removeprefix(cmd_prefix).strip().split() + if not self._has_group_permission(group_id, qqid, "发送指令权限"): + self._reply_permission_denied(group_id, qqid) + return True + if len(args) == 0: + self._reply_to_qq(group_id, qqid, f"参数错误,格式:{cmd_prefix}[指令]") + return True + + self.on_qq_execute_cmd(group_id, qqid, args) + return True + + def _handle_group_orion_triggers( + self, + group_id: int, + qqid: int, + clean_msg: str, + ) -> bool: + """处理 Orion 封禁/解封相关的前缀命令。""" + if self._handle_orion_trigger( + group_id, + qqid, + clean_msg, + self.get_group_orion_ban_triggers(group_id), + self.on_qq_orion_ban, + "[玩家名/xuid] [封禁时间] [原因可选]", + lambda args: len(args) == 0 or len(args) >= 2, + ): + return True + return self._handle_orion_trigger( + group_id, + qqid, + clean_msg, + self.get_group_orion_unban_triggers(group_id), + self.on_qq_orion_unban, + "[玩家名/xuid]", + lambda args: len(args) in (0, 1), + ) + + def _handle_orion_trigger( + self, + group_id: int, + qqid: int, + clean_msg: str, + triggers: list[str], + handler, + args_hint: str, + args_validator, + ) -> bool: + """处理一组 Orion 触发词。""" + for trigger in triggers: + if not clean_msg.startswith(trigger): + continue + args = clean_msg.removeprefix(trigger).strip().split() + if not self._has_group_permission(group_id, qqid, "封禁/解封玩家权限"): + self._reply_permission_denied(group_id, qqid) + return True + if not args_validator(args): + self._reply_to_qq( + group_id, qqid, f"参数错误,格式:{trigger} {args_hint}") + return True + handler(group_id, qqid, args) + return True + return False + + def _handle_external_trigger( + self, + group_id: int, + qqid: int, + msg: str) -> bool: + """处理外部插件注册进来的自定义触发词。""" + for trigger in self.triggers: + matched = trigger.match(msg) + if not matched: + continue + + if trigger.op_only and not self.is_group_admin(group_id, qqid): + self._reply_permission_denied(group_id, qqid) + return True + + args = msg.removeprefix(matched).strip().split() + if not trigger.args_pd(len(args)): + self._reply_trigger_arg_error( + group_id, + qqid, + matched, + trigger.argument_hint, + ) + return True + + if trigger.accept_group: + trigger.func(group_id, qqid, args) + else: + trigger.func(qqid, args) + return True + return False + + def _reply_permission_denied(self, group_id: int, qqid: int): + """统一处理没有管理权限时的回复。""" + if easter_egg := EASTER_EGG_QQIDS.get(qqid): + _name, nickname = easter_egg + self._reply_to_qq(group_id, qqid, f"你没有权限执行此指令,即使你是 {nickname}..") + return + self._reply_to_qq(group_id, qqid, "你没有权限执行此指令") + + def _reply_trigger_arg_error( + self, + group_id: int, + qqid: int, + trigger: str, + argument_hint: str | None, + ): + """统一处理外部触发器参数不足时的回复。""" + suffix = f" {argument_hint}" if argument_hint else "" + self._reply_to_qq(group_id, qqid, f"参数错误,格式:{trigger}{suffix}") + + def _has_group_permission( + self, + group_id: int, + qqid: int, + permission_name: str) -> bool: + if hasattr(self, "has_group_permission"): + return self.has_group_permission(group_id, qqid, permission_name) + return self.is_group_admin(group_id, qqid) + + def _has_any_group_permission( + self, + group_id: int, + qqid: int, + permission_names: tuple[str, ...], + ) -> bool: + return any( + self._has_group_permission(group_id, qqid, permission_name) + for permission_name in permission_names + ) + + def _run_permission_action( + self, + group_id: int, + qqid: int, + permission_name: str, + action, + ) -> bool: + """执行按配置权限控制的动作。""" + if not self._has_group_permission(group_id, qqid, permission_name): + self._reply_permission_denied(group_id, qqid) + return True + action() + return True + + def _run_admin_only_action(self, group_id: int, qqid: int, action) -> bool: + """执行仅群管理员可用的动作。""" + if not self.is_group_admin(group_id, qqid): + self._reply_to_qq(group_id, qqid, "你没有权限执行此指令") + return True + action() + return True + + def on_sendmsg_test(self, args: list[str]): + """供控制台快速验证群消息发送链路是否正常。""" + if not self.ws: + self.print_console_error("还没有连接到群服互通云链版Ultra版") + return + if not args: + self.print_console_error("请输入要发送的消息") + return + target_group = None + if len(args) >= 2: + maybe_gid = utils.try_int(args[0]) + if maybe_gid in self.group_cfgs: + target_group = maybe_gid + args = args[1:] + if target_group is not None: + self.sendmsg(target_group, " ".join(args)) + return + for group_id in self.group_order: + self.sendmsg(group_id, " ".join(args)) + + def sendmsg(self, group: int, msg: str, do_remove_cq_code=True): + """向目标群发消息。 + 这里顺手处理了两件事: + - 在还没连上云链时直接忽略发送,避免抛异常 + - at 消息后面补换行,让群里显示更自然 + """ + if self.ws is None: + raise RuntimeError("WebSocket 尚未初始化") + if not self.available: + self._print_cloud_status( + "群服互通 云链连接", + "忽略发送", + ["当前未连接云链", f"已忽略发送到群 {group} 的消息"], + level="warn", + ) + return + if msg.startswith("[CQ:at,qq="): + cq_end = msg.find("]") + if cq_end != -1: + head = msg[: cq_end + 1] + tail = msg[cq_end + 1:].lstrip() + msg = head if tail == "" else head + "\n" + tail + if do_remove_cq_code: + msg = remove_cq_code(msg) + payload = { + "action": "send_group_msg", + "params": {"group_id": group, "message": msg}, + } + self.ws.send(json.dumps(payload)) + + def api_send_group_msg( + self, + group_id: int | str, + message: str, + remove_cq_code: bool = True, + ) -> tuple[bool, str]: + """Send a QQ group message and return a stable result tuple.""" + try: + gid = int(str(group_id).strip()) + except (TypeError, ValueError): + return False, "群号无效" + if gid <= 0: + return False, "群号无效" + if self.ws is None: + return False, "WebSocket 尚未初始化" + if not self.available: + return False, "云链当前未连接" + try: + self.sendmsg( + gid, + str(message), + do_remove_cq_code=bool(remove_cq_code)) + except Exception as err: + return False, f"发送群消息失败: {err}" + return True, "已发送群消息" + + def api_reply_group_member( + self, + group_id: int | str, + qqid: int | str, + message: str, + ) -> tuple[bool, str]: + """Reply to a QQ group member with an at-mention.""" + try: + qid = int(str(qqid).strip()) + except (TypeError, ValueError): + return False, "QQ号无效" + if qid <= 0: + return False, "QQ号无效" + return self.api_send_group_msg( + group_id, + f"[CQ:at,qq={qid}] {message}", + remove_cq_code=False, + ) + + def api_send_private_msg( + self, + qqid: int | str, + message: str, + ) -> tuple[bool, str]: + """Send a private QQ message and return a stable result tuple.""" + try: + qid = int(str(qqid).strip()) + except (TypeError, ValueError): + return False, "QQ号无效" + if qid <= 0: + return False, "QQ号无效" + if self.ws is None: + return False, "WebSocket 尚未初始化" + if not self.available: + return False, "云链当前未连接" + try: + self.send_private_msg(qid, str(message)) + except Exception as err: + return False, f"发送私信失败: {err}" + return True, "已发送私信" diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_abnf.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_abnf.py" index 5c44f64d..01d9cd51 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_abnf.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_abnf.py" @@ -1,480 +1,496 @@ -""" -_abnf.py -websocket - WebSocket client library for Python - -Copyright 2024 engn33r - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" - -import array -import os -import struct -import sys -from threading import Lock -from typing import Callable, Optional, Union - -from ._exceptions import WebSocketPayloadException, WebSocketProtocolException -from ._utils import validate_utf8 - -try: - # If wsaccel is available, use compiled routines to mask data. - # wsaccel only provides around a 10% speed boost compared - # to the websocket-client _mask() implementation. - # Note that wsaccel is unmaintained. - from wsaccel.xormask import XorMaskerSimple - - def _mask(mask_value: array.array, data_value: array.array) -> bytes: - """Apply the accelerated XOR mask implementation.""" - mask_result: bytes = XorMaskerSimple(mask_value).process(data_value) - return mask_result - -except ImportError: - # wsaccel is not available, use websocket-client _mask() - native_byteorder = sys.byteorder - - def _mask(mask_value: array.array, data_value: array.array) -> bytes: - """Apply the pure-Python XOR mask implementation.""" - datalen = len(data_value) - int_data_value = int.from_bytes(data_value, native_byteorder) - int_mask_value = int.from_bytes( - mask_value * (datalen // 4) + mask_value[: datalen % 4], native_byteorder - ) - return (int_data_value ^ int_mask_value).to_bytes(datalen, native_byteorder) - - -__all__ = [ - "ABNF", - "continuous_frame", - "frame_buffer", - "STATUS_NORMAL", - "STATUS_GOING_AWAY", - "STATUS_PROTOCOL_ERROR", - "STATUS_UNSUPPORTED_DATA_TYPE", - "STATUS_STATUS_NOT_AVAILABLE", - "STATUS_ABNORMAL_CLOSED", - "STATUS_INVALID_PAYLOAD", - "STATUS_POLICY_VIOLATION", - "STATUS_MESSAGE_TOO_BIG", - "STATUS_INVALID_EXTENSION", - "STATUS_UNEXPECTED_CONDITION", - "STATUS_BAD_GATEWAY", - "STATUS_TLS_HANDSHAKE_ERROR", -] - -# closing frame status codes. -STATUS_NORMAL = 1000 -STATUS_GOING_AWAY = 1001 -STATUS_PROTOCOL_ERROR = 1002 -STATUS_UNSUPPORTED_DATA_TYPE = 1003 -STATUS_STATUS_NOT_AVAILABLE = 1005 -STATUS_ABNORMAL_CLOSED = 1006 -STATUS_INVALID_PAYLOAD = 1007 -STATUS_POLICY_VIOLATION = 1008 -STATUS_MESSAGE_TOO_BIG = 1009 -STATUS_INVALID_EXTENSION = 1010 -STATUS_UNEXPECTED_CONDITION = 1011 -STATUS_SERVICE_RESTART = 1012 -STATUS_TRY_AGAIN_LATER = 1013 -STATUS_BAD_GATEWAY = 1014 -STATUS_TLS_HANDSHAKE_ERROR = 1015 - -VALID_CLOSE_STATUS = ( - STATUS_NORMAL, - STATUS_GOING_AWAY, - STATUS_PROTOCOL_ERROR, - STATUS_UNSUPPORTED_DATA_TYPE, - STATUS_INVALID_PAYLOAD, - STATUS_POLICY_VIOLATION, - STATUS_MESSAGE_TOO_BIG, - STATUS_INVALID_EXTENSION, - STATUS_UNEXPECTED_CONDITION, - STATUS_SERVICE_RESTART, - STATUS_TRY_AGAIN_LATER, - STATUS_BAD_GATEWAY, -) - - -class ABNF: - """ - ABNF frame class. - See http://tools.ietf.org/html/rfc5234 - and http://tools.ietf.org/html/rfc6455#section-5.2 - """ - - # operation code values. - OPCODE_CONT = 0x0 - OPCODE_TEXT = 0x1 - OPCODE_BINARY = 0x2 - OPCODE_CLOSE = 0x8 - OPCODE_PING = 0x9 - OPCODE_PONG = 0xA - - # available operation code value tuple - OPCODES = ( - OPCODE_CONT, - OPCODE_TEXT, - OPCODE_BINARY, - OPCODE_CLOSE, - OPCODE_PING, - OPCODE_PONG, - ) - - # opcode human readable string - OPCODE_MAP = { - OPCODE_CONT: "cont", - OPCODE_TEXT: "text", - OPCODE_BINARY: "binary", - OPCODE_CLOSE: "close", - OPCODE_PING: "ping", - OPCODE_PONG: "pong", - } - - # data length threshold. - LENGTH_7 = 0x7E - LENGTH_16 = 1 << 16 - LENGTH_63 = 1 << 63 - - def __init__( - self, - fin: int = 0, - rsv1: int = 0, - rsv2: int = 0, - rsv3: int = 0, - opcode: int = OPCODE_TEXT, - mask_value: int = 1, - data: Union[str, bytes, None] = "", - ) -> None: - """Construct an ABNF frame. Please check the RFC for arguments.""" - self.fin = fin - self.rsv1 = rsv1 - self.rsv2 = rsv2 - self.rsv3 = rsv3 - self.opcode = opcode - self.mask_value = mask_value - if data is None: - data = "" - self.data = data - self.get_mask_key = os.urandom - - def validate(self, skip_utf8_validation: bool = False) -> None: - """ - Validate the ABNF frame. - - Parameters - ---------- - skip_utf8_validation: skip utf8 validation. - """ - if self.rsv1 or self.rsv2 or self.rsv3: - raise WebSocketProtocolException("rsv is not implemented, yet") - - if self.opcode not in ABNF.OPCODES: - raise WebSocketProtocolException(f"Invalid opcode {self.opcode!r}") - - if self.opcode == ABNF.OPCODE_PING and not self.fin: - raise WebSocketProtocolException("Invalid ping frame.") - - if self.opcode == ABNF.OPCODE_CLOSE: - data_length = len(self.data) - if not data_length: - return - if data_length == 1 or data_length >= 126: - raise WebSocketProtocolException("Invalid close frame.") - if ( - data_length > 2 - and not skip_utf8_validation - and not validate_utf8(self.data[2:]) - ): - raise WebSocketProtocolException("Invalid close frame.") - - code = 256 * int(self.data[0]) + int(self.data[1]) - if not self._is_valid_close_status(code): - raise WebSocketProtocolException(f"Invalid close opcode {code!r}") - - @staticmethod - def _is_valid_close_status(code: int) -> bool: - """Return whether the close status code is allowed by the protocol.""" - return code in VALID_CLOSE_STATUS or (3000 <= code < 5000) - - def __str__(self) -> str: - """Return a concise human-readable representation of the frame.""" - return f"fin={self.fin} opcode={self.opcode} data={self.data}" - - @staticmethod - def create_frame(data: Union[bytes, str], opcode: int, fin: int = 1) -> "ABNF": - """ - Create frame to send text, binary and other data. - - Parameters - ---------- - data: str - data to send. This is string value(byte array). - If opcode is OPCODE_TEXT and this value is unicode, - data value is converted into unicode string, automatically. - opcode: int - operation code. please see OPCODE_MAP. - fin: int - fin flag. if set to 0, create continue fragmentation. - """ - if opcode == ABNF.OPCODE_TEXT and isinstance(data, str): - data = data.encode("utf-8") - # mask must be set if send data from client - return ABNF(fin, 0, 0, 0, opcode, 1, data) - - def format(self) -> bytes: - """Format this object to the byte sequence sent to the server.""" - if any(x not in (0, 1) for x in [self.fin, self.rsv1, self.rsv2, self.rsv3]): - raise ValueError("not 0 or 1") - if self.opcode not in ABNF.OPCODES: - raise ValueError("Invalid OPCODE") - length = len(self.data) - if length >= ABNF.LENGTH_63: - raise ValueError("data is too long") - - frame_header = chr( - self.fin << 7 - | self.rsv1 << 6 - | self.rsv2 << 5 - | self.rsv3 << 4 - | self.opcode - ).encode("latin-1") - if length < ABNF.LENGTH_7: - frame_header += chr(self.mask_value << 7 | length).encode("latin-1") - elif length < ABNF.LENGTH_16: - frame_header += chr(self.mask_value << 7 | 0x7E).encode("latin-1") - frame_header += struct.pack("!H", length) - else: - frame_header += chr(self.mask_value << 7 | 0x7F).encode("latin-1") - frame_header += struct.pack("!Q", length) - - if not self.mask_value: - if isinstance(self.data, str): - self.data = self.data.encode("utf-8") - return frame_header + self.data - mask_key = self.get_mask_key(4) - return frame_header + self._get_masked(mask_key) - - def _get_masked(self, mask_key: Union[str, bytes]) -> bytes: - """Return the payload prefixed with its masking key.""" - s = ABNF.mask(mask_key, self.data) - - if isinstance(mask_key, str): - mask_key = mask_key.encode("utf-8") - - return mask_key + s - - @staticmethod - def mask(mask_key: Union[str, bytes], data: Union[str, bytes]) -> bytes: - """ - Mask or unmask data. Just do xor for each byte - - Parameters - ---------- - mask_key: bytes or str - 4 byte mask. - data: bytes or str - data to mask/unmask. - """ - if data is None: - data = "" - - if isinstance(mask_key, str): - mask_key = mask_key.encode("latin-1") - - if isinstance(data, str): - data = data.encode("latin-1") - - return _mask(array.array("B", mask_key), array.array("B", data)) - - -class frame_buffer: - """Buffer and decode raw bytes into complete WebSocket frames.""" - - _HEADER_MASK_INDEX = 5 - _HEADER_LENGTH_INDEX = 6 - - def __init__( - self, recv_fn: Callable[[int], int], skip_utf8_validation: bool - ) -> None: - """Initialize a frame buffer backed by the provided receive function.""" - self.recv = recv_fn - self.skip_utf8_validation = skip_utf8_validation - self.header: Optional[tuple] = None - self.length: Optional[int] = None - self.mask_value: Union[bytes, str, None] = None - # Buffers over the packets from the layer beneath until desired amount - # bytes of bytes are received. - self.recv_buffer: list = [] - self.clear() - self.lock = Lock() - - def clear(self) -> None: - """Reset the partially decoded frame state.""" - self.header: Optional[tuple] = None - self.length: Optional[int] = None - self.mask_value: Union[bytes, str, None] = None - - def has_received_header(self) -> bool: - """Return whether the current frame header still needs to be read.""" - return self.header is None - - def recv_header(self) -> None: - """Read and parse the fixed two-byte WebSocket frame header.""" - header = self.recv_strict(2) - b1 = header[0] - fin = b1 >> 7 & 1 - rsv1 = b1 >> 6 & 1 - rsv2 = b1 >> 5 & 1 - rsv3 = b1 >> 4 & 1 - opcode = b1 & 0xF - b2 = header[1] - has_mask = b2 >> 7 & 1 - length_bits = b2 & 0x7F - - self.header = (fin, rsv1, rsv2, rsv3, opcode, has_mask, length_bits) - - def has_mask(self) -> Union[bool, int]: - """Return whether the current frame carries a masking key.""" - if not self.header: - return False - header_val: int = self.header[frame_buffer._HEADER_MASK_INDEX] - return header_val - - def has_received_length(self) -> bool: - """Return whether the payload length still needs to be read.""" - return self.length is None - - def recv_length(self) -> None: - """Read the payload length using the current header metadata.""" - bits = self.header[frame_buffer._HEADER_LENGTH_INDEX] - length_bits = bits & 0x7F - if length_bits == 0x7E: - v = self.recv_strict(2) - self.length = struct.unpack("!H", v)[0] - elif length_bits == 0x7F: - v = self.recv_strict(8) - self.length = struct.unpack("!Q", v)[0] - else: - self.length = length_bits - - def has_received_mask(self) -> bool: - """Return whether the mask key still needs to be read.""" - return self.mask_value is None - - def recv_mask(self) -> None: - """Read the masking key when the current frame is masked.""" - self.mask_value = self.recv_strict(4) if self.has_mask() else "" - - def recv_frame(self) -> ABNF: - """Read bytes from the socket until a complete frame is decoded.""" - with self.lock: - # Header - if self.has_received_header(): - self.recv_header() - (fin, rsv1, rsv2, rsv3, opcode, has_mask, _) = self.header - - # Frame length - if self.has_received_length(): - self.recv_length() - length = self.length - - # Mask - if self.has_received_mask(): - self.recv_mask() - mask_value = self.mask_value - - # Payload - payload = self.recv_strict(length) - if has_mask: - payload = ABNF.mask(mask_value, payload) - - # Reset for next frame - self.clear() - - frame = ABNF(fin, rsv1, rsv2, rsv3, opcode, has_mask, payload) - frame.validate(self.skip_utf8_validation) - - return frame - - def recv_strict(self, bufsize: int) -> bytes: - """Read exactly ``bufsize`` bytes from the underlying recv function.""" - shortage = bufsize - sum(map(len, self.recv_buffer)) - while shortage > 0: - # Limit buffer size that we pass to socket.recv() to avoid - # fragmenting the heap -- the number of bytes recv() actually - # reads is limited by socket buffer and is relatively small, - # yet passing large numbers repeatedly causes lots of large - # buffers allocated and then shrunk, which results in - # fragmentation. - bytes_ = self.recv(min(16384, shortage)) - self.recv_buffer.append(bytes_) - shortage -= len(bytes_) - - unified = b"".join(self.recv_buffer) - - if shortage == 0: - self.recv_buffer = [] - return unified - self.recv_buffer = [unified[bufsize:]] - return unified[:bufsize] - - -class continuous_frame: - """Collect fragmented frames until a full logical message is available.""" - - def __init__(self, fire_cont_frame: bool, skip_utf8_validation: bool) -> None: - """Initialize continuation-frame assembly state.""" - self.fire_cont_frame = fire_cont_frame - self.skip_utf8_validation = skip_utf8_validation - self.cont_data: Optional[list] = None - self.recving_frames: Optional[int] = None - - def validate(self, frame: ABNF) -> None: - """Validate that a continuation frame arrives in a legal sequence.""" - if not self.recving_frames and frame.opcode == ABNF.OPCODE_CONT: - raise WebSocketProtocolException("Illegal frame") - if self.recving_frames and frame.opcode in ( - ABNF.OPCODE_TEXT, - ABNF.OPCODE_BINARY, - ): - raise WebSocketProtocolException("Illegal frame") - - def add(self, frame: ABNF) -> None: - """Append a fragment to the buffered logical message.""" - if self.cont_data: - self.cont_data[1] += frame.data - else: - if frame.opcode in (ABNF.OPCODE_TEXT, ABNF.OPCODE_BINARY): - self.recving_frames = frame.opcode - self.cont_data = [frame.opcode, frame.data] - - if frame.fin: - self.recving_frames = None - - def is_fire(self, frame: ABNF) -> Union[bool, int]: - """Return whether the buffered message should be emitted now.""" - return frame.fin or self.fire_cont_frame - - def extract(self, frame: ABNF) -> tuple: - """Finalize the buffered message and return its opcode with frame.""" - data = self.cont_data - self.cont_data = None - frame.data = data[1] - if ( - not self.fire_cont_frame - and data[0] == ABNF.OPCODE_TEXT - and not self.skip_utf8_validation - and not validate_utf8(frame.data) - ): - raise WebSocketPayloadException(f"cannot decode: {repr(frame.data)}") - return data[0], frame +""" +_abnf.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import array +import os +import struct +import sys +from threading import Lock +from typing import Callable, Optional, Union + +from ._exceptions import WebSocketPayloadException, WebSocketProtocolException +from ._utils import validate_utf8 + +try: + # If wsaccel is available, use compiled routines to mask data. + # wsaccel only provides around a 10% speed boost compared + # to the websocket-client _mask() implementation. + # Note that wsaccel is unmaintained. + from wsaccel.xormask import XorMaskerSimple + + def _mask(mask_value: array.array, data_value: array.array) -> bytes: + """Apply the accelerated XOR mask implementation.""" + mask_result: bytes = XorMaskerSimple(mask_value).process(data_value) + return mask_result + +except ImportError: + # wsaccel is not available, use websocket-client _mask() + native_byteorder = sys.byteorder + + def _mask(mask_value: array.array, data_value: array.array) -> bytes: + """Apply the pure-Python XOR mask implementation.""" + datalen = len(data_value) + int_data_value = int.from_bytes(data_value, native_byteorder) + int_mask_value = int.from_bytes( + mask_value * (datalen // 4) + mask_value[: datalen % 4], + native_byteorder) + return ( + int_data_value ^ int_mask_value).to_bytes( + datalen, native_byteorder) + + +__all__ = [ + "ABNF", + "continuous_frame", + "frame_buffer", + "STATUS_NORMAL", + "STATUS_GOING_AWAY", + "STATUS_PROTOCOL_ERROR", + "STATUS_UNSUPPORTED_DATA_TYPE", + "STATUS_STATUS_NOT_AVAILABLE", + "STATUS_ABNORMAL_CLOSED", + "STATUS_INVALID_PAYLOAD", + "STATUS_POLICY_VIOLATION", + "STATUS_MESSAGE_TOO_BIG", + "STATUS_INVALID_EXTENSION", + "STATUS_UNEXPECTED_CONDITION", + "STATUS_BAD_GATEWAY", + "STATUS_TLS_HANDSHAKE_ERROR", +] + +# closing frame status codes. +STATUS_NORMAL = 1000 +STATUS_GOING_AWAY = 1001 +STATUS_PROTOCOL_ERROR = 1002 +STATUS_UNSUPPORTED_DATA_TYPE = 1003 +STATUS_STATUS_NOT_AVAILABLE = 1005 +STATUS_ABNORMAL_CLOSED = 1006 +STATUS_INVALID_PAYLOAD = 1007 +STATUS_POLICY_VIOLATION = 1008 +STATUS_MESSAGE_TOO_BIG = 1009 +STATUS_INVALID_EXTENSION = 1010 +STATUS_UNEXPECTED_CONDITION = 1011 +STATUS_SERVICE_RESTART = 1012 +STATUS_TRY_AGAIN_LATER = 1013 +STATUS_BAD_GATEWAY = 1014 +STATUS_TLS_HANDSHAKE_ERROR = 1015 + +VALID_CLOSE_STATUS = ( + STATUS_NORMAL, + STATUS_GOING_AWAY, + STATUS_PROTOCOL_ERROR, + STATUS_UNSUPPORTED_DATA_TYPE, + STATUS_INVALID_PAYLOAD, + STATUS_POLICY_VIOLATION, + STATUS_MESSAGE_TOO_BIG, + STATUS_INVALID_EXTENSION, + STATUS_UNEXPECTED_CONDITION, + STATUS_SERVICE_RESTART, + STATUS_TRY_AGAIN_LATER, + STATUS_BAD_GATEWAY, +) + + +class ABNF: + """ + ABNF frame class. + See http://tools.ietf.org/html/rfc5234 + and http://tools.ietf.org/html/rfc6455#section-5.2 + """ + + # operation code values. + OPCODE_CONT = 0x0 + OPCODE_TEXT = 0x1 + OPCODE_BINARY = 0x2 + OPCODE_CLOSE = 0x8 + OPCODE_PING = 0x9 + OPCODE_PONG = 0xA + + # available operation code value tuple + OPCODES = ( + OPCODE_CONT, + OPCODE_TEXT, + OPCODE_BINARY, + OPCODE_CLOSE, + OPCODE_PING, + OPCODE_PONG, + ) + + # opcode human readable string + OPCODE_MAP = { + OPCODE_CONT: "cont", + OPCODE_TEXT: "text", + OPCODE_BINARY: "binary", + OPCODE_CLOSE: "close", + OPCODE_PING: "ping", + OPCODE_PONG: "pong", + } + + # data length threshold. + LENGTH_7 = 0x7E + LENGTH_16 = 1 << 16 + LENGTH_63 = 1 << 63 + + def __init__( + self, + fin: int = 0, + rsv1: int = 0, + rsv2: int = 0, + rsv3: int = 0, + opcode: int = OPCODE_TEXT, + mask_value: int = 1, + data: Union[str, bytes, None] = "", + ) -> None: + """Construct an ABNF frame. Please check the RFC for arguments.""" + self.fin = fin + self.rsv1 = rsv1 + self.rsv2 = rsv2 + self.rsv3 = rsv3 + self.opcode = opcode + self.mask_value = mask_value + if data is None: + data = "" + self.data = data + self.get_mask_key = os.urandom + + def validate(self, skip_utf8_validation: bool = False) -> None: + """ + Validate the ABNF frame. + + Parameters + ---------- + skip_utf8_validation: skip utf8 validation. + """ + if self.rsv1 or self.rsv2 or self.rsv3: + raise WebSocketProtocolException("rsv is not implemented, yet") + + if self.opcode not in ABNF.OPCODES: + raise WebSocketProtocolException(f"Invalid opcode {self.opcode!r}") + + if self.opcode == ABNF.OPCODE_PING and not self.fin: + raise WebSocketProtocolException("Invalid ping frame.") + + if self.opcode == ABNF.OPCODE_CLOSE: + data_length = len(self.data) + if not data_length: + return + if data_length == 1 or data_length >= 126: + raise WebSocketProtocolException("Invalid close frame.") + if ( + data_length > 2 + and not skip_utf8_validation + and not validate_utf8(self.data[2:]) + ): + raise WebSocketProtocolException("Invalid close frame.") + + code = 256 * int(self.data[0]) + int(self.data[1]) + if not self._is_valid_close_status(code): + raise WebSocketProtocolException( + f"Invalid close opcode {code!r}") + + @staticmethod + def _is_valid_close_status(code: int) -> bool: + """Return whether the close status code is allowed by the protocol.""" + return code in VALID_CLOSE_STATUS or (3000 <= code < 5000) + + def __str__(self) -> str: + """Return a concise human-readable representation of the frame.""" + return f"fin={self.fin} opcode={self.opcode} data={self.data}" + + @staticmethod + def create_frame(data: Union[bytes, str], + opcode: int, fin: int = 1) -> "ABNF": + """ + Create frame to send text, binary and other data. + + Parameters + ---------- + data: str + data to send. This is string value(byte array). + If opcode is OPCODE_TEXT and this value is unicode, + data value is converted into unicode string, automatically. + opcode: int + operation code. please see OPCODE_MAP. + fin: int + fin flag. if set to 0, create continue fragmentation. + """ + if opcode == ABNF.OPCODE_TEXT and isinstance(data, str): + data = data.encode("utf-8") + # mask must be set if send data from client + return ABNF(fin, 0, 0, 0, opcode, 1, data) + + def format(self) -> bytes: + """Format this object to the byte sequence sent to the server.""" + if any( + x not in ( + 0, + 1) for x in [ + self.fin, + self.rsv1, + self.rsv2, + self.rsv3]): + raise ValueError("not 0 or 1") + if self.opcode not in ABNF.OPCODES: + raise ValueError("Invalid OPCODE") + length = len(self.data) + if length >= ABNF.LENGTH_63: + raise ValueError("data is too long") + + frame_header = chr( + self.fin << 7 + | self.rsv1 << 6 + | self.rsv2 << 5 + | self.rsv3 << 4 + | self.opcode + ).encode("latin-1") + if length < ABNF.LENGTH_7: + frame_header += chr(self.mask_value << 7 | + length).encode("latin-1") + elif length < ABNF.LENGTH_16: + frame_header += chr(self.mask_value << 7 | 0x7E).encode("latin-1") + frame_header += struct.pack("!H", length) + else: + frame_header += chr(self.mask_value << 7 | 0x7F).encode("latin-1") + frame_header += struct.pack("!Q", length) + + if not self.mask_value: + if isinstance(self.data, str): + self.data = self.data.encode("utf-8") + return frame_header + self.data + mask_key = self.get_mask_key(4) + return frame_header + self._get_masked(mask_key) + + def _get_masked(self, mask_key: Union[str, bytes]) -> bytes: + """Return the payload prefixed with its masking key.""" + s = ABNF.mask(mask_key, self.data) + + if isinstance(mask_key, str): + mask_key = mask_key.encode("utf-8") + + return mask_key + s + + @staticmethod + def mask(mask_key: Union[str, bytes], data: Union[str, bytes]) -> bytes: + """ + Mask or unmask data. Just do xor for each byte + + Parameters + ---------- + mask_key: bytes or str + 4 byte mask. + data: bytes or str + data to mask/unmask. + """ + if data is None: + data = "" + + if isinstance(mask_key, str): + mask_key = mask_key.encode("latin-1") + + if isinstance(data, str): + data = data.encode("latin-1") + + return _mask(array.array("B", mask_key), array.array("B", data)) + + +class frame_buffer: + """Buffer and decode raw bytes into complete WebSocket frames.""" + + _HEADER_MASK_INDEX = 5 + _HEADER_LENGTH_INDEX = 6 + + def __init__( + self, recv_fn: Callable[[int], int], skip_utf8_validation: bool + ) -> None: + """Initialize a frame buffer backed by the provided receive function.""" + self.recv = recv_fn + self.skip_utf8_validation = skip_utf8_validation + self.header: Optional[tuple] = None + self.length: Optional[int] = None + self.mask_value: Union[bytes, str, None] = None + # Buffers over the packets from the layer beneath until desired amount + # bytes of bytes are received. + self.recv_buffer: list = [] + self.clear() + self.lock = Lock() + + def clear(self) -> None: + """Reset the partially decoded frame state.""" + self.header: Optional[tuple] = None + self.length: Optional[int] = None + self.mask_value: Union[bytes, str, None] = None + + def has_received_header(self) -> bool: + """Return whether the current frame header still needs to be read.""" + return self.header is None + + def recv_header(self) -> None: + """Read and parse the fixed two-byte WebSocket frame header.""" + header = self.recv_strict(2) + b1 = header[0] + fin = b1 >> 7 & 1 + rsv1 = b1 >> 6 & 1 + rsv2 = b1 >> 5 & 1 + rsv3 = b1 >> 4 & 1 + opcode = b1 & 0xF + b2 = header[1] + has_mask = b2 >> 7 & 1 + length_bits = b2 & 0x7F + + self.header = (fin, rsv1, rsv2, rsv3, opcode, has_mask, length_bits) + + def has_mask(self) -> Union[bool, int]: + """Return whether the current frame carries a masking key.""" + if not self.header: + return False + header_val: int = self.header[self._HEADER_MASK_INDEX] + return header_val + + def has_received_length(self) -> bool: + """Return whether the payload length still needs to be read.""" + return self.length is None + + def recv_length(self) -> None: + """Read the payload length using the current header metadata.""" + bits = self.header[self._HEADER_LENGTH_INDEX] + length_bits = bits & 0x7F + if length_bits == 0x7E: + v = self.recv_strict(2) + self.length = struct.unpack("!H", v)[0] + elif length_bits == 0x7F: + v = self.recv_strict(8) + self.length = struct.unpack("!Q", v)[0] + else: + self.length = length_bits + + def has_received_mask(self) -> bool: + """Return whether the mask key still needs to be read.""" + return self.mask_value is None + + def recv_mask(self) -> None: + """Read the masking key when the current frame is masked.""" + self.mask_value = self.recv_strict(4) if self.has_mask() else "" + + def recv_frame(self) -> ABNF: + """Read bytes from the socket until a complete frame is decoded.""" + with self.lock: + # Header + if self.has_received_header(): + self.recv_header() + (fin, rsv1, rsv2, rsv3, opcode, has_mask, _) = self.header + + # Frame length + if self.has_received_length(): + self.recv_length() + length = self.length + + # Mask + if self.has_received_mask(): + self.recv_mask() + mask_value = self.mask_value + + # Payload + payload = self.recv_strict(length) + if has_mask: + payload = ABNF.mask(mask_value, payload) + + # Reset for next frame + self.clear() + + frame = ABNF(fin, rsv1, rsv2, rsv3, opcode, has_mask, payload) + frame.validate(self.skip_utf8_validation) + + return frame + + def recv_strict(self, bufsize: int) -> bytes: + """Read exactly ``bufsize`` bytes from the underlying recv function.""" + shortage = bufsize - sum(map(len, self.recv_buffer)) + while shortage > 0: + # Limit buffer size that we pass to socket.recv() to avoid + # fragmenting the heap -- the number of bytes recv() actually + # reads is limited by socket buffer and is relatively small, + # yet passing large numbers repeatedly causes lots of large + # buffers allocated and then shrunk, which results in + # fragmentation. + bytes_ = self.recv(min(16384, shortage)) + self.recv_buffer.append(bytes_) + shortage -= len(bytes_) + + unified = b"".join(self.recv_buffer) + + if shortage == 0: + self.recv_buffer = [] + return unified + self.recv_buffer = [unified[bufsize:]] + return unified[:bufsize] + + +class continuous_frame: + """Collect fragmented frames until a full logical message is available.""" + + def __init__( + self, + fire_cont_frame: bool, + skip_utf8_validation: bool) -> None: + """Initialize continuation-frame assembly state.""" + self.fire_cont_frame = fire_cont_frame + self.skip_utf8_validation = skip_utf8_validation + self.cont_data: Optional[list] = None + self.recving_frames: Optional[int] = None + + def validate(self, frame: ABNF) -> None: + """Validate that a continuation frame arrives in a legal sequence.""" + if not self.recving_frames and frame.opcode == ABNF.OPCODE_CONT: + raise WebSocketProtocolException("Illegal frame") + if self.recving_frames and frame.opcode in ( + ABNF.OPCODE_TEXT, + ABNF.OPCODE_BINARY, + ): + raise WebSocketProtocolException("Illegal frame") + + def add(self, frame: ABNF) -> None: + """Append a fragment to the buffered logical message.""" + if self.cont_data: + self.cont_data[1] += frame.data + else: + if frame.opcode in (ABNF.OPCODE_TEXT, ABNF.OPCODE_BINARY): + self.recving_frames = frame.opcode + self.cont_data = [frame.opcode, frame.data] + + if frame.fin: + self.recving_frames = None + + def is_fire(self, frame: ABNF) -> Union[bool, int]: + """Return whether the buffered message should be emitted now.""" + return frame.fin or self.fire_cont_frame + + def extract(self, frame: ABNF) -> tuple: + """Finalize the buffered message and return its opcode with frame.""" + data = self.cont_data + self.cont_data = None + frame.data = data[1] + if ( + not self.fire_cont_frame + and data[0] == ABNF.OPCODE_TEXT + and not self.skip_utf8_validation + and not validate_utf8(frame.data) + ): + raise WebSocketPayloadException( + f"cannot decode: {repr(frame.data)}") + return data[0], frame diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_app.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_app.py" index aa526c07..48a742a2 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_app.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_app.py" @@ -1,672 +1,690 @@ -""" -_app.py -websocket - WebSocket client library for Python - -Copyright 2024 engn33r - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" - -import inspect -import selectors -import socket -import threading -import time -from typing import Any, Callable, Optional, Union - -from . import _logging -from ._abnf import ABNF -from ._core import WebSocket, getdefaulttimeout -from ._exceptions import ( - WebSocketConnectionClosedException, - WebSocketException, - WebSocketTimeoutException, -) -from ._ssl_compat import SSLEOFError -from ._url import parse_url - -__all__ = ["WebSocketApp"] - -RECONNECT_SETTINGS = {"interval": 0} - - -def setReconnect(reconnectInterval: int) -> None: - """Update the default reconnect interval used by ``WebSocketApp``.""" - RECONNECT_SETTINGS["interval"] = reconnectInterval - - -class DispatcherBase: - """Base dispatcher that coordinates socket reads and reconnects.""" - - def __init__(self, app: Any, ping_timeout: Union[float, int, None]) -> None: - """Store the owning app and ping timeout used by the dispatcher.""" - self.app = app - self.ping_timeout = ping_timeout - - @staticmethod - def timeout(seconds: Union[float, int, None], callback: Callable) -> None: - """Sleep for ``seconds`` and then invoke ``callback``.""" - time.sleep(seconds) - callback() - - @staticmethod - def reconnect(seconds: int, reconnector: Callable) -> None: - """Wait for ``seconds`` and then invoke the reconnect callback.""" - _logging.info( - "reconnect() - retrying in %s seconds [%s frames in stack]", - seconds, - len(inspect.stack()), - ) - time.sleep(seconds) - reconnector(reconnecting=True) - - -class Dispatcher(DispatcherBase): - """Dispatcher for plain sockets using ``selectors``.""" - - def read( - self, - sock: socket.socket, - read_callback: Callable, - check_callback: Callable, - ) -> None: - """Poll the socket and invoke callbacks while the app keeps running.""" - sel = selectors.DefaultSelector() - sel.register(self.app.sock.sock, selectors.EVENT_READ) - try: - while self.app.keep_running: - if sel.select(self.ping_timeout) and not read_callback(): - break - check_callback() - finally: - sel.close() - - -class SSLDispatcher(DispatcherBase): - """Dispatcher for SSL sockets that may already have pending bytes.""" - - def read( - self, - sock: socket.socket, - read_callback: Callable, - check_callback: Callable, - ) -> None: - """Read SSL socket events while also handling pending decrypted bytes.""" - sock = self.app.sock.sock - sel = selectors.DefaultSelector() - sel.register(sock, selectors.EVENT_READ) - try: - while self.app.keep_running: - if self.select(sock, sel) and not read_callback(): - break - check_callback() - finally: - sel.close() - - def select(self, sock, sel: selectors.DefaultSelector): - """Return ready events from the SSL socket, if any are available.""" - sock = self.app.sock.sock - if sock.pending(): - return [ - sock, - ] - - r = sel.select(self.ping_timeout) - - if len(r) > 0: - return r[0][0] - return None - - -class WrappedDispatcher: - """Adapter for custom dispatcher implementations.""" - - def __init__(self, app, ping_timeout: Union[float, int, None], dispatcher) -> None: - """Wrap a custom dispatcher and register its abort signal handler.""" - self.app = app - self.ping_timeout = ping_timeout - self.dispatcher = dispatcher - dispatcher.signal(2, dispatcher.abort) # keyboard interrupt - - def read( - self, - sock: socket.socket, - read_callback: Callable, - check_callback: Callable, - ) -> None: - """Delegate reads to the custom dispatcher and apply timeout checks.""" - self.dispatcher.read(sock, read_callback) - if self.ping_timeout: - self.timeout(self.ping_timeout, check_callback) - - def timeout(self, seconds: float, callback: Callable) -> None: - """Delegate timeout handling to the wrapped dispatcher.""" - self.dispatcher.timeout(seconds, callback) - - def reconnect(self, seconds: int, reconnector: Callable) -> None: - """Delegate reconnect scheduling to the wrapped dispatcher.""" - self.timeout(seconds, reconnector) - - -class WebSocketApp: - """Higher-level WebSocket API similar to the JavaScript WebSocket object.""" - - def __init__( - self, - url: str, - header: Union[list, dict, Callable, None] = None, - on_open: Optional[Callable[[WebSocket], None]] = None, - on_reconnect: Optional[Callable[[WebSocket], None]] = None, - on_message: Optional[Callable[[WebSocket, Any], None]] = None, - on_error: Optional[Callable[[WebSocket, Any], None]] = None, - on_close: Optional[Callable[[WebSocket, Any, Any], None]] = None, - on_ping: Optional[Callable] = None, - on_pong: Optional[Callable] = None, - on_cont_message: Optional[Callable] = None, - keep_running: bool = True, - get_mask_key: Optional[Callable] = None, - cookie: Optional[str] = None, - subprotocols: Optional[list] = None, - on_data: Optional[Callable] = None, - prepared_socket: Optional[socket.socket] = None, - ) -> None: - """ - WebSocketApp initialization - - Parameters - ---------- - url: str - Websocket url. - header: list or dict or Callable - Custom header for websocket handshake. - If the parameter is a callable object, it is called just before - the connection attempt. - The returned dict or list is used as custom header value. - This could be useful in order to properly setup timestamp dependent headers. - on_open: function - Callback object which is called at opening websocket. - on_open has one argument. - The 1st argument is this class object. - on_reconnect: function - Callback object which is called at reconnecting websocket. - on_reconnect has one argument. - The 1st argument is this class object. - on_message: function - Callback object which is called when received data. - on_message has 2 arguments. - The 1st argument is this class object. - The 2nd argument is utf-8 data received from the server. - on_error: function - Callback object which is called when we get error. - on_error has 2 arguments. - The 1st argument is this class object. - The 2nd argument is exception object. - on_close: function - Callback object which is called when connection is closed. - on_close has 3 arguments. - The 1st argument is this class object. - The 2nd argument is close_status_code. - The 3rd argument is close_msg. - on_cont_message: function - Callback object which is called when a continuation - frame is received. - on_cont_message has 3 arguments. - The 1st argument is this class object. - The 2nd argument is utf-8 string which we get from the server. - The 3rd argument is continue flag. if 0, the data continue - to next frame data - on_data: function - Callback object which is called when a message received. - This is called before on_message or on_cont_message, - and then on_message or on_cont_message is called. - on_data has 4 argument. - The 1st argument is this class object. - The 2nd argument is utf-8 string which we get from the server. - The 3rd argument is data type. - ABNF.OPCODE_TEXT or ABNF.OPCODE_BINARY will be came. - The 4th argument is continue flag. If 0, the data continue - keep_running: bool - This parameter is obsolete and ignored. - get_mask_key: function - A callable function to get new mask keys, see the - WebSocket.set_mask_key's docstring for more information. - cookie: str - Cookie value. - subprotocols: list - List of available sub protocols. Default is None. - prepared_socket: socket - Pre-initialized stream socket. - """ - self.url = url - self.header = header if header is not None else [] - self.cookie = cookie - - self.on_open = on_open - self.on_reconnect = on_reconnect - self.on_message = on_message - self.on_data = on_data - self.on_error = on_error - self.on_close = on_close - self.on_ping = on_ping - self.on_pong = on_pong - self.on_cont_message = on_cont_message - self.keep_running = False - self.get_mask_key = get_mask_key - self.sock: Optional[WebSocket] = None - self.last_ping_tm = float(0) - self.last_pong_tm = float(0) - self.ping_thread: Optional[threading.Thread] = None - self.stop_ping: Optional[threading.Event] = None - self.ping_interval = float(0) - self.ping_timeout: Union[float, int, None] = None - self.ping_payload = "" - self.subprotocols = subprotocols - self.prepared_socket = prepared_socket - self.has_errored = False - self.has_done_teardown = False - self.has_done_teardown_lock = threading.Lock() - - def send(self, data: Union[bytes, str], opcode: int = ABNF.OPCODE_TEXT) -> None: - """Send a message using the supplied opcode.""" - if not self.sock or self.sock.send(data, opcode) == 0: - raise WebSocketConnectionClosedException("Connection is already closed.") - - def send_text(self, text_data: str) -> None: - """Send UTF-8 encoded text data.""" - if not self.sock or self.sock.send(text_data, ABNF.OPCODE_TEXT) == 0: - raise WebSocketConnectionClosedException("Connection is already closed.") - - def send_bytes(self, data: Union[bytes, bytearray]) -> None: - """Send binary data.""" - if not self.sock or self.sock.send(data, ABNF.OPCODE_BINARY) == 0: - raise WebSocketConnectionClosedException("Connection is already closed.") - - def close(self, **kwargs) -> None: - """Close the websocket connection.""" - self.keep_running = False - if self.sock: - self.sock.close(**kwargs) - self.sock = None - - def _start_ping_thread(self) -> None: - """Start the background ping thread used by ``run_forever``.""" - self.last_ping_tm = self.last_pong_tm = float(0) - self.stop_ping = threading.Event() - self.ping_thread = threading.Thread(target=self._send_ping) - self.ping_thread.daemon = True - self.ping_thread.start() - - def _stop_ping_thread(self) -> None: - """Stop the background ping thread and reset ping timestamps.""" - if self.stop_ping: - self.stop_ping.set() - if self.ping_thread and self.ping_thread.is_alive(): - self.ping_thread.join(3) - self.last_ping_tm = self.last_pong_tm = float(0) - - def _send_ping(self) -> None: - """Periodically send ping frames while the connection stays open.""" - if self.stop_ping.wait(self.ping_interval) or self.keep_running is False: - return - while not self.stop_ping.wait(self.ping_interval) and self.keep_running is True: - if self.sock: - self.last_ping_tm = time.time() - try: - _logging.debug("Sending ping") - self.sock.ping(self.ping_payload) - except Exception as e: - _logging.debug("Failed to send ping: %s", e) - - def run_forever( # skipcq: PY-R1000 - self, - sockopt: tuple = None, - sslopt: dict = None, - ping_interval: Union[float, int] = 0, - ping_timeout: Union[float, int, None] = None, - ping_payload: str = "", - http_proxy_host: str = None, - http_proxy_port: Union[int, str] = None, - http_no_proxy: list = None, - http_proxy_auth: tuple = None, - http_proxy_timeout: Optional[float] = None, - skip_utf8_validation: bool = False, - host: str = None, - origin: str = None, - dispatcher=None, - suppress_origin: bool = False, - proxy_type: str = None, - reconnect: int = None, - ) -> bool: - """ - Run event loop for WebSocket framework. - - This loop is an infinite loop and is alive while websocket is available. - - Parameters - ---------- - sockopt: tuple - Values for socket.setsockopt. - sockopt must be tuple - and each element is argument of sock.setsockopt. - sslopt: dict - Optional dict object for ssl socket option. - ping_interval: int or float - Automatically send "ping" command - every specified period (in seconds). - If set to 0, no ping is sent periodically. - ping_timeout: int or float - Timeout (in seconds) if the pong message is not received. - ping_payload: str - Payload message to send with each ping. - http_proxy_host: str - HTTP proxy host name. - http_proxy_port: int or str - HTTP proxy port. If not set, set to 80. - http_no_proxy: list - Whitelisted host names that don't use the proxy. - http_proxy_timeout: int or float - HTTP proxy timeout, default is 60 sec as per python-socks. - http_proxy_auth: tuple - HTTP proxy auth information. - Tuple of username and password. Default is None. - skip_utf8_validation: bool - skip utf8 validation. - host: str - update host header. - origin: str - update origin header. - dispatcher: Dispatcher object - customize reading data from socket. - suppress_origin: bool - suppress outputting origin header. - proxy_type: str - type of proxy from: http, socks4, socks4a, socks5, socks5h - reconnect: int - delay interval when reconnecting - - Returns - ------- - teardown: bool - False if the `WebSocketApp` is closed or caught KeyboardInterrupt, - True if any other exception was raised during a loop. - """ - if reconnect is None: - reconnect = RECONNECT_SETTINGS["interval"] - - if ping_timeout is not None and ping_timeout <= 0: - raise WebSocketException("Ensure ping_timeout > 0") - if ping_interval is not None and ping_interval < 0: - raise WebSocketException("Ensure ping_interval >= 0") - if ping_timeout and ping_interval and ping_interval <= ping_timeout: - raise WebSocketException("Ensure ping_interval > ping_timeout") - if not sockopt: - sockopt = () - if not sslopt: - sslopt = {} - if self.sock: - raise WebSocketException("socket is already opened") - - self.ping_interval = ping_interval - self.ping_timeout = ping_timeout - self.ping_payload = ping_payload - self.has_done_teardown = False - self.keep_running = True - - def read() -> bool: - """Read one frame from the socket and dispatch the matching callbacks.""" - if not self.keep_running: - return teardown() - - try: - op_code, frame = self.sock.recv_data_frame(True) - except ( - WebSocketConnectionClosedException, - KeyboardInterrupt, - SSLEOFError, - ) as e: - if custom_dispatcher: - return handleDisconnect(e, bool(reconnect)) - raise - - if op_code == ABNF.OPCODE_CLOSE: - return teardown(frame) - if op_code == ABNF.OPCODE_PING: - self._callback(self.on_ping, frame.data) - if op_code == ABNF.OPCODE_PONG: - self.last_pong_tm = time.time() - self._callback(self.on_pong, frame.data) - elif op_code == ABNF.OPCODE_CONT and self.on_cont_message: - self._callback(self.on_data, frame.data, frame.opcode, frame.fin) - self._callback(self.on_cont_message, frame.data, frame.fin) - else: - data = frame.data - if op_code == ABNF.OPCODE_TEXT and not skip_utf8_validation: - data = data.decode("utf-8") - self._callback(self.on_data, data, frame.opcode, True) - self._callback(self.on_message, data) - - return True - - def check() -> bool: - """Check whether the connection exceeded the configured ping timeout.""" - if self.ping_timeout: - has_timeout_expired = ( - time.time() - self.last_ping_tm > self.ping_timeout - ) - has_pong_not_arrived_after_last_ping = ( - self.last_pong_tm - self.last_ping_tm < 0 - ) - has_pong_arrived_too_late = ( - self.last_pong_tm - self.last_ping_tm > self.ping_timeout - ) - - if ( - self.last_ping_tm - and has_timeout_expired - and ( - has_pong_not_arrived_after_last_ping - or has_pong_arrived_too_late - ) - ): - raise WebSocketTimeoutException("ping/pong timed out") - return True - - def handleDisconnect( - e: Union[ - WebSocketConnectionClosedException, - ConnectionRefusedError, - KeyboardInterrupt, - SystemExit, - Exception, - ], - reconnecting: bool = False, - ) -> bool: - """Handle disconnects, teardown, and optional reconnect scheduling.""" - self.has_errored = True - self._stop_ping_thread() - if not reconnecting: - self._callback(self.on_error, e) - - if isinstance(e, (KeyboardInterrupt, SystemExit)): - teardown() - # 这里的 e 来自外层回调参数,不处在 except 语句块里, - # 不能用 bare raise;按原异常类型重建后继续向上传播。 - if isinstance(e, KeyboardInterrupt): - raise KeyboardInterrupt(*e.args) from e - raise SystemExit(*e.args) from e - - if reconnect: - _logging.info("%s - reconnect", e) - if custom_dispatcher: - _logging.debug( - "Calling custom dispatcher reconnect [%s frames in stack]", - len(inspect.stack()), - ) - dispatcher.reconnect(reconnect, setSock) - else: - _logging.error("%s - goodbye", e) - teardown() - - def teardown(close_frame: ABNF = None): - """ - Tears down the connection. - - Parameters - ---------- - close_frame: ABNF frame - If close_frame is set, the on_close handler is invoked - with the statusCode and reason from the provided frame. - """ - # teardown() is called in many code paths to ensure resources are - # cleaned up and on_close is fired. The flag and lock ensure that - # cleanup still runs only once. - with self.has_done_teardown_lock: - if self.has_done_teardown: - return - self.has_done_teardown = True - - self._stop_ping_thread() - self.keep_running = False - if self.sock: - self.sock.close() - close_status_code, close_reason = self._get_close_args( - close_frame if close_frame else None - ) - self.sock = None - - # Finally call the callback AFTER all teardown is complete - self._callback(self.on_close, close_status_code, close_reason) - - def setSock(reconnecting: bool = False) -> None: - """Create the socket, perform the handshake, and start reading.""" - if reconnecting and self.sock: - self.sock.shutdown() - - self.sock = WebSocket( - self.get_mask_key, - sockopt=sockopt, - sslopt=sslopt, - fire_cont_frame=self.on_cont_message is not None, - skip_utf8_validation=skip_utf8_validation, - enable_multithread=True, - ) - - self.sock.settimeout(getdefaulttimeout()) - try: - header = self.header() if callable(self.header) else self.header - - self.sock.connect( - self.url, - header=header, - cookie=self.cookie, - http_proxy_host=http_proxy_host, - http_proxy_port=http_proxy_port, - http_no_proxy=http_no_proxy, - http_proxy_auth=http_proxy_auth, - http_proxy_timeout=http_proxy_timeout, - subprotocols=self.subprotocols, - host=host, - origin=origin, - suppress_origin=suppress_origin, - proxy_type=proxy_type, - socket=self.prepared_socket, - ) - - _logging.info("Websocket connected") - - if self.ping_interval: - self._start_ping_thread() - - if reconnecting and self.on_reconnect: - self._callback(self.on_reconnect) - else: - self._callback(self.on_open) - - dispatcher.read(self.sock.sock, read, check) - except ( - WebSocketConnectionClosedException, - ConnectionRefusedError, - KeyboardInterrupt, - SystemExit, - ) as e: - handleDisconnect(e, reconnecting) - except Exception as e: - handleDisconnect(e, reconnecting) - - custom_dispatcher = bool(dispatcher) - dispatcher = self.create_dispatcher( - ping_timeout, dispatcher, parse_url(self.url)[3] - ) - - try: - setSock() - if not custom_dispatcher and reconnect: - while self.keep_running: - _logging.debug( - "Calling dispatcher reconnect [%s frames in stack]", - len(inspect.stack()), - ) - dispatcher.reconnect(reconnect, setSock) - except (KeyboardInterrupt, Exception) as e: - _logging.info("tearing down on exception %s", e) - teardown() - finally: - if not custom_dispatcher: - # Ensure teardown was called before returning from run_forever - teardown() - - return self.has_errored - - def create_dispatcher( - self, - ping_timeout: Union[float, int, None], - dispatcher: Optional[DispatcherBase] = None, - is_ssl: bool = False, - ) -> Union[Dispatcher, SSLDispatcher, WrappedDispatcher]: - """Create the dispatcher object used by ``run_forever``.""" - if dispatcher: # If custom dispatcher is set, use WrappedDispatcher - return WrappedDispatcher(self, ping_timeout, dispatcher) - timeout = ping_timeout or 10 - if is_ssl: - return SSLDispatcher(self, timeout) - return Dispatcher(self, timeout) - - def _get_close_args(self, close_frame: ABNF) -> list: - """Extract the close code and reason from a close frame if present.""" - # Need to catch the case where close_frame is None - # Otherwise the following if statement causes an error - if not self.on_close or not close_frame: - return [None, None] - - # Extract close frame status code - if close_frame.data and len(close_frame.data) >= 2: - close_status_code = 256 * int(close_frame.data[0]) + int( - close_frame.data[1] - ) - reason = close_frame.data[2:] - if isinstance(reason, bytes): - reason = reason.decode("utf-8") - return [close_status_code, reason] - # Most likely reached this because len(close_frame_data.data) < 2 - return [None, None] - - def _callback(self, callback, *args) -> None: - """Invoke a callback and forward callback failures to ``on_error``.""" - if callback: - try: - callback(self, *args) - - except Exception as e: - _logging.error("error from callback %s: %s", callback, e) - if self.on_error: - self.on_error(self, e) +""" +_app.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import inspect +import selectors +import socket +import threading +import time +from typing import Any, Callable, Optional, Union + +from . import _logging +from ._abnf import ABNF +from ._core import WebSocket, getdefaulttimeout +from ._exceptions import ( + WebSocketConnectionClosedException, + WebSocketException, + WebSocketTimeoutException, +) +from ._ssl_compat import SSLEOFError +from ._url import parse_url + +__all__ = ["WebSocketApp"] + +RECONNECT_SETTINGS = {"interval": 0} + + +def setReconnect(reconnectInterval: int) -> None: + """Update the default reconnect interval used by ``WebSocketApp``.""" + RECONNECT_SETTINGS["interval"] = reconnectInterval + + +class DispatcherBase: + """Base dispatcher that coordinates socket reads and reconnects.""" + + def __init__(self, + app: Any, + ping_timeout: Union[float, + int, + None]) -> None: + """Store the owning app and ping timeout used by the dispatcher.""" + self.app = app + self.ping_timeout = ping_timeout + + @staticmethod + def timeout(seconds: Union[float, int, None], callback: Callable) -> None: + """Sleep for ``seconds`` and then invoke ``callback``.""" + time.sleep(seconds) + callback() + + @staticmethod + def reconnect(seconds: int, reconnector: Callable) -> None: + """Wait for ``seconds`` and then invoke the reconnect callback.""" + _logging.info( + "reconnect() - retrying in %s seconds [%s frames in stack]", + seconds, + len(inspect.stack()), + ) + time.sleep(seconds) + reconnector(reconnecting=True) + + +class Dispatcher(DispatcherBase): + """Dispatcher for plain sockets using ``selectors``.""" + + def read( + self, + sock: socket.socket, + read_callback: Callable, + check_callback: Callable, + ) -> None: + """Poll the socket and invoke callbacks while the app keeps running.""" + sel = selectors.DefaultSelector() + sel.register(self.app.sock.sock, selectors.EVENT_READ) + try: + while self.app.keep_running: + if sel.select(self.ping_timeout) and not read_callback(): + break + check_callback() + finally: + sel.close() + + +class SSLDispatcher(DispatcherBase): + """Dispatcher for SSL sockets that may already have pending bytes.""" + + def read( + self, + sock: socket.socket, + read_callback: Callable, + check_callback: Callable, + ) -> None: + """Read SSL socket events while also handling pending decrypted bytes.""" + sock = self.app.sock.sock + sel = selectors.DefaultSelector() + sel.register(sock, selectors.EVENT_READ) + try: + while self.app.keep_running: + if self.select(sock, sel) and not read_callback(): + break + check_callback() + finally: + sel.close() + + def select(self, sock, sel: selectors.DefaultSelector): + """Return ready events from the SSL socket, if any are available.""" + sock = self.app.sock.sock + if sock.pending(): + return [ + sock, + ] + + r = sel.select(self.ping_timeout) + + if len(r) > 0: + return r[0][0] + return None + + +class WrappedDispatcher: + """Adapter for custom dispatcher implementations.""" + + def __init__(self, + app, + ping_timeout: Union[float, + int, + None], + dispatcher) -> None: + """Wrap a custom dispatcher and register its abort signal handler.""" + self.app = app + self.ping_timeout = ping_timeout + self.dispatcher = dispatcher + dispatcher.signal(2, dispatcher.abort) # keyboard interrupt + + def read( + self, + sock: socket.socket, + read_callback: Callable, + check_callback: Callable, + ) -> None: + """Delegate reads to the custom dispatcher and apply timeout checks.""" + self.dispatcher.read(sock, read_callback) + if self.ping_timeout: + self.timeout(self.ping_timeout, check_callback) + + def timeout(self, seconds: float, callback: Callable) -> None: + """Delegate timeout handling to the wrapped dispatcher.""" + self.dispatcher.timeout(seconds, callback) + + def reconnect(self, seconds: int, reconnector: Callable) -> None: + """Delegate reconnect scheduling to the wrapped dispatcher.""" + self.timeout(seconds, reconnector) + + +class WebSocketApp: + """Higher-level WebSocket API similar to the JavaScript WebSocket object.""" + + def __init__( + self, + url: str, + header: Union[list, dict, Callable, None] = None, + on_open: Optional[Callable[[WebSocket], None]] = None, + on_reconnect: Optional[Callable[[WebSocket], None]] = None, + on_message: Optional[Callable[[WebSocket, Any], None]] = None, + on_error: Optional[Callable[[WebSocket, Any], None]] = None, + on_close: Optional[Callable[[WebSocket, Any, Any], None]] = None, + on_ping: Optional[Callable] = None, + on_pong: Optional[Callable] = None, + on_cont_message: Optional[Callable] = None, + keep_running: bool = True, + get_mask_key: Optional[Callable] = None, + cookie: Optional[str] = None, + subprotocols: Optional[list] = None, + on_data: Optional[Callable] = None, + prepared_socket: Optional[socket.socket] = None, + ) -> None: + """ + WebSocketApp initialization + + Parameters + ---------- + url: str + Websocket url. + header: list or dict or Callable + Custom header for websocket handshake. + If the parameter is a callable object, it is called just before + the connection attempt. + The returned dict or list is used as custom header value. + This could be useful in order to properly setup timestamp dependent headers. + on_open: function + Callback object which is called at opening websocket. + on_open has one argument. + The 1st argument is this class object. + on_reconnect: function + Callback object which is called at reconnecting websocket. + on_reconnect has one argument. + The 1st argument is this class object. + on_message: function + Callback object which is called when received data. + on_message has 2 arguments. + The 1st argument is this class object. + The 2nd argument is utf-8 data received from the server. + on_error: function + Callback object which is called when we get error. + on_error has 2 arguments. + The 1st argument is this class object. + The 2nd argument is exception object. + on_close: function + Callback object which is called when connection is closed. + on_close has 3 arguments. + The 1st argument is this class object. + The 2nd argument is close_status_code. + The 3rd argument is close_msg. + on_cont_message: function + Callback object which is called when a continuation + frame is received. + on_cont_message has 3 arguments. + The 1st argument is this class object. + The 2nd argument is utf-8 string which we get from the server. + The 3rd argument is continue flag. if 0, the data continue + to next frame data + on_data: function + Callback object which is called when a message received. + This is called before on_message or on_cont_message, + and then on_message or on_cont_message is called. + on_data has 4 argument. + The 1st argument is this class object. + The 2nd argument is utf-8 string which we get from the server. + The 3rd argument is data type. + ABNF.OPCODE_TEXT or ABNF.OPCODE_BINARY will be came. + The 4th argument is continue flag. If 0, the data continue + keep_running: bool + This parameter is obsolete and ignored. + get_mask_key: function + A callable function to get new mask keys, see the + WebSocket.set_mask_key's docstring for more information. + cookie: str + Cookie value. + subprotocols: list + List of available sub protocols. Default is None. + prepared_socket: socket + Pre-initialized stream socket. + """ + self.url = url + self.header = header if header is not None else [] + self.cookie = cookie + + self.on_open = on_open + self.on_reconnect = on_reconnect + self.on_message = on_message + self.on_data = on_data + self.on_error = on_error + self.on_close = on_close + self.on_ping = on_ping + self.on_pong = on_pong + self.on_cont_message = on_cont_message + self.keep_running = False + self.get_mask_key = get_mask_key + self.sock: Optional[WebSocket] = None + self.last_ping_tm = float(0) + self.last_pong_tm = float(0) + self.ping_thread: Optional[threading.Thread] = None + self.stop_ping: Optional[threading.Event] = None + self.ping_interval = float(0) + self.ping_timeout: Union[float, int, None] = None + self.ping_payload = "" + self.subprotocols = subprotocols + self.prepared_socket = prepared_socket + self.has_errored = False + self.has_done_teardown = False + self.has_done_teardown_lock = threading.Lock() + + def send(self, data: Union[bytes, str], + opcode: int = ABNF.OPCODE_TEXT) -> None: + """Send a message using the supplied opcode.""" + if not self.sock or self.sock.send(data, opcode) == 0: + raise WebSocketConnectionClosedException( + "Connection is already closed.") + + def send_text(self, text_data: str) -> None: + """Send UTF-8 encoded text data.""" + if not self.sock or self.sock.send(text_data, ABNF.OPCODE_TEXT) == 0: + raise WebSocketConnectionClosedException( + "Connection is already closed.") + + def send_bytes(self, data: Union[bytes, bytearray]) -> None: + """Send binary data.""" + if not self.sock or self.sock.send(data, ABNF.OPCODE_BINARY) == 0: + raise WebSocketConnectionClosedException( + "Connection is already closed.") + + def close(self, **kwargs) -> None: + """Close the websocket connection.""" + self.keep_running = False + if self.sock: + self.sock.close(**kwargs) + self.sock = None + + def _start_ping_thread(self) -> None: + """Start the background ping thread used by ``run_forever``.""" + self.last_ping_tm = self.last_pong_tm = float(0) + self.stop_ping = threading.Event() + self.ping_thread = threading.Thread(target=self._send_ping) + self.ping_thread.daemon = True + self.ping_thread.start() + + def _stop_ping_thread(self) -> None: + """Stop the background ping thread and reset ping timestamps.""" + if self.stop_ping: + self.stop_ping.set() + if self.ping_thread and self.ping_thread.is_alive(): + self.ping_thread.join(3) + self.last_ping_tm = self.last_pong_tm = float(0) + + def _send_ping(self) -> None: + """Periodically send ping frames while the connection stays open.""" + if self.stop_ping.wait( + self.ping_interval) or not self.keep_running: + return + while not self.stop_ping.wait( + self.ping_interval) and self.keep_running: + if self.sock: + self.last_ping_tm = time.time() + try: + _logging.debug("Sending ping") + self.sock.ping(self.ping_payload) + except Exception as e: + _logging.debug("Failed to send ping: %s", e) + + def run_forever( # skipcq: PY-R1000 + self, + sockopt: tuple = None, + sslopt: dict = None, + ping_interval: Union[float, int] = 0, + ping_timeout: Union[float, int, None] = None, + ping_payload: str = "", + http_proxy_host: str = None, + http_proxy_port: Union[int, str] = None, + http_no_proxy: list = None, + http_proxy_auth: tuple = None, + http_proxy_timeout: Optional[float] = None, + skip_utf8_validation: bool = False, + host: str = None, + origin: str = None, + dispatcher=None, + suppress_origin: bool = False, + proxy_type: str = None, + reconnect: int = None, + ) -> bool: + """ + Run event loop for WebSocket framework. + + This loop is an infinite loop and is alive while websocket is available. + + Parameters + ---------- + sockopt: tuple + Values for socket.setsockopt. + sockopt must be tuple + and each element is argument of sock.setsockopt. + sslopt: dict + Optional dict object for ssl socket option. + ping_interval: int or float + Automatically send "ping" command + every specified period (in seconds). + If set to 0, no ping is sent periodically. + ping_timeout: int or float + Timeout (in seconds) if the pong message is not received. + ping_payload: str + Payload message to send with each ping. + http_proxy_host: str + HTTP proxy host name. + http_proxy_port: int or str + HTTP proxy port. If not set, set to 80. + http_no_proxy: list + Whitelisted host names that don't use the proxy. + http_proxy_timeout: int or float + HTTP proxy timeout, default is 60 sec as per python-socks. + http_proxy_auth: tuple + HTTP proxy auth information. + Tuple of username and password. Default is None. + skip_utf8_validation: bool + skip utf8 validation. + host: str + update host header. + origin: str + update origin header. + dispatcher: Dispatcher object + customize reading data from socket. + suppress_origin: bool + suppress outputting origin header. + proxy_type: str + type of proxy from: http, socks4, socks4a, socks5, socks5h + reconnect: int + delay interval when reconnecting + + Returns + ------- + teardown: bool + False if the `WebSocketApp` is closed or caught KeyboardInterrupt, + True if any other exception was raised during a loop. + """ + if reconnect is None: + reconnect = RECONNECT_SETTINGS["interval"] + + if ping_timeout is not None and ping_timeout <= 0: + raise WebSocketException("Ensure ping_timeout > 0") + if ping_interval is not None and ping_interval < 0: + raise WebSocketException("Ensure ping_interval >= 0") + if ping_timeout and ping_interval and ping_interval <= ping_timeout: + raise WebSocketException("Ensure ping_interval > ping_timeout") + if not sockopt: + sockopt = () + if not sslopt: + sslopt = {} + if self.sock: + raise WebSocketException("socket is already opened") + + self.ping_interval = ping_interval + self.ping_timeout = ping_timeout + self.ping_payload = ping_payload + self.has_done_teardown = False + self.keep_running = True + + def read() -> bool: + """Read one frame from the socket and dispatch the matching callbacks.""" + if not self.keep_running: + return teardown() + + try: + op_code, frame = self.sock.recv_data_frame(True) + except ( + WebSocketConnectionClosedException, + KeyboardInterrupt, + SSLEOFError, + ) as e: + if custom_dispatcher: + return handleDisconnect(e, bool(reconnect)) + raise + + if op_code == ABNF.OPCODE_CLOSE: + return teardown(frame) + if op_code == ABNF.OPCODE_PING: + self._callback(self.on_ping, frame.data) + if op_code == ABNF.OPCODE_PONG: + self.last_pong_tm = time.time() + self._callback(self.on_pong, frame.data) + elif op_code == ABNF.OPCODE_CONT and self.on_cont_message: + self._callback( + self.on_data, + frame.data, + frame.opcode, + frame.fin) + self._callback(self.on_cont_message, frame.data, frame.fin) + else: + data = frame.data + if op_code == ABNF.OPCODE_TEXT and not skip_utf8_validation: + data = data.decode("utf-8") + self._callback(self.on_data, data, frame.opcode, True) + self._callback(self.on_message, data) + + return True + + def check() -> bool: + """Check whether the connection exceeded the configured ping timeout.""" + if self.ping_timeout: + has_timeout_expired = ( + time.time() - self.last_ping_tm > self.ping_timeout + ) + has_pong_not_arrived_after_last_ping = ( + self.last_pong_tm - self.last_ping_tm < 0 + ) + has_pong_arrived_too_late = ( + self.last_pong_tm - self.last_ping_tm > self.ping_timeout + ) + + if ( + self.last_ping_tm + and has_timeout_expired + and ( + has_pong_not_arrived_after_last_ping + or has_pong_arrived_too_late + ) + ): + raise WebSocketTimeoutException("ping/pong timed out") + return True + + def handleDisconnect( + e: Union[ + WebSocketConnectionClosedException, + ConnectionRefusedError, + KeyboardInterrupt, + SystemExit, + Exception, + ], + reconnecting: bool = False, + ) -> bool: + """Handle disconnects, teardown, and optional reconnect scheduling.""" + self.has_errored = True + self._stop_ping_thread() + if not reconnecting: + self._callback(self.on_error, e) + + if isinstance(e, (KeyboardInterrupt, SystemExit)): + teardown() + # 这里的 e 来自外层回调参数,不处在 except 语句块里, + # 不能用 bare raise;按原异常类型重建后继续向上传播。 + if isinstance(e, KeyboardInterrupt): + raise KeyboardInterrupt(*e.args) from e + raise SystemExit(*e.args) from e + + if reconnect: + _logging.info("%s - reconnect", e) + if custom_dispatcher: + _logging.debug( + "Calling custom dispatcher reconnect [%s frames in stack]", len( + inspect.stack()),) + dispatcher.reconnect(reconnect, setSock) + else: + _logging.error("%s - goodbye", e) + teardown() + + def teardown(close_frame: ABNF = None): + """ + Tears down the connection. + + Parameters + ---------- + close_frame: ABNF frame + If close_frame is set, the on_close handler is invoked + with the statusCode and reason from the provided frame. + """ + # teardown() is called in many code paths to ensure resources are + # cleaned up and on_close is fired. The flag and lock ensure that + # cleanup still runs only once. + with self.has_done_teardown_lock: + if self.has_done_teardown: + return + self.has_done_teardown = True + + self._stop_ping_thread() + self.keep_running = False + if self.sock: + self.sock.close() + close_status_code, close_reason = self._get_close_args( + close_frame if close_frame else None + ) + self.sock = None + + # Finally call the callback AFTER all teardown is complete + self._callback(self.on_close, close_status_code, close_reason) + + def setSock(reconnecting: bool = False) -> None: + """Create the socket, perform the handshake, and start reading.""" + if reconnecting and self.sock: + self.sock.shutdown() + + self.sock = WebSocket( + self.get_mask_key, + sockopt=sockopt, + sslopt=sslopt, + fire_cont_frame=self.on_cont_message is not None, + skip_utf8_validation=skip_utf8_validation, + enable_multithread=True, + ) + + self.sock.settimeout(getdefaulttimeout()) + try: + header = self.header() if callable(self.header) else self.header + + self.sock.connect( + self.url, + header=header, + cookie=self.cookie, + http_proxy_host=http_proxy_host, + http_proxy_port=http_proxy_port, + http_no_proxy=http_no_proxy, + http_proxy_auth=http_proxy_auth, + http_proxy_timeout=http_proxy_timeout, + subprotocols=self.subprotocols, + host=host, + origin=origin, + suppress_origin=suppress_origin, + proxy_type=proxy_type, + socket=self.prepared_socket, + ) + + _logging.info("Websocket connected") + + if self.ping_interval: + self._start_ping_thread() + + if reconnecting and self.on_reconnect: + self._callback(self.on_reconnect) + else: + self._callback(self.on_open) + + dispatcher.read(self.sock.sock, read, check) + except ( + WebSocketConnectionClosedException, + ConnectionRefusedError, + KeyboardInterrupt, + SystemExit, + ) as e: + handleDisconnect(e, reconnecting) + except Exception as e: + handleDisconnect(e, reconnecting) + + custom_dispatcher = bool(dispatcher) + dispatcher = self.create_dispatcher( + ping_timeout, dispatcher, parse_url(self.url)[3] + ) + + try: + setSock() + if not custom_dispatcher and reconnect: + while self.keep_running: + _logging.debug( + "Calling dispatcher reconnect [%s frames in stack]", + len(inspect.stack()), + ) + dispatcher.reconnect(reconnect, setSock) + except (KeyboardInterrupt, Exception) as e: + _logging.info("tearing down on exception %s", e) + teardown() + finally: + if not custom_dispatcher: + # Ensure teardown was called before returning from run_forever + teardown() + + return self.has_errored + + def create_dispatcher( + self, + ping_timeout: Union[float, int, None], + dispatcher: Optional[DispatcherBase] = None, + is_ssl: bool = False, + ) -> Union[Dispatcher, SSLDispatcher, WrappedDispatcher]: + """Create the dispatcher object used by ``run_forever``.""" + if dispatcher: # If custom dispatcher is set, use WrappedDispatcher + return WrappedDispatcher(self, ping_timeout, dispatcher) + timeout = ping_timeout or 10 + if is_ssl: + return SSLDispatcher(self, timeout) + return Dispatcher(self, timeout) + + def _get_close_args(self, close_frame: ABNF) -> list: + """Extract the close code and reason from a close frame if present.""" + # Need to catch the case where close_frame is None + # Otherwise the following if statement causes an error + if not self.on_close or not close_frame: + return [None, None] + + # Extract close frame status code + if close_frame.data and len(close_frame.data) >= 2: + close_status_code = 256 * int(close_frame.data[0]) + int( + close_frame.data[1] + ) + reason = close_frame.data[2:] + if isinstance(reason, bytes): + reason = reason.decode("utf-8") + return [close_status_code, reason] + # Most likely reached this because len(close_frame_data.data) < 2 + return [None, None] + + def _callback(self, callback, *args) -> None: + """Invoke a callback and forward callback failures to ``on_error``.""" + if callback: + try: + callback(self, *args) + + except Exception as e: + _logging.error("error from callback %s: %s", callback, e) + if self.on_error: + self.on_error(self, e) diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_core.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_core.py" index 2461386c..c875347b 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_core.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_core.py" @@ -1,628 +1,635 @@ -""" -_core.py -websocket - WebSocket client library for Python - -Copyright 2024 engn33r - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" - -import socket -import struct -import threading -import time -from typing import Optional, Union - -# websocket modules -from ._abnf import ABNF, STATUS_NORMAL, continuous_frame, frame_buffer -from ._exceptions import WebSocketProtocolException, WebSocketConnectionClosedException -from ._handshake import SUPPORTED_REDIRECT_STATUSES, handshake -from ._http import connect, proxy_info -from ._logging import debug, error, trace, isEnabledForError, isEnabledForTrace -from ._socket import getdefaulttimeout, recv, send, sock_opt -from ._ssl_compat import ssl -from ._utils import NoLock - -__all__ = ["WebSocket", "create_connection"] - - -class WebSocket: - """ - Low level WebSocket interface. - - This class is based on the WebSocket protocol - `draft-hixie-thewebsocketprotocol-76 - `_ - - We can connect to the websocket server and send/receive data. - The following example is an echo client. - - >>> import websocket - >>> ws = websocket.WebSocket() - >>> ws.connect("ws://echo.websocket.events") - >>> ws.recv() - 'echo.websocket.events sponsored by Lob.com' - >>> ws.send("Hello, Server") - 19 - >>> ws.recv() - 'Hello, Server' - >>> ws.close() - - Parameters - ---------- - get_mask_key: func - A callable function to get new mask keys, see the - WebSocket.set_mask_key's docstring for more information. - sockopt: tuple - Values for socket.setsockopt. - sockopt must be tuple and each element is argument of sock.setsockopt. - sslopt: dict - Optional dict object for ssl socket options. See FAQ for details. - fire_cont_frame: bool - Fire recv event for each cont frame. Default is False. - enable_multithread: bool - If set to True, lock send method. - skip_utf8_validation: bool - Skip utf8 validation. - """ - - def __init__( - self, - get_mask_key=None, - sockopt=None, - sslopt=None, - fire_cont_frame: bool = False, - enable_multithread: bool = True, - skip_utf8_validation: bool = False, - **_, - ): - """ - Initialize WebSocket object. - - Parameters - ---------- - sslopt: dict - Optional dict object for ssl socket options. See FAQ for details. - """ - self.sock_opt = sock_opt(sockopt, sslopt) - self.handshake_response = None - self.sock: Optional[socket.socket] = None - - self.connected = False - self.get_mask_key = get_mask_key - # These buffer over the build-up of a single frame. - self.frame_buffer = frame_buffer(self._recv, skip_utf8_validation) - self.cont_frame = continuous_frame(fire_cont_frame, skip_utf8_validation) - - if enable_multithread: - self.lock = threading.Lock() - self.readlock = threading.Lock() - else: - self.lock = NoLock() - self.readlock = NoLock() - - def __iter__(self): - """Allow iteration over websocket messages.""" - while True: - yield self.recv() - - def __next__(self): - """Return the next received websocket message.""" - return self.recv() - - def next(self): - """Backward-compatible alias for ``__next__``.""" - return self.__next__() - - def fileno(self): - """Return the file descriptor of the underlying socket.""" - return self.sock.fileno() - - def set_mask_key(self, func): - """ - Set function to create mask key. You can customize mask key generator. - Mainly, this is for testing purpose. - - Parameters - ---------- - func: func - callable object. the func takes 1 argument as integer. - The argument means length of mask key. - This func must return string(byte array), - which length is argument specified. - """ - self.get_mask_key = func - - def gettimeout(self) -> Union[float, int, None]: - """ - Get the websocket timeout (in seconds) as an int or float - - Returns - ---------- - timeout: int or float - returns timeout value (in seconds). - This value could be either float/integer. - """ - return self.sock_opt.timeout - - def settimeout(self, timeout: Union[float, int, None]): - """ - Set the timeout to the websocket. - - Parameters - ---------- - timeout: int or float - timeout time (in seconds). This value could be either float/integer. - """ - self.sock_opt.timeout = timeout - if self.sock: - self.sock.settimeout(timeout) - - timeout = property(gettimeout, settimeout) - - def getsubprotocol(self): - """Return the negotiated websocket subprotocol, if one exists.""" - if self.handshake_response: - return self.handshake_response.subprotocol - return None - - subprotocol = property(getsubprotocol) - - def getstatus(self): - """Return the HTTP status from the opening handshake.""" - if self.handshake_response: - return self.handshake_response.status - return None - - status = property(getstatus) - - def getheaders(self): - """Return the headers received during the opening handshake.""" - if self.handshake_response: - return self.handshake_response.headers - return None - - def is_ssl(self): - """Return whether the underlying socket is wrapped with TLS.""" - try: - return isinstance(self.sock, ssl.SSLSocket) - except Exception: - return False - - headers = property(getheaders) - - def connect(self, url, **options): - """ - Connect to url. url is websocket url scheme. - ie. ws://host:port/resource - You can customize using 'options'. - If you set "header" list object, you can set your own custom header. - - >>> ws = WebSocket() - >>> ws.connect("ws://echo.websocket.events", - ... header=["User-Agent: MyProgram", - ... "x-custom: header"]) - - Parameters - ---------- - header: list or dict - Custom http header list or dict. - cookie: str - Cookie value. - origin: str - Custom origin url. - connection: str - Custom connection header value. - Default value "Upgrade" set in _handshake.py - suppress_origin: bool - Suppress outputting origin header. - host: str - Custom host header string. - timeout: int or float - Socket timeout time. This value is an integer or float. - If you set None for this value, it means "use default_timeout value" - http_proxy_host: str - HTTP proxy host name. - http_proxy_port: str or int - HTTP proxy port. Default is 80. - http_no_proxy: list - Whitelisted host names that don't use the proxy. - http_proxy_auth: tuple - HTTP proxy auth information. - Tuple of username and password. Default is None. - http_proxy_timeout: int or float - HTTP proxy timeout, default is 60 sec as per python-socks. - redirect_limit: int - Number of redirects to follow. - subprotocols: list - List of available subprotocols. Default is None. - socket: socket - Pre-initialized stream socket. - """ - self.sock_opt.timeout = options.get("timeout", self.sock_opt.timeout) - self.sock, addrs = connect( - url, self.sock_opt, proxy_info(**options), options.pop("socket", None) - ) - - try: - self.handshake_response = handshake(self.sock, url, *addrs, **options) - for _ in range(options.pop("redirect_limit", 3)): - if self.handshake_response.status in SUPPORTED_REDIRECT_STATUSES: - url = self.handshake_response.headers["location"] - self.sock.close() - self.sock, addrs = connect( - url, - self.sock_opt, - proxy_info(**options), - options.pop("socket", None), - ) - self.handshake_response = handshake( - self.sock, url, *addrs, **options - ) - self.connected = True - except Exception: - if self.sock: - self.sock.close() - self.sock = None - raise - - def send(self, payload: Union[bytes, str], opcode: int = ABNF.OPCODE_TEXT) -> int: - """ - Send the data as string. - - Parameters - ---------- - payload: str - Payload must be utf-8 string or unicode, - If the opcode is OPCODE_TEXT. - Otherwise, it must be string(byte array). - opcode: int - Operation code (opcode) to send. - """ - frame = ABNF.create_frame(payload, opcode) - return self.send_frame(frame) - - def send_text(self, text_data: str) -> int: - """Send UTF-8 encoded text.""" - return self.send(text_data, ABNF.OPCODE_TEXT) - - def send_bytes(self, data: Union[bytes, bytearray]) -> int: - """Send binary data.""" - return self.send(data, ABNF.OPCODE_BINARY) - - def send_frame(self, frame) -> int: - """ - Send the data frame. - - >>> ws = create_connection("ws://echo.websocket.events") - >>> frame = ABNF.create_frame("Hello", ABNF.OPCODE_TEXT) - >>> ws.send_frame(frame) - >>> cont_frame = ABNF.create_frame("My name is ", ABNF.OPCODE_CONT, 0) - >>> ws.send_frame(frame) - >>> cont_frame = ABNF.create_frame("Foo Bar", ABNF.OPCODE_CONT, 1) - >>> ws.send_frame(frame) - - Parameters - ---------- - frame: ABNF frame - frame data created by ABNF.create_frame - """ - if self.get_mask_key: - frame.get_mask_key = self.get_mask_key - data = frame.format() - length = len(data) - if isEnabledForTrace(): - trace(f"++Sent raw: {data!r}") - trace(f"++Sent decoded: {frame}") - with self.lock: - while data: - sent_length = self._send(data) - data = data[sent_length:] - - return length - - def send_binary(self, payload: bytes) -> int: - """ - Send a binary message (OPCODE_BINARY). - - Parameters - ---------- - payload: bytes - payload of message to send. - """ - return self.send(payload, ABNF.OPCODE_BINARY) - - def ping(self, payload: Union[str, bytes] = ""): - """ - Send ping data. - - Parameters - ---------- - payload: str - data payload to send server. - """ - if isinstance(payload, str): - payload = payload.encode("utf-8") - self.send(payload, ABNF.OPCODE_PING) - - def pong(self, payload: Union[str, bytes] = ""): - """ - Send pong data. - - Parameters - ---------- - payload: str - data payload to send server. - """ - if isinstance(payload, str): - payload = payload.encode("utf-8") - self.send(payload, ABNF.OPCODE_PONG) - - def recv(self) -> Union[str, bytes]: - """ - Receive string data(byte array) from the server. - - Returns - ---------- - data: string (byte array) value. - """ - with self.readlock: - opcode, data = self.recv_data() - if opcode == ABNF.OPCODE_TEXT: - data_received: Union[bytes, str] = data - if isinstance(data_received, bytes): - return data_received.decode("utf-8") - if isinstance(data_received, str): - return data_received - if opcode == ABNF.OPCODE_BINARY: - data_binary: bytes = data - return data_binary - return "" - - def recv_data(self, control_frame: bool = False) -> tuple: - """ - Receive data with operation code. - - Parameters - ---------- - control_frame: bool - a boolean flag indicating whether to return control frame - data, defaults to False - - Returns - ------- - opcode, frame.data: tuple - tuple of operation code and string(byte array) value. - """ - opcode, frame = self.recv_data_frame(control_frame) - return opcode, frame.data - - def recv_data_frame(self, control_frame: bool = False) -> tuple: - """ - Receive data with operation code. - - If a valid ping message is received, a pong response is sent. - - Parameters - ---------- - control_frame: bool - a boolean flag indicating whether to return control frame - data, defaults to False - - Returns - ------- - frame.opcode, frame: tuple - tuple of operation code and string(byte array) value. - """ - while True: - frame = self.recv_frame() - if isEnabledForTrace(): - trace(f"++Rcv raw: {repr(frame.format())}") - trace(f"++Rcv decoded: {frame.__str__()}") - if not frame: - # handle error: - # 'NoneType' object has no attribute 'opcode' - raise WebSocketProtocolException(f"Not a valid frame {frame}") - if frame.opcode in ( - ABNF.OPCODE_TEXT, - ABNF.OPCODE_BINARY, - ABNF.OPCODE_CONT, - ): - self.cont_frame.validate(frame) - self.cont_frame.add(frame) - - if self.cont_frame.is_fire(frame): - return self.cont_frame.extract(frame) - - if frame.opcode == ABNF.OPCODE_CLOSE: - self.send_close() - return frame.opcode, frame - if frame.opcode in (ABNF.OPCODE_PING, ABNF.OPCODE_PONG): - if frame.opcode == ABNF.OPCODE_PING: - if len(frame.data) < 126: - self.pong(frame.data) - else: - raise WebSocketProtocolException("Ping message is too long") - if control_frame: - return frame.opcode, frame - - def recv_frame(self): - """Receive and decode a single frame from the server.""" - return self.frame_buffer.recv_frame() - - def send_close(self, status: int = STATUS_NORMAL, reason: bytes = b""): - """ - Send close data to the server. - - Parameters - ---------- - status: int - Status code to send. See STATUS_XXX. - reason: str or bytes - The reason to close. This must be string or UTF-8 bytes. - """ - if status < 0 or status >= ABNF.LENGTH_16: - raise ValueError("code is invalid range") - self.connected = False - self.send(struct.pack("!H", status) + reason, ABNF.OPCODE_CLOSE) - - def close(self, status: int = STATUS_NORMAL, reason: bytes = b"", timeout: int = 3): - """ - Close Websocket object - - Parameters - ---------- - status: int - Status code to send. See VALID_CLOSE_STATUS in ABNF. - reason: bytes - The reason to close in UTF-8. - timeout: int or float - Timeout until receive a close frame. - If None, it will wait forever until receive a close frame. - """ - if not self.connected: - return - if status < 0 or status >= ABNF.LENGTH_16: - raise ValueError("code is invalid range") - - try: - self.connected = False - self.send(struct.pack("!H", status) + reason, ABNF.OPCODE_CLOSE) - sock_timeout = self.sock.gettimeout() - self.sock.settimeout(timeout) - start_time = time.time() - while timeout is None or time.time() - start_time < timeout: - try: - frame = self.recv_frame() - if frame.opcode != ABNF.OPCODE_CLOSE: - continue - if isEnabledForError(): - recv_status = struct.unpack("!H", frame.data[0:2])[0] - if 3000 <= recv_status <= 4999: - debug(f"close status: {repr(recv_status)}") - elif recv_status != STATUS_NORMAL: - error(f"close status: {repr(recv_status)}") - break - except Exception: - break - self.sock.settimeout(sock_timeout) - self.sock.shutdown(socket.SHUT_RDWR) - except Exception: - pass - - self.shutdown() - - def abort(self): - """Wake threads blocked in low-level receive operations.""" - if self.connected: - self.sock.shutdown(socket.SHUT_RDWR) - - def shutdown(self): - """Close the underlying socket immediately.""" - if self.sock: - self.sock.close() - self.sock = None - self.connected = False - - def _send(self, data: Union[str, bytes]): - """Send raw bytes through the underlying socket.""" - return send(self.sock, data) - - def _recv(self, bufsize): - """Receive raw bytes through the underlying socket.""" - try: - return recv(self.sock, bufsize) - except WebSocketConnectionClosedException: - if self.sock: - self.sock.close() - self.sock = None - self.connected = False - raise - - -def create_connection(url: str, timeout=None, class_=WebSocket, **options): - """ - Connect to url and return websocket object. - - Connect to url and return the WebSocket object. - Passing optional timeout parameter will set the timeout on the socket. - If no timeout is supplied, - the global default timeout setting returned by getdefaulttimeout() is used. - You can customize using 'options'. - If you set "header" list object, you can set your own custom header. - - >>> conn = create_connection("ws://echo.websocket.events", - ... header=["User-Agent: MyProgram", - ... "x-custom: header"]) - - Parameters - ---------- - class_: class - class to instantiate when creating the connection. It has to implement - settimeout and connect. It's __init__ should be compatible with - WebSocket.__init__, i.e. accept all of it's kwargs. - header: list or dict - custom http header list or dict. - cookie: str - Cookie value. - origin: str - custom origin url. - suppress_origin: bool - suppress outputting origin header. - host: str - custom host header string. - timeout: int or float - socket timeout time. This value could be either float/integer. - If set to None, it uses the default_timeout value. - http_proxy_host: str - HTTP proxy host name. - http_proxy_port: str or int - HTTP proxy port. If not set, set to 80. - http_no_proxy: list - Whitelisted host names that don't use the proxy. - http_proxy_auth: tuple - HTTP proxy auth information. tuple of username and password. Default is None. - http_proxy_timeout: int or float - HTTP proxy timeout, default is 60 sec as per python-socks. - enable_multithread: bool - Enable lock for multithread. - redirect_limit: int - Number of redirects to follow. - sockopt: tuple - Values for socket.setsockopt. - sockopt must be a tuple and each element is an argument of sock.setsockopt. - sslopt: dict - Optional dict object for ssl socket options. See FAQ for details. - subprotocols: list - List of available subprotocols. Default is None. - skip_utf8_validation: bool - Skip utf8 validation. - socket: socket - Pre-initialized stream socket. - """ - sockopt = options.pop("sockopt", []) - sslopt = options.pop("sslopt", {}) - fire_cont_frame = options.pop("fire_cont_frame", False) - enable_multithread = options.pop("enable_multithread", True) - skip_utf8_validation = options.pop("skip_utf8_validation", False) - websock = class_( - sockopt=sockopt, - sslopt=sslopt, - fire_cont_frame=fire_cont_frame, - enable_multithread=enable_multithread, - skip_utf8_validation=skip_utf8_validation, - **options, - ) - websock.settimeout(timeout if timeout is not None else getdefaulttimeout()) - websock.connect(url, **options) - return websock +""" +_core.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import socket +import struct +import threading +import time +from typing import Optional, Union + +# websocket modules +from ._abnf import ABNF, STATUS_NORMAL, continuous_frame, frame_buffer +from ._exceptions import WebSocketProtocolException, WebSocketConnectionClosedException +from ._handshake import SUPPORTED_REDIRECT_STATUSES, handshake +from ._http import connect, proxy_info +from ._logging import debug, error, trace, isEnabledForError, isEnabledForTrace +from ._socket import getdefaulttimeout, recv, send, sock_opt +from ._ssl_compat import ssl +from ._utils import NoLock + +__all__ = ["WebSocket", "create_connection"] + + +class WebSocket: + """ + Low level WebSocket interface. + + This class is based on the WebSocket protocol + `draft-hixie-thewebsocketprotocol-76 + `_ + + We can connect to the websocket server and send/receive data. + The following example is an echo client. + + >>> import websocket + >>> ws = websocket.WebSocket() + >>> ws.connect("ws://echo.websocket.events") + >>> ws.recv() + 'echo.websocket.events sponsored by Lob.com' + >>> ws.send("Hello, Server") + 19 + >>> ws.recv() + 'Hello, Server' + >>> ws.close() + + Parameters + ---------- + get_mask_key: func + A callable function to get new mask keys, see the + WebSocket.set_mask_key's docstring for more information. + sockopt: tuple + Values for socket.setsockopt. + sockopt must be tuple and each element is argument of sock.setsockopt. + sslopt: dict + Optional dict object for ssl socket options. See FAQ for details. + fire_cont_frame: bool + Fire recv event for each cont frame. Default is False. + enable_multithread: bool + If set to True, lock send method. + skip_utf8_validation: bool + Skip utf8 validation. + """ + + def __init__( + self, + get_mask_key=None, + sockopt=None, + sslopt=None, + fire_cont_frame: bool = False, + enable_multithread: bool = True, + skip_utf8_validation: bool = False, + **_, + ): + """ + Initialize WebSocket object. + + Parameters + ---------- + sslopt: dict + Optional dict object for ssl socket options. See FAQ for details. + """ + self.sock_opt = sock_opt(sockopt, sslopt) + self.handshake_response = None + self.sock: Optional[socket.socket] = None + + self.connected = False + self.get_mask_key = get_mask_key + # These buffer over the build-up of a single frame. + self.frame_buffer = frame_buffer(self._recv, skip_utf8_validation) + self.cont_frame = continuous_frame( + fire_cont_frame, skip_utf8_validation) + + if enable_multithread: + self.lock = threading.Lock() + self.readlock = threading.Lock() + else: + self.lock = NoLock() + self.readlock = NoLock() + + def __iter__(self): + """Allow iteration over websocket messages.""" + while True: + yield self.recv() + + def __next__(self): + """Return the next received websocket message.""" + return self.recv() + + def next(self): + """Backward-compatible alias for ``__next__``.""" + return self.__next__() + + def fileno(self): + """Return the file descriptor of the underlying socket.""" + return self.sock.fileno() + + def set_mask_key(self, func): + """ + Set function to create mask key. You can customize mask key generator. + Mainly, this is for testing purpose. + + Parameters + ---------- + func: func + callable object. the func takes 1 argument as integer. + The argument means length of mask key. + This func must return string(byte array), + which length is argument specified. + """ + self.get_mask_key = func + + def gettimeout(self) -> Union[float, int, None]: + """ + Get the websocket timeout (in seconds) as an int or float + + Returns + ---------- + timeout: int or float + returns timeout value (in seconds). + This value could be either float/integer. + """ + return self.sock_opt.timeout + + def settimeout(self, timeout: Union[float, int, None]): + """ + Set the timeout to the websocket. + + Parameters + ---------- + timeout: int or float + timeout time (in seconds). This value could be either float/integer. + """ + self.sock_opt.timeout = timeout + if self.sock: + self.sock.settimeout(timeout) + + timeout = property(gettimeout, settimeout) + + def getsubprotocol(self): + """Return the negotiated websocket subprotocol, if one exists.""" + if self.handshake_response: + return self.handshake_response.subprotocol + return None + + subprotocol = property(getsubprotocol) + + def getstatus(self): + """Return the HTTP status from the opening handshake.""" + if self.handshake_response: + return self.handshake_response.status + return None + + status = property(getstatus) + + def getheaders(self): + """Return the headers received during the opening handshake.""" + if self.handshake_response: + return self.handshake_response.headers + return None + + def is_ssl(self): + """Return whether the underlying socket is wrapped with TLS.""" + try: + return isinstance(self.sock, ssl.SSLSocket) + except Exception: + return False + + headers = property(getheaders) + + def connect(self, url, **options): + """ + Connect to url. url is websocket url scheme. + ie. ws://host:port/resource + You can customize using 'options'. + If you set "header" list object, you can set your own custom header. + + >>> ws = WebSocket() + >>> ws.connect("ws://echo.websocket.events", + ... header=["User-Agent: MyProgram", + ... "x-custom: header"]) + + Parameters + ---------- + header: list or dict + Custom http header list or dict. + cookie: str + Cookie value. + origin: str + Custom origin url. + connection: str + Custom connection header value. + Default value "Upgrade" set in _handshake.py + suppress_origin: bool + Suppress outputting origin header. + host: str + Custom host header string. + timeout: int or float + Socket timeout time. This value is an integer or float. + If you set None for this value, it means "use default_timeout value" + http_proxy_host: str + HTTP proxy host name. + http_proxy_port: str or int + HTTP proxy port. Default is 80. + http_no_proxy: list + Whitelisted host names that don't use the proxy. + http_proxy_auth: tuple + HTTP proxy auth information. + Tuple of username and password. Default is None. + http_proxy_timeout: int or float + HTTP proxy timeout, default is 60 sec as per python-socks. + redirect_limit: int + Number of redirects to follow. + subprotocols: list + List of available subprotocols. Default is None. + socket: socket + Pre-initialized stream socket. + """ + self.sock_opt.timeout = options.get("timeout", self.sock_opt.timeout) + self.sock, addrs = connect(url, self.sock_opt, proxy_info( + **options), options.pop("socket", None)) + + try: + self.handshake_response = handshake( + self.sock, url, *addrs, **options) + for _ in range(options.pop("redirect_limit", 3)): + if self.handshake_response.status in SUPPORTED_REDIRECT_STATUSES: + url = self.handshake_response.headers["location"] + self.sock.close() + self.sock, addrs = connect( + url, + self.sock_opt, + proxy_info(**options), + options.pop("socket", None), + ) + self.handshake_response = handshake( + self.sock, url, *addrs, **options + ) + self.connected = True + except Exception: + if self.sock: + self.sock.close() + self.sock = None + raise + + def send(self, payload: Union[bytes, str], + opcode: int = ABNF.OPCODE_TEXT) -> int: + """ + Send the data as string. + + Parameters + ---------- + payload: str + Payload must be utf-8 string or unicode, + If the opcode is OPCODE_TEXT. + Otherwise, it must be string(byte array). + opcode: int + Operation code (opcode) to send. + """ + frame = ABNF.create_frame(payload, opcode) + return self.send_frame(frame) + + def send_text(self, text_data: str) -> int: + """Send UTF-8 encoded text.""" + return self.send(text_data, ABNF.OPCODE_TEXT) + + def send_bytes(self, data: Union[bytes, bytearray]) -> int: + """Send binary data.""" + return self.send(data, ABNF.OPCODE_BINARY) + + def send_frame(self, frame) -> int: + """ + Send the data frame. + + >>> ws = create_connection("ws://echo.websocket.events") + >>> frame = ABNF.create_frame("Hello", ABNF.OPCODE_TEXT) + >>> ws.send_frame(frame) + >>> cont_frame = ABNF.create_frame("My name is ", ABNF.OPCODE_CONT, 0) + >>> ws.send_frame(frame) + >>> cont_frame = ABNF.create_frame("Foo Bar", ABNF.OPCODE_CONT, 1) + >>> ws.send_frame(frame) + + Parameters + ---------- + frame: ABNF frame + frame data created by ABNF.create_frame + """ + if self.get_mask_key: + frame.get_mask_key = self.get_mask_key + data = frame.format() + length = len(data) + if isEnabledForTrace(): + trace(f"++Sent raw: {data!r}") + trace(f"++Sent decoded: {frame}") + with self.lock: + while data: + sent_length = self._send(data) + data = data[sent_length:] + + return length + + def send_binary(self, payload: bytes) -> int: + """ + Send a binary message (OPCODE_BINARY). + + Parameters + ---------- + payload: bytes + payload of message to send. + """ + return self.send(payload, ABNF.OPCODE_BINARY) + + def ping(self, payload: Union[str, bytes] = ""): + """ + Send ping data. + + Parameters + ---------- + payload: str + data payload to send server. + """ + if isinstance(payload, str): + payload = payload.encode("utf-8") + self.send(payload, ABNF.OPCODE_PING) + + def pong(self, payload: Union[str, bytes] = ""): + """ + Send pong data. + + Parameters + ---------- + payload: str + data payload to send server. + """ + if isinstance(payload, str): + payload = payload.encode("utf-8") + self.send(payload, ABNF.OPCODE_PONG) + + def recv(self) -> Union[str, bytes]: + """ + Receive string data(byte array) from the server. + + Returns + ---------- + data: string (byte array) value. + """ + with self.readlock: + opcode, data = self.recv_data() + if opcode == ABNF.OPCODE_TEXT: + data_received: Union[bytes, str] = data + if isinstance(data_received, bytes): + return data_received.decode("utf-8") + if isinstance(data_received, str): + return data_received + if opcode == ABNF.OPCODE_BINARY: + data_binary: bytes = data + return data_binary + return "" + + def recv_data(self, control_frame: bool = False) -> tuple: + """ + Receive data with operation code. + + Parameters + ---------- + control_frame: bool + a boolean flag indicating whether to return control frame + data, defaults to False + + Returns + ------- + opcode, frame.data: tuple + tuple of operation code and string(byte array) value. + """ + opcode, frame = self.recv_data_frame(control_frame) + return opcode, frame.data + + def recv_data_frame(self, control_frame: bool = False) -> tuple: + """ + Receive data with operation code. + + If a valid ping message is received, a pong response is sent. + + Parameters + ---------- + control_frame: bool + a boolean flag indicating whether to return control frame + data, defaults to False + + Returns + ------- + frame.opcode, frame: tuple + tuple of operation code and string(byte array) value. + """ + while True: + frame = self.recv_frame() + if isEnabledForTrace(): + trace(f"++Rcv raw: {repr(frame.format())}") + trace(f"++Rcv decoded: {frame.__str__()}") + if not frame: + # handle error: + # 'NoneType' object has no attribute 'opcode' + raise WebSocketProtocolException(f"Not a valid frame {frame}") + if frame.opcode in ( + ABNF.OPCODE_TEXT, + ABNF.OPCODE_BINARY, + ABNF.OPCODE_CONT, + ): + self.cont_frame.validate(frame) + self.cont_frame.add(frame) + + if self.cont_frame.is_fire(frame): + return self.cont_frame.extract(frame) + + if frame.opcode == ABNF.OPCODE_CLOSE: + self.send_close() + return frame.opcode, frame + if frame.opcode in (ABNF.OPCODE_PING, ABNF.OPCODE_PONG): + if frame.opcode == ABNF.OPCODE_PING: + if len(frame.data) < 126: + self.pong(frame.data) + else: + raise WebSocketProtocolException( + "Ping message is too long") + if control_frame: + return frame.opcode, frame + + def recv_frame(self): + """Receive and decode a single frame from the server.""" + return self.frame_buffer.recv_frame() + + def send_close(self, status: int = STATUS_NORMAL, reason: bytes = b""): + """ + Send close data to the server. + + Parameters + ---------- + status: int + Status code to send. See STATUS_XXX. + reason: str or bytes + The reason to close. This must be string or UTF-8 bytes. + """ + if status < 0 or status >= ABNF.LENGTH_16: + raise ValueError("code is invalid range") + self.connected = False + self.send(struct.pack("!H", status) + reason, ABNF.OPCODE_CLOSE) + + def close( + self, + status: int = STATUS_NORMAL, + reason: bytes = b"", + timeout: int = 3): + """ + Close Websocket object + + Parameters + ---------- + status: int + Status code to send. See VALID_CLOSE_STATUS in ABNF. + reason: bytes + The reason to close in UTF-8. + timeout: int or float + Timeout until receive a close frame. + If None, it will wait forever until receive a close frame. + """ + if not self.connected: + return + if status < 0 or status >= ABNF.LENGTH_16: + raise ValueError("code is invalid range") + + try: + self.connected = False + self.send(struct.pack("!H", status) + reason, ABNF.OPCODE_CLOSE) + sock_timeout = self.sock.gettimeout() + self.sock.settimeout(timeout) + start_time = time.time() + while timeout is None or time.time() - start_time < timeout: + try: + frame = self.recv_frame() + if frame.opcode != ABNF.OPCODE_CLOSE: + continue + if isEnabledForError(): + recv_status = struct.unpack("!H", frame.data[0:2])[0] + if 3000 <= recv_status <= 4999: + debug(f"close status: {repr(recv_status)}") + elif recv_status != STATUS_NORMAL: + error(f"close status: {repr(recv_status)}") + break + except Exception: + break + self.sock.settimeout(sock_timeout) + self.sock.shutdown(socket.SHUT_RDWR) + except Exception: + pass + + self.shutdown() + + def abort(self): + """Wake threads blocked in low-level receive operations.""" + if self.connected: + self.sock.shutdown(socket.SHUT_RDWR) + + def shutdown(self): + """Close the underlying socket immediately.""" + if self.sock: + self.sock.close() + self.sock = None + self.connected = False + + def _send(self, data: Union[str, bytes]): + """Send raw bytes through the underlying socket.""" + return send(self.sock, data) + + def _recv(self, bufsize): + """Receive raw bytes through the underlying socket.""" + try: + return recv(self.sock, bufsize) + except WebSocketConnectionClosedException: + if self.sock: + self.sock.close() + self.sock = None + self.connected = False + raise + + +def create_connection(url: str, timeout=None, class_=WebSocket, **options): + """ + Connect to url and return websocket object. + + Connect to url and return the WebSocket object. + Passing optional timeout parameter will set the timeout on the socket. + If no timeout is supplied, + the global default timeout setting returned by getdefaulttimeout() is used. + You can customize using 'options'. + If you set "header" list object, you can set your own custom header. + + >>> conn = create_connection("ws://echo.websocket.events", + ... header=["User-Agent: MyProgram", + ... "x-custom: header"]) + + Parameters + ---------- + class_: class + class to instantiate when creating the connection. It has to implement + settimeout and connect. It's __init__ should be compatible with + WebSocket.__init__, i.e. accept all of it's kwargs. + header: list or dict + custom http header list or dict. + cookie: str + Cookie value. + origin: str + custom origin url. + suppress_origin: bool + suppress outputting origin header. + host: str + custom host header string. + timeout: int or float + socket timeout time. This value could be either float/integer. + If set to None, it uses the default_timeout value. + http_proxy_host: str + HTTP proxy host name. + http_proxy_port: str or int + HTTP proxy port. If not set, set to 80. + http_no_proxy: list + Whitelisted host names that don't use the proxy. + http_proxy_auth: tuple + HTTP proxy auth information. tuple of username and password. Default is None. + http_proxy_timeout: int or float + HTTP proxy timeout, default is 60 sec as per python-socks. + enable_multithread: bool + Enable lock for multithread. + redirect_limit: int + Number of redirects to follow. + sockopt: tuple + Values for socket.setsockopt. + sockopt must be a tuple and each element is an argument of sock.setsockopt. + sslopt: dict + Optional dict object for ssl socket options. See FAQ for details. + subprotocols: list + List of available subprotocols. Default is None. + skip_utf8_validation: bool + Skip utf8 validation. + socket: socket + Pre-initialized stream socket. + """ + sockopt = options.pop("sockopt", []) + sslopt = options.pop("sslopt", {}) + fire_cont_frame = options.pop("fire_cont_frame", False) + enable_multithread = options.pop("enable_multithread", True) + skip_utf8_validation = options.pop("skip_utf8_validation", False) + websock = class_( + sockopt=sockopt, + sslopt=sslopt, + fire_cont_frame=fire_cont_frame, + enable_multithread=enable_multithread, + skip_utf8_validation=skip_utf8_validation, + **options, + ) + websock.settimeout(timeout if timeout is not None else getdefaulttimeout()) + websock.connect(url, **options) + return websock diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_handshake.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_handshake.py" index 424e5b21..52f6ab11 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_handshake.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_handshake.py" @@ -40,7 +40,8 @@ HTTPStatus.TEMPORARY_REDIRECT, HTTPStatus.PERMANENT_REDIRECT, ) -SUCCESS_STATUSES = SUPPORTED_REDIRECT_STATUSES + (HTTPStatus.SWITCHING_PROTOCOLS,) +SUCCESS_STATUSES = SUPPORTED_REDIRECT_STATUSES + \ + (HTTPStatus.SWITCHING_PROTOCOLS,) CookieJar = SimpleCookieJar() @@ -60,7 +61,8 @@ def handshake( sock, url: str, hostname: str, port: int, resource: str, **options ) -> handshake_response: """Perform the client handshake and validate the server response.""" - headers, key = _get_handshake_headers(resource, url, hostname, port, options) + headers, key = _get_handshake_headers( + resource, url, hostname, port, options) header_str = "\r\n".join(headers) send(sock, header_str) @@ -111,13 +113,16 @@ def _get_handshake_headers( # skipcq: PY-R1000 key = _create_sec_websocket_key() - # Append Sec-WebSocket-Key & Sec-WebSocket-Version if not manually specified - if not options.get("header") or "Sec-WebSocket-Key" not in options["header"]: + # Append Sec-WebSocket-Key & Sec-WebSocket-Version if not manually + # specified + if not options.get( + "header") or "Sec-WebSocket-Key" not in options["header"]: headers.append(f"Sec-WebSocket-Key: {key}") else: key = options["header"]["Sec-WebSocket-Key"] - if not options.get("header") or "Sec-WebSocket-Version" not in options["header"]: + if not options.get( + "header") or "Sec-WebSocket-Version" not in options["header"]: headers.append(f"Sec-WebSocket-Version: {VERSION}") if not options.get("connection"): @@ -130,7 +135,8 @@ def _get_handshake_headers( # skipcq: PY-R1000 if header := options.get("header"): if isinstance(header, dict): - header = [": ".join([k, v]) for k, v in header.items() if v is not None] + header = [": ".join([k, v]) + for k, v in header.items() if v is not None] headers.extend(header) server_cookie = CookieJar.get(host) @@ -143,7 +149,9 @@ def _get_handshake_headers( # skipcq: PY-R1000 return headers, key -def _get_resp_headers(sock, success_statuses: tuple = SUCCESS_STATUSES) -> tuple: +def _get_resp_headers( + sock, + success_statuses: tuple = SUCCESS_STATUSES) -> tuple: """Read and validate the HTTP response headers for the handshake.""" status, resp_headers, status_message = read_headers(sock) if status not in success_statuses: @@ -176,7 +184,8 @@ def _get_resp_headers(sock, success_statuses: tuple = SUCCESS_STATUSES) -> tuple def _create_sec_websocket_accept_digest(value: bytes) -> bytes: """Return the RFC 6455-required SHA-1 digest for Sec-WebSocket-Accept.""" try: - return hashlib.sha1(value, usedforsecurity=False).digest() # skipcq: PTC-W1003 + return hashlib.sha1( + value, usedforsecurity=False).digest() # skipcq: PTC-W1003 except TypeError: return hashlib.sha1(value).digest() # skipcq: PTC-W1003 @@ -194,7 +203,8 @@ def _validate(headers, key: str, subprotocols) -> tuple: if subprotocols: subproto = headers.get("sec-websocket-protocol") - if not subproto or subproto.lower() not in [s.lower() for s in subprotocols]: + if not subproto or subproto.lower() not in [ + s.lower() for s in subprotocols]: error(f"Invalid subprotocol: {subprotocols}") return False, None subproto = subproto.lower() diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_http.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_http.py" index eda0ecf2..74b5beb0 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_http.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_http.py" @@ -1,396 +1,410 @@ -""" -_http.py -websocket - WebSocket client library for Python - -Copyright 2024 engn33r - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" -import errno -import os -import socket -from base64 import encodebytes as base64encode - -from ._exceptions import ( - WebSocketAddressException, - WebSocketException, - WebSocketProxyException, -) -from ._logging import debug, dump, trace -from ._socket import DEFAULT_SOCKET_OPTION, recv_line, send -from ._ssl_compat import HAVE_SSL, ssl -from ._url import get_proxy_info, parse_url - -__all__ = ["proxy_info", "connect", "read_headers"] - -try: - from python_socks._errors import ( - ProxyConnectionError, - ProxyError, - ProxyTimeoutError, - ) - from python_socks._types import ProxyType - from python_socks.sync import Proxy - - HAVE_PYTHON_SOCKS = True -except Exception: - HAVE_PYTHON_SOCKS = False - - class ProxyError(Exception): - """Fallback proxy error used when python-socks is unavailable.""" - - __slots__ = () - - class ProxyTimeoutError(Exception): - """Fallback proxy timeout error used when python-socks is unavailable.""" - - __slots__ = () - - class ProxyConnectionError(Exception): - """Fallback proxy connection error used when python-socks is unavailable.""" - - __slots__ = () - - -class proxy_info: - """Normalized proxy configuration extracted from connection options.""" - - def __init__(self, **options): - """Parse proxy-related options into a small structured object.""" - self.proxy_host = options.get("http_proxy_host") - if self.proxy_host: - self.proxy_port = options.get("http_proxy_port", 0) - self.auth = options.get("http_proxy_auth") - self.no_proxy = options.get("http_no_proxy") - self.proxy_protocol = options.get("proxy_type", "http") - # If timeout is not specified, python-socks defaults to 60 seconds. - self.proxy_timeout = options.get("http_proxy_timeout") - if self.proxy_protocol not in [ - "http", - "socks4", - "socks4a", - "socks5", - "socks5h", - ]: - raise ProxyError( - "Only http, socks4, socks5 proxy protocols are supported" - ) - else: - self.proxy_port = 0 - self.auth = None - self.no_proxy = None - self.proxy_protocol = "http" - - -def _start_proxied_socket(url: str, options, proxy) -> tuple: - """Open a socket through a SOCKS proxy and wrap it for TLS if needed.""" - if not HAVE_PYTHON_SOCKS: - raise WebSocketException( - "Python Socks is needed for SOCKS proxying but is not available" - ) - - hostname, port, resource, is_secure = parse_url(url) - - if proxy.proxy_protocol == "socks4": - rdns = False - proxy_type = ProxyType.SOCKS4 - # socks4a sends DNS through proxy - elif proxy.proxy_protocol == "socks4a": - rdns = True - proxy_type = ProxyType.SOCKS4 - elif proxy.proxy_protocol == "socks5": - rdns = False - proxy_type = ProxyType.SOCKS5 - # socks5h sends DNS through proxy - elif proxy.proxy_protocol == "socks5h": - rdns = True - proxy_type = ProxyType.SOCKS5 - - ws_proxy = Proxy.create( - proxy_type=proxy_type, - host=proxy.proxy_host, - port=int(proxy.proxy_port), - username=proxy.auth[0] if proxy.auth else None, - password=proxy.auth[1] if proxy.auth else None, - rdns=rdns, - ) - - sock = ws_proxy.connect(hostname, port, timeout=proxy.proxy_timeout) - - if is_secure: - if HAVE_SSL: - sock = _ssl_socket(sock, options.sslopt, hostname) - else: - raise WebSocketException("SSL not available.") - - return sock, (hostname, port, resource) - - -def connect(url: str, options, proxy, prepared_socket): - """Create and return a socket connected to the websocket endpoint.""" - # Use _start_proxied_socket() only for socks4 or socks5 proxy - # Use _tunnel() for http proxy - # NOTE: HTTP proxying still uses _tunnel() until python-socks is adopted - # for that path as well. - if proxy.proxy_host and not prepared_socket and proxy.proxy_protocol != "http": - return _start_proxied_socket(url, options, proxy) - - hostname, port_from_url, resource, is_secure = parse_url(url) - - if prepared_socket: - return prepared_socket, (hostname, port_from_url, resource) - - addrinfo_list, need_tunnel, auth = _get_addrinfo_list( - hostname, port_from_url, is_secure, proxy - ) - if not addrinfo_list: - raise WebSocketException(f"Host not found.: {hostname}:{port_from_url}") - - sock = None - try: - sock = _open_socket(addrinfo_list, options.sockopt, options.timeout) - if need_tunnel: - sock = _tunnel(sock, hostname, port_from_url, auth) - - if is_secure: - if HAVE_SSL: - sock = _ssl_socket(sock, options.sslopt, hostname) - else: - raise WebSocketException("SSL not available.") - - return sock, (hostname, port_from_url, resource) - except Exception: - if sock: - sock.close() - raise - - -def _get_addrinfo_list(hostname, port: int, is_secure: bool, proxy) -> tuple: - """Resolve the address list used for a direct or proxied connection.""" - phost, pport, pauth = get_proxy_info( - hostname, - is_secure, - proxy.proxy_host, - proxy.proxy_port, - proxy.auth, - proxy.no_proxy, - ) - try: - # On Windows 10, getaddrinfo without socktype can return socktype 0, - # which later breaks socket creation. Force SOCK_STREAM explicitly. - if not phost: - addrinfo_list = socket.getaddrinfo( - hostname, port, 0, socket.SOCK_STREAM, socket.SOL_TCP - ) - return addrinfo_list, False, None - pport = pport and pport or 80 - # Apply the same SOCK_STREAM workaround for the proxy endpoint. - addrinfo_list = socket.getaddrinfo( - phost, pport, 0, socket.SOCK_STREAM, socket.SOL_TCP - ) - return addrinfo_list, True, pauth - except socket.gaierror as e: - raise WebSocketAddressException(e) - - -def _open_socket(addrinfo_list, sockopt, timeout): - """Try each resolved address until a socket connection succeeds.""" - err = None - for addrinfo in addrinfo_list: - family, socktype, proto = addrinfo[:3] - sock = socket.socket(family, socktype, proto) - sock.settimeout(timeout) - for opts in DEFAULT_SOCKET_OPTION: - sock.setsockopt(*opts) - for opts in sockopt: - sock.setsockopt(*opts) - - address = addrinfo[4] - err = None - while not err: - try: - sock.connect(address) - except socket.error as error: - sock.close() - error.remote_ip = str(address[0]) - try: - eConnRefused = ( - errno.ECONNREFUSED, - errno.WSAECONNREFUSED, - errno.ENETUNREACH, - ) - except AttributeError: - eConnRefused = (errno.ECONNREFUSED, errno.ENETUNREACH) - if error.errno not in eConnRefused: - raise error - err = error - continue - else: - break - else: - continue - break - else: - if err: - raise err - - return sock - - -def _wrap_sni_socket(sock: socket.socket, sslopt: dict, hostname, _check_hostname): - """Wrap a plain socket with TLS/SNI using the provided SSL options.""" - context = sslopt.get("context") - if not context: - context = ssl.SSLContext(sslopt.get("ssl_version", ssl.PROTOCOL_TLS_CLIENT)) - # Non-default contexts need keylog_filename set manually to honor - # SSLKEYLOGFILE. - # For more details see also: - # * https://docs.python.org/3.8/library/ssl.html - # ?highlight=sslkeylogfile#context-creation - # * https://docs.python.org/3.8/library/ssl.html - # ?highlight=sslkeylogfile#ssl.SSLContext.keylog_filename - context.keylog_filename = os.environ.get("SSLKEYLOGFILE") - - if sslopt.get("cert_reqs", ssl.CERT_NONE) != ssl.CERT_NONE: - cafile = sslopt.get("ca_certs") - capath = sslopt.get("ca_cert_path") - if cafile or capath: - context.load_verify_locations(cafile=cafile, capath=capath) - elif hasattr(context, "load_default_certs"): - context.load_default_certs(ssl.Purpose.SERVER_AUTH) - if sslopt.get("certfile"): - context.load_cert_chain( - sslopt["certfile"], - sslopt.get("keyfile"), - sslopt.get("password"), - ) - - # Python 3.10 switched PROTOCOL_TLS_CLIENT defaults to - # cert_reqs=CERT_REQUIRED and check_hostname=True. - # If both are disabled, set check_hostname before verify_mode. - # See: - # https://github.com/liris/websocket-client/commit/ - # b96a2e8fa765753e82eea531adb19716b52ca3ca#commitcomment-10803153 - if sslopt.get("cert_reqs", ssl.CERT_NONE) == ssl.CERT_NONE and not sslopt.get( - "check_hostname", False - ): - context.check_hostname = False - context.verify_mode = ssl.CERT_NONE - else: - context.check_hostname = sslopt.get("check_hostname", True) - context.verify_mode = sslopt.get("cert_reqs", ssl.CERT_REQUIRED) - - if "ciphers" in sslopt: - context.set_ciphers(sslopt["ciphers"]) - if "cert_chain" in sslopt: - certfile, keyfile, password = sslopt["cert_chain"] - context.load_cert_chain(certfile, keyfile, password) - if "ecdh_curve" in sslopt: - context.set_ecdh_curve(sslopt["ecdh_curve"]) - - return context.wrap_socket( - sock, - do_handshake_on_connect=sslopt.get("do_handshake_on_connect", True), - suppress_ragged_eofs=sslopt.get("suppress_ragged_eofs", True), - server_hostname=hostname, - ) - - -def _ssl_socket(sock: socket.socket, user_sslopt: dict, hostname): - """Apply TLS settings and wrap the socket for secure websocket use.""" - sslopt: dict = {"cert_reqs": ssl.CERT_REQUIRED} - sslopt.update(user_sslopt) - - cert_path = os.environ.get("WEBSOCKET_CLIENT_CA_BUNDLE") - if ( - cert_path - and os.path.isfile(cert_path) - and user_sslopt.get("ca_certs") is None - ): - sslopt["ca_certs"] = cert_path - elif ( - cert_path - and os.path.isdir(cert_path) - and user_sslopt.get("ca_cert_path") is None - ): - sslopt["ca_cert_path"] = cert_path - - if sslopt.get("server_hostname"): - hostname = sslopt["server_hostname"] - - check_hostname = sslopt.get("check_hostname", True) - sock = _wrap_sni_socket(sock, sslopt, hostname, check_hostname) - - return sock - - -def _tunnel(sock: socket.socket, host, port: int, auth) -> socket.socket: - """Open an HTTP CONNECT tunnel through the configured proxy.""" - debug("Connecting proxy...") - connect_header = f"CONNECT {host}:{port} HTTP/1.1\r\n" - connect_header += f"Host: {host}:{port}\r\n" - - # NOTE: Only basic proxy authentication is implemented here. - if auth and auth[0]: - auth_str = auth[0] - if auth[1]: - auth_str += f":{auth[1]}" - encoded_str = base64encode(auth_str.encode()).strip().decode().replace("\n", "") - connect_header += f"Proxy-Authorization: Basic {encoded_str}\r\n" - connect_header += "\r\n" - dump("request header", connect_header) - - send(sock, connect_header) - - try: - status, _, _ = read_headers(sock) - except Exception as e: - raise WebSocketProxyException(str(e)) - - if status != 200: - raise WebSocketProxyException(f"failed CONNECT via proxy status: {status}") - - return sock - - -def read_headers(sock: socket.socket) -> tuple: - """Read an HTTP status line plus headers from the socket.""" - status = None - status_message = None - headers: dict = {} - trace("--- response header ---") - - while True: - line = recv_line(sock) - line = line.decode("utf-8").strip() - if not line: - break - trace(line) - if not status: - status_info = line.split(" ", 2) - status = int(status_info[1]) - if len(status_info) > 2: - status_message = status_info[2] - else: - kv = line.split(":", 1) - if len(kv) != 2: - raise WebSocketException("Invalid header") - key, value = kv - if key.lower() == "set-cookie" and headers.get("set-cookie"): - headers["set-cookie"] = headers.get("set-cookie") + "; " + value.strip() - else: - headers[key.lower()] = value.strip() - - trace("-----------------------") - - return status, headers, status_message +""" +_http.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +import errno +import os +import socket +from base64 import encodebytes as base64encode + +from ._exceptions import ( + WebSocketAddressException, + WebSocketException, + WebSocketProxyException, +) +from ._logging import debug, dump, trace +from ._socket import DEFAULT_SOCKET_OPTION, recv_line, send +from ._ssl_compat import HAVE_SSL, ssl +from ._url import get_proxy_info, parse_url + +__all__ = ["proxy_info", "connect", "read_headers"] + +try: + from python_socks._errors import ( + ProxyConnectionError, + ProxyError, + ProxyTimeoutError, + ) + from python_socks._types import ProxyType + from python_socks.sync import Proxy + + HAVE_PYTHON_SOCKS = True +except Exception: + HAVE_PYTHON_SOCKS = False + + class ProxyError(Exception): + """Fallback proxy error used when python-socks is unavailable.""" + + __slots__ = () + + class ProxyTimeoutError(Exception): + """Fallback proxy timeout error used when python-socks is unavailable.""" + + __slots__ = () + + class ProxyConnectionError(Exception): + """Fallback proxy connection error used when python-socks is unavailable.""" + + __slots__ = () + + +class proxy_info: + """Normalized proxy configuration extracted from connection options.""" + + def __init__(self, **options): + """Parse proxy-related options into a small structured object.""" + self.proxy_host = options.get("http_proxy_host") + if self.proxy_host: + self.proxy_port = options.get("http_proxy_port", 0) + self.auth = options.get("http_proxy_auth") + self.no_proxy = options.get("http_no_proxy") + self.proxy_protocol = options.get("proxy_type", "http") + # If timeout is not specified, python-socks defaults to 60 seconds. + self.proxy_timeout = options.get("http_proxy_timeout") + if self.proxy_protocol not in [ + "http", + "socks4", + "socks4a", + "socks5", + "socks5h", + ]: + raise ProxyError( + "Only http, socks4, socks5 proxy protocols are supported" + ) + else: + self.proxy_port = 0 + self.auth = None + self.no_proxy = None + self.proxy_protocol = "http" + + +def _start_proxied_socket(url: str, options, proxy) -> tuple: + """Open a socket through a SOCKS proxy and wrap it for TLS if needed.""" + if not HAVE_PYTHON_SOCKS: + raise WebSocketException( + "Python Socks is needed for SOCKS proxying but is not available" + ) + + hostname, port, resource, is_secure = parse_url(url) + + if proxy.proxy_protocol == "socks4": + rdns = False + proxy_type = ProxyType.SOCKS4 + # socks4a sends DNS through proxy + elif proxy.proxy_protocol == "socks4a": + rdns = True + proxy_type = ProxyType.SOCKS4 + elif proxy.proxy_protocol == "socks5": + rdns = False + proxy_type = ProxyType.SOCKS5 + # socks5h sends DNS through proxy + elif proxy.proxy_protocol == "socks5h": + rdns = True + proxy_type = ProxyType.SOCKS5 + + ws_proxy = Proxy.create( + proxy_type=proxy_type, + host=proxy.proxy_host, + port=int(proxy.proxy_port), + username=proxy.auth[0] if proxy.auth else None, + password=proxy.auth[1] if proxy.auth else None, + rdns=rdns, + ) + + sock = ws_proxy.connect(hostname, port, timeout=proxy.proxy_timeout) + + if is_secure: + if HAVE_SSL: + sock = _ssl_socket(sock, options.sslopt, hostname) + else: + raise WebSocketException("SSL not available.") + + return sock, (hostname, port, resource) + + +def connect(url: str, options, proxy, prepared_socket): + """Create and return a socket connected to the websocket endpoint.""" + # Use _start_proxied_socket() only for socks4 or socks5 proxy + # Use _tunnel() for http proxy + # NOTE: HTTP proxying still uses _tunnel() until python-socks is adopted + # for that path as well. + if proxy.proxy_host and not prepared_socket and proxy.proxy_protocol != "http": + return _start_proxied_socket(url, options, proxy) + + hostname, port_from_url, resource, is_secure = parse_url(url) + + if prepared_socket: + return prepared_socket, (hostname, port_from_url, resource) + + addrinfo_list, need_tunnel, auth = _get_addrinfo_list( + hostname, port_from_url, is_secure, proxy + ) + if not addrinfo_list: + raise WebSocketException( + f"Host not found.: {hostname}:{port_from_url}") + + sock = None + try: + sock = _open_socket(addrinfo_list, options.sockopt, options.timeout) + if need_tunnel: + sock = _tunnel(sock, hostname, port_from_url, auth) + + if is_secure: + if HAVE_SSL: + sock = _ssl_socket(sock, options.sslopt, hostname) + else: + raise WebSocketException("SSL not available.") + + return sock, (hostname, port_from_url, resource) + except Exception: + if sock: + sock.close() + raise + + +def _get_addrinfo_list(hostname, port: int, is_secure: bool, proxy) -> tuple: + """Resolve the address list used for a direct or proxied connection.""" + phost, pport, pauth = get_proxy_info( + hostname, + is_secure, + proxy.proxy_host, + proxy.proxy_port, + proxy.auth, + proxy.no_proxy, + ) + try: + # On Windows 10, getaddrinfo without socktype can return socktype 0, + # which later breaks socket creation. Force SOCK_STREAM explicitly. + if not phost: + addrinfo_list = socket.getaddrinfo( + hostname, port, 0, socket.SOCK_STREAM, socket.SOL_TCP + ) + return addrinfo_list, False, None + pport = pport and pport or 80 + # Apply the same SOCK_STREAM workaround for the proxy endpoint. + addrinfo_list = socket.getaddrinfo( + phost, pport, 0, socket.SOCK_STREAM, socket.SOL_TCP + ) + return addrinfo_list, True, pauth + except socket.gaierror as e: + raise WebSocketAddressException(e) + + +def _open_socket(addrinfo_list, sockopt, timeout): + """Try each resolved address until a socket connection succeeds.""" + err = None + for addrinfo in addrinfo_list: + family, socktype, proto = addrinfo[:3] + sock = socket.socket(family, socktype, proto) + sock.settimeout(timeout) + for opts in DEFAULT_SOCKET_OPTION: + sock.setsockopt(*opts) + for opts in sockopt: + sock.setsockopt(*opts) + + address = addrinfo[4] + err = None + while not err: + try: + sock.connect(address) + except socket.error as error: + sock.close() + error.remote_ip = str(address[0]) + try: + eConnRefused = ( + errno.ECONNREFUSED, + errno.WSAECONNREFUSED, + errno.ENETUNREACH, + ) + except AttributeError: + eConnRefused = (errno.ECONNREFUSED, errno.ENETUNREACH) + if error.errno not in eConnRefused: + raise error + err = error + continue + else: + break + else: + continue + break + else: + if err: + raise err + + return sock + + +def _wrap_sni_socket( + sock: socket.socket, + sslopt: dict, + hostname, + _check_hostname): + """Wrap a plain socket with TLS/SNI using the provided SSL options.""" + context = sslopt.get("context") + if not context: + context = ssl.SSLContext( + sslopt.get( + "ssl_version", + ssl.PROTOCOL_TLS_CLIENT)) + # Non-default contexts need keylog_filename set manually to honor + # SSLKEYLOGFILE. + # For more details see also: + # * https://docs.python.org/3.8/library/ssl.html + # ?highlight=sslkeylogfile#context-creation + # * https://docs.python.org/3.8/library/ssl.html + # ?highlight=sslkeylogfile#ssl.SSLContext.keylog_filename + context.keylog_filename = os.environ.get("SSLKEYLOGFILE") + + if sslopt.get("cert_reqs", ssl.CERT_NONE) != ssl.CERT_NONE: + cafile = sslopt.get("ca_certs") + capath = sslopt.get("ca_cert_path") + if cafile or capath: + context.load_verify_locations(cafile=cafile, capath=capath) + elif hasattr(context, "load_default_certs"): + context.load_default_certs(ssl.Purpose.SERVER_AUTH) + if sslopt.get("certfile"): + context.load_cert_chain( + sslopt["certfile"], + sslopt.get("keyfile"), + sslopt.get("password"), + ) + + # Python 3.10 switched PROTOCOL_TLS_CLIENT defaults to + # cert_reqs=CERT_REQUIRED and check_hostname=True. + # If both are disabled, set check_hostname before verify_mode. + # See: + # https://github.com/liris/websocket-client/commit/ + # b96a2e8fa765753e82eea531adb19716b52ca3ca#commitcomment-10803153 + if sslopt.get( + "cert_reqs", + ssl.CERT_NONE) == ssl.CERT_NONE and not sslopt.get( + "check_hostname", + False): + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + else: + context.check_hostname = sslopt.get("check_hostname", True) + context.verify_mode = sslopt.get("cert_reqs", ssl.CERT_REQUIRED) + + if "ciphers" in sslopt: + context.set_ciphers(sslopt["ciphers"]) + if "cert_chain" in sslopt: + certfile, keyfile, password = sslopt["cert_chain"] + context.load_cert_chain(certfile, keyfile, password) + if "ecdh_curve" in sslopt: + context.set_ecdh_curve(sslopt["ecdh_curve"]) + + return context.wrap_socket( + sock, + do_handshake_on_connect=sslopt.get("do_handshake_on_connect", True), + suppress_ragged_eofs=sslopt.get("suppress_ragged_eofs", True), + server_hostname=hostname, + ) + + +def _ssl_socket(sock: socket.socket, user_sslopt: dict, hostname): + """Apply TLS settings and wrap the socket for secure websocket use.""" + sslopt: dict = {"cert_reqs": ssl.CERT_REQUIRED} + sslopt.update(user_sslopt) + + cert_path = os.environ.get("WEBSOCKET_CLIENT_CA_BUNDLE") + if ( + cert_path + and os.path.isfile(cert_path) + and user_sslopt.get("ca_certs") is None + ): + sslopt["ca_certs"] = cert_path + elif ( + cert_path + and os.path.isdir(cert_path) + and user_sslopt.get("ca_cert_path") is None + ): + sslopt["ca_cert_path"] = cert_path + + if sslopt.get("server_hostname"): + hostname = sslopt["server_hostname"] + + check_hostname = sslopt.get("check_hostname", True) + sock = _wrap_sni_socket(sock, sslopt, hostname, check_hostname) + + return sock + + +def _tunnel(sock: socket.socket, host, port: int, auth) -> socket.socket: + """Open an HTTP CONNECT tunnel through the configured proxy.""" + debug("Connecting proxy...") + connect_header = f"CONNECT {host}:{port} HTTP/1.1\r\n" + connect_header += f"Host: {host}:{port}\r\n" + + # NOTE: Only basic proxy authentication is implemented here. + if auth and auth[0]: + auth_str = auth[0] + if auth[1]: + auth_str += f":{auth[1]}" + encoded_str = base64encode( + auth_str.encode()).strip().decode().replace( + "\n", "") + connect_header += f"Proxy-Authorization: Basic {encoded_str}\r\n" + connect_header += "\r\n" + dump("request header", connect_header) + + send(sock, connect_header) + + try: + status, _, _ = read_headers(sock) + except Exception as e: + raise WebSocketProxyException(str(e)) + + if status != 200: + raise WebSocketProxyException( + f"failed CONNECT via proxy status: {status}") + + return sock + + +def read_headers(sock: socket.socket) -> tuple: + """Read an HTTP status line plus headers from the socket.""" + status = None + status_message = None + headers: dict = {} + trace("--- response header ---") + + while True: + line = recv_line(sock) + line = line.decode("utf-8").strip() + if not line: + break + trace(line) + if not status: + status_info = line.split(" ", 2) + status = int(status_info[1]) + if len(status_info) > 2: + status_message = status_info[2] + else: + kv = line.split(":", 1) + if len(kv) != 2: + raise WebSocketException("Invalid header") + key, value = kv + if key.lower() == "set-cookie" and headers.get("set-cookie"): + headers["set-cookie"] = headers.get( + "set-cookie") + "; " + value.strip() + else: + headers[key.lower()] = value.strip() + + trace("-----------------------") + + return status, headers, status_message diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_socket.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_socket.py" index 220d4622..2425c8cb 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_socket.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_socket.py" @@ -1,192 +1,193 @@ -""" -_socket.py -websocket - WebSocket client library for Python - -Copyright 2024 engn33r - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" - -import errno -import selectors -import socket -from typing import Union - -from ._exceptions import ( - WebSocketConnectionClosedException, - WebSocketTimeoutException, -) -from ._ssl_compat import SSLError, SSLWantReadError, SSLWantWriteError -from ._utils import extract_error_code, extract_err_message - -DEFAULT_SOCKET_OPTION = [(socket.SOL_TCP, socket.TCP_NODELAY, 1)] -if hasattr(socket, "SO_KEEPALIVE"): - DEFAULT_SOCKET_OPTION.append((socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)) -if hasattr(socket, "TCP_KEEPIDLE"): - DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPIDLE, 30)) -if hasattr(socket, "TCP_KEEPINTVL"): - DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPINTVL, 10)) -if hasattr(socket, "TCP_KEEPCNT"): - DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPCNT, 3)) - -_DEFAULT_TIMEOUT = {"value": None} - -__all__ = [ - "DEFAULT_SOCKET_OPTION", - "sock_opt", - "setdefaulttimeout", - "getdefaulttimeout", - "recv", - "recv_line", - "send", -] - - -class sock_opt: - """Container for socket and SSL options used during connection setup.""" - - def __init__(self, sockopt: list, sslopt: dict) -> None: - """Normalize and store socket-related option dictionaries.""" - if sockopt is None: - sockopt = [] - if sslopt is None: - sslopt = {} - self.sockopt = sockopt - self.sslopt = sslopt - self.timeout = None - - -def setdefaulttimeout(timeout: Union[int, float, None]) -> None: - """ - Set the global timeout setting to connect. - - Parameters - ---------- - timeout: int or float - default socket timeout time (in seconds) - """ - _DEFAULT_TIMEOUT["value"] = timeout - - -def getdefaulttimeout() -> Union[int, float, None]: - """ - Get default timeout - - Returns - ---------- - _default_timeout: int or float - Return the global timeout setting (in seconds) to connect. - """ - return _DEFAULT_TIMEOUT["value"] - - -def recv(sock: socket.socket, bufsize: int) -> bytes: - """Receive up to ``bufsize`` bytes from the socket with timeout handling.""" - if not sock: - raise WebSocketConnectionClosedException("socket is already closed.") - - def _recv(): - """Retry reads when the socket temporarily blocks.""" - try: - return sock.recv(bufsize) - except SSLWantReadError: - pass - except socket.error as exc: - error_code = extract_error_code(exc) - if error_code not in [errno.EAGAIN, errno.EWOULDBLOCK]: - raise - - sel = selectors.DefaultSelector() - sel.register(sock, selectors.EVENT_READ) - - r = sel.select(sock.gettimeout()) - sel.close() - - if r: - return sock.recv(bufsize) - return None - - try: - if sock.gettimeout() == 0: - bytes_ = sock.recv(bufsize) - else: - bytes_ = _recv() - except socket.timeout as e: - message = extract_err_message(e) - raise WebSocketTimeoutException(message or "Connection timed out") - except SSLError as e: - message = extract_err_message(e) - if isinstance(message, str) and "timed out" in message: - raise WebSocketTimeoutException(message) - raise - - if not bytes_: - raise WebSocketConnectionClosedException("Connection to remote host was lost.") - - return bytes_ - - -def recv_line(sock: socket.socket) -> bytes: - """Receive bytes until a newline terminator is encountered.""" - line = [] - while True: - c = recv(sock, 1) - line.append(c) - if c == b"\n": - break - return b"".join(line) - - -def send(sock: socket.socket, data: Union[bytes, str]) -> int: - """Send data through the socket with timeout and retry handling.""" - if isinstance(data, str): - data = data.encode("utf-8") - - if not sock: - raise WebSocketConnectionClosedException("socket is already closed.") - - def _send(): - """Retry sends when the socket temporarily blocks.""" - try: - return sock.send(data) - except SSLWantWriteError: - pass - except socket.error as exc: - error_code = extract_error_code(exc) - if error_code is None: - raise - if error_code not in [errno.EAGAIN, errno.EWOULDBLOCK]: - raise - - sel = selectors.DefaultSelector() - sel.register(sock, selectors.EVENT_WRITE) - - w = sel.select(sock.gettimeout()) - sel.close() - - if w: - return sock.send(data) - return None - - try: - if sock.gettimeout() == 0: - return sock.send(data) - return _send() - except socket.timeout as e: - message = extract_err_message(e) - raise WebSocketTimeoutException(message) - except Exception as e: - message = extract_err_message(e) - if isinstance(message, str) and "timed out" in message: - raise WebSocketTimeoutException(message) - raise +""" +_socket.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import errno +import selectors +import socket +from typing import Union + +from ._exceptions import ( + WebSocketConnectionClosedException, + WebSocketTimeoutException, +) +from ._ssl_compat import SSLError, SSLWantReadError, SSLWantWriteError +from ._utils import extract_error_code, extract_err_message + +DEFAULT_SOCKET_OPTION = [(socket.SOL_TCP, socket.TCP_NODELAY, 1)] +if hasattr(socket, "SO_KEEPALIVE"): + DEFAULT_SOCKET_OPTION.append((socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)) +if hasattr(socket, "TCP_KEEPIDLE"): + DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPIDLE, 30)) +if hasattr(socket, "TCP_KEEPINTVL"): + DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPINTVL, 10)) +if hasattr(socket, "TCP_KEEPCNT"): + DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPCNT, 3)) + +_DEFAULT_TIMEOUT = {"value": None} + +__all__ = [ + "DEFAULT_SOCKET_OPTION", + "sock_opt", + "setdefaulttimeout", + "getdefaulttimeout", + "recv", + "recv_line", + "send", +] + + +class sock_opt: + """Container for socket and SSL options used during connection setup.""" + + def __init__(self, sockopt: list, sslopt: dict) -> None: + """Normalize and store socket-related option dictionaries.""" + if sockopt is None: + sockopt = [] + if sslopt is None: + sslopt = {} + self.sockopt = sockopt + self.sslopt = sslopt + self.timeout = None + + +def setdefaulttimeout(timeout: Union[int, float, None]) -> None: + """ + Set the global timeout setting to connect. + + Parameters + ---------- + timeout: int or float + default socket timeout time (in seconds) + """ + _DEFAULT_TIMEOUT["value"] = timeout + + +def getdefaulttimeout() -> Union[int, float, None]: + """ + Get default timeout + + Returns + ---------- + _default_timeout: int or float + Return the global timeout setting (in seconds) to connect. + """ + return _DEFAULT_TIMEOUT["value"] + + +def recv(sock: socket.socket, bufsize: int) -> bytes: + """Receive up to ``bufsize`` bytes from the socket with timeout handling.""" + if not sock: + raise WebSocketConnectionClosedException("socket is already closed.") + + def _recv(): + """Retry reads when the socket temporarily blocks.""" + try: + return sock.recv(bufsize) + except SSLWantReadError: + pass + except socket.error as exc: + error_code = extract_error_code(exc) + if error_code not in [errno.EAGAIN, errno.EWOULDBLOCK]: + raise + + sel = selectors.DefaultSelector() + sel.register(sock, selectors.EVENT_READ) + + r = sel.select(sock.gettimeout()) + sel.close() + + if r: + return sock.recv(bufsize) + return None + + try: + if sock.gettimeout() == 0: + bytes_ = sock.recv(bufsize) + else: + bytes_ = _recv() + except socket.timeout as e: + message = extract_err_message(e) + raise WebSocketTimeoutException(message or "Connection timed out") + except SSLError as e: + message = extract_err_message(e) + if isinstance(message, str) and "timed out" in message: + raise WebSocketTimeoutException(message) + raise + + if not bytes_: + raise WebSocketConnectionClosedException( + "Connection to remote host was lost.") + + return bytes_ + + +def recv_line(sock: socket.socket) -> bytes: + """Receive bytes until a newline terminator is encountered.""" + line = [] + while True: + c = recv(sock, 1) + line.append(c) + if c == b"\n": + break + return b"".join(line) + + +def send(sock: socket.socket, data: Union[bytes, str]) -> int: + """Send data through the socket with timeout and retry handling.""" + if isinstance(data, str): + data = data.encode("utf-8") + + if not sock: + raise WebSocketConnectionClosedException("socket is already closed.") + + def _send(): + """Retry sends when the socket temporarily blocks.""" + try: + return sock.send(data) + except SSLWantWriteError: + pass + except socket.error as exc: + error_code = extract_error_code(exc) + if error_code is None: + raise + if error_code not in [errno.EAGAIN, errno.EWOULDBLOCK]: + raise + + sel = selectors.DefaultSelector() + sel.register(sock, selectors.EVENT_WRITE) + + w = sel.select(sock.gettimeout()) + sel.close() + + if w: + return sock.send(data) + return None + + try: + if sock.gettimeout() == 0: + return sock.send(data) + return _send() + except socket.timeout as e: + message = extract_err_message(e) + raise WebSocketTimeoutException(message) + except Exception as e: + message = extract_err_message(e) + if isinstance(message, str) and "timed out" in message: + raise WebSocketTimeoutException(message) + raise diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_url.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_url.py" index 8ea66074..2cca29b8 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_url.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_url.py" @@ -1,187 +1,196 @@ -""" -_url.py -websocket - WebSocket client library for Python - -Copyright 2024 engn33r - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" - -import os -import socket -import struct -from typing import Optional -from urllib.parse import unquote, urlparse -from ._exceptions import WebSocketProxyException - -__all__ = ["parse_url", "get_proxy_info"] - - -def parse_url(url: str) -> tuple: - """ - parse url and the result is tuple of - (hostname, port, resource path and the flag of secure mode) - - Parameters - ---------- - url: str - url string. - """ - if ":" not in url: - raise ValueError("url is invalid") - - scheme, url = url.split(":", 1) - - parsed = urlparse(url, scheme="http") - if parsed.hostname: - hostname = parsed.hostname - else: - raise ValueError("hostname is invalid") - port = 0 - if parsed.port: - port = parsed.port - - is_secure = False - if scheme == "ws": - if not port: - port = 80 - elif scheme == "wss": - is_secure = True - if not port: - port = 443 - else: - raise ValueError(f"scheme {scheme} is invalid") - - if parsed.path: - resource = parsed.path - else: - resource = "/" - - if parsed.query: - resource += f"?{parsed.query}" - - return hostname, port, resource, is_secure - - -DEFAULT_NO_PROXY_HOST = ["localhost", "127.0.0.1"] - - -def _is_ip_address(addr: str) -> bool: - """Return whether ``addr`` is a valid IPv4 address.""" - try: - socket.inet_aton(addr) - except socket.error: - return False - return True - - -def _is_subnet_address(hostname: str) -> bool: - """Return whether ``hostname`` looks like an IPv4 subnet expression.""" - try: - addr, netmask = hostname.split("/") - return _is_ip_address(addr) and 0 <= int(netmask) < 32 - except ValueError: - return False - - -def _is_address_in_network(ip: str, net: str) -> bool: - """Return whether the IPv4 address ``ip`` belongs to subnet ``net``.""" - ipaddr: int = struct.unpack("!I", socket.inet_aton(ip))[0] - netaddr, netmask = net.split("/") - netaddr: int = struct.unpack("!I", socket.inet_aton(netaddr))[0] - - netmask = (0xFFFFFFFF << (32 - int(netmask))) & 0xFFFFFFFF - return ipaddr & netmask == netaddr - - -def _is_no_proxy_host(hostname: str, no_proxy: Optional[list]) -> bool: - """Return whether ``hostname`` should bypass proxy settings.""" - if not no_proxy: - if v := os.environ.get("no_proxy", os.environ.get("NO_PROXY", "")).replace( - " ", "" - ): - no_proxy = v.split(",") - if not no_proxy: - no_proxy = DEFAULT_NO_PROXY_HOST - - if "*" in no_proxy: - return True - if hostname in no_proxy: - return True - if _is_ip_address(hostname): - return any( - _is_address_in_network(hostname, subnet) - for subnet in no_proxy - if _is_subnet_address(subnet) - ) - for domain in [domain for domain in no_proxy if domain.startswith(".")]: - if hostname.endswith(domain): - return True - return False - - -def get_proxy_info( - hostname: str, - is_secure: bool, - proxy_host: Optional[str] = None, - proxy_port: int = 0, - proxy_auth: Optional[tuple] = None, - no_proxy: Optional[list] = None, - _proxy_type: str = "http", -) -> tuple: - """Return proxy host, port, and auth information for the target host. - - Parameters - ---------- - hostname: str - Websocket server name. - is_secure: bool - Is the connection secure? (wss) looks for "https_proxy" in env - instead of "http_proxy" - proxy_host: str - http proxy host name. - proxy_port: str or int - http proxy port. - no_proxy: list - Whitelisted host names that don't use the proxy. - proxy_auth: tuple - HTTP proxy auth information. Tuple of username and password. Default is None. - proxy_type: str - Specify the proxy protocol - (http, socks4, socks4a, socks5, socks5h). Default is "http". - Use socks4a or socks5h if you want to send DNS requests through the proxy. - """ - if _is_no_proxy_host(hostname, no_proxy): - return None, 0, None - - if proxy_host: - if not proxy_port: - raise WebSocketProxyException("Cannot use port 0 when proxy_host specified") - port = proxy_port - auth = proxy_auth - return proxy_host, port, auth - - env_key = "https_proxy" if is_secure else "http_proxy" - value = os.environ.get(env_key, os.environ.get(env_key.upper(), "")).replace( - " ", "" - ) - if value: - proxy = urlparse(value) - auth = ( - (unquote(proxy.username), unquote(proxy.password)) - if proxy.username - else None - ) - return proxy.hostname, proxy.port, auth - - return None, 0, None +""" +_url.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import os +import socket +import struct +from typing import Optional +from urllib.parse import unquote, urlparse +from ._exceptions import WebSocketProxyException + +__all__ = ["parse_url", "get_proxy_info"] + + +def parse_url(url: str) -> tuple: + """ + parse url and the result is tuple of + (hostname, port, resource path and the flag of secure mode) + + Parameters + ---------- + url: str + url string. + """ + if ":" not in url: + raise ValueError("url is invalid") + + scheme, url = url.split(":", 1) + + parsed = urlparse(url, scheme="http") + if parsed.hostname: + hostname = parsed.hostname + else: + raise ValueError("hostname is invalid") + port = 0 + if parsed.port: + port = parsed.port + + is_secure = False + if scheme == "ws": + if not port: + port = 80 + elif scheme == "wss": + is_secure = True + if not port: + port = 443 + else: + raise ValueError(f"scheme {scheme} is invalid") + + if parsed.path: + resource = parsed.path + else: + resource = "/" + + if parsed.query: + resource += f"?{parsed.query}" + + return hostname, port, resource, is_secure + + +DEFAULT_NO_PROXY_HOST = ["localhost", "127.0.0.1"] + + +def _is_ip_address(addr: str) -> bool: + """Return whether ``addr`` is a valid IPv4 address.""" + try: + socket.inet_aton(addr) + except socket.error: + return False + return True + + +def _is_subnet_address(hostname: str) -> bool: + """Return whether ``hostname`` looks like an IPv4 subnet expression.""" + try: + addr, netmask = hostname.split("/") + return _is_ip_address(addr) and 0 <= int(netmask) < 32 + except ValueError: + return False + + +def _is_address_in_network(ip: str, net: str) -> bool: + """Return whether the IPv4 address ``ip`` belongs to subnet ``net``.""" + ipaddr: int = struct.unpack("!I", socket.inet_aton(ip))[0] + netaddr, netmask = net.split("/") + netaddr: int = struct.unpack("!I", socket.inet_aton(netaddr))[0] + + netmask = (0xFFFFFFFF << (32 - int(netmask))) & 0xFFFFFFFF + return ipaddr & netmask == netaddr + + +def _is_no_proxy_host(hostname: str, no_proxy: Optional[list]) -> bool: + """Return whether ``hostname`` should bypass proxy settings.""" + if not no_proxy: + if v := os.environ.get( + "no_proxy", + os.environ.get( + "NO_PROXY", + "")).replace( + " ", + ""): + no_proxy = v.split(",") + if not no_proxy: + no_proxy = DEFAULT_NO_PROXY_HOST + + if "*" in no_proxy: + return True + if hostname in no_proxy: + return True + if _is_ip_address(hostname): + return any( + _is_address_in_network(hostname, subnet) + for subnet in no_proxy + if _is_subnet_address(subnet) + ) + for domain in [domain for domain in no_proxy if domain.startswith(".")]: + if hostname.endswith(domain): + return True + return False + + +def get_proxy_info( + hostname: str, + is_secure: bool, + proxy_host: Optional[str] = None, + proxy_port: int = 0, + proxy_auth: Optional[tuple] = None, + no_proxy: Optional[list] = None, + _proxy_type: str = "http", +) -> tuple: + """Return proxy host, port, and auth information for the target host. + + Parameters + ---------- + hostname: str + Websocket server name. + is_secure: bool + Is the connection secure? (wss) looks for "https_proxy" in env + instead of "http_proxy" + proxy_host: str + http proxy host name. + proxy_port: str or int + http proxy port. + no_proxy: list + Whitelisted host names that don't use the proxy. + proxy_auth: tuple + HTTP proxy auth information. Tuple of username and password. Default is None. + proxy_type: str + Specify the proxy protocol + (http, socks4, socks4a, socks5, socks5h). Default is "http". + Use socks4a or socks5h if you want to send DNS requests through the proxy. + """ + if _is_no_proxy_host(hostname, no_proxy): + return None, 0, None + + if proxy_host: + if not proxy_port: + raise WebSocketProxyException( + "Cannot use port 0 when proxy_host specified") + port = proxy_port + auth = proxy_auth + return proxy_host, port, auth + + env_key = "https_proxy" if is_secure else "http_proxy" + value = os.environ.get( + env_key, + os.environ.get( + env_key.upper(), + "")).replace( + " ", + "") + if value: + proxy = urlparse(value) + auth = ( + (unquote(proxy.username), unquote(proxy.password)) + if proxy.username + else None + ) + return proxy.hostname, proxy.port, auth + + return None, 0, None diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_utils.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_utils.py" index f9d6db0a..c17053a9 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_utils.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_utils.py" @@ -1,464 +1,471 @@ -""" -_utils.py -websocket - WebSocket client library for Python - -Copyright 2024 engn33r - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" - -from typing import Union -__all__ = ["NoLock", "validate_utf8", "extract_err_message", "extract_error_code"] - - -class NoLock: - """Context manager that performs no locking.""" - - def __enter__(self) -> None: - """Enter the no-op context manager.""" - return None - - def __exit__(self, exc_type, exc_value, traceback) -> None: - """Exit the no-op context manager.""" - return None - - -try: - # If wsaccel is available we use compiled routines to validate UTF-8 - # strings. - from wsaccel.utf8validator import Utf8Validator - - def _validate_utf8(utfbytes: Union[str, bytes]) -> bool: - """Validate UTF-8 bytes using the optional wsaccel accelerator.""" - result: bool = Utf8Validator().validate(utfbytes)[0] - return result - -except ImportError: - # UTF-8 validator - # python implementation of http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ - - _UTF8_ACCEPT = 0 - _UTF8_REJECT = 12 - - _UTF8D = [ - # The first part of the table maps bytes to character classes that - # to reduce the size of the transition table and create bitmasks. - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 8, - 8, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 10, - 3, - 3, - 3, - 3, - 3, - 3, - 3, - 3, - 3, - 3, - 3, - 3, - 4, - 3, - 3, - 11, - 6, - 6, - 6, - 5, - 8, - 8, - 8, - 8, - 8, - 8, - 8, - 8, - 8, - 8, - 8, - # The second part is a transition table that maps a combination - # of a state of the automaton and a character class to a state. - 0, - 12, - 24, - 36, - 60, - 96, - 84, - 12, - 12, - 12, - 48, - 72, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 0, - 12, - 12, - 12, - 12, - 12, - 0, - 12, - 0, - 12, - 12, - 12, - 24, - 12, - 12, - 12, - 12, - 12, - 24, - 12, - 24, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 24, - 12, - 12, - 12, - 12, - 12, - 24, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 24, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 36, - 12, - 36, - 12, - 12, - 12, - 36, - 12, - 12, - 12, - 12, - 12, - 36, - 12, - 36, - 12, - 12, - 12, - 36, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - ] - - def _decode(state: int, codep: int, ch: int) -> tuple: - """Advance the UTF-8 DFA by one byte.""" - tp = _UTF8D[ch] - - codep = ( - (ch & 0x3F) | (codep << 6) if (state != _UTF8_ACCEPT) else (0xFF >> tp) & ch - ) - state = _UTF8D[256 + state + tp] - - return state, codep - - def _validate_utf8(utfbytes: Union[str, bytes]) -> bool: - """Validate UTF-8 bytes using the bundled DFA implementation.""" - state = _UTF8_ACCEPT - codep = 0 - for i in utfbytes: - state, codep = _decode(state, codep, int(i)) - if state == _UTF8_REJECT: - return False - - return True - - -def validate_utf8(utfbytes: Union[str, bytes]) -> bool: - """Return whether ``utfbytes`` contains a valid UTF-8 byte sequence.""" - return _validate_utf8(utfbytes) - - -def extract_err_message(exception: Exception) -> Union[str, None]: - """Return the first positional argument from ``exception`` if present.""" - if exception.args: - exception_message: str = exception.args[0] - return exception_message - return None - - -def extract_error_code(exception: Exception) -> Union[int, None]: - """Return the integer error code stored in ``exception.args`` if present.""" - if exception.args and len(exception.args) > 1: - return exception.args[0] if isinstance(exception.args[0], int) else None - return None +""" +_utils.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from typing import Union +__all__ = [ + "NoLock", + "validate_utf8", + "extract_err_message", + "extract_error_code"] + + +class NoLock: + """Context manager that performs no locking.""" + + def __enter__(self) -> None: + """Enter the no-op context manager.""" + return None + + def __exit__(self, exc_type, exc_value, traceback) -> None: + """Exit the no-op context manager.""" + return None + + +try: + # If wsaccel is available we use compiled routines to validate UTF-8 + # strings. + from wsaccel.utf8validator import Utf8Validator + + def _validate_utf8(utfbytes: Union[str, bytes]) -> bool: + """Validate UTF-8 bytes using the optional wsaccel accelerator.""" + result: bool = Utf8Validator().validate(utfbytes)[0] + return result + +except ImportError: + # UTF-8 validator + # python implementation of http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ + + _UTF8_ACCEPT = 0 + _UTF8_REJECT = 12 + + _UTF8D = [ + # The first part of the table maps bytes to character classes that + # to reduce the size of the transition table and create bitmasks. + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 8, + 8, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 10, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 4, + 3, + 3, + 11, + 6, + 6, + 6, + 5, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + # The second part is a transition table that maps a combination + # of a state of the automaton and a character class to a state. + 0, + 12, + 24, + 36, + 60, + 96, + 84, + 12, + 12, + 12, + 48, + 72, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 0, + 12, + 12, + 12, + 12, + 12, + 0, + 12, + 0, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 36, + 12, + 36, + 12, + 12, + 12, + 36, + 12, + 12, + 12, + 12, + 12, + 36, + 12, + 36, + 12, + 12, + 12, + 36, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + ] + + def _decode(state: int, codep: int, ch: int) -> tuple: + """Advance the UTF-8 DFA by one byte.""" + tp = _UTF8D[ch] + + codep = ( + (ch & 0x3F) | ( + codep << 6) if ( + state != _UTF8_ACCEPT) else ( + 0xFF >> tp) & ch) + state = _UTF8D[256 + state + tp] + + return state, codep + + def _validate_utf8(utfbytes: Union[str, bytes]) -> bool: + """Validate UTF-8 bytes using the bundled DFA implementation.""" + state = _UTF8_ACCEPT + codep = 0 + for i in utfbytes: + state, codep = _decode(state, codep, int(i)) + if state == _UTF8_REJECT: + return False + + return True + + +def validate_utf8(utfbytes: Union[str, bytes]) -> bool: + """Return whether ``utfbytes`` contains a valid UTF-8 byte sequence.""" + return _validate_utf8(utfbytes) + + +def extract_err_message(exception: Exception) -> Union[str, None]: + """Return the first positional argument from ``exception`` if present.""" + if exception.args: + exception_message: str = exception.args[0] + return exception_message + return None + + +def extract_error_code(exception: Exception) -> Union[int, None]: + """Return the integer error code stored in ``exception.args`` if present.""" + if exception.args and len(exception.args) > 1: + return exception.args[0] if isinstance( + exception.args[0], int) else None + return None diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_wsdump.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_wsdump.py" index 14d676b0..bb5e3a2c 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_wsdump.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_wsdump.py" @@ -1,264 +1,274 @@ -#!/usr/bin/env python3 - -""" -wsdump.py -websocket - WebSocket client library for Python - -Copyright 2024 engn33r - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" - -import argparse -import code -import gzip -import ssl -import sys -import threading -import time -import zlib -from urllib.parse import urlparse - -import websocket - -try: - import readline -except ImportError: - readline = None - - -def get_encoding() -> str: - """Return the normalized stdin encoding used by the console helpers.""" - encoding = getattr(sys.stdin, "encoding", "") - if not encoding: - return "utf-8" - return encoding.lower() - - -OPCODE_DATA = (websocket.ABNF.OPCODE_TEXT, websocket.ABNF.OPCODE_BINARY) -ENCODING = get_encoding() - - -class VAction(argparse.Action): - """Argparse action that counts repeated ``-v`` occurrences.""" - - def __call__( - self, - parser: argparse.Namespace, - args: tuple, - values: str, - option_string: str = None, - ) -> None: - """Normalize verbose values from integers or repeated ``v`` flags.""" - if values is None: - values = "1" - try: - values = int(values) - except ValueError: - values = values.count("v") + 1 - setattr(args, self.dest, values) - - -def parse_args() -> argparse.Namespace: - """Parse command-line arguments for the websocket dump utility.""" - parser = argparse.ArgumentParser(description="WebSocket Simple Dump Tool") - parser.add_argument( - "url", metavar="ws_url", help="websocket url. ex. ws://echo.websocket.events/" - ) - parser.add_argument("-p", "--proxy", help="proxy url. ex. http://127.0.0.1:8080") - parser.add_argument( - "-v", - "--verbose", - default=0, - nargs="?", - action=VAction, - dest="verbose", - help="set verbose mode. If set to 1, show opcode. " - "If set to 2, enable to trace websocket module", - ) - parser.add_argument( - "-n", "--nocert", action="store_true", help="Ignore invalid SSL cert" - ) - parser.add_argument("-r", "--raw", action="store_true", help="raw output") - parser.add_argument("-s", "--subprotocols", nargs="*", help="Set subprotocols") - parser.add_argument("-o", "--origin", help="Set origin") - parser.add_argument( - "--eof-wait", - default=0, - type=int, - help="wait time(second) after 'EOF' received.", - ) - parser.add_argument("-t", "--text", help="Send initial text") - parser.add_argument( - "--timings", action="store_true", help="Print timings in seconds" - ) - parser.add_argument("--headers", help="Set custom headers. Use ',' as separator") - - return parser.parse_args() - - -class RawInput: - """Compatibility wrapper around ``input`` that normalizes encoding.""" - - @staticmethod - def raw_input(prompt: str = "") -> str: - """Read a line and normalize it to UTF-8 encoded bytes when needed.""" - line = input(prompt) - - if ENCODING and ENCODING != "utf-8" and not isinstance(line, str): - line = line.decode(ENCODING).encode("utf-8") - elif isinstance(line, str): - line = line.encode("utf-8") - - return line - - -class InteractiveConsole(RawInput, code.InteractiveConsole): - """Interactive console that renders inbound messages with terminal color.""" - - @staticmethod - def write(data: str) -> None: - """Render received data above the current prompt.""" - sys.stdout.write("\033[2K\033[E") - sys.stdout.write("\033[34m< " + data + "\033[39m") - sys.stdout.write("\n> ") - sys.stdout.flush() - - def read(self) -> str: - """Read the next outbound message from stdin.""" - return self.raw_input("> ") - - -class NonInteractive(RawInput): - """Console facade for non-interactive stdout output.""" - - @staticmethod - def write(data: str) -> None: - """Write received data directly to stdout.""" - sys.stdout.write(data) - sys.stdout.write("\n") - sys.stdout.flush() - - def read(self) -> str: - """Read the next outbound message without a visible prompt.""" - return self.raw_input("") - - -def main() -> None: # skipcq: PY-R1000 - """Run the standalone websocket dump client.""" - start_time = time.time() - args = parse_args() - if args.verbose > 1: - websocket.enableTrace(True) - options = {} - if args.proxy: - p = urlparse(args.proxy) - options["http_proxy_host"] = p.hostname - options["http_proxy_port"] = p.port - if args.origin: - options["origin"] = args.origin - if args.subprotocols: - options["subprotocols"] = args.subprotocols - opts = {} - if args.nocert: - opts = {"cert_reqs": ssl.CERT_NONE, "check_hostname": False} - if args.headers: - options["header"] = list(map(str.strip, args.headers.split(","))) - ws = websocket.create_connection(args.url, sslopt=opts, **options) - if args.raw: - console = NonInteractive() - else: - console = InteractiveConsole() - print("Press Ctrl+C to quit") - - def recv() -> tuple: - """Receive one websocket frame and normalize control opcodes.""" - try: - frame = ws.recv_frame() - except websocket.WebSocketException: - return websocket.ABNF.OPCODE_CLOSE, "" - if not frame: - raise websocket.WebSocketException(f"Not a valid frame {frame}") - if frame.opcode in OPCODE_DATA: - return frame.opcode, frame.data - if frame.opcode == websocket.ABNF.OPCODE_CLOSE: - ws.send_close() - return frame.opcode, "" - if frame.opcode == websocket.ABNF.OPCODE_PING: - ws.pong(frame.data) - return frame.opcode, frame.data - - return frame.opcode, frame.data - - def recv_ws() -> None: - """Continuously read websocket messages and print them to the console.""" - while True: - opcode, data = recv() - msg = None - if opcode == websocket.ABNF.OPCODE_TEXT and isinstance(data, bytes): - data = str(data, "utf-8") - if ( - isinstance(data, bytes) and len(data) > 2 and data[:2] == b"\037\213" - ): # gzip magick - try: - data = "[gzip] " + str(gzip.decompress(data), "utf-8") - except Exception: - pass - elif isinstance(data, bytes): - try: - data = "[zlib] " + str( - zlib.decompress(data, -zlib.MAX_WBITS), "utf-8" - ) - except Exception: - pass - - if isinstance(data, bytes): - data = repr(data) - - if args.verbose: - msg = f"{websocket.ABNF.OPCODE_MAP.get(opcode)}: {data}" - else: - msg = data - - if msg is not None: - if args.timings: - console.write(f"{time.time() - start_time}: {msg}") - else: - console.write(msg) - - if opcode == websocket.ABNF.OPCODE_CLOSE: - break - - thread = threading.Thread(target=recv_ws) - thread.daemon = True - thread.start() - - if args.text: - ws.send(args.text) - - while True: - try: - message = console.read() - ws.send(message) - except KeyboardInterrupt: - return - except EOFError: - time.sleep(args.eof_wait) - return - - -if __name__ == "__main__": - try: - main() - except Exception as e: - print(e) +#!/usr/bin/env python3 + +""" +wsdump.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import argparse +import code +import gzip +import ssl +import sys +import threading +import time +import zlib +from urllib.parse import urlparse + +import websocket + +try: + import readline +except ImportError: + readline = None + + +def get_encoding() -> str: + """Return the normalized stdin encoding used by the console helpers.""" + encoding = getattr(sys.stdin, "encoding", "") + if not encoding: + return "utf-8" + return encoding.lower() + + +OPCODE_DATA = (websocket.ABNF.OPCODE_TEXT, websocket.ABNF.OPCODE_BINARY) +ENCODING = get_encoding() + + +class VAction(argparse.Action): + """Argparse action that counts repeated ``-v`` occurrences.""" + + def __call__( + self, + parser: argparse.Namespace, + args: tuple, + values: str, + option_string: str = None, + ) -> None: + """Normalize verbose values from integers or repeated ``v`` flags.""" + if values is None: + values = "1" + try: + values = int(values) + except ValueError: + values = values.count("v") + 1 + setattr(args, self.dest, values) + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments for the websocket dump utility.""" + parser = argparse.ArgumentParser(description="WebSocket Simple Dump Tool") + parser.add_argument( + "url", + metavar="ws_url", + help="websocket url. ex. ws://echo.websocket.events/") + parser.add_argument( + "-p", + "--proxy", + help="proxy url. ex. http://127.0.0.1:8080") + parser.add_argument( + "-v", + "--verbose", + default=0, + nargs="?", + action=VAction, + dest="verbose", + help="set verbose mode. If set to 1, show opcode. " + "If set to 2, enable to trace websocket module", + ) + parser.add_argument( + "-n", "--nocert", action="store_true", help="Ignore invalid SSL cert" + ) + parser.add_argument("-r", "--raw", action="store_true", help="raw output") + parser.add_argument( + "-s", + "--subprotocols", + nargs="*", + help="Set subprotocols") + parser.add_argument("-o", "--origin", help="Set origin") + parser.add_argument( + "--eof-wait", + default=0, + type=int, + help="wait time(second) after 'EOF' received.", + ) + parser.add_argument("-t", "--text", help="Send initial text") + parser.add_argument( + "--timings", action="store_true", help="Print timings in seconds" + ) + parser.add_argument( + "--headers", + help="Set custom headers. Use ',' as separator") + + return parser.parse_args() + + +class RawInput: + """Compatibility wrapper around ``input`` that normalizes encoding.""" + + @staticmethod + def raw_input(prompt: str = "") -> str: + """Read a line and normalize it to UTF-8 encoded bytes when needed.""" + line = input(prompt) + + if ENCODING and ENCODING != "utf-8" and not isinstance(line, str): + line = line.decode(ENCODING).encode("utf-8") + elif isinstance(line, str): + line = line.encode("utf-8") + + return line + + +class InteractiveConsole(RawInput, code.InteractiveConsole): + """Interactive console that renders inbound messages with terminal color.""" + + @staticmethod + def write(data: str) -> None: + """Render received data above the current prompt.""" + sys.stdout.write("\033[2K\033[E") + sys.stdout.write("\033[34m< " + data + "\033[39m") + sys.stdout.write("\n> ") + sys.stdout.flush() + + def read(self) -> str: + """Read the next outbound message from stdin.""" + return self.raw_input("> ") + + +class NonInteractive(RawInput): + """Console facade for non-interactive stdout output.""" + + @staticmethod + def write(data: str) -> None: + """Write received data directly to stdout.""" + sys.stdout.write(data) + sys.stdout.write("\n") + sys.stdout.flush() + + def read(self) -> str: + """Read the next outbound message without a visible prompt.""" + return self.raw_input("") + + +def main() -> None: # skipcq: PY-R1000 + """Run the standalone websocket dump client.""" + start_time = time.time() + args = parse_args() + if args.verbose > 1: + websocket.enableTrace(True) + options = {} + if args.proxy: + p = urlparse(args.proxy) + options["http_proxy_host"] = p.hostname + options["http_proxy_port"] = p.port + if args.origin: + options["origin"] = args.origin + if args.subprotocols: + options["subprotocols"] = args.subprotocols + opts = {} + if args.nocert: + opts = {"cert_reqs": ssl.CERT_NONE, "check_hostname": False} + if args.headers: + options["header"] = list(map(str.strip, args.headers.split(","))) + ws = websocket.create_connection(args.url, sslopt=opts, **options) + if args.raw: + console = NonInteractive() + else: + console = InteractiveConsole() + print("Press Ctrl+C to quit") + + def recv() -> tuple: + """Receive one websocket frame and normalize control opcodes.""" + try: + frame = ws.recv_frame() + except websocket.WebSocketException: + return websocket.ABNF.OPCODE_CLOSE, "" + if not frame: + raise websocket.WebSocketException(f"Not a valid frame {frame}") + if frame.opcode in OPCODE_DATA: + return frame.opcode, frame.data + if frame.opcode == websocket.ABNF.OPCODE_CLOSE: + ws.send_close() + return frame.opcode, "" + if frame.opcode == websocket.ABNF.OPCODE_PING: + ws.pong(frame.data) + return frame.opcode, frame.data + + return frame.opcode, frame.data + + def recv_ws() -> None: + """Continuously read websocket messages and print them to the console.""" + while True: + opcode, data = recv() + msg = None + if opcode == websocket.ABNF.OPCODE_TEXT and isinstance( + data, bytes): + data = str(data, "utf-8") + if (isinstance(data, bytes) and len(data) > + 2 and data[:2] == b"\037\213"): # gzip magick + try: + data = "[gzip] " + str(gzip.decompress(data), "utf-8") + except Exception: + pass + elif isinstance(data, bytes): + try: + data = "[zlib] " + str( + zlib.decompress(data, -zlib.MAX_WBITS), "utf-8" + ) + except Exception: + pass + + if isinstance(data, bytes): + data = repr(data) + + if args.verbose: + msg = f"{websocket.ABNF.OPCODE_MAP.get(opcode)}: {data}" + else: + msg = data + + if msg is not None: + if args.timings: + console.write(f"{time.time() - start_time}: {msg}") + else: + console.write(msg) + + if opcode == websocket.ABNF.OPCODE_CLOSE: + break + + thread = threading.Thread(target=recv_ws) + thread.daemon = True + thread.start() + + if args.text: + ws.send(args.text) + + while True: + try: + message = console.read() + ws.send(message) + except KeyboardInterrupt: + return + except EOFError: + time.sleep(args.eof_wait) + return + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(e) diff --git "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" new file mode 100644 index 00000000..899dafb5 --- /dev/null +++ "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -0,0 +1,2352 @@ +"""Land protection cloud interop ToolDelta plugin.""" + +import copy +import json +import time +import threading +import os +import re +import random +import math +import shutil +import uuid +from typing import Dict, List, Optional, Tuple, Any +from tooldelta import Plugin, cfg, fmts, Player, Chat, plugin_entry, utils, game_utils + +from .config import ( + CONFIG_FILE_DIR, + DYNAMIC_LOAD_DEFAULT_INTERVAL, + DYNAMIC_LOAD_ENABLED_KEY, + DYNAMIC_LOAD_INTERVAL_KEY, + DYNAMIC_LOAD_SETTINGS_KEY, + NO_CREATE_REGIONS_FILE, + default_config, + default_no_create_regions, +) +from .geometry import ( + box_radius_for_size, + bounds_from_center_size, + boxes_intersect, + distance_to_land, + land_overlaps_candidate, + sphere_intersects_box, +) +from .models import LandData, LandMember, LandRank + + +PLUGIN_NAME = "领地系统云链联动版" +LEGACY_PLUGIN_NAME = "领地系统" + + +class ConsoleMenuExit(Exception): + """Signal that the console menu should exit.""" + + pass + + +class LandPlugin(Plugin): + """ToolDelta plugin entrypoint for land protection cloud interop.""" + + name = PLUGIN_NAME + author = "小石潭记qwq" + version = (0, 1, 18) + + def __init__(self, frame): + super().__init__(frame) + self.ListenPreload(self.on_preload) + self._stop_event = threading.Event() + self._config_file_state = None + self._no_create_regions_file_state = None + self._cfg_default = default_config() + self._cfg_std = cfg.auto_to_std(self._cfg_default) + self.make_data_path() + self._migrate_legacy_data_path() + self.no_create_regions_file = self.format_data_path( + NO_CREATE_REGIONS_FILE) + self.cfg = self._load_config(self._cfg_default, self._cfg_std) + self.data_file = self.format_data_path(str(self.cfg["数据文件"])) + self._apply_runtime_config_fields(reload_data_file=False) + self.no_create_regions_raw = self._load_no_create_regions() + self.no_create_regions = self._normalize_no_create_regions( + self.no_create_regions_raw) + + # 数据 + self.lands: Dict[str, LandData] = {} # land_id -> LandData + # xuid -> list of land_id + self.player_land_cache: Dict[str, List[str]] = {} + self.xuid_getter = None + + self.coords: Dict[str, Tuple[float, float, float]] = {} + self.coords_lock = threading.Lock() + self.recent_tp: Dict[str, float] = {} # xuid -> last tp time + self.tp_cooldown = 5 + self._detection_started = False + + self._ensure_dirs() + self._load_data() + + # 事件监听 + self.ListenChat(self.on_chat) + self.ListenPlayerJoin(self.on_player_join) + self.ListenPlayerLeave(self.on_player_leave) + self.ListenActive(self.on_active) + self.ListenFrameExit(self.on_frame_exit) + self.refresh_config_file_state() + self.config_thread = utils.createThread( + self.config_reload_task, + usage="领地系统配置热更新任务", + ) + + # 控制台测试指令 + self.frame.add_console_cmd_trigger( + ["领地云链测试", "领地测试"], "[玩家]", "测试领地系统云链联动版", self.console_test) + self.frame.add_console_cmd_trigger( + ["领地系统云链联动版", "领地系统"], None, "打开领地系统云链联动版控制台管理菜单", self.console_manage) + + def on_preload(self): + self.xuid_getter = self.GetPluginAPI("XUID获取", (0, 0, 7)) + + def on_active(self): + if self.enabled: + self._start_detection() + + def on_frame_exit(self, _): + self._stop_event.set() + + @staticmethod + def _ui_border() -> str: + return "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧" + + @staticmethod + def _ui_title(title: str) -> str: + return f"§l§d❐§f 『§6领地系统云链联动版§f』 §b{title}" + + def _ui_menu(self, + title: str, + options: List[str], + hints: Optional[List[str]] = None) -> str: + lines = [self._ui_border(), self._ui_title(title)] + lines.extend(f"§l§b[ §e{i}§b ] §r§e{option}" for i, + option in enumerate(options, 1)) + lines.append(self._ui_border()) + for hint in hints or []: + lines.append(f"§a❀ §b{hint}") + return "\n".join(lines) + + def _ui_card(self, + title: str, + lines: List[str], + hints: Optional[List[str]] = None) -> str: + body = [self._ui_border(), self._ui_title(title)] + body.extend(f"§a❀ §b{line}" for line in lines) + body.append(self._ui_border()) + for hint in hints or []: + body.append(f"§a❀ §b{hint}") + return "\n".join(body) + + @staticmethod + def _success(text: str) -> str: + return f"§a❀ §b{text}" + + @staticmethod + def _error(text: str) -> str: + return f"§c❀ §e{text}" + + @staticmethod + def _warn(text: str) -> str: + return f"§6❀ §e{text}" + + @staticmethod + def _notice(text: str) -> str: + return f"§a❀ §b{text}" + + @staticmethod + def _normalize_wake_words(raw: Any) -> List[str]: + if isinstance(raw, str): + words = [raw] + elif isinstance(raw, list): + words = [str(item) for item in raw if isinstance(item, str)] + else: + words = [] + cleaned = [] + for word in words: + word = word.strip() + if word and word not in cleaned: + cleaned.append(word) + return cleaned or [".领地"] + + @classmethod + def _merge_config_with_default(cls, raw: Any, default: Any): + if isinstance(default, dict): + result = { + key: cls._merge_config_with_default( + raw.get(key) if isinstance(raw, dict) else None, + value, + ) + for key, value in default.items() + } + if isinstance(raw, dict): + for key, value in raw.items(): + if key not in result: + result[key] = copy.deepcopy(value) + return result + return copy.deepcopy( + raw) if raw is not None else copy.deepcopy(default) + + @staticmethod + def _trim_fixed_keys(raw: Any, default: Dict[str, Any]) -> Dict[str, Any]: + raw = raw if isinstance(raw, dict) else {} + return { + key: copy.deepcopy(raw.get(key, value)) + for key, value in default.items() + } + + @staticmethod + def _normalize_config_bool(value: Any, fallback: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in ("true", "1", "yes", "y", "on", "启用", "是", "真"): + return True + if text in ("false", "0", "no", "n", "off", "禁用", "否", "假"): + return False + if isinstance(value, (int, float)) and not isinstance(value, bool): + return bool(value) + return bool(fallback) + + @staticmethod + def _normalize_config_positive_int(value: Any, fallback: int) -> int: + if isinstance(value, bool): + return fallback + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + @staticmethod + def _normalize_config_non_negative_int(value: Any, fallback: int) -> int: + if isinstance(value, bool): + return fallback + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result >= 0 else fallback + + @staticmethod + def _normalize_config_str( + value: Any, + fallback: str, + *, + allow_empty: bool = False) -> str: + if value is None: + return fallback + text = str(value) + if text or allow_empty: + return text + return fallback + + @classmethod + def _normalize_config_string_list( + cls, + value: Any, + fallback: List[str], + *, + allow_empty: bool = False, + ) -> List[str]: + if isinstance(value, str): + candidates = [value] + elif isinstance(value, list): + candidates = value + else: + return copy.deepcopy(fallback) + + result: List[str] = [] + for item in candidates: + text = cls._normalize_config_str( + item, "", allow_empty=True).strip() + if text and text not in result: + result.append(text) + if result or allow_empty: + return result + return copy.deepcopy(fallback) + + @classmethod + def _normalize_runtime_config( + cls, raw_cfg: Any, default_cfg: Dict[str, Any]) -> Dict[str, Any]: + merged_cfg = cls._merge_config_with_default(raw_cfg, default_cfg) + normalized = cls._trim_fixed_keys(merged_cfg, default_cfg) + + dynamic_default = default_cfg[DYNAMIC_LOAD_SETTINGS_KEY] + dynamic = cls._trim_fixed_keys( + normalized.get(DYNAMIC_LOAD_SETTINGS_KEY), + dynamic_default, + ) + dynamic[DYNAMIC_LOAD_ENABLED_KEY] = cls._normalize_config_bool( + dynamic.get(DYNAMIC_LOAD_ENABLED_KEY), + dynamic_default[DYNAMIC_LOAD_ENABLED_KEY], + ) + dynamic[DYNAMIC_LOAD_INTERVAL_KEY] = cls._normalize_config_positive_int( + dynamic.get(DYNAMIC_LOAD_INTERVAL_KEY), + dynamic_default[DYNAMIC_LOAD_INTERVAL_KEY], + ) + normalized[DYNAMIC_LOAD_SETTINGS_KEY] = dynamic + + normalized["是否启用"] = cls._normalize_config_bool( + normalized.get("是否启用"), + default_cfg["是否启用"], + ) + normalized["唤醒词"] = cls._normalize_config_string_list( + normalized.get("唤醒词"), + default_cfg["唤醒词"], + ) + normalized["数据文件"] = cls._normalize_config_str( + normalized.get("数据文件"), + default_cfg["数据文件"], + ) + for key in ( + "检测间隔", + "传送半径", + "最大领地半径", + "最大领地长", + "最大领地高", + "最大领地宽", + "最大领地数量", + ): + normalized[key] = cls._normalize_config_positive_int( + normalized.get(key), + default_cfg[key], + ) + normalized["缓冲区距离"] = cls._normalize_config_non_negative_int( + normalized.get("缓冲区距离"), + default_cfg["缓冲区距离"], + ) + normalized["白名单"] = cls._normalize_config_string_list( + normalized.get("白名单"), + default_cfg["白名单"], + allow_empty=True, + ) + return normalized + + def _load_config( + self, default_cfg: Dict[str, Any], cfg_std: Any) -> Dict[str, Any]: + try: + raw_cfg, _ = cfg.get_plugin_config_and_version( + self.name, + {}, + default_cfg, + self.version, + ) + merged_cfg = self._normalize_runtime_config(raw_cfg, default_cfg) + cfg.check_auto(cfg_std, merged_cfg) + except Exception as err: + fmts.print_err(f"领地系统云链联动版配置文件自动更新失败,已使用默认配置: {err}") + merged_cfg = self._normalize_runtime_config({}, default_cfg) + cfg.check_auto(cfg_std, merged_cfg) + cfg.upgrade_plugin_config(self.name, merged_cfg, self.version) + return merged_cfg + + def _apply_runtime_config_fields(self, reload_data_file: bool = False): + self.enabled = bool(self.cfg["是否启用"]) + self.wake_words = self._normalize_wake_words(self.cfg["唤醒词"]) + new_data_file = self.format_data_path(str(self.cfg["数据文件"])) + data_file_changed = getattr(self, "data_file", None) != new_data_file + self.data_file = new_data_file + self.check_interval = self._positive_float(self.cfg["检测间隔"], 2.0) + self.buffer_dist = self._non_negative_float(self.cfg["缓冲区距离"], 5.0) + self.tp_radius = self._positive_float(self.cfg["传送半径"], 5000.0) + self.max_radius = self._positive_int(self.cfg["最大领地半径"], 200) + self.max_length = self._positive_int(self.cfg["最大领地长"], 200) + self.max_height = self._positive_int(self.cfg["最大领地高"], 200) + self.max_width = self._positive_int(self.cfg["最大领地宽"], 200) + self.max_lands_per_player = self._positive_int(self.cfg["最大领地数量"], 4) + self.whitelist = {str(name).lower() for name in self.cfg["白名单"]} + if reload_data_file and data_file_changed: + self.lands.clear() + self.player_land_cache.clear() + self._ensure_dirs() + self._load_data() + + @staticmethod + def _positive_int(value: Any, fallback: int) -> int: + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + @staticmethod + def _positive_float(value: Any, fallback: float) -> float: + try: + result = float(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + @staticmethod + def _non_negative_float(value: Any, fallback: float) -> float: + try: + result = float(value) + except (TypeError, ValueError): + return fallback + return result if result >= 0 else fallback + + def config_file_path(self) -> str: + return os.path.join(CONFIG_FILE_DIR, f"{self.name}.json") + + @staticmethod + def file_state(path: str) -> tuple[int, int] | None: + try: + stat = os.stat(path) + except OSError: + return None + return stat.st_mtime_ns, stat.st_size + + def refresh_config_file_state(self): + self._config_file_state = self.file_state(self.config_file_path()) + self._no_create_regions_file_state = self.file_state( + self.no_create_regions_file) + + def is_dynamic_config_reload_enabled(self) -> bool: + settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return True + return bool(settings.get(DYNAMIC_LOAD_ENABLED_KEY, True)) + + def dynamic_config_reload_interval(self) -> int: + settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return DYNAMIC_LOAD_DEFAULT_INTERVAL + try: + interval = int(settings.get(DYNAMIC_LOAD_INTERVAL_KEY, + DYNAMIC_LOAD_DEFAULT_INTERVAL)) + except (TypeError, ValueError): + return DYNAMIC_LOAD_DEFAULT_INTERVAL + return interval if interval > 0 else DYNAMIC_LOAD_DEFAULT_INTERVAL + + def apply_runtime_config(self, config_items: Dict[str, Any]) -> None: + """Apply already parsed config-center data to this plugin.""" + self.cfg = self._normalize_runtime_config( + config_items, self._cfg_default) + cfg.check_auto(self._cfg_std, self.cfg) + self._apply_runtime_config_fields(reload_data_file=True) + if self.enabled: + self._start_detection() + self.refresh_config_file_state() + + def reload_runtime_config(self, announce: bool = False): + self.cfg = self._load_config(self._cfg_default, self._cfg_std) + self._apply_runtime_config_fields(reload_data_file=True) + if self.enabled: + self._start_detection() + if announce: + fmts.print_suc(f"{self.name} 配置文件已热更新") + + def reload_no_create_regions_config(self, announce: bool = False): + self.no_create_regions_raw = self._load_no_create_regions() + self._reload_no_create_regions() + if announce: + fmts.print_suc(f"{self.name} 不可创建领地区域配置已热更新") + + def config_reload_task(self): + while not self._stop_event.wait(self.dynamic_config_reload_interval()): + if not self.is_dynamic_config_reload_enabled(): + self.refresh_config_file_state() + continue + current_config_state = self.file_state(self.config_file_path()) + current_regions_state = self.file_state( + self.no_create_regions_file) + if ( + current_config_state == self._config_file_state + and current_regions_state == self._no_create_regions_file_state + ): + continue + try: + if current_config_state != self._config_file_state: + self.reload_runtime_config(announce=True) + if current_regions_state != self._no_create_regions_file_state: + self.reload_no_create_regions_config(announce=True) + self.refresh_config_file_state() + except Exception as err: + self._config_file_state = current_config_state + self._no_create_regions_file_state = current_regions_state + fmts.print_err(f"{self.name} 配置文件热更新失败: {err}") + + def api_reload_land_config(self) -> Tuple[bool, str, Dict[str, Any]]: + try: + self.reload_runtime_config(announce=False) + self.reload_no_create_regions_config(announce=False) + self.refresh_config_file_state() + except Exception as err: + return False, f"领地系统配置重载失败: {err}", self.api_get_runtime_status() + return True, "领地系统配置已重载", self.api_get_runtime_status() + + def api_get_runtime_status(self) -> Dict[str, Any]: + return { + "enabled": self.enabled, + "wake_words": list(self.wake_words), + "data_file": self.data_file, + "check_interval": self.check_interval, + "land_count": len(self.lands), + "no_create_region_count": len(self.no_create_regions), + "dynamic_reload_enabled": self.is_dynamic_config_reload_enabled(), + "dynamic_reload_interval": self.dynamic_config_reload_interval(), + } + + def _load_no_create_regions(self) -> List[Dict[str, Any]]: + if not os.path.exists(self.no_create_regions_file): + regions = default_no_create_regions() + self._save_no_create_regions(regions) + return regions + try: + with open(self.no_create_regions_file, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return data + except Exception as err: + fmts.print_err(f"读取不可创建领地区域数据失败: {err}") + regions = default_no_create_regions() + self._save_no_create_regions(regions) + return regions + + def _save_no_create_regions( + self, regions: Optional[List[Dict[str, Any]]] = None): + data = self.no_create_regions_raw if regions is None else regions + os.makedirs( + os.path.dirname( + self.no_create_regions_file), + exist_ok=True) + with open(self.no_create_regions_file, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def _reload_no_create_regions(self): + self.no_create_regions = self._normalize_no_create_regions( + self.no_create_regions_raw) + + @staticmethod + def _as_float_pos(raw: Any) -> Optional[Tuple[float, float, float]]: + if not isinstance(raw, list) or len(raw) != 3: + return None + try: + return (float(raw[0]), float(raw[1]), float(raw[2])) + except (TypeError, ValueError): + return None + + def _normalize_no_create_regions(self, raw: Any) -> List[Dict[str, Any]]: + regions: List[Dict[str, Any]] = [] + if not isinstance(raw, list): + return regions + for index, item in enumerate(raw, 1): + if not isinstance(item, dict) or not item.get("启用", True): + continue + region_type = str(item.get("类型", "")).strip().lower() + name = str(item.get("名称", f"区域{index}")).strip() or f"区域{index}" + if region_type in ("圆形", "circle", "round"): + center = self._as_float_pos(item.get("中心")) + try: + radius = float(item.get("半径", 0)) + except (TypeError, ValueError): + radius = 0 + if center is None or radius <= 0: + fmts.print_war(f"领地系统云链联动版: 不可创建领地区域 {name} 配置无效,已忽略") + continue + regions.append({ + "名称": name, + "类型": "圆形", + "中心": center, + "半径": radius, + }) + elif region_type in ("方形", "矩形", "长方形", "square", "box", "立方体", "长方体"): + start = self._as_float_pos(item.get("起点")) + end = self._as_float_pos(item.get("终点")) + if start is None or end is None: + fmts.print_war(f"领地系统云链联动版: 不可创建领地区域 {name} 配置无效,已忽略") + continue + min_pos = tuple(min(start[i], end[i]) for i in range(3)) + max_pos = tuple(max(start[i], end[i]) for i in range(3)) + regions.append({ + "名称": name, + "类型": "方形", + "起点": min_pos, + "终点": max_pos, + }) + else: + fmts.print_war(f"领地系统云链联动版: 不可创建领地区域 {name} 类型未知,已忽略") + return regions + + def _get_no_create_overlap_reason( + self, + center: Tuple[float, float, float], + radius: int, + shape: str = "圆形", + size: Optional[Tuple[int, int, int]] = None, + ) -> Optional[str]: + shape = LandData.normalize_shape(shape) + box_bounds = None + if shape == "方形": + box_bounds = bounds_from_center_size( + center, LandData.normalize_size(size, radius)) + x, y, z = center + for region in self.no_create_regions: + if shape == "方形" and box_bounds is not None: + if region["类型"] == "圆形": + if sphere_intersects_box( + region["中心"], + region["半径"], + box_bounds[0], + box_bounds[1]): + return region["名称"] + elif region["类型"] == "方形": + if boxes_intersect( + box_bounds[0], + box_bounds[1], + region["起点"], + region["终点"]): + return region["名称"] + elif region["类型"] == "圆形": + rx, ry, rz = region["中心"] + if math.sqrt((x - rx) ** 2 + (y - ry) ** 2 + + (z - rz) ** 2) <= radius + region["半径"]: + return region["名称"] + elif region["类型"] == "方形": + if sphere_intersects_box( + center, radius, region["起点"], region["终点"]): + return region["名称"] + return None + + @staticmethod + def _is_exit_input(text: str) -> bool: + return text.strip().lower() in (".", "。", "q", "quit", "退出") + + def _wait_menu_input( + self, + player: Player, + timeout: int = 60) -> Optional[str]: + msg = game_utils.waitMsg(player.name, timeout) + if msg is None: + player.show(self._error("回复超时,已退出菜单")) + return None + msg = msg.strip() + if self._is_exit_input(msg): + player.show(self._success("已退出领地菜单")) + return None + return msg + + def _select_menu( + self, + player: Player, + title: str, + options: List[str], + timeout: int = 60) -> Optional[int]: + while True: + hints = [ + f"输入 §e[1-{len(options)}]§b 之间的数字以选择功能", + "输入 §c.§b 退出", + ] + player.show(self._ui_menu( + title, + options, + )) + player.show("\n".join(f"§a❀ §b{hint}" for hint in hints)) + msg = self._wait_menu_input(player, timeout) + if msg is None: + return None + choice = utils.try_int(msg.strip().strip("[]")) + if choice is None or choice not in range(1, len(options) + 1): + player.show(self._error("您的输入有误")) + continue + return choice + + def _prompt_text( + self, + player: Player, + title: str, + prompt: str, + timeout: int = 60) -> Optional[str]: + player.show(self._ui_card(title, [prompt], ["输入 §c.§b 退出"])) + return self._wait_menu_input(player, timeout) + + def _parse_box_size_input( + self, raw: str) -> Tuple[Optional[Tuple[int, int, int]], Optional[str]]: + parts = raw.replace(",", " ").replace(",", " ").split() + if len(parts) != 3: + return None, "格式错误,需要输入 长 高 宽 三个整数" + try: + length, height, width = ( + int(parts[0]), int(parts[1]), int(parts[2])) + except ValueError: + return None, "长、高、宽必须为整数" + if length <= 0 or height <= 0 or width <= 0: + return None, "长、高、宽必须大于 0" + if length > self.max_length: + return None, f"长不能超过 {self.max_length}" + if height > self.max_height: + return None, f"高不能超过 {self.max_height}" + if width > self.max_width: + return None, f"宽不能超过 {self.max_width}" + return (length, height, width), None + + def _get_xuid_by_name( + self, + playername: str, + allow_offline: bool = False) -> Optional[str]: + try: + return str( + self.xuid_getter.get_xuid_by_name( + playername, + allow_offline=allow_offline)) + except Exception as err: + self.print_war(f"无法获取玩家 {playername} 的 XUID: {err}") + return None + + def _get_player_xuid(self, player: Player) -> Optional[str]: + xuid = getattr(player, "xuid", None) + if xuid: + return str(xuid) + return self._get_xuid_by_name(player.name) + + def _make_member(self, playername: str, rank: LandRank, + allow_offline: bool = False) -> Optional[LandMember]: + xuid = self._get_xuid_by_name(playername, allow_offline=allow_offline) + if xuid is None: + return None + return LandMember(name=playername, xuid=xuid, rank=rank) + + def _select_land( + self, + player: Player, + title: str, + lands: List[LandData]) -> Optional[LandData]: + if not lands: + player.show(self._error("没有可选择的领地")) + return None + choice = self._select_menu( + player, + title, + [f"{land.name} §7- 领主: {land.owner}, {land.range_text()}" for land in lands], + ) + if choice is None: + return None + return lands[choice - 1] + + def _land_summary(self, land: LandData) -> Dict[str, Any]: + admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] + members = [m.name for m in land.members if m.rank == LandRank.MEMBER] + return { + "land_id": land.land_id, + "name": land.name, + "owner": land.owner, + "owner_xuid": land.owner_xuid, + "center": land.center, + "radius": land.radius, + "shape": land.shape, + "size": land.get_size() if land.is_box() else None, + "dimension": land.dimension, + "range_text": land.range_text(), + "admins": admins, + "members": members, + "member_count": len(land.members), + } + + def _find_land_by_name_or_id(self, query: str) -> Optional[LandData]: + query = str(query).strip() + if not query: + return None + if query in self.lands: + return self.lands[query] + return next((land for land in self.lands.values() + if land.name == query), None) + + def _migrate_legacy_data_path(self): + legacy_path = os.path.join( + os.path.dirname( + self.data_path), + LEGACY_PLUGIN_NAME) + if not os.path.isdir(legacy_path) or os.path.abspath( + legacy_path) == os.path.abspath(self.data_path): + return + os.makedirs(self.data_path, exist_ok=True) + for filename in ("领地数据.json", "不可创建领地区域.json"): + old_file = os.path.join(legacy_path, filename) + new_file = self.format_data_path(filename) + if os.path.isfile(old_file) and not os.path.exists(new_file): + try: + shutil.copy2(old_file, new_file) + except Exception as err: + self.print_war(f"迁移旧版领地系统数据文件 {filename} 失败: {err}") + + def _ensure_dirs(self): + os.makedirs(os.path.dirname(self.data_file) or ".", exist_ok=True) + + # ---------- 玩家-领地 缓存维护 ---------- + def _add_player_land(self, xuid: str, land_id: str): + if xuid not in self.player_land_cache: + self.player_land_cache[xuid] = [] + if land_id not in self.player_land_cache[xuid]: + self.player_land_cache[xuid].append(land_id) + + def _remove_player_land(self, xuid: str, land_id: str): + if xuid in self.player_land_cache: + lst = self.player_land_cache[xuid] + if land_id in lst: + lst.remove(land_id) + if not lst: + del self.player_land_cache[xuid] + + def _rebuild_player_land_cache(self): + self.player_land_cache.clear() + for land_id, land in self.lands.items(): + for member in land.members: + self._add_player_land(member.xuid, land_id) + + # ---------- 数据加载/保存 ---------- + def _load_data(self): + try: + if os.path.exists(self.data_file): + with open(self.data_file, "r", encoding="utf-8") as f: + raw = json.load(f) + self.player_land_cache.clear() + for land_id, data in raw.items(): + try: + land = LandData.from_dict(data) + self.lands[land_id] = land + for m in land.members: + self._add_player_land(m.xuid, land_id) + except Exception as e: + fmts.print_err(f"解析领地 {land_id} 失败: {e}") + except Exception as e: + fmts.print_err(f"加载数据失败: {e}") + + def _save_data(self): + try: + raw = {lid: land.to_dict() for lid, land in self.lands.items()} + with open(self.data_file, "w", encoding="utf-8") as f: + json.dump(raw, f, ensure_ascii=False, indent=2) + except Exception as e: + fmts.print_err(f"保存数据失败: {e}") + + # ---------- 坐标获取 ---------- + def _get_player_coord( + self, player: str) -> Optional[Tuple[float, float, float]]: + try: + pos_dict = game_utils.getPos(player) + if pos_dict and "position" in pos_dict: + p = pos_dict["position"] + return (p.get("x", 0), p.get("y", 0), p.get("z", 0)) + except Exception: + pass + cmd = f"/data get entity {player} Pos" + try: + resp = self.game_ctrl.sendwscmd_with_resp(cmd, timeout=2) + if resp.SuccessCount: + for out in resp.OutputMessages: + msg = out.Message + nums = re.findall(r"[-+]?\d*\.?\d+[df]?", msg) + if len(nums) >= 3: + x = float(nums[0].replace('d', '').replace('f', '')) + y = float(nums[1].replace('d', '').replace('f', '')) + z = float(nums[2].replace('d', '').replace('f', '')) + return (x, y, z) + except Exception: + pass + return None + + def _manual_coord( + self, player: Player) -> Optional[Tuple[float, float, float]]: + player.show(self._ui_card( + "手动坐标输入", + [ + "请输入你的当前坐标", + "格式:x y z,例如 100 64 200", + "请在聊天栏直接输入数字,用空格分隔", + ], + ["30 秒内回复有效"], + )) + msg = game_utils.waitMsg(player.name, 30) + if not msg: + player.show(self._error("输入超时")) + return None + parts = msg.strip().split() + if len(parts) != 3: + player.show(self._error("格式错误,需要三个数字")) + return None + try: + x = float(parts[0]) + y = float(parts[1]) + z = float(parts[2]) + return (x, y, z) + except ValueError: + player.show(self._error("请输入有效的数字")) + return None + + # ---------- 领地查找辅助 ---------- + def _find_land_at(self, + pos: Tuple[float, + float, + float], + dimension: int = 0) -> Optional[LandData]: + x, y, z = pos + for land in self.lands.values(): + if land.dimension != dimension: + continue + if land.contains_pos((x, y, z)): + return land + return None + + def _find_land_at_player(self, player: str) -> Optional[LandData]: + pos = self._get_player_coord(player) + if pos: + return self._find_land_at(pos) + return None + + # ---------- 检测线程 ---------- + def _start_detection(self): + if self._detection_started: + return + self._detection_started = True + + def loop(): + while not self._stop_event.wait(self.check_interval): + try: + self._update_coords() + self._check_lands() + except Exception as e: + fmts.print_err(f"检测异常: {e}") + threading.Thread(target=loop, daemon=True).start() + + def _update_coords(self): + if not self.enabled: + return + players = self.game_ctrl.allplayers + new_coords = {} + for name in players: + pos = self._get_player_coord(name) + if pos: + new_coords[name] = pos + with self.coords_lock: + self.coords = new_coords + + def _check_lands(self): + if not self.enabled: + return + with self.coords_lock: + players = dict(self.coords) + + now = time.time() + expired = [ + p for p, + t in self.recent_tp.items() if now - + t > self.tp_cooldown] + for p in expired: + del self.recent_tp[p] + + for name, (x, y, z) in players.items(): + if name.lower() in self.whitelist: + continue + xuid = self._get_xuid_by_name(name) + if xuid is None: + continue + + in_land = self._find_land_at((x, y, z)) + + if in_land: + if in_land.get_member(xuid): + continue + if xuid in self.recent_tp: + continue + self._tp_random(name, x, y, z) + self.recent_tp[xuid] = now + self.game_ctrl.say_to(name, self._error( + f"你闯入了 {in_land.owner} 的领地,已被传送离开")) + else: + for land in self.lands.values(): + if land.dimension != 0: + continue + dist = distance_to_land((x, y, z), land) + if 0 <= dist <= self.buffer_dist: + if land.get_member(xuid): + continue + self.game_ctrl.sendwocmd(f"/gamemode adventure {name}") + self.game_ctrl.say_to(name, self._warn( + f"你正在靠近 {land.owner} 的领地,请勿进入")) + break + + def _tp_random(self, player: str, x: float, y: float, z: float): + angle = random.uniform(0, 2 * math.pi) + dist = random.uniform(10, self.tp_radius) + nx = x + dist * math.cos(angle) + nz = z + dist * math.sin(angle) + ny = y + cmd = f"/tp {player} {nx} {ny} {nz}" + self.game_ctrl.sendwocmd(cmd) + + # ---------- 实体标记(可选) ---------- + def _spawn_entity(self, land: LandData): + try: + x, y, z = land.center + self._remove_entity(land) + cmd = f"summon area_effect_cloud {x} {y} {z} { + Duration:2147483647,WaitTime:2147483647,Tags:[\"land_{ + land.land_id}\"]} " + self.game_ctrl.sendwocmd(cmd) + except Exception: + pass + + def _remove_entity(self, land: LandData): + try: + cmd = f"kill @e[type=area_effect_cloud,tag=land_{land.land_id}]" + self.game_ctrl.sendwocmd(cmd) + except Exception: + pass + + # ---------- 命令处理 ---------- + def on_chat(self, chat: Chat): + if not self.enabled: + return + msg = chat.msg.strip() + if msg not in self.wake_words: + return + self._main_menu(chat.player) + + def _main_menu(self, player: Player): + choice = self._select_menu( + player, + "功能菜单", + [ + "创建领地", + "删除领地", + "查看领地信息", + "成员管理", + "管理员管理", + "传送到领地", + "查看领地列表", + "测试当前位置", + ], + timeout=90, + ) + if choice is None: + return + if choice == 1: + self._menu_create(player) + elif choice == 2: + self._menu_delete(player) + elif choice == 3: + self._menu_info(player) + elif choice == 4: + self._menu_member(player) + elif choice == 5: + self._menu_admin(player) + elif choice == 6: + self._menu_tp(player) + elif choice == 7: + self._list(player) + elif choice == 8: + self._test(player) + + def _menu_create(self, player: Player): + name = self._prompt_text(player, "创建领地", "请输入领地名称") + if name is None: + return + shape_choice = self._select_menu(player, "创建领地类型", ["圆形领地", "方形领地"]) + if shape_choice is None: + return + if shape_choice == 1: + radius = self._prompt_text( + player, "创建圆形领地", f"请输入领地半径,范围 1~{self.max_radius}") + if radius is None: + return + self._create(player, [name, "圆形", radius]) + else: + size_text = self._prompt_text( + player, + "创建方形领地", + f"请输入 长 高 宽,最大 { + self.max_length} { + self.max_height} { + self.max_width}", + ) + if size_text is None: + return + size, err = self._parse_box_size_input(size_text) + if size is None: + player.show(self._error(err or "方形领地尺寸无效")) + return + self._create( + player, [ + name, "方形", str( + size[0]), str( + size[1]), str( + size[2])]) + + def _menu_delete(self, player: Player): + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + owned = [land for land in self.lands.values( + ) if land.owner_xuid == player_xuid] + land = self._select_land(player, "删除领地", owned) + if land is None: + return + self._delete(player, [land.name]) + + def _menu_info(self, player: Player): + choice = self._select_menu( + player, + "查看领地信息", + ["查看当前所在领地", "从我的领地中选择", "从全部领地中选择", "输入领地名称"], + ) + if choice is None: + return + if choice == 1: + self._info(player, []) + elif choice == 2: + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + lands = [land for land in self.lands.values() if land.owner_xuid == + player_xuid] + land = self._select_land(player, "我的领地", lands) + if land: + self._info(player, [land.name]) + elif choice == 3: + land = self._select_land(player, "全部领地", list(self.lands.values())) + if land: + self._info(player, [land.name]) + elif choice == 4: + name = self._prompt_text(player, "查看领地信息", "请输入领地名称") + if name: + self._info(player, [name]) + + def _menu_member(self, player: Player): + choice = self._select_menu(player, "成员管理", ["添加成员", "移除成员", "查看成员列表"]) + if choice is None: + return + if choice == 3: + self._member(player, ["列表"]) + return + target = self._prompt_text( + player, + "成员管理", + "请输入玩家名", + ) + if target is None: + return + self._member(player, [["添加", "移除"][choice - 1], target]) + + def _menu_admin(self, player: Player): + choice = self._select_menu(player, "管理员管理", ["添加管理员", "移除管理员"]) + if choice is None: + return + target = self._prompt_text(player, "管理员管理", "请输入玩家名") + if target is None: + return + self._admin(player, [["添加", "移除"][choice - 1], target]) + + def _menu_tp(self, player: Player): + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + lands = [land for land in self.lands.values( + ) if land.has_permission(player_xuid, "tp")] + land = self._select_land(player, "传送到领地", lands) + if land is None: + return + self._tp(player, [land.name]) + + def _create(self, player: Player, args: List[str]): + if len(args) < 2: + player.show(self._error( + f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入创建菜单")) + return + name = args[0] + shape_arg = str(args[1]).strip().lower() + explicit_circle = shape_arg in ("圆形", "circle", "round") + explicit_box = LandData.normalize_shape(shape_arg) == "方形" + shape = "方形" if explicit_box else "圆形" + size = None + if shape == "方形": + if len(args) < 5: + player.show(self._error("方形领地需要输入 长 高 宽")) + return + size, err = self._parse_box_size_input(" ".join(args[2:5])) + if size is None: + player.show(self._error(err or "方形领地尺寸无效")) + return + radius = box_radius_for_size(size) + else: + radius_arg = args[2] if len( + args) >= 3 and explicit_circle else args[1] + try: + radius = int(radius_arg) + except ValueError: + player.show(self._error("半径必须为整数")) + return + if radius <= 0 or radius > self.max_radius: + player.show(self._error(f"半径必须在 1~{self.max_radius} 之间")) + return + + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + owned = [ + land_item for land_item in self.lands.values() + if land_item.owner_xuid == player_xuid + ] + if len(owned) >= self.max_lands_per_player: + player.show( + self._error( + f"你最多只能拥有 { + self.max_lands_per_player} 个领地")) + return + + pos = self._get_player_coord(player.name) + if not pos: + player.show(self._warn("无法自动获取坐标,请手动输入")) + pos = self._manual_coord(player) + if not pos: + return + x, y, z = pos + + overlap = self._get_land_candidate_overlap_reason( + (x, y, z), radius, shape, size) + if overlap: + player.show(self._error(overlap)) + return + + land_id = str(uuid.uuid4()) + member = LandMember( + name=player.name, + xuid=player_xuid, + rank=LandRank.OWNER) + land = LandData( + land_id=land_id, + name=name, + owner=player.name, + owner_xuid=player_xuid, + center=(x, y, z), + radius=radius, + shape=shape, + size=size, + members=[member] + ) + self.lands[land_id] = land + self._add_player_land(player_xuid, land_id) + self._save_data() + player.show(self._success(f"成功创建领地 '{name}',{land.range_text()}")) + self._spawn_entity(land) + + def _delete(self, player: Player, args: List[str]): + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + owned = [ + land_item for land_item in self.lands.values() + if land_item.owner_xuid == player_xuid + ] + if not owned: + player.show(self._error("你没有拥有任何领地")) + return + + if args: + name = args[0] + land = next( + (land_item for land_item in owned if land_item.name == name), + None, + ) + if not land: + player.show(self._error(f"你没有名为 '{name}' 的领地")) + return + else: + if len(owned) > 1: + names = "、".join(land_item.name for land_item in owned) + player.show(self._error(f"你拥有多个领地,请指定名称:{names}")) + return + land = owned[0] + + self._remove_entity(land) + for m in land.members: + self._remove_player_land(m.xuid, land.land_id) + del self.lands[land.land_id] + self._save_data() + player.show(self._success(f"已删除领地 '{land.name}'")) + + def _info(self, player: Player, args: List[str]): + land = None + if args: + name = args[0] + land = next( + ( + land_item for land_item in self.lands.values() + if land_item.name == name + ), + None, + ) + if not land: + player.show(self._error(f"领地 '{name}' 不存在")) + return + else: + land = self._find_land_at_player(player.name) + if not land: + player.show(self._error("你当前不在任何领地内,请指定领地名称")) + return + + admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] + members = [m.name for m in land.members if m.rank == LandRank.MEMBER] + player.show(self._ui_card( + f"领地信息 - {land.name}", + [ + f"中心:{land.center[0]}, {land.center[1]}, {land.center[2]}", + f"范围:{land.range_text()}", + f"领主:{land.owner}", + f"管理员:{', '.join(admins) or '无'}", + f"成员:{', '.join(members) or '无'}", + ], + )) + + def _member(self, player: Player, args: List[str]): + if len(args) < 1: + player.show(self._error( + f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入成员管理菜单")) + return + sub = args[0].lower() + if sub == "列表": + land = self._find_land_at_player(player.name) + if not land: + player.show(self._error("你当前不在任何领地内")) + return + self._info(player, [land.name]) + return + + if len(args) < 2: + player.show(self._error( + f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入成员管理菜单")) + return + target = args[1] + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + target_member = self._make_member( + target, LandRank.MEMBER, allow_offline=True) + if target_member is None: + player.show(self._error(f"无法获取 {target} 的 XUID")) + return + + land = self._find_land_at_player(player.name) + if not land: + player.show(self._error("你当前不在任何领地内")) + return + + if not land.has_permission(player_xuid, "manage_member"): + player.show(self._error("你没有权限管理成员")) + return + + if sub == "添加": + if land.get_member(target_member.xuid): + player.show(self._warn(f"{target} 已是成员")) + return + land.members.append(target_member) + self._add_player_land(target_member.xuid, land.land_id) + self._save_data() + player.show(self._success(f"已将 {target} 添加为成员")) + elif sub == "移除": + if not land.get_member(target_member.xuid): + player.show(self._error(f"{target} 不是成员")) + return + if not land.can_manage_member(player_xuid, target_member.xuid): + player.show(self._error("你不能移除该成员")) + return + land.members = [ + m for m in land.members if m.xuid != target_member.xuid] + self._remove_player_land(target_member.xuid, land.land_id) + self._save_data() + player.show(self._success(f"已将 {target} 移除成员")) + else: + player.show(self._error("未知子命令")) + + def _admin(self, player: Player, args: List[str]): + if len(args) < 2: + player.show(self._error( + f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入管理员管理菜单")) + return + sub = args[0].lower() + target = args[1] + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + target_xuid = self._get_xuid_by_name(target, allow_offline=True) + if target_xuid is None: + player.show(self._error(f"无法获取 {target} 的 XUID")) + return + + land = self._find_land_at_player(player.name) + if not land: + player.show(self._error("你当前不在任何领地内")) + return + + if land.owner_xuid != player_xuid: + player.show(self._error("只有领主可以管理管理员")) + return + + if sub == "添加": + member = land.get_member(target_xuid) + if not member: + player.show(self._error(f"{target} 不是成员")) + return + if member.rank == LandRank.ADMIN: + player.show(self._warn(f"{target} 已经是管理员")) + return + member.rank = LandRank.ADMIN + self._save_data() + player.show(self._success(f"已将 {target} 设为管理员")) + elif sub == "移除": + member = land.get_member(target_xuid) + if not member or member.rank != LandRank.ADMIN: + player.show(self._error(f"{target} 不是管理员")) + return + member.rank = LandRank.MEMBER + self._save_data() + player.show(self._success(f"已将 {target} 移除管理员")) + else: + player.show(self._error("未知子命令")) + + def _tp(self, player: Player, args: List[str]): + land = None + if args: + name = args[0] + land = next( + ( + land_item for land_item in self.lands.values() + if land_item.name == name + ), + None, + ) + if not land: + player.show(self._error(f"领地 '{name}' 不存在")) + return + else: + land = self._find_land_at_player(player.name) + if not land: + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + owned = [ + land_item for land_item in self.lands.values() + if land_item.owner_xuid == player_xuid + ] + if owned: + land = owned[0] + else: + player.show(self._error("你当前不在任何领地内,且你没有拥有领地,请指定名称")) + return + + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + if not land.has_permission(player_xuid, "tp"): + player.show(self._error("你没有权限传送到该领地")) + return + + cx, cy, cz = land.center + self.game_ctrl.sendwocmd(f"/tp {player.name} {cx} {cy + 1} {cz}") + player.show(self._success(f"已传送到领地 '{land.name}'")) + + def _list(self, player: Player): + if not self.lands: + player.show(self._error("暂无任何领地")) + return + options = [ + f"{land.name} §7- 领主: {land.owner}, {land.range_text()}" + for land in self.lands.values() + ] + player.show(self._ui_menu("领地列表", options, [f"共 {len(options)} 个领地"])) + + def _test(self, player: Player): + pos = self._get_player_coord(player.name) + lines = [] + if pos: + lines.append(f"当前坐标:{pos[0]:.1f}, {pos[1]:.1f}, {pos[2]:.1f}") + else: + lines.append("无法获取坐标") + + in_land = self._find_land_at_player(player.name) + if in_land: + lines.append(f"当前位置:位于领地 '{in_land.name}' 内") + else: + lines.append("当前位置:不在任何领地内") + + if self.lands: + lines.append( + "所有领地:" + "、".join(f"{land.name}({land.owner})" for land in self.lands.values())) + else: + lines.append("所有领地:无") + player.show(self._ui_card("测试信息", lines)) + + def on_player_join(self, player: Player): + if not self.enabled: + return + pass + + def on_player_leave(self, player: Player): + if not self.enabled: + return + pass + + # ---------- 外部插件 API ---------- + def api_list_lands(self) -> Tuple[bool, str, List[Dict[str, Any]]]: + lands = [self._land_summary(land) for land in self.lands.values()] + return True, f"共 {len(lands)} 个领地", lands + + def api_get_land( + self, land_query: str) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + return True, "查询成功", self._land_summary(land) + + def api_add_land( + self, + owner: str, + name: str, + center: Tuple[float, float, float], + shape: str = "圆形", + radius: Optional[int] = None, + size: Optional[Tuple[int, int, int]] = None, + dimension: int = 0, + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + owner = str(owner).strip() + name = str(name).strip() + if not owner or not name: + return False, "领地主人和领地名称不能为空", None + if self._find_land_by_name_or_id(name): + return False, f"领地 '{name}' 已存在", None + owner_member = self._make_member( + owner, LandRank.OWNER, allow_offline=True) + if owner_member is None: + return False, f"无法获取 {owner} 的 XUID", None + if len([land for land in self.lands.values() if land.owner_xuid == + owner_member.xuid]) >= self.max_lands_per_player: + return False, f"{owner} 已达到最大领地数量 { + self.max_lands_per_player}", None + try: + center_tuple = ( + float( + center[0]), float( + center[1]), float( + center[2])) + except (TypeError, ValueError, IndexError): + return False, "中心坐标无效", None + + normalized_shape = LandData.normalize_shape(shape) + normalized_size = None + if normalized_shape == "方形": + if size is None: + return False, "方形领地需要提供 长 高 宽", None + normalized_size = LandData.normalize_size(size, radius or 1) + if normalized_size[0] > self.max_length: + return False, f"长不能超过 {self.max_length}", None + if normalized_size[1] > self.max_height: + return False, f"高不能超过 {self.max_height}", None + if normalized_size[2] > self.max_width: + return False, f"宽不能超过 {self.max_width}", None + land_radius = box_radius_for_size(normalized_size) + else: + try: + land_radius = int(radius) + except (TypeError, ValueError): + return False, "圆形领地半径必须为整数", None + if land_radius <= 0 or land_radius > self.max_radius: + return False, f"半径必须在 1~{self.max_radius} 之间", None + + overlap = self._get_land_candidate_overlap_reason( + center_tuple, + land_radius, + normalized_shape, + normalized_size, + dimension, + ) + if overlap: + return False, overlap, None + + land_id = str(uuid.uuid4()) + land = LandData( + land_id=land_id, + name=name, + owner=owner, + owner_xuid=owner_member.xuid, + center=center_tuple, + radius=land_radius, + shape=normalized_shape, + size=normalized_size, + dimension=dimension, + members=[owner_member], + ) + self.lands[land_id] = land + self._rebuild_player_land_cache() + self._save_data() + self._spawn_entity(land) + return True, f"已新增玩家 {owner} 的领地 '{name}',{ + land.range_text()}", self._land_summary(land) + + def api_delete_land(self, land_query: str) -> Tuple[bool, str, None]: + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + self._remove_entity(land) + del self.lands[land.land_id] + self._rebuild_player_land_cache() + self._save_data() + return True, f"已删除领地 '{land.name}'", None + + def api_add_member(self, + land_query: str, + player_name: str, + rank: str = "member") -> Tuple[bool, + str, + Optional[Dict[str, + Any]]]: + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + target_rank = LandRank.ADMIN if str(rank).lower() in ( + "admin", "管理员", "管理") else LandRank.MEMBER + member = self._make_member( + player_name, target_rank, allow_offline=True) + if member is None: + return False, f"无法获取 {player_name} 的 XUID", None + old_member = land.get_member(member.xuid) + if old_member: + if old_member.rank == LandRank.OWNER: + return False, "所有者不能被改为成员或管理员", self._land_summary(land) + old_member.name = player_name + old_member.rank = target_rank + else: + land.members.append(member) + self._rebuild_player_land_cache() + self._save_data() + role_name = "管理员" if target_rank == LandRank.ADMIN else "成员" + return True, f"已将 {player_name} 添加为领地 '{ + land.name}' 的{role_name}", self._land_summary(land) + + def api_set_member_rank( + self, + land_query: str, + player_name: str, + rank: str, + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + target_rank = LandRank.ADMIN if str(rank).lower() in ( + "admin", "管理员", "管理") else LandRank.MEMBER + target_xuid = self._get_xuid_by_name(player_name, allow_offline=True) + if target_xuid is None: + return False, f"无法获取 {player_name} 的 XUID", None + member = land.get_member(target_xuid) + if member is None: + return False, f"{player_name} 不在领地 '{ + land.name}' 中", self._land_summary(land) + if member.rank == LandRank.OWNER: + return False, "所有者不能修改身份", self._land_summary(land) + member.name = player_name + member.rank = target_rank + self._save_data() + role_name = "管理员" if target_rank == LandRank.ADMIN else "成员" + return True, f"已将 {player_name} 设为领地 '{ + land.name}' 的{role_name}", self._land_summary(land) + + def api_remove_member(self, + land_query: str, + player_name: str) -> Tuple[bool, + str, + Optional[Dict[str, + Any]]]: + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + target_xuid = self._get_xuid_by_name(player_name, allow_offline=True) + if target_xuid is None: + return False, f"无法获取 {player_name} 的 XUID", None + member = land.get_member(target_xuid) + if not member: + return False, f"{player_name} 不在领地 '{ + land.name}' 中", self._land_summary(land) + if member.rank == LandRank.OWNER: + return False, "不能删除领地所有者,请先转移所有者", self._land_summary(land) + land.members = [m for m in land.members if m.xuid != target_xuid] + self._rebuild_player_land_cache() + self._save_data() + return True, f"已从领地 '{ + land.name}' 移除 {player_name}", self._land_summary(land) + + def api_transfer_owner( + self, land_query: str, owner: str) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + owner_member_data = self._make_member( + owner, LandRank.OWNER, allow_offline=True) + if owner_member_data is None: + return False, f"无法获取 {owner} 的 XUID", None + land.owner = owner + land.owner_xuid = owner_member_data.xuid + for member in land.members: + if member.rank == LandRank.OWNER: + member.rank = LandRank.ADMIN + owner_member = land.get_member(owner_member_data.xuid) + if owner_member: + owner_member.name = owner + owner_member.rank = LandRank.OWNER + else: + land.members.append(owner_member_data) + self._rebuild_player_land_cache() + self._save_data() + return True, f"已修改领地 '{ + land.name}' 所有者为 {owner}", self._land_summary(land) + + def api_update_land_center( + self, + land_query: str, + center: Tuple[float, float, float], + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + try: + center_tuple = ( + float( + center[0]), float( + center[1]), float( + center[2])) + except (TypeError, ValueError, IndexError): + return False, "中心坐标无效", self._land_summary(land) + overlap = self._get_land_edit_overlap_reason( + land, center_tuple, land.radius) + if overlap: + return False, overlap, self._land_summary(land) + land.center = center_tuple + self._save_data() + self._spawn_entity(land) + return True, f"已修改领地 '{land.name}' 中心点", self._land_summary(land) + + def api_update_land_range( + self, + land_query: str, + radius: Optional[int] = None, + size: Optional[Tuple[int, int, int]] = None, + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + if land.is_box(): + if size is None: + return False, "方形领地需要提供 长 高 宽", self._land_summary(land) + normalized_size = LandData.normalize_size(size, land.radius) + if normalized_size[0] > self.max_length: + return False, f"长不能超过 { + self.max_length}", self._land_summary(land) + if normalized_size[1] > self.max_height: + return False, f"高不能超过 { + self.max_height}", self._land_summary(land) + if normalized_size[2] > self.max_width: + return False, f"宽不能超过 { + self.max_width}", self._land_summary(land) + land_radius = box_radius_for_size(normalized_size) + overlap = self._get_land_edit_overlap_reason( + land, land.center, land_radius, "方形", normalized_size) + if overlap: + return False, overlap, self._land_summary(land) + land.size = normalized_size + land.radius = land_radius + else: + try: + land_radius = int(radius) + except (TypeError, ValueError): + return False, "半径必须为整数", self._land_summary(land) + if land_radius <= 0 or land_radius > self.max_radius: + return False, f"半径必须在 1~{ + self.max_radius} 之间", self._land_summary(land) + overlap = self._get_land_edit_overlap_reason( + land, land.center, land_radius, "圆形", None) + if overlap: + return False, overlap, self._land_summary(land) + land.radius = land_radius + land.size = None + self._save_data() + return True, f"已修改领地 '{ + land.name}' 范围为 { + land.range_text()}", self._land_summary(land) + + def _console_print(self, text: str): + fmts.print_inf(text) + + def _console_prompt(self, prompt: str) -> Optional[str]: + value = input(fmts.fmt_info( + f"§a❀ §b{prompt} §7(输入 . 退出整个领地系统云链联动版管理菜单): ")).strip() + if self._is_exit_input(value): + raise ConsoleMenuExit + return value + + def _console_select(self, title: str, options: List[str]) -> Optional[int]: + while True: + self._console_print(self._ui_menu( + title, + options, + [f"输入 §e[1-{len(options)}]§b 之间的数字以选择", "输入 §c.§b 退出整个领地系统云链联动版管理菜单"], + )) + value = self._console_prompt("请输入序号") + if value is None: + return None + choice = utils.try_int(value.strip().strip("[]")) + if choice is None or choice not in range(1, len(options) + 1): + fmts.print_err(self._error("输入有误")) + continue + return choice + + def _console_prompt_float(self, prompt: str) -> Optional[float]: + value = self._console_prompt(prompt) + if value is None: + return None + try: + return float(value) + except ValueError: + fmts.print_err(self._error("请输入有效数字")) + return None + + def _console_prompt_int(self, prompt: str) -> Optional[int]: + value = self._console_prompt(prompt) + if value is None: + return None + try: + return int(value) + except ValueError: + fmts.print_err(self._error("请输入有效整数")) + return None + + def _console_prompt_pos(self, prompt: str) -> Optional[List[float]]: + value = self._console_prompt(f"{prompt},格式 x y z") + if value is None: + return None + parts = value.split() + if len(parts) != 3: + fmts.print_err(self._error("坐标格式错误,需要 x y z 三个数字")) + return None + try: + return [float(parts[0]), float(parts[1]), float(parts[2])] + except ValueError: + fmts.print_err(self._error("坐标必须是数字")) + return None + + def _console_select_land( + self, title: str, lands: Optional[List[LandData]] = None) -> Optional[LandData]: + lands = list(self.lands.values()) if lands is None else lands + if not lands: + fmts.print_err(self._error("暂无可选择的领地")) + return None + choice = self._console_select( + title, + [f"{land.name} - 领主: {land.owner}, {land.range_text()}" for land in lands], + ) + if choice is None: + return None + return lands[choice - 1] + + def console_manage(self, _args: List[str]): + try: + while True: + choice = self._console_select( + "控制台管理菜单", + ["新增不可创建领地区域", "删除不可创建领地区域", "新增玩家领地", "删除玩家领地", "管理玩家领地"], + ) + if choice is None: + fmts.print_inf(self._success("已退出领地系统云链联动版管理菜单")) + return + if choice == 1: + self._console_add_no_create_region() + elif choice == 2: + self._console_delete_no_create_region() + elif choice == 3: + self._console_add_land() + elif choice == 4: + self._console_delete_land() + elif choice == 5: + self._console_manage_land() + except ConsoleMenuExit: + fmts.print_inf(self._success("已退出领地系统云链联动版管理菜单")) + + def _console_add_no_create_region(self): + name = self._console_prompt("请输入区域名称") + if name is None: + return + region_choice = self._console_select("新增不可创建区域", ["圆形区域", "方形区域"]) + if region_choice is None: + return + if region_choice == 1: + center = self._console_prompt_pos("请输入圆形区域中心") + radius = self._console_prompt_float("请输入圆形区域半径") + if center is None or radius is None or radius <= 0: + fmts.print_err(self._error("区域参数无效")) + return + region = { + "名称": name, + "启用": True, + "类型": "圆形", + "中心": center, + "半径": radius} + else: + start = self._console_prompt_pos("请输入方形区域起点") + end = self._console_prompt_pos("请输入方形区域终点") + if start is None or end is None: + fmts.print_err(self._error("区域参数无效")) + return + region = { + "名称": name, + "启用": True, + "类型": "方形", + "起点": start, + "终点": end} + self.no_create_regions_raw.append(region) + self._save_no_create_regions() + self._reload_no_create_regions() + fmts.print_suc(self._success(f"已新增不可创建领地区域 '{name}'")) + + def _console_delete_no_create_region(self): + if not self.no_create_regions_raw: + fmts.print_err(self._error("暂无不可创建领地区域")) + return + choice = self._console_select( + "删除不可创建区域", + [ + f"{region.get('名称', f'区域{i}')} - {region.get('类型', '未知') + } - {'启用' if region.get('启用', True) else '禁用'}" + for i, region in enumerate(self.no_create_regions_raw, 1) + ], + ) + if choice is None: + return + region = self.no_create_regions_raw.pop(choice - 1) + self._save_no_create_regions() + self._reload_no_create_regions() + fmts.print_suc( + self._success( + f"已删除不可创建领地区域 '{ + region.get( + '名称', + choice)}'")) + + def _console_add_land(self): + owner = self._console_prompt("请输入领地主人玩家名") + name = self._console_prompt("请输入领地名称") + center = self._console_prompt_pos("请输入领地中心坐标") + shape_choice = self._console_select("请选择领地类型", ["圆形领地", "方形领地"]) + if owner is None or name is None or center is None or shape_choice is None: + return + owner_member = self._make_member( + owner, LandRank.OWNER, allow_offline=True) + if owner_member is None: + fmts.print_err(self._error(f"无法获取 {owner} 的 XUID")) + return + shape = "圆形" + size = None + if shape_choice == 1: + radius = self._console_prompt_int( + f"请输入领地半径,范围 1~{self.max_radius}") + if radius is None: + return + if radius <= 0 or radius > self.max_radius: + fmts.print_err(self._error(f"半径必须在 1~{self.max_radius} 之间")) + return + else: + size_text = self._console_prompt( + f"请输入方形领地 长 高 宽,最大 { + self.max_length} { + self.max_height} { + self.max_width}") + if size_text is None: + return + size, err = self._parse_box_size_input(size_text) + if size is None: + fmts.print_err(self._error(err or "方形领地尺寸无效")) + return + shape = "方形" + radius = box_radius_for_size(size) + overlap = self._get_land_candidate_overlap_reason( + tuple(center), radius, shape, size) + if overlap: + fmts.print_err(self._error(overlap)) + return + land_id = str(uuid.uuid4()) + land = LandData( + land_id=land_id, + name=name, + owner=owner, + owner_xuid=owner_member.xuid, + center=tuple(center), + radius=radius, + shape=shape, + size=size, + members=[owner_member], + ) + self.lands[land_id] = land + self._rebuild_player_land_cache() + self._save_data() + self._spawn_entity(land) + fmts.print_suc( + self._success( + f"已新增玩家 {owner} 的领地 '{name}',{ + land.range_text()}")) + + def _console_delete_land(self): + land = self._console_select_land("删除玩家领地") + if land is None: + return + self._remove_entity(land) + del self.lands[land.land_id] + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已删除领地 '{land.name}'")) + + def _console_manage_land(self): + land = self._console_select_land("管理玩家领地") + if land is None: + return + while True: + choice = self._console_select( + f"管理领地 - {land.name}", + ["管理用户", "管理管理人员", "管理所有者", "管理领地中心点", "管理领地范围", "查看领地信息"], + ) + if choice is None: + return + if choice == 1: + self._console_manage_land_users(land) + elif choice == 2: + self._console_manage_land_admins(land) + elif choice == 3: + self._console_manage_land_owner(land) + elif choice == 4: + self._console_manage_land_center(land) + elif choice == 5: + self._console_manage_land_radius(land) + elif choice == 6: + self._console_show_land_info(land) + + def _console_show_land_info(self, land: LandData): + admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] + members = [m.name for m in land.members if m.rank == LandRank.MEMBER] + fmts.print_inf(self._ui_card( + f"领地信息 - {land.name}", + [ + f"领主:{land.owner}", + f"中心:{land.center[0]}, {land.center[1]}, {land.center[2]}", + f"范围:{land.range_text()}", + f"管理员:{', '.join(admins) or '无'}", + f"用户:{', '.join(members) or '无'}", + ], + )) + + def _console_manage_land_users(self, land: LandData): + while True: + choice = self._console_select( + f"管理用户 - {land.name}", + ["添加用户", "删除用户", "查看用户列表"], + ) + if choice is None: + return + if choice == 1: + target = self._console_prompt("请输入要添加的玩家名") + if not target: + continue + member = self._make_member( + target, LandRank.MEMBER, allow_offline=True) + if member is None: + fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) + continue + if land.get_member(member.xuid): + fmts.print_err(self._warn(f"{target} 已在该领地中")) + continue + land.members.append(member) + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已添加用户 {target}")) + elif choice == 2: + target = self._console_prompt("请输入要删除的玩家名") + if not target: + continue + target_xuid = self._get_xuid_by_name( + target, allow_offline=True) + if target_xuid is None: + fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) + continue + member = land.get_member(target_xuid) + if not member: + fmts.print_err(self._error(f"{target} 不在该领地中")) + continue + if member.rank == LandRank.OWNER: + fmts.print_err(self._error("不能在用户管理中删除所有者,请先转移所有者")) + continue + land.members = [ + m for m in land.members if m.xuid != target_xuid] + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已删除用户 {target}")) + elif choice == 3: + users = [ + f"{m.name}({m.rank.display_name})" for m in land.members] + fmts.print_inf(self._ui_card( + f"用户列表 - {land.name}", + [", ".join(users) if users else "无用户"], + )) + + def _console_manage_land_admins(self, land: LandData): + while True: + choice = self._console_select( + f"管理管理人员 - {land.name}", + ["添加管理人员", "删除管理人员", "查看管理人员"], + ) + if choice is None: + return + if choice == 1: + target = self._console_prompt("请输入要设为管理人员的玩家名") + if not target: + continue + target_xuid = self._get_xuid_by_name( + target, allow_offline=True) + if target_xuid is None: + fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) + continue + member = land.get_member(target_xuid) + if member and member.rank == LandRank.OWNER: + fmts.print_err(self._error("所有者不能设为管理人员")) + continue + if member: + member.name = target + member.rank = LandRank.ADMIN + else: + land.members.append( + LandMember( + target, + target_xuid, + LandRank.ADMIN)) + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已将 {target} 设为管理人员")) + elif choice == 2: + target = self._console_prompt("请输入要删除管理权限的玩家名") + if not target: + continue + target_xuid = self._get_xuid_by_name( + target, allow_offline=True) + if target_xuid is None: + fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) + continue + member = land.get_member(target_xuid) + if not member or member.rank != LandRank.ADMIN: + fmts.print_err(self._error(f"{target} 不是管理人员")) + continue + member.rank = LandRank.MEMBER + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已删除 {target} 的管理权限")) + elif choice == 3: + admins = [ + m.name for m in land.members if m.rank == LandRank.ADMIN] + fmts.print_inf(self._ui_card( + f"管理人员 - {land.name}", + [", ".join(admins) if admins else "暂无管理人员"], + )) + + def _console_manage_land_owner(self, land: LandData): + while True: + choice = self._console_select( + f"管理所有者 - {land.name}", + ["修改所有者", "查看所有者"], + ) + if choice is None: + return + if choice == 1: + owner = self._console_prompt("请输入新所有者玩家名") + if not owner: + continue + owner_member_data = self._make_member( + owner, LandRank.OWNER, allow_offline=True) + if owner_member_data is None: + fmts.print_err(self._error(f"无法获取 {owner} 的 XUID")) + continue + land.owner = owner + land.owner_xuid = owner_member_data.xuid + for member in land.members: + if member.rank == LandRank.OWNER: + member.rank = LandRank.ADMIN + owner_member = land.get_member(owner_member_data.xuid) + if owner_member: + owner_member.name = owner + owner_member.rank = LandRank.OWNER + else: + land.members.append(owner_member_data) + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已修改所有者为 {owner}")) + elif choice == 2: + fmts.print_inf(self._ui_card( + f"所有者 - {land.name}", [land.owner])) + + def _console_manage_land_center(self, land: LandData): + while True: + choice = self._console_select( + f"管理领地中心点 - {land.name}", + ["修改中心点", "查看中心点"], + ) + if choice is None: + return + if choice == 1: + center = self._console_prompt_pos("请输入新的领地中心点") + if center is None: + continue + center_tuple = tuple(center) + overlap = self._get_land_edit_overlap_reason( + land, center_tuple, land.radius) + if overlap: + fmts.print_err(self._error(overlap)) + continue + land.center = center_tuple + self._save_data() + self._spawn_entity(land) + fmts.print_suc(self._success( + f"已修改领地中心点为 {center[0]}, {center[1]}, {center[2]}")) + elif choice == 2: + fmts.print_inf(self._ui_card( + f"领地中心点 - {land.name}", + [f"{land.center[0]}, {land.center[1]}, {land.center[2]}"], + )) + + def _console_manage_land_radius(self, land: LandData): + while True: + options = [ + "修改方形长高宽", + "查看方形长高宽"] if land.is_box() else [ + "修改范围半径", + "查看范围半径"] + choice = self._console_select( + f"管理领地范围 - {land.name}", + options, + ) + if choice is None: + return + if choice == 1: + if land.is_box(): + size_text = self._console_prompt( + f"请输入新的 长 高 宽,最大 { + self.max_length} { + self.max_height} { + self.max_width}") + if size_text is None: + continue + size, err = self._parse_box_size_input(size_text) + if size is None: + fmts.print_err(self._error(err or "方形领地尺寸无效")) + continue + radius = box_radius_for_size(size) + overlap = self._get_land_edit_overlap_reason( + land, land.center, radius, "方形", size) + if overlap: + fmts.print_err(self._error(overlap)) + continue + land.shape = "方形" + land.size = size + land.radius = radius + self._save_data() + fmts.print_suc(self._success( + f"已修改方形领地范围为 长:{size[0]}, 高:{size[1]}, 宽:{size[2]}")) + else: + radius = self._console_prompt_int( + f"请输入新范围半径,范围 1~{self.max_radius}") + if radius is None: + continue + if radius <= 0 or radius > self.max_radius: + fmts.print_err( + self._error( + f"半径必须在 1~{ + self.max_radius} 之间")) + continue + overlap = self._get_land_edit_overlap_reason( + land, land.center, radius, "圆形", None) + if overlap: + fmts.print_err(self._error(overlap)) + continue + land.shape = "圆形" + land.size = None + land.radius = radius + self._save_data() + fmts.print_suc(self._success(f"已修改领地范围半径为 {radius}")) + elif choice == 2: + fmts.print_inf(self._ui_card( + f"领地范围 - {land.name}", [land.range_text()])) + + def _get_land_candidate_overlap_reason( + self, + center: Tuple[float, float, float], + radius: int, + shape: str, + size: Optional[Tuple[int, int, int]] = None, + dimension: int = 0, + skip_land_id: Optional[str] = None, + ) -> Optional[str]: + blocked_region = self._get_no_create_overlap_reason( + center, radius, shape, size) + if blocked_region: + return f"领地不能与不可创建区域 '{blocked_region}' 重叠" + for other in self.lands.values(): + if other.land_id == skip_land_id or other.dimension != dimension: + continue + if land_overlaps_candidate(other, center, radius, shape, size): + return f"领地与 '{other.name}' 重叠" + return None + + def _get_land_edit_overlap_reason( + self, + land: LandData, + center: Tuple[float, float, float], + radius: int, + shape: Optional[str] = None, + size: Optional[Tuple[int, int, int]] = None, + ) -> Optional[str]: + edit_shape = shape or land.shape + edit_size = size if size is not None else land.size + return self._get_land_candidate_overlap_reason( + center, + radius, + edit_shape, + edit_size, + land.dimension, + land.land_id, + ) + + def console_test(self, args: List[str]): + if not self.enabled: + fmts.print_war(self._warn("领地系统云链联动版当前已在配置中禁用")) + return + if args: + target = args[0] + pos = self._get_player_coord(target) + if pos: + fmts.print_inf( + self._ui_card( + "控制台测试", [ + f"玩家 {target} 坐标:{pos}"])) + else: + fmts.print_err(self._error("无法获取坐标")) + else: + fmts.print_inf(self._notice("用法: 领地测试 <玩家名>")) + + +entry = plugin_entry(LandPlugin, "领地系统云链联动版", (0, 1, 18)) diff --git "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/config.py" "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/config.py" new file mode 100644 index 00000000..d6af627b --- /dev/null +++ "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/config.py" @@ -0,0 +1,56 @@ +from typing import Any, Dict, List + + +NO_CREATE_REGIONS_FILE = "不可创建领地区域.json" +CONFIG_FILE_DIR = "插件配置文件" +DYNAMIC_LOAD_SETTINGS_KEY = "动态载入设置" +DYNAMIC_LOAD_ENABLED_KEY = "是否启用动态载入配置文件(仅用于本插件)" +DYNAMIC_LOAD_INTERVAL_KEY = "动态载入检测时间间隔(单位:秒)" +DYNAMIC_LOAD_DEFAULT_INTERVAL = 5 + + +def default_config() -> Dict[str, Any]: + return { + DYNAMIC_LOAD_SETTINGS_KEY: { + DYNAMIC_LOAD_ENABLED_KEY: True, + DYNAMIC_LOAD_INTERVAL_KEY: DYNAMIC_LOAD_DEFAULT_INTERVAL, + }, + "是否启用": True, + "唤醒词": [".领地"], + "数据文件": "领地数据.json", + "检测间隔": 2, + "缓冲区距离": 5, + "传送半径": 5000, + "最大领地半径": 200, + "最大领地长": 200, + "最大领地高": 200, + "最大领地宽": 200, + "最大领地数量": 4, + "白名单": ["小石潭记qwq"], + } + + +def default_no_create_regions() -> List[Dict[str, Any]]: + return [ + { + "名称": "主城保护范围", + "启用": True, + "类型": "圆形", + "中心": [10017, 209, 20016], + "半径": 500, + }, + { + "名称": "示例圆形区域", + "启用": False, + "类型": "圆形", + "中心": [0, 64, 0], + "半径": 100, + }, + { + "名称": "示例方形区域", + "启用": False, + "类型": "方形", + "起点": [0, -64, 0], + "终点": [100, 320, 100], + }, + ] diff --git "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" new file mode 100644 index 00000000..bca57f40 --- /dev/null +++ "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" @@ -0,0 +1,11 @@ +{ + "author": "小石潭记qwq", + "version": "0.1.18", + "description": "领地系统云链联动版,支持创建圆形/方形领地、成员管理、自动防护、传送、配置热载入与外部插件 API 调用", + "limit_launcher": null, + "pre-plugins": { + "XUID获取": "0.0.7" + }, + "plugin-type": "classic", + "plugin-id": "领地系统云链联动版" +} diff --git "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/geometry.py" "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/geometry.py" new file mode 100644 index 00000000..4a9263da --- /dev/null +++ "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/geometry.py" @@ -0,0 +1,98 @@ +"""Geometry helpers for land overlap and distance checks.""" + +import math +from typing import Optional, Tuple + +from .models import LandData + + +Position = Tuple[float, float, float] +Bounds = Tuple[Position, Position] + + +def sphere_intersects_box( + center: Position, + radius: float, + min_pos: Position, + max_pos: Position) -> bool: + distance_sq = 0.0 + for i in range(3): + if center[i] < min_pos[i]: + distance_sq += (min_pos[i] - center[i]) ** 2 + elif center[i] > max_pos[i]: + distance_sq += (center[i] - max_pos[i]) ** 2 + return distance_sq <= radius ** 2 + + +def boxes_intersect( + min_a: Position, + max_a: Position, + min_b: Position, + max_b: Position) -> bool: + return all(min_a[i] <= max_b[i] and max_a[i] >= min_b[i] for i in range(3)) + + +def box_distance(pos: Position, min_pos: Position, max_pos: Position) -> float: + distance_sq = 0.0 + for i in range(3): + if pos[i] < min_pos[i]: + distance_sq += (min_pos[i] - pos[i]) ** 2 + elif pos[i] > max_pos[i]: + distance_sq += (pos[i] - max_pos[i]) ** 2 + return math.sqrt(distance_sq) + + +def bounds_from_center_size( + center: Position, size: Tuple[int, int, int]) -> Bounds: + length, height, width = size + cx, cy, cz = center + half = (length / 2, height / 2, width / 2) + return ( + (cx - half[0], cy - half[1], cz - half[2]), + (cx + half[0], cy + half[1], cz + half[2]), + ) + + +def distance_to_land(pos: Position, land: LandData) -> float: + if land.is_box(): + min_pos, max_pos = land.get_bounds() + return box_distance(pos, min_pos, max_pos) + x, y, z = pos + cx, cy, cz = land.center + return math.sqrt((x - cx) ** 2 + (y - cy) ** + 2 + (z - cz) ** 2) - land.radius + + +def land_overlaps_candidate( + land: LandData, + center: Position, + radius: int, + shape: str, + size: Optional[Tuple[int, int, int]] = None, +) -> bool: + shape = LandData.normalize_shape(shape) + if shape == "方形": + candidate_bounds = bounds_from_center_size( + center, LandData.normalize_size(size, radius)) + if land.is_box(): + return boxes_intersect( + candidate_bounds[0], + candidate_bounds[1], + *land.get_bounds()) + return sphere_intersects_box( + land.center, + land.radius, + candidate_bounds[0], + candidate_bounds[1]) + if land.is_box(): + min_pos, max_pos = land.get_bounds() + return sphere_intersects_box(center, radius, min_pos, max_pos) + lx, ly, lz = land.center + return ( + math.sqrt((center[0] - lx) ** 2 + (center[1] - ly) ** 2 + (center[2] - lz) ** 2) + <= radius + land.radius + ) + + +def box_radius_for_size(size: Tuple[int, int, int]) -> int: + return max(1, math.ceil(max(size) / 2)) diff --git "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/models.py" "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/models.py" new file mode 100644 index 00000000..ce1c24a9 --- /dev/null +++ "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/models.py" @@ -0,0 +1,193 @@ +"""Data models for the land protection cloud interop plugin.""" + +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, List, Optional, Tuple + + +class LandRank(Enum): + """Land membership roles.""" + + OWNER = "owner" + ADMIN = "admin" + MEMBER = "member" + + @property + def display_name(self): + return { + "owner": "§c领主", + "admin": "§6管理员", + "member": "§a成员", + }[self.value] + + +@dataclass +class LandMember: + """One member entry inside a land claim.""" + + name: str + xuid: str + rank: LandRank + join_time: float = field(default_factory=time.time) + + def to_dict(self): + return { + "name": self.name, + "xuid": self.xuid, + "rank": self.rank.value, + "join_time": self.join_time, + } + + @classmethod + def from_dict(cls, d): + return cls( + name=d["name"], + xuid=str(d["xuid"]), + rank=LandRank(d["rank"]), + join_time=d.get("join_time", time.time()), + ) + + +@dataclass +class LandData: + """Persisted land claim data.""" + + land_id: str + name: str + owner: str + owner_xuid: str + center: Tuple[float, float, float] + radius: int + shape: str = "圆形" + dimension: int = 0 + size: Optional[Tuple[int, int, int]] = None + members: List[LandMember] = field(default_factory=list) + create_time: float = field(default_factory=time.time) + + def to_dict(self): + return { + "land_id": self.land_id, + "name": self.name, + "owner": self.owner, + "owner_xuid": self.owner_xuid, + "center": list(self.center), + "radius": self.radius, + "shape": self.shape, + "size": list(self.get_size()) if self.is_box() else None, + "dimension": self.dimension, + "members": [m.to_dict() for m in self.members], + "create_time": self.create_time, + } + + @classmethod + def from_dict(cls, d): + members = [LandMember.from_dict(m) for m in d.get("members", [])] + shape = cls.normalize_shape(d.get("shape", "圆形")) + radius = d["radius"] + size = cls.normalize_size( + d.get("size"), + radius) if shape == "方形" else None + return cls( + land_id=d["land_id"], + name=d["name"], + owner=d["owner"], + owner_xuid=str(d["owner_xuid"]), + center=tuple(d["center"]), + radius=radius, + shape=shape, + dimension=d.get("dimension", 0), + size=size, + members=members, + create_time=d.get("create_time", time.time()), + ) + + @staticmethod + def normalize_shape(raw: Any) -> str: + shape = str(raw or "圆形").strip().lower() + if shape in ( + "方形", + "矩形", + "长方形", + "square", + "box", + "立方体", + "长方体", + "cuboid"): + return "方形" + return "圆形" + + @staticmethod + def normalize_size( + raw: Any, fallback_radius: int = 1) -> Tuple[int, int, int]: + if isinstance(raw, (list, tuple)) and len(raw) == 3: + try: + length = max(1, int(raw[0])) + height = max(1, int(raw[1])) + width = max(1, int(raw[2])) + return (length, height, width) + except (TypeError, ValueError): + pass + fallback = max(1, int(fallback_radius) * 2) + return (fallback, fallback, fallback) + + def is_box(self) -> bool: + return self.normalize_shape(self.shape) == "方形" + + def get_size(self) -> Tuple[int, int, int]: + return self.normalize_size(self.size, self.radius) + + def get_bounds(self) -> Tuple[Tuple[float, + float, float], Tuple[float, float, float]]: + length, height, width = self.get_size() + cx, cy, cz = self.center + half = (length / 2, height / 2, width / 2) + return ( + (cx - half[0], cy - half[1], cz - half[2]), + (cx + half[0], cy + half[1], cz + half[2]), + ) + + def contains_pos(self, pos: Tuple[float, float, float]) -> bool: + x, y, z = pos + if self.is_box(): + min_pos, max_pos = self.get_bounds() + return all(min_pos[i] <= pos[i] <= max_pos[i] for i in range(3)) + cx, cy, cz = self.center + return (x - cx) ** 2 + (y - cy) ** 2 + \ + (z - cz) ** 2 <= self.radius ** 2 + + def range_text(self) -> str: + if self.is_box(): + length, height, width = self.get_size() + return f"方形 长:{length}, 高:{height}, 宽:{width}" + return f"圆形 半径:{self.radius}" + + def get_member(self, xuid: str) -> Optional[LandMember]: + xuid = str(xuid) + for member in self.members: + if member.xuid == xuid: + return member + return None + + def has_permission(self, xuid: str, perm: str) -> bool: + member = self.get_member(xuid) + if not member: + return False + if member.rank == LandRank.OWNER: + return True + if member.rank == LandRank.ADMIN and perm in ["manage_member"]: + return True + if member.rank == LandRank.MEMBER and perm == "tp": + return True + return False + + def can_manage_member(self, manager_xuid: str, target_xuid: str) -> bool: + manager = self.get_member(manager_xuid) + target = self.get_member(target_xuid) + if not manager or not target: + return False + if manager.rank == LandRank.OWNER: + return True + if manager.rank == LandRank.ADMIN and target.rank == LandRank.MEMBER: + return True + return False From 25666ef2ca1472e9b341122d12c9798368f5ef42 Mon Sep 17 00:00:00 2001 From: ljxbx Date: Thu, 11 Jun 2026 22:39:14 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8D=AB=E7=94=9F?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__init__.py" | 2043 ++--- .../__init__.py" | 529 +- .../__pycache__/api.cpython-313.pyc" | Bin 0 -> 94653 bytes .../__pycache__/config.cpython-313.pyc" | Bin 0 -> 85070 bytes .../config_watcher.cpython-313.pyc" | Bin 0 -> 4059 bytes .../__pycache__/control.cpython-313.pyc" | Bin 0 -> 21591 bytes .../__pycache__/handlers.cpython-313.pyc" | Bin 0 -> 119266 bytes .../handlers_quick.cpython-313.pyc" | Bin 0 -> 22658 bytes .../__pycache__/logic.cpython-313.pyc" | Bin 0 -> 58387 bytes .../__pycache__/matchers.cpython-313.pyc" | Bin 0 -> 6582 bytes .../__pycache__/models.cpython-313.pyc" | Bin 0 -> 33073 bytes .../__pycache__/prompts.cpython-313.pyc" | Bin 0 -> 4213 bytes .../__pycache__/service.cpython-313.pyc" | Bin 0 -> 2870 bytes .../__pycache__/ui.cpython-313.pyc" | Bin 0 -> 11478 bytes .../__pycache__/validators.cpython-313.pyc" | Bin 0 -> 2819 bytes .../guild_cloud_interop/api.py" | 59 +- .../guild_cloud_interop/config.py" | 3400 +++---- .../guild_cloud_interop/config_watcher.py" | 5 + .../guild_cloud_interop/handlers.py" | 50 +- .../guild_cloud_interop/logic.py" | 2279 ++--- .../guild_cloud_interop/models.py" | 1489 ++-- .../guild_cloud_interop/prompts.py" | 3 + .../guild_cloud_interop/ui.py" | 460 +- .../__init__.py" | 1158 +-- .../binding_mixin.py" | 1361 +-- .../config_editor_mixin.py" | 1252 +-- .../config_mixin.py" | 3235 +++---- .../qq_mixin.py" | 7797 +++++++++-------- .../runtime_mixin.py" | 2018 ++--- .../__pycache__/__init__.cpython-313.pyc" | Bin 0 -> 1293 bytes .../__pycache__/_abnf.cpython-313.pyc" | Bin 0 -> 20595 bytes .../__pycache__/_app.cpython-313.pyc" | Bin 0 -> 31309 bytes .../__pycache__/_cookiejar.cpython-313.pyc" | Bin 0 -> 4168 bytes .../__pycache__/_core.cpython-313.pyc" | Bin 0 -> 25396 bytes .../__pycache__/_exceptions.cpython-313.pyc" | Bin 0 -> 3445 bytes .../__pycache__/_handshake.cpython-313.pyc" | Bin 0 -> 10099 bytes .../__pycache__/_http.cpython-313.pyc" | Bin 0 -> 15850 bytes .../__pycache__/_logging.cpython-313.pyc" | Bin 0 -> 4245 bytes .../__pycache__/_socket.cpython-313.pyc" | Bin 0 -> 8047 bytes .../__pycache__/_ssl_compat.cpython-313.pyc" | Bin 0 -> 2158 bytes .../__pycache__/_url.cpython-313.pyc" | Bin 0 -> 7399 bytes .../__pycache__/_utils.cpython-313.pyc" | Bin 0 -> 5980 bytes .../__pycache__/_wsdump.cpython-313.pyc" | Bin 0 -> 13091 bytes .../__init__.py" | 4824 +++++----- .../config.py" | 2 + .../geometry.py" | 203 +- .../models.py" | 400 +- 47 files changed, 16519 insertions(+), 16048 deletions(-) create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/api.cpython-313.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/config.cpython-313.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/config_watcher.cpython-313.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/control.cpython-313.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/handlers.cpython-313.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/handlers_quick.cpython-313.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/logic.cpython-313.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/matchers.cpython-313.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/models.cpython-313.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/prompts.cpython-313.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/service.cpython-313.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/ui.cpython-313.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/validators.cpython-313.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/__init__.cpython-313.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_abnf.cpython-313.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_app.cpython-313.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_cookiejar.cpython-313.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_core.cpython-313.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_exceptions.cpython-313.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_handshake.cpython-313.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_http.cpython-313.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_logging.cpython-313.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_socket.cpython-313.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_ssl_compat.cpython-313.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_url.cpython-313.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_utils.cpython-313.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_wsdump.cpython-313.pyc" diff --git "a/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index 3d762c52..c083ba7e 100644 --- "a/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -1,108 +1,108 @@ -"""Cloud-linked quest system plugin.""" - -import copy -import os -import time -import threading -from dataclasses import dataclass -from typing import Any -from tooldelta import ( - cfg as config, - utils, - fmts, - game_utils, - Plugin, - Player, - TYPE_CHECKING, - plugin_entry, -) - - -CONFIG_FILE_DIR = "插件配置文件" -DYNAMIC_LOAD_SETTINGS_KEY = "动态载入设置" -DYNAMIC_LOAD_ENABLED_KEY = "是否启用动态载入配置文件(仅用于本插件)" -DYNAMIC_LOAD_INTERVAL_KEY = "动态载入检测时间间隔(单位:秒)" -DYNAMIC_LOAD_DEFAULT_INTERVAL = 5 - - -@dataclass -class Quest: - """Runtime definition for one configured quest.""" - - tag_name: str - "标签名, 即文件夹/文件名去json" - show_name: str - "展示名" - description: str - "描述" - detect_cmds: list[str] - "检测命令" - need_items: dict[str, list] - "需要的物品" - cooldown: int - "任务冷却的秒数" - exec_cmds_when_finished: list[str] - "完成时执行的指令" - items_give_when_finished: dict - "完成时给予的物品" - start_quest_when_finished: list[str] - "完成时开始的任务" - command_block_only: bool - "只能由命令方块来完成任务" - # EXTRA - need_quests_prefix: list[str] | None - - def __hash__(self) -> int: - return id(self) - - -class TaskSystemCloudInterop(Plugin): - """ToolDelta plugin that manages cloud-linked quest workflows.""" - - name = "任务系统云链联动版" - author = "SuperScript" - version = (0, 0, 5) - - def __init__(self, frame): - super().__init__(frame) - self._config_reload_stop = threading.Event() - self._config_file_state = None - self.QUEST_PATH = os.path.join(self.data_path, "任务") - self.QUEST_DATA_PATH = os.path.join(self.data_path, "任务数据") - self.tmpjson = utils.tempjson - self.quest_data_paths: set[str] = set() - self.in_plot_running = {} - self.quests: dict[str, Quest] = {} - for ipath in [self.QUEST_PATH, self.QUEST_DATA_PATH]: - os.makedirs(ipath, exist_ok=True) - CFG_STD = { - DYNAMIC_LOAD_SETTINGS_KEY: { - DYNAMIC_LOAD_ENABLED_KEY: bool, - DYNAMIC_LOAD_INTERVAL_KEY: config.PInt, - }, - "任务设置": { - "任务列表显示格式": config.JsonList(str), - "接到新任务时执行的指令": config.JsonList(str), - "任务无法提交的显示": { - "格式": str, - }, - "任务完成执行的指令": config.JsonList(str), - "任务无法开始的显示": { - "格式": str, - }, - } - } - CFG_DEFAULT = { - DYNAMIC_LOAD_SETTINGS_KEY: { - DYNAMIC_LOAD_ENABLED_KEY: True, - DYNAMIC_LOAD_INTERVAL_KEY: DYNAMIC_LOAD_DEFAULT_INTERVAL, - }, - "任务设置": { - "任务列表显示格式": [ - "§7▶ 当前正在进行的任务:", - " §f[i] §7- §f[任务显示名]\n §7[任务描述] ", - "§7在15s内输入§f任务前的序号§r§7可以提交此任务 §f其他§7以退出", - ], +"""Cloud-linked quest system plugin.""" + +import copy +import os +import time +import threading +from dataclasses import dataclass +from typing import Any +from tooldelta import ( + cfg as config, + utils, + fmts, + game_utils, + Plugin, + Player, + TYPE_CHECKING, + plugin_entry, +) + + +CONFIG_FILE_DIR = "插件配置文件" +DYNAMIC_LOAD_SETTINGS_KEY = "动态载入设置" +DYNAMIC_LOAD_ENABLED_KEY = "是否启用动态载入配置文件(仅用于本插件)" +DYNAMIC_LOAD_INTERVAL_KEY = "动态载入检测时间间隔(单位:秒)" +DYNAMIC_LOAD_DEFAULT_INTERVAL = 5 + + +@dataclass +class Quest: + """Runtime definition for one configured quest.""" + + tag_name: str + "标签名, 即文件夹/文件名去json" + show_name: str + "展示名" + description: str + "描述" + detect_cmds: list[str] + "检测命令" + need_items: dict[str, list] + "需要的物品" + cooldown: int + "任务冷却的秒数" + exec_cmds_when_finished: list[str] + "完成时执行的指令" + items_give_when_finished: dict + "完成时给予的物品" + start_quest_when_finished: list[str] + "完成时开始的任务" + command_block_only: bool + "只能由命令方块来完成任务" + # EXTRA + need_quests_prefix: list[str] | None + + def __hash__(self) -> int: + return id(self) + + +class TaskSystemCloudInterop(Plugin): + """ToolDelta plugin that manages cloud-linked quest workflows.""" + + name = "任务系统云链联动版" + author = "SuperScript" + version = (0, 0, 5) + + def __init__(self, frame): + super().__init__(frame) + self._config_reload_stop = threading.Event() + self._config_file_state = None + self.QUEST_PATH = os.path.join(self.data_path, "任务") + self.QUEST_DATA_PATH = os.path.join(self.data_path, "任务数据") + self.tmpjson = utils.tempjson + self.quest_data_paths: set[str] = set() + self.in_plot_running = {} + self.quests: dict[str, Quest] = {} + for ipath in [self.QUEST_PATH, self.QUEST_DATA_PATH]: + os.makedirs(ipath, exist_ok=True) + CFG_STD = { + DYNAMIC_LOAD_SETTINGS_KEY: { + DYNAMIC_LOAD_ENABLED_KEY: bool, + DYNAMIC_LOAD_INTERVAL_KEY: config.PInt, + }, + "任务设置": { + "任务列表显示格式": config.JsonList(str), + "接到新任务时执行的指令": config.JsonList(str), + "任务无法提交的显示": { + "格式": str, + }, + "任务完成执行的指令": config.JsonList(str), + "任务无法开始的显示": { + "格式": str, + }, + } + } + CFG_DEFAULT = { + DYNAMIC_LOAD_SETTINGS_KEY: { + DYNAMIC_LOAD_ENABLED_KEY: True, + DYNAMIC_LOAD_INTERVAL_KEY: DYNAMIC_LOAD_DEFAULT_INTERVAL, + }, + "任务设置": { + "任务列表显示格式": [ + "§7▶ 当前正在进行的任务:", + " §f[i] §7- §f[任务显示名]\n §7[任务描述] ", + "§7在15s内输入§f任务前的序号§r§7可以提交此任务 §f其他§7以退出", + ], "接到新任务时执行的指令": [ "/execute as @a[name=[玩家名]] at @s run playsound note.pling @s ~~~ 1 1.4", ( @@ -111,896 +111,943 @@ def __init__(self, frame): ' §7[任务描述] \n§3输入§b.rw§3以提交任务"}]}' ), ], - "任务无法提交的显示": { - "格式": "§c任务无法达成, 原因:\n [原因]", - }, - "任务无法开始的显示": { - "格式": "§c任务无法开始, 原因:\n [原因]"}, - "任务完成执行的指令": [ - '/tellraw @a[name=[玩家名]] {"rawtext":[{"text":"§a任务完成"}]}', - "/execute as @a[name=[玩家名]] at @s run playsound random.levelup @s", - ], - }, - } - self._cfg_std = CFG_STD - self._cfg_default = CFG_DEFAULT - QUEST_STD = { - "显示名": str, - "描述": str, - "检测的指令": config.JsonList(str), - "需要的物品": config.AnyKeyValue(config.JsonList((str, int))), - "只能由命令方块触发完成": bool, - "任务模式(-1=一次性 0=可重复做 >0为任务冷却秒数)": int, - "任务完成": { - "执行的指令": config.JsonList(str), - "给予的物品": config.AnyKeyValue(config.JsonList((str, int), 2)), - "开启的新任务": config.JsonList(str), - }, - } - self._quest_std = QUEST_STD - self.load_runtime_config(announce=False) - self.load_quest_configs(announce=True) - self.refresh_config_file_state() - self.config_thread = utils.createThread( - self.config_reload_task, - usage="任务系统配置热更新任务", - ) - self.ListenPreload(self.on_def) - self.ListenActive(self.on_inject) - self.ListenPlayerJoin(self.on_player_join) - self.ListenFrameExit(self.on_frame_exit) - - # --- Config hot reload --- - - @classmethod - def _merge_config_with_default(cls, raw: Any, default: Any): - if isinstance(default, dict): - result = { - key: cls._merge_config_with_default( - raw.get(key) if isinstance(raw, dict) else None, - value, - ) - for key, value in default.items() - } - if isinstance(raw, dict): - for key, value in raw.items(): - if key not in result: - result[key] = copy.deepcopy(value) - return result - return copy.deepcopy( - raw) if raw is not None else copy.deepcopy(default) - - @staticmethod - def _trim_fixed_keys(raw: Any, default: dict[str, Any]) -> dict[str, Any]: - raw = raw if isinstance(raw, dict) else {} - return { - key: copy.deepcopy(raw.get(key, value)) - for key, value in default.items() - } - - @staticmethod - def _normalize_bool(value: Any, fallback: bool) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - text = value.strip().lower() - if text in ("true", "1", "yes", "y", "on", "启用", "是", "真"): - return True - if text in ("false", "0", "no", "n", "off", "禁用", "否", "假"): - return False - if isinstance(value, (int, float)) and not isinstance(value, bool): - return bool(value) - return bool(fallback) - - @staticmethod - def _normalize_positive_int(value: Any, fallback: int) -> int: - if isinstance(value, bool): - return fallback - try: - result = int(value) - except (TypeError, ValueError): - return fallback - return result if result > 0 else fallback - - @staticmethod - def _normalize_str( - value: Any, - fallback: str, - *, - allow_empty: bool = False) -> str: - if value is None: - return fallback - text = str(value) - if text or allow_empty: - return text - return fallback - - @classmethod - def _normalize_string_list( - cls, - value: Any, - fallback: list[str], - *, - allow_empty: bool = False, - ) -> list[str]: - if isinstance(value, str): - candidates = [value] - elif isinstance(value, list): - candidates = value - else: - return copy.deepcopy(fallback) - - result: list[str] = [] - for item in candidates: - text = cls._normalize_str(item, "", allow_empty=True).strip() - if text and text not in result: - result.append(text) - if result or allow_empty: - return result - return copy.deepcopy(fallback) - - @classmethod - def _normalize_format_config( - cls, - value: Any, - fallback: dict[str, str], - ) -> dict[str, str]: - value = value if isinstance(value, dict) else {} - return { - "格式": cls._normalize_str( - value.get("格式"), - fallback["格式"], - allow_empty=False, - ) - } - - @classmethod - def _normalize_runtime_config( - cls, - raw_cfg: Any, - default_cfg: dict[str, Any], - ) -> dict[str, Any]: - merged_cfg = cls._merge_config_with_default(raw_cfg, default_cfg) - normalized = cls._trim_fixed_keys(merged_cfg, default_cfg) - - dynamic_default = default_cfg[DYNAMIC_LOAD_SETTINGS_KEY] - dynamic = cls._trim_fixed_keys( - normalized.get(DYNAMIC_LOAD_SETTINGS_KEY), - dynamic_default, - ) - dynamic[DYNAMIC_LOAD_ENABLED_KEY] = cls._normalize_bool( - dynamic.get(DYNAMIC_LOAD_ENABLED_KEY), - dynamic_default[DYNAMIC_LOAD_ENABLED_KEY], - ) - dynamic[DYNAMIC_LOAD_INTERVAL_KEY] = cls._normalize_positive_int( - dynamic.get(DYNAMIC_LOAD_INTERVAL_KEY), - dynamic_default[DYNAMIC_LOAD_INTERVAL_KEY], - ) - normalized[DYNAMIC_LOAD_SETTINGS_KEY] = dynamic - - task_default = default_cfg["任务设置"] - task_settings = cls._trim_fixed_keys( - normalized.get("任务设置"), - task_default, - ) - list_format = cls._normalize_string_list( - task_settings.get("任务列表显示格式"), - task_default["任务列表显示格式"], - ) - task_settings["任务列表显示格式"] = ( - list_format - if len(list_format) >= 3 - else copy.deepcopy(task_default["任务列表显示格式"]) - ) - task_settings["接到新任务时执行的指令"] = cls._normalize_string_list( - task_settings.get("接到新任务时执行的指令"), - task_default["接到新任务时执行的指令"], - allow_empty=True, - ) - task_settings["任务完成执行的指令"] = cls._normalize_string_list( - task_settings.get("任务完成执行的指令"), - task_default["任务完成执行的指令"], - allow_empty=True, - ) - task_settings["任务无法提交的显示"] = cls._normalize_format_config( - task_settings.get("任务无法提交的显示"), - task_default["任务无法提交的显示"], - ) - task_settings["任务无法开始的显示"] = cls._normalize_format_config( - task_settings.get("任务无法开始的显示"), - task_default["任务无法开始的显示"], - ) - normalized["任务设置"] = task_settings - return normalized - - def load_runtime_config(self, announce: bool = False): - try: - raw_cfg, _ = config.get_plugin_config_and_version( - self.name, {}, self._cfg_default, self.version - ) - merged_cfg = self._normalize_runtime_config( - raw_cfg, self._cfg_default) - config.check_auto(self._cfg_std, merged_cfg) - except Exception as err: - fmts.print_err(f"{self.name} 主配置文件自动更新失败,已使用默认配置: {err}") - merged_cfg = self._normalize_runtime_config({}, self._cfg_default) - config.check_auto(self._cfg_std, merged_cfg) - config.upgrade_plugin_config(self.name, merged_cfg, self.version) - self.cfg = merged_cfg - if announce: - fmts.print_suc(f"{self.name} 主配置文件已热更新") - - def load_quest_configs(self, announce: bool = False): - quests: dict[str, Quest] = {} - total_quest_files = 0 - for cfg_quest_dir in os.listdir(self.QUEST_PATH): - file = "" - try: - sub_path = os.path.join(self.QUEST_PATH, cfg_quest_dir) - if not os.path.isdir(sub_path): - continue - for file in os.listdir(sub_path): - if not file.endswith(".json"): - continue - cfg = config.get_cfg( - os.path.join( - sub_path, - file), - self._quest_std) - tag_name = f"{cfg_quest_dir}/{file[:-5]}" - quests[tag_name] = Quest( - tag_name, - cfg["显示名"], - cfg["描述"], - cfg["检测的指令"], - cfg["需要的物品"], - cfg["任务模式(-1=一次性 0=可重复做 >0为任务冷却秒数)"], - cfg["任务完成"]["执行的指令"], - cfg["任务完成"]["给予的物品"], - cfg["任务完成"]["开启的新任务"], - cfg["只能由命令方块触发完成"], - cfg.get("需要完成的前置任务"), - ) - total_quest_files += 1 - except config.ConfigError as err: - fmts.print_err(f"任务系统云链联动版: 任务配置文件 {file} 出错: ") - fmts.print_err(err.args[0]) - old_quests = self.quests - self.quests = quests - for quest in self.quests.values(): - for i in quest.start_quest_when_finished: - try: - if (quest := self.get_quest(i)) is None: - file = i - raise config.ConfigError(f"任务 {i} 不存在") - if quest.need_quests_prefix: - for i in quest.need_quests_prefix: - if self.get_quest(i) is None: - file = i - raise config.ConfigError(f"要求的前置任务 {i} 不存在") - except config.ConfigError as err: - fmts.print_err(f"任务系统云链联动版: 任务配置文件 {file} 出错: ") - fmts.print_err(err.args[0]) - if not self.quests and old_quests: - self.quests = old_quests - fmts.print_err(f"{self.name}: 任务配置热更新未加载到有效任务,已保留旧任务表") - return - if announce: - fmts.print_with_info( - f"§a共加载 §b{total_quest_files}§a 个任务文件.", "§b Task §r" - ) - - def config_file_path(self) -> str: - return os.path.join(CONFIG_FILE_DIR, f"{self.name}.json") - - @staticmethod - def file_state(path: str) -> tuple[int, int] | None: - try: - stat = os.stat(path) - except OSError: - return None - return stat.st_mtime_ns, stat.st_size - - def quest_config_state(self) -> tuple[tuple[str, tuple[int, int]], ...]: - states: list[tuple[str, tuple[int, int]]] = [] - for root, _dirs, files in os.walk(self.QUEST_PATH): - for filename in files: - if not filename.endswith(".json"): - continue - path = os.path.join(root, filename) - state = self.file_state(path) - if state is not None: - states.append( - (os.path.relpath( - path, self.QUEST_PATH), state)) - return tuple(sorted(states)) - - def refresh_config_file_state(self): - self._config_file_state = self.file_state(self.config_file_path()) - self._quest_config_state = self.quest_config_state() - - def is_dynamic_config_reload_enabled(self) -> bool: - settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) - if not isinstance(settings, dict): - return True - return bool(settings.get(DYNAMIC_LOAD_ENABLED_KEY, True)) - - def dynamic_config_reload_interval(self) -> int: - settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) - if not isinstance(settings, dict): - return DYNAMIC_LOAD_DEFAULT_INTERVAL - try: - interval = int(settings.get(DYNAMIC_LOAD_INTERVAL_KEY, - DYNAMIC_LOAD_DEFAULT_INTERVAL)) - except (TypeError, ValueError): - return DYNAMIC_LOAD_DEFAULT_INTERVAL - return interval if interval > 0 else DYNAMIC_LOAD_DEFAULT_INTERVAL - - def config_reload_task(self): - while not self._config_reload_stop.wait( - self.dynamic_config_reload_interval()): - if not self.is_dynamic_config_reload_enabled(): - self.refresh_config_file_state() - continue - current_cfg_state = self.file_state(self.config_file_path()) - current_quest_state = self.quest_config_state() - if ( - current_cfg_state == self._config_file_state - and current_quest_state == self._quest_config_state - ): - continue - try: - if current_cfg_state != self._config_file_state: - self.load_runtime_config(announce=True) - if current_quest_state != self._quest_config_state: - self.load_quest_configs(announce=True) - self.refresh_config_file_state() - except Exception as err: - self._config_file_state = current_cfg_state - self._quest_config_state = current_quest_state - fmts.print_err(f"{self.name} 配置文件热更新失败: {err}") - - def api_reload_task_config(self) -> tuple[bool, str]: - try: - self.load_runtime_config(announce=False) - self.load_quest_configs(announce=False) - self.refresh_config_file_state() - except Exception as err: - return False, f"任务系统配置重载失败: {err}" - return True, f"任务系统配置已重载,当前加载 {len(self.quests)} 个任务" - - # --- API --- - - def get_quest(self, tag_name: str) -> Quest | None: - """ - 根据任务名获取任务对象 - - Args: - tag_name (str): 任务名 - - Returns: - Quest | None: 任务对象 (或找不到任务) - """ - return self.quests.get(tag_name) - - def get_online_player(self, player_name: str) -> Player | None: - return self.frame.get_players().getPlayerByName(player_name) - - @staticmethod - def get_quest_label(quest: Quest) -> str: - if quest.show_name == quest.tag_name: - return quest.tag_name - return f"{quest.show_name} ({quest.tag_name})" - - def find_quest(self, quest_query: str) -> tuple[Quest | None, str]: - quest_query = quest_query.strip() - if not quest_query: - return None, "任务标识不能为空" - if quest := self.get_quest(quest_query): - return quest, "" - - exact_matches = [ - i for i in self.quests.values() if i.show_name == quest_query] - if len(exact_matches) == 1: - return exact_matches[0], "" - if len(exact_matches) > 1: - labels = "、".join(self.get_quest_label(i) - for i in exact_matches[:5]) - return None, f"匹配到多个同名任务,请改用任务标签名:{labels}" - - query_lower = quest_query.casefold() - fuzzy_matches = [ - i - for i in self.quests.values() - if query_lower in i.tag_name.casefold() - or query_lower in i.show_name.casefold() - ] - if len(fuzzy_matches) == 1: - return fuzzy_matches[0], "" - if len(fuzzy_matches) > 1: - labels = "、".join(self.get_quest_label(i) - for i in fuzzy_matches[:5]) - if len(fuzzy_matches) > 5: - labels += "……" - return None, f"匹配到多个任务:{labels}" - return None, f"任务不存在:{quest_query}" - - def can_add_quest(self, player: Player, quest: Quest) -> tuple[bool, str]: - quests = self.read_quests(player) - if quest in quests: - return False, "当前任务正在进行中,无法重复领取" - quest_time = self.read_quests_finished(player).get(quest, None) - quest_mode = quest.cooldown - if quest_mode == -1 and quest_time is not None: - return False, "你已经完成该任务" - if ( - quest_time is not None - and quest_mode > 0 - and time.time() - quest_time < quest.cooldown - ): - fmt_text = r"%d 天 %H 时 %M 分" - left_time = self.sec_to_timer( - quest.cooldown - int(time.time()) + quest_time, fmt_text - ) - return False, f"该任务仍在冷却中,还需等待:{left_time}" - return True, "" - - def get_online_player_task_progress( - self, player_name: str - ) -> tuple[bool, dict | str]: - player = self.get_online_player(player_name) - if player is None: - return False, f"玩家不在线或不存在:{player_name}" - in_progress = [] - for quest in self.read_quests(player): - if quest is None: - continue - in_progress.append( - { - "tag_name": quest.tag_name, - "show_name": quest.show_name, - "description": quest.description, - } - ) - completed = [] - for quest, finished_time in sorted( - self.read_quests_finished(player).items(), - key=lambda item: item[1], - reverse=True, - ): - completed.append( - { - "tag_name": quest.tag_name, - "show_name": quest.show_name, - "description": quest.description, - "finished_time": finished_time, - } - ) - return True, { - "player_name": player.name, - "in_progress": in_progress, - "completed": completed, - } - - def add_quest_to_online_player( - self, player_name: str, quest_query: str - ) -> tuple[bool, str]: - player = self.get_online_player(player_name) - if player is None: - return False, f"玩家不在线或不存在:{player_name}" - quest, err = self.find_quest(quest_query) - if quest is None: - return False, err - ok, reason = self.can_add_quest(player, quest) - if not ok: - return False, reason - if not self.add_quest(player, quest): - return False, f"下发任务失败:{self.get_quest_label(quest)}" - return True, f"已向玩家 {player.name} 下发任务:{self.get_quest_label(quest)}" - - def finish_quest_for_online_player( - self, player_name: str, quest_query: str - ) -> tuple[bool, str]: - player = self.get_online_player(player_name) - if player is None: - return False, f"玩家不在线或不存在:{player_name}" - quest, err = self.find_quest(quest_query) - if quest is None: - return False, err - if not self.is_quest_in_progress(player, quest): - return False, f"该任务未解锁或已经完成:{self.get_quest_label(quest)}" - if not self.finish_quest(player, quest): - return False, f"完成任务失败:{self.get_quest_label(quest)}" - return True, f"已为玩家 {player.name} 完成任务:{self.get_quest_label(quest)}" - - def list_available_quests(self) -> list[dict[str, str]]: - quests = sorted(self.quests.values(), key=lambda item: item.tag_name) - return [ - { - "tag_name": quest.tag_name, - "show_name": quest.show_name, - "description": quest.description, - } - for quest in quests - ] - - def add_quest(self, player: Player, quest: Quest) -> bool: - """ - 向玩家下发任务 - - Args: - player (Player): 玩家对象 - quest (Quest): 任务对象 - - Returns: - bool: 是否下发成功 - """ - ok, reason = self.can_add_quest(player, quest) - if not ok: - player.show( - utils.simple_fmt( - {"[玩家名]": player.name, "[原因]": f"§c{reason}"}, - self.cfg["任务设置"]["任务无法开始的显示"]["格式"], - ), - ) - return False - for cmd in self.cfg["任务设置"]["接到新任务时执行的指令"]: - s_cmd = utils.simple_fmt( - { - "[任务显示名]": quest.show_name, - "[任务描述]": quest.description, - "[玩家名]": player.name, - }, - cmd, - ) - self.game_ctrl.sendwocmd(s_cmd) - o = self.read_player_quest_data(player) - o["in_quests"].append(quest.tag_name) - self.write_player_quest_data(player, o) - return True - - def is_quest_in_progress(self, player: Player, quest: Quest) -> bool: - o = self.read_player_quest_data(player) - return quest.tag_name in o["in_quests"] - - def detect_quest( - self, player: Player, quest: Quest, allow_command_block: bool = False - ) -> tuple[bool, str]: - """ - 检测任务是否可以提交 - - Args: - player (Player): 玩家对象 - quest (Quest): 任务对象 - - Returns: - tuple[bool, str]: 是否可提交; 无法提交的信息 - """ - o = self.read_player_quest_data(player) - if quest.tag_name not in o["in_quests"]: - if quest.tag_name in o["quests_ok"]: - return False, "§6该任务已经完成" - return False, "§6该任务未解锁或已经完成" - if quest.cooldown == -1 and quest.tag_name in o["quests_ok"]: - return False, "§6该任务已经完成" - if quest.command_block_only and not allow_command_block: - return False, "§6无法手动提交该任务" - if quest.need_quests_prefix: - err_strs = [] - player_finished_quests = self.read_quests_finished(player) - for quest_name in quest.need_quests_prefix: - need_quest = self.get_quest(quest_name) - if need_quest is None: - err_strs.append(f"{quest_name} (任务配置不存在)") - continue - if need_quest not in player_finished_quests: - err_strs.append(need_quest.show_name) - if err_strs: - return False, "需要完成任务:\n " + "\n ".join(err_strs) - if quest.need_items: - err_strs = [] - for item_name, (item_id, *ext_data) in quest.need_items.items(): - if len(ext_data) == 2: - count, data = ext_data - else: - count = ext_data[0] - data = 0 - if (item_count_now := player.getItemCount(item_id, data)) < count: - err_strs.append( - f"§f{item_name} §7(§c{item_count_now}§7/§f{count}§7)" - ) - if err_strs: - return False, "缺少物品: \n " + "\n ".join(err_strs) - if quest.detect_cmds: - for cmd in quest.detect_cmds: - if not game_utils.isCmdSuccess( - utils.simple_fmt({"[玩家名]": player.name}, cmd) - ): - return False, "§6未达成条件" - return True, "" - - def finish_quest(self, player: Player, quest: Quest) -> bool: - """ - 令玩家完成任务 (强制性, 无论条件是否满足) - - Args: - player (Player): 玩家对象 - quest (Quest): 任务对象 - """ - o = self.read_player_quest_data(player) - if quest.tag_name not in o["in_quests"]: - if quest.tag_name in o["quests_ok"]: - self.show_fail(player, "该任务已经完成") - return False - self.show_fail(player, "该任务未解锁或已经完成") - return False - if quest.cooldown == -1 and quest.tag_name in o["quests_ok"]: - self.show_fail(player, "该任务已经完成") - return False - o["quests_ok"][quest.tag_name] = int(time.time()) - o["in_quests"] = [tag_name for tag_name in o["in_quests"] - if tag_name != quest.tag_name] - self.write_player_quest_data(player, o) - self.game_ctrl.sendwocmd( - f"/execute as @a[name={player.name}] at @s run playsound random.levelup @s" - ) - player.show("§a۞ §l任务完成 §r§e奖励已下发~") - for cmd in quest.exec_cmds_when_finished: - self.game_ctrl.sendwocmd( - utils.simple_fmt({"[玩家名]": player.name}, cmd)) - for item_name, (item_id, - count) in quest.items_give_when_finished.items(): - self.game_ctrl.sendwocmd( - f"give @a[name={player.name}] {item_id} {count}") - player.show(f" §7 + {count}x§f{item_name}") - self.show_succ(player, "任务已提交, 请退出聊天栏") - for new_quest_name in quest.start_quest_when_finished: - new_quest = self.get_quest(new_quest_name) - if new_quest is None: - fmts.print_err( - f"{self.name}: 完成任务后开始的任务不存在: {new_quest_name}" - ) - continue - self.add_quest(player, new_quest) - return True - - # ------------- - - def on_def(self): - self.interper = self.GetPluginAPI("ZBasic", (0, 0, 1), False) - self.chatbar = self.GetPluginAPI("聊天栏菜单") - self.cb2bot = self.GetPluginAPI("Cb2Bot通信") - if TYPE_CHECKING: - from ZBasic_Lang_中文编程 import ToolDelta_ZBasic - from 前置_聊天栏菜单 import ChatbarMenu - from 前置_Cb2Bot通信 import TellrawCb2Bot - - self.interper: ToolDelta_ZBasic - self.chatbar: ChatbarMenu - self.cb2bot: TellrawCb2Bot - self.cb2bot.regist_message_cb("quest.ok", self.on_quest_ok) - self.cb2bot.regist_message_cb("quest.start", self.on_quest_start) - - def show_succ(self, player: Player, msg): - player.show(f"§7<§a§o√§r§7> §a{msg}") - - def show_warn(self, player: Player, msg): - player.show(f"§7<§6§o!§r§7> §6{msg}") - - def show_fail(self, player: Player, msg): - player.show(f"§7<§c§o!§r§7> §c{msg}") - - def show_inf(self, player: Player, msg): - player.show(f"§7<§f§o!§r§7> §f{msg}") - - @utils.thread_func("任务的游戏初始化") - def on_inject(self): - self.cmp_scripts = {} - self.chatbar.add_new_trigger( - [".rw", ".任务"], - [], - "查看正在进行的任务列表", - lambda player, _: self.list_player_quests(player), - ) - self.chatbar.add_new_trigger( - [".addrw", ".添加任务"], - [("任务标签名", str, None)], - "向玩家添加任务", - self.force_add_quest_menu, - op_only=True, - ) - for player in self.frame.get_players().getAllPlayers(): - self.init_player(player) - - @utils.thread_func("初始化玩家剧情任务数据") - def on_player_join(self, player: Player): - self.init_player(player) - - def on_quest_ok(self, args: list[str]): - target_name, quest_name = args - quest = self.get_quest(quest_name) - target = self.frame.get_players().getPlayerByName(target_name) - if target is None: - self.print(f"§6on_quest_ok: 玩家 {target_name} 不存在") - return - if quest is not None: - ok, reason = self.detect_quest( - target, quest, allow_command_block=True) - if not ok: - self.show_fail(target, reason) - return - self.finish_quest(target, quest) - - def on_quest_start(self, args: list[str]): - target_name, quest_name = args - quest = self.get_quest(quest_name) - target = self.frame.get_players().getPlayerByName(target_name) - if target is None: - self.print(f"§6on_quest_ok: 玩家 {target_name} 不存在") - return - if quest is not None: - self.add_quest(target, quest) - - def init_player(self, player: Player): - quest_path = self.get_player_quest_data_path(player) - if not os.path.isfile(quest_path): - self.write_player_quest_data(player, self.init_quest_file()) - else: - self.read_player_quest_data(player) - - def init_quest_file(self): - return {"in_quests": [], "quests_ok": {}} - - def get_player_quest_data_path(self, player: Player) -> str: - path = os.path.join(self.QUEST_DATA_PATH, player.xuid + ".json") - self.quest_data_paths.add(path) - return path - - def read_player_quest_data(self, player: Player) -> dict: - data = self.tmpjson.load_and_read( - self.get_player_quest_data_path(player), - need_file_exists=False, - default=self.init_quest_file(), - ) - if not isinstance(data, dict): - return self.init_quest_file() - if not isinstance(data.get("in_quests"), list): - data["in_quests"] = [] - if not isinstance(data.get("quests_ok"), dict): - data["quests_ok"] = {} - return data - - def write_player_quest_data(self, player: Player, data: dict): - path = self.get_player_quest_data_path(player) - self.tmpjson.load_and_write(path, data, need_file_exists=False) - self.tmpjson.flush(path) - - def on_frame_exit(self, _): - self._config_reload_stop.set() - for path in tuple(self.quest_data_paths): - try: - self.tmpjson.unload(path) - except Exception: - pass - - def read_quests(self, player: Player) -> list[Quest]: - o = self.read_player_quest_data(player) - output = [] - o = o or {"in_quests": []} - for i in o["in_quests"]: - output.append(self.get_quest(i)) - return output - - def read_quests_finished(self, player: Player) -> dict[Quest, int]: - o = self.read_player_quest_data(player) - output = {} - for k, v in o["quests_ok"].items(): - quest = self.get_quest(k) - if quest: - output[quest] = v - return output - - @utils.thread_func("管理员向玩家添加任务") - def force_add_quest_menu(self, player: Player, args: tuple): - # with utils.ChatbarLock(player, lambda _: - # print(utils.chatbar_lock_list)): - (quest_tagname,) = args - if (quest := self.get_quest(quest_tagname)) is None: - player.show("§c任务标签名不存在") - return - onlines = self.frame.get_players().getAllPlayers() - self.show_inf(player, "§6选择一个玩家以向他添加任务:") - for i, j in enumerate(onlines): - player.show(f" §a{i + 1}§7 - §f{j.name}") - resp = utils.try_int(player.input()) - self.show_inf(player, "§7输入玩家名前的§6序号§7:") - if resp is None: - player.show("§c序号错误, 已退出") - return - if resp not in range(1, len(onlines) + 1): - player.show("§c序号不在范围内, 已退出") - return - getting = onlines[resp - 1] - player.show( - f"§6向玩家{getting}添加任务" - + ["§c失败", "§a成功"][self.add_quest(getting, quest)], - ) - - @utils.thread_func("列出任务列表") - def list_player_quests(self, player: Player): - # with utils.ChatbarLock(player): - player_quests = self.read_quests(player) - if not player_quests: - self.show_fail(player, "你没有正在进行的任务") - return - else: - player.show(self.cfg["任务设置"]["任务列表显示格式"][0]) - for i, quest_data in enumerate(player_quests): - if quest_data is None: - player.show( - utils.simple_fmt( - { - "[任务显示名]": "§c<任务失效>§f", - "[任务描述]": "--", - "[i]": i + 1, - }, - self.cfg["任务设置"]["任务列表显示格式"][1], - ), - ) - else: - player.show( - utils.simple_fmt( - { - "[任务显示名]": quest_data.show_name, - "[任务描述]": quest_data.description, - "[i]": i + 1, - }, - self.cfg["任务设置"]["任务列表显示格式"][1], - ), - ) - resp = player.input( - utils.simple_fmt( - {"[任务数量]": len(player_quests)}, - self.cfg["任务设置"]["任务列表显示格式"][2], - ) - ) - if resp is None: - return - resp = utils.try_int(resp.strip("[]")) - if resp is None: - player.show("§c序号不合法") - return - if resp not in range(1, len(player_quests) + 1): - self.show_fail(player, "序号超出范围") - return - getting_quest = player_quests[resp - 1] - if getting_quest is None: - self.show_fail(player, "无法完成失效的任务") - return - ok, reason = self.detect_quest(player, getting_quest) - if not ok: - player.show( - utils.simple_fmt( - {"[玩家名]": player.name, "[原因]": reason}, - self.cfg["任务设置"]["任务无法提交的显示"]["格式"], - ), - ) - return - else: - self.finish_quest(player, getting_quest) - - def sec_to_timer(self, timesec: int, fmt: str): - days, left = divmod(timesec, 86400) - hrs, left = divmod(left, 3600) - mins, secs = divmod(left, 60) - if secs > 0 and mins == 0: - mins = 1 - return utils.simple_fmt( - {"%d": days, "%H": hrs, "%M": mins, "%S": secs}, fmt) - - -entry = plugin_entry( - TaskSystemCloudInterop, - "任务系统云链联动版", - (0, 0, 1), -) + "任务无法提交的显示": { + "格式": "§c任务无法达成, 原因:\n [原因]", + }, + "任务无法开始的显示": { + "格式": "§c任务无法开始, 原因:\n [原因]"}, + "任务完成执行的指令": [ + '/tellraw @a[name=[玩家名]] {"rawtext":[{"text":"§a任务完成"}]}', + "/execute as @a[name=[玩家名]] at @s run playsound random.levelup @s", + ], + }, + } + self._cfg_std = CFG_STD + self._cfg_default = CFG_DEFAULT + QUEST_STD = { + "显示名": str, + "描述": str, + "检测的指令": config.JsonList(str), + "需要的物品": config.AnyKeyValue(config.JsonList((str, int))), + "只能由命令方块触发完成": bool, + "任务模式(-1=一次性 0=可重复做 >0为任务冷却秒数)": int, + "任务完成": { + "执行的指令": config.JsonList(str), + "给予的物品": config.AnyKeyValue(config.JsonList((str, int), 2)), + "开启的新任务": config.JsonList(str), + }, + } + self._quest_std = QUEST_STD + self.load_runtime_config(announce=False) + self.load_quest_configs(announce=True) + self.refresh_config_file_state() + self.config_thread = utils.createThread( + self.config_reload_task, + usage="任务系统配置热更新任务", + ) + self.ListenPreload(self.on_def) + self.ListenActive(self.on_inject) + self.ListenPlayerJoin(self.on_player_join) + self.ListenFrameExit(self.on_frame_exit) + + # --- Config hot reload --- + + @classmethod + def _merge_config_with_default(cls, raw: Any, default: Any): + """Implement the merge config with default operation.""" + if isinstance(default, dict): + result = { + key: cls._merge_config_with_default( + raw.get(key) if isinstance(raw, dict) else None, + value, + ) + for key, value in default.items() + } + if isinstance(raw, dict): + for key, value in raw.items(): + if key not in result: + result[key] = copy.deepcopy(value) + return result + return copy.deepcopy( + raw) if raw is not None else copy.deepcopy(default) + + @staticmethod + def _trim_fixed_keys(raw: Any, default: dict[str, Any]) -> dict[str, Any]: + """Implement the trim fixed keys operation.""" + raw = raw if isinstance(raw, dict) else {} + return { + key: copy.deepcopy(raw.get(key, value)) + for key, value in default.items() + } + + @staticmethod + def _normalize_bool(value: Any, fallback: bool) -> bool: + """Normalize bool values.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in ("true", "1", "yes", "y", "on", "启用", "是", "真"): + return True + if text in ("false", "0", "no", "n", "off", "禁用", "否", "假"): + return False + if isinstance(value, (int, float)) and not isinstance(value, bool): + return bool(value) + return bool(fallback) + + @staticmethod + def _normalize_positive_int(value: Any, fallback: int) -> int: + """Normalize positive int values.""" + if isinstance(value, bool): + return fallback + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + @staticmethod + def _normalize_str( + value: Any, + fallback: str, + *, + allow_empty: bool = False) -> str: + """Normalize str values.""" + if value is None: + return fallback + text = str(value) + if text or allow_empty: + return text + return fallback + + @classmethod + def _normalize_string_list( + cls, + value: Any, + fallback: list[str], + *, + allow_empty: bool = False, + ) -> list[str]: + """Normalize string list values.""" + if isinstance(value, str): + candidates = [value] + elif isinstance(value, list): + candidates = value + else: + return copy.deepcopy(fallback) + + result: list[str] = [] + for item in candidates: + text = cls._normalize_str(item, "", allow_empty=True).strip() + if text and text not in result: + result.append(text) + if result or allow_empty: + return result + return copy.deepcopy(fallback) + + @classmethod + def _normalize_format_config( + cls, + value: Any, + fallback: dict[str, str], + ) -> dict[str, str]: + """Normalize format config values.""" + value = value if isinstance(value, dict) else {} + return { + "格式": cls._normalize_str( + value.get("格式"), + fallback["格式"], + allow_empty=False, + ) + } + + @classmethod + def _normalize_runtime_config( + cls, + raw_cfg: Any, + default_cfg: dict[str, Any], + ) -> dict[str, Any]: + """Normalize runtime config values.""" + merged_cfg = cls._merge_config_with_default(raw_cfg, default_cfg) + normalized = cls._trim_fixed_keys(merged_cfg, default_cfg) + + dynamic_default = default_cfg[DYNAMIC_LOAD_SETTINGS_KEY] + dynamic = cls._trim_fixed_keys( + normalized.get(DYNAMIC_LOAD_SETTINGS_KEY), + dynamic_default, + ) + dynamic[DYNAMIC_LOAD_ENABLED_KEY] = cls._normalize_bool( + dynamic.get(DYNAMIC_LOAD_ENABLED_KEY), + dynamic_default[DYNAMIC_LOAD_ENABLED_KEY], + ) + dynamic[DYNAMIC_LOAD_INTERVAL_KEY] = cls._normalize_positive_int( + dynamic.get(DYNAMIC_LOAD_INTERVAL_KEY), + dynamic_default[DYNAMIC_LOAD_INTERVAL_KEY], + ) + normalized[DYNAMIC_LOAD_SETTINGS_KEY] = dynamic + + task_default = default_cfg["任务设置"] + task_settings = cls._trim_fixed_keys( + normalized.get("任务设置"), + task_default, + ) + list_format = cls._normalize_string_list( + task_settings.get("任务列表显示格式"), + task_default["任务列表显示格式"], + ) + task_settings["任务列表显示格式"] = ( + list_format + if len(list_format) >= 3 + else copy.deepcopy(task_default["任务列表显示格式"]) + ) + task_settings["接到新任务时执行的指令"] = cls._normalize_string_list( + task_settings.get("接到新任务时执行的指令"), + task_default["接到新任务时执行的指令"], + allow_empty=True, + ) + task_settings["任务完成执行的指令"] = cls._normalize_string_list( + task_settings.get("任务完成执行的指令"), + task_default["任务完成执行的指令"], + allow_empty=True, + ) + task_settings["任务无法提交的显示"] = cls._normalize_format_config( + task_settings.get("任务无法提交的显示"), + task_default["任务无法提交的显示"], + ) + task_settings["任务无法开始的显示"] = cls._normalize_format_config( + task_settings.get("任务无法开始的显示"), + task_default["任务无法开始的显示"], + ) + normalized["任务设置"] = task_settings + return normalized + + def load_runtime_config(self, announce: bool = False): + """Load runtime config data.""" + try: + raw_cfg, _ = config.get_plugin_config_and_version( + self.name, {}, self._cfg_default, self.version + ) + merged_cfg = self._normalize_runtime_config( + raw_cfg, self._cfg_default) + config.check_auto(self._cfg_std, merged_cfg) + except Exception as err: + fmts.print_err(f"{self.name} 主配置文件自动更新失败,已使用默认配置: {err}") + merged_cfg = self._normalize_runtime_config({}, self._cfg_default) + config.check_auto(self._cfg_std, merged_cfg) + config.upgrade_plugin_config(self.name, merged_cfg, self.version) + self.cfg = merged_cfg + if announce: + fmts.print_suc(f"{self.name} 主配置文件已热更新") + + def load_quest_configs(self, announce: bool = False): + """Load quest configs data.""" + quests: dict[str, Quest] = {} + total_quest_files = 0 + for cfg_quest_dir in os.listdir(self.QUEST_PATH): + file = "" + try: + sub_path = os.path.join(self.QUEST_PATH, cfg_quest_dir) + if not os.path.isdir(sub_path): + continue + for file in os.listdir(sub_path): + if not file.endswith(".json"): + continue + cfg = config.get_cfg( + os.path.join( + sub_path, + file), + self._quest_std) + tag_name = f"{cfg_quest_dir}/{file[:-5]}" + quests[tag_name] = Quest( + tag_name, + cfg["显示名"], + cfg["描述"], + cfg["检测的指令"], + cfg["需要的物品"], + cfg["任务模式(-1=一次性 0=可重复做 >0为任务冷却秒数)"], + cfg["任务完成"]["执行的指令"], + cfg["任务完成"]["给予的物品"], + cfg["任务完成"]["开启的新任务"], + cfg["只能由命令方块触发完成"], + cfg.get("需要完成的前置任务"), + ) + total_quest_files += 1 + except config.ConfigError as err: + fmts.print_err(f"任务系统云链联动版: 任务配置文件 {file} 出错: ") + fmts.print_err(err.args[0]) + old_quests = self.quests + self.quests = quests + for quest in self.quests.values(): + for i in quest.start_quest_when_finished: + try: + if (quest := self.get_quest(i)) is None: + file = i + raise config.ConfigError(f"任务 {i} 不存在") + if quest.need_quests_prefix: + for i in quest.need_quests_prefix: + if self.get_quest(i) is None: + file = i + raise config.ConfigError(f"要求的前置任务 {i} 不存在") + except config.ConfigError as err: + fmts.print_err(f"任务系统云链联动版: 任务配置文件 {file} 出错: ") + fmts.print_err(err.args[0]) + if not self.quests and old_quests: + self.quests = old_quests + fmts.print_err(f"{self.name}: 任务配置热更新未加载到有效任务,已保留旧任务表") + return + if announce: + fmts.print_with_info( + f"§a共加载 §b{total_quest_files}§a 个任务文件.", "§b Task §r" + ) + + def config_file_path(self) -> str: + """Implement the config file path operation.""" + return os.path.join(CONFIG_FILE_DIR, f"{self.name}.json") + + @staticmethod + def file_state(path: str) -> tuple[int, int] | None: + """Implement the file state operation.""" + try: + stat = os.stat(path) + except OSError: + return None + return stat.st_mtime_ns, stat.st_size + + def quest_config_state(self) -> tuple[tuple[str, tuple[int, int]], ...]: + """Implement the quest config state operation.""" + states: list[tuple[str, tuple[int, int]]] = [] + for root, _dirs, files in os.walk(self.QUEST_PATH): + for filename in files: + if not filename.endswith(".json"): + continue + path = os.path.join(root, filename) + state = self.file_state(path) + if state is not None: + states.append( + (os.path.relpath( + path, self.QUEST_PATH), state)) + return tuple(sorted(states)) + + def refresh_config_file_state(self): + """Implement the refresh config file state operation.""" + self._config_file_state = self.file_state(self.config_file_path()) + self._quest_config_state = self.quest_config_state() + + def is_dynamic_config_reload_enabled(self) -> bool: + """Implement the is dynamic config reload enabled operation.""" + settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return True + return bool(settings.get(DYNAMIC_LOAD_ENABLED_KEY, True)) + + def dynamic_config_reload_interval(self) -> int: + """Implement the dynamic config reload interval operation.""" + settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return DYNAMIC_LOAD_DEFAULT_INTERVAL + try: + interval = int(settings.get(DYNAMIC_LOAD_INTERVAL_KEY, + DYNAMIC_LOAD_DEFAULT_INTERVAL)) + except (TypeError, ValueError): + return DYNAMIC_LOAD_DEFAULT_INTERVAL + return interval if interval > 0 else DYNAMIC_LOAD_DEFAULT_INTERVAL + + def config_reload_task(self): + """Implement the config reload task operation.""" + while not self._config_reload_stop.wait( + self.dynamic_config_reload_interval()): + if not self.is_dynamic_config_reload_enabled(): + self.refresh_config_file_state() + continue + current_cfg_state = self.file_state(self.config_file_path()) + current_quest_state = self.quest_config_state() + if ( + current_cfg_state == self._config_file_state + and current_quest_state == self._quest_config_state + ): + continue + try: + if current_cfg_state != self._config_file_state: + self.load_runtime_config(announce=True) + if current_quest_state != self._quest_config_state: + self.load_quest_configs(announce=True) + self.refresh_config_file_state() + except Exception as err: + self._config_file_state = current_cfg_state + self._quest_config_state = current_quest_state + fmts.print_err(f"{self.name} 配置文件热更新失败: {err}") + + def api_reload_task_config(self) -> tuple[bool, str]: + """Expose the api reload task config API operation.""" + try: + self.load_runtime_config(announce=False) + self.load_quest_configs(announce=False) + self.refresh_config_file_state() + except Exception as err: + return False, f"任务系统配置重载失败: {err}" + return True, f"任务系统配置已重载,当前加载 {len(self.quests)} 个任务" + + # --- API --- + + def get_quest(self, tag_name: str) -> Quest | None: + """ + 根据任务名获取任务对象 + + Args: + tag_name (str): 任务名 + + Returns: + Quest | None: 任务对象 (或找不到任务) + """ + return self.quests.get(tag_name) + + def get_online_player(self, player_name: str) -> Player | None: + """Return online player data.""" + return self.frame.get_players().getPlayerByName(player_name) + + @staticmethod + def get_quest_label(quest: Quest) -> str: + """Return quest label data.""" + if quest.show_name == quest.tag_name: + return quest.tag_name + return f"{quest.show_name} ({quest.tag_name})" + + def find_quest(self, quest_query: str) -> tuple[Quest | None, str]: + """Implement the find quest operation.""" + quest_query = quest_query.strip() + if not quest_query: + return None, "任务标识不能为空" + if quest := self.get_quest(quest_query): + return quest, "" + + exact_matches = [ + i for i in self.quests.values() if i.show_name == quest_query] + if len(exact_matches) == 1: + return exact_matches[0], "" + if len(exact_matches) > 1: + labels = "、".join(self.get_quest_label(i) + for i in exact_matches[:5]) + return None, f"匹配到多个同名任务,请改用任务标签名:{labels}" + + query_lower = quest_query.casefold() + fuzzy_matches = [ + i + for i in self.quests.values() + if query_lower in i.tag_name.casefold() + or query_lower in i.show_name.casefold() + ] + if len(fuzzy_matches) == 1: + return fuzzy_matches[0], "" + if len(fuzzy_matches) > 1: + labels = "、".join(self.get_quest_label(i) + for i in fuzzy_matches[:5]) + if len(fuzzy_matches) > 5: + labels += "……" + return None, f"匹配到多个任务:{labels}" + return None, f"任务不存在:{quest_query}" + + def can_add_quest(self, player: Player, quest: Quest) -> tuple[bool, str]: + """Implement the can add quest operation.""" + quests = self.read_quests(player) + if quest in quests: + return False, "当前任务正在进行中,无法重复领取" + quest_time = self.read_quests_finished(player).get(quest, None) + quest_mode = quest.cooldown + if quest_mode == -1 and quest_time is not None: + return False, "你已经完成该任务" + if ( + quest_time is not None + and quest_mode > 0 + and time.time() - quest_time < quest.cooldown + ): + fmt_text = r"%d 天 %H 时 %M 分" + left_time = self.sec_to_timer( + quest.cooldown - int(time.time()) + quest_time, fmt_text + ) + return False, f"该任务仍在冷却中,还需等待:{left_time}" + return True, "" + + def get_online_player_task_progress( + self, player_name: str + ) -> tuple[bool, dict | str]: + """Return online player task progress data.""" + player = self.get_online_player(player_name) + if player is None: + return False, f"玩家不在线或不存在:{player_name}" + in_progress = [] + for quest in self.read_quests(player): + if quest is None: + continue + in_progress.append( + { + "tag_name": quest.tag_name, + "show_name": quest.show_name, + "description": quest.description, + } + ) + completed = [] + for quest, finished_time in sorted( + self.read_quests_finished(player).items(), + key=lambda item: item[1], + reverse=True, + ): + completed.append( + { + "tag_name": quest.tag_name, + "show_name": quest.show_name, + "description": quest.description, + "finished_time": finished_time, + } + ) + return True, { + "player_name": player.name, + "in_progress": in_progress, + "completed": completed, + } + + def add_quest_to_online_player( + self, player_name: str, quest_query: str + ) -> tuple[bool, str]: + """Implement the add quest to online player operation.""" + player = self.get_online_player(player_name) + if player is None: + return False, f"玩家不在线或不存在:{player_name}" + quest, err = self.find_quest(quest_query) + if quest is None: + return False, err + ok, reason = self.can_add_quest(player, quest) + if not ok: + return False, reason + if not self.add_quest(player, quest): + return False, f"下发任务失败:{self.get_quest_label(quest)}" + return True, f"已向玩家 {player.name} 下发任务:{self.get_quest_label(quest)}" + + def finish_quest_for_online_player( + self, player_name: str, quest_query: str + ) -> tuple[bool, str]: + """Implement the finish quest for online player operation.""" + player = self.get_online_player(player_name) + if player is None: + return False, f"玩家不在线或不存在:{player_name}" + quest, err = self.find_quest(quest_query) + if quest is None: + return False, err + if not self.is_quest_in_progress(player, quest): + return False, f"该任务未解锁或已经完成:{self.get_quest_label(quest)}" + if not self.finish_quest(player, quest): + return False, f"完成任务失败:{self.get_quest_label(quest)}" + return True, f"已为玩家 {player.name} 完成任务:{self.get_quest_label(quest)}" + + def list_available_quests(self) -> list[dict[str, str]]: + """Implement the list available quests operation.""" + quests = sorted(self.quests.values(), key=lambda item: item.tag_name) + return [ + { + "tag_name": quest.tag_name, + "show_name": quest.show_name, + "description": quest.description, + } + for quest in quests + ] + + def add_quest(self, player: Player, quest: Quest) -> bool: + """ + 向玩家下发任务 + + Args: + player (Player): 玩家对象 + quest (Quest): 任务对象 + + Returns: + bool: 是否下发成功 + """ + ok, reason = self.can_add_quest(player, quest) + if not ok: + player.show( + utils.simple_fmt( + {"[玩家名]": player.name, "[原因]": f"§c{reason}"}, + self.cfg["任务设置"]["任务无法开始的显示"]["格式"], + ), + ) + return False + for cmd in self.cfg["任务设置"]["接到新任务时执行的指令"]: + s_cmd = utils.simple_fmt( + { + "[任务显示名]": quest.show_name, + "[任务描述]": quest.description, + "[玩家名]": player.name, + }, + cmd, + ) + self.game_ctrl.sendwocmd(s_cmd) + o = self.read_player_quest_data(player) + o["in_quests"].append(quest.tag_name) + self.write_player_quest_data(player, o) + return True + + def is_quest_in_progress(self, player: Player, quest: Quest) -> bool: + """Implement the is quest in progress operation.""" + o = self.read_player_quest_data(player) + return quest.tag_name in o["in_quests"] + + def detect_quest( + self, player: Player, quest: Quest, allow_command_block: bool = False + ) -> tuple[bool, str]: + """ + 检测任务是否可以提交 + + Args: + player (Player): 玩家对象 + quest (Quest): 任务对象 + + Returns: + tuple[bool, str]: 是否可提交; 无法提交的信息 + """ + o = self.read_player_quest_data(player) + if quest.tag_name not in o["in_quests"]: + if quest.tag_name in o["quests_ok"]: + return False, "§6该任务已经完成" + return False, "§6该任务未解锁或已经完成" + if quest.cooldown == -1 and quest.tag_name in o["quests_ok"]: + return False, "§6该任务已经完成" + if quest.command_block_only and not allow_command_block: + return False, "§6无法手动提交该任务" + if quest.need_quests_prefix: + err_strs = [] + player_finished_quests = self.read_quests_finished(player) + for quest_name in quest.need_quests_prefix: + need_quest = self.get_quest(quest_name) + if need_quest is None: + err_strs.append(f"{quest_name} (任务配置不存在)") + continue + if need_quest not in player_finished_quests: + err_strs.append(need_quest.show_name) + if err_strs: + return False, "需要完成任务:\n " + "\n ".join(err_strs) + if quest.need_items: + err_strs = [] + for item_name, (item_id, *ext_data) in quest.need_items.items(): + if len(ext_data) == 2: + count, data = ext_data + else: + count = ext_data[0] + data = 0 + if (item_count_now := player.getItemCount(item_id, data)) < count: + err_strs.append( + f"§f{item_name} §7(§c{item_count_now}§7/§f{count}§7)" + ) + if err_strs: + return False, "缺少物品: \n " + "\n ".join(err_strs) + if quest.detect_cmds: + for cmd in quest.detect_cmds: + if not game_utils.isCmdSuccess( + utils.simple_fmt({"[玩家名]": player.name}, cmd) + ): + return False, "§6未达成条件" + return True, "" + + def finish_quest(self, player: Player, quest: Quest) -> bool: + """ + 令玩家完成任务 (强制性, 无论条件是否满足) + + Args: + player (Player): 玩家对象 + quest (Quest): 任务对象 + """ + o = self.read_player_quest_data(player) + if quest.tag_name not in o["in_quests"]: + if quest.tag_name in o["quests_ok"]: + self.show_fail(player, "该任务已经完成") + return False + self.show_fail(player, "该任务未解锁或已经完成") + return False + if quest.cooldown == -1 and quest.tag_name in o["quests_ok"]: + self.show_fail(player, "该任务已经完成") + return False + o["quests_ok"][quest.tag_name] = int(time.time()) + o["in_quests"] = [tag_name for tag_name in o["in_quests"] + if tag_name != quest.tag_name] + self.write_player_quest_data(player, o) + self.game_ctrl.sendwocmd( + f"/execute as @a[name={player.name}] at @s run playsound random.levelup @s" + ) + player.show("§a۞ §l任务完成 §r§e奖励已下发~") + for cmd in quest.exec_cmds_when_finished: + self.game_ctrl.sendwocmd( + utils.simple_fmt({"[玩家名]": player.name}, cmd)) + for item_name, (item_id, + count) in quest.items_give_when_finished.items(): + self.game_ctrl.sendwocmd( + f"give @a[name={player.name}] {item_id} {count}") + player.show(f" §7 + {count}x§f{item_name}") + self.show_succ(player, "任务已提交, 请退出聊天栏") + for new_quest_name in quest.start_quest_when_finished: + new_quest = self.get_quest(new_quest_name) + if new_quest is None: + fmts.print_err( + f"{self.name}: 完成任务后开始的任务不存在: {new_quest_name}" + ) + continue + self.add_quest(player, new_quest) + return True + + # ------------- + + def on_def(self): + """Implement the on def operation.""" + self.interper = self.GetPluginAPI("ZBasic", (0, 0, 1), False) + self.chatbar = self.GetPluginAPI("聊天栏菜单") + self.cb2bot = self.GetPluginAPI("Cb2Bot通信") + if TYPE_CHECKING: + from ZBasic_Lang_中文编程 import ToolDelta_ZBasic + from 前置_聊天栏菜单 import ChatbarMenu + from 前置_Cb2Bot通信 import TellrawCb2Bot + + self.interper: ToolDelta_ZBasic + self.chatbar: ChatbarMenu + self.cb2bot: TellrawCb2Bot + self.cb2bot.regist_message_cb("quest.ok", self.on_quest_ok) + self.cb2bot.regist_message_cb("quest.start", self.on_quest_start) + + def show_succ(self, player: Player, msg): + """Implement the show succ operation.""" + player.show(f"§7<§a§o√§r§7> §a{msg}") + + def show_warn(self, player: Player, msg): + """Implement the show warn operation.""" + player.show(f"§7<§6§o!§r§7> §6{msg}") + + def show_fail(self, player: Player, msg): + """Implement the show fail operation.""" + player.show(f"§7<§c§o!§r§7> §c{msg}") + + def show_inf(self, player: Player, msg): + """Implement the show inf operation.""" + player.show(f"§7<§f§o!§r§7> §f{msg}") + + @utils.thread_func("任务的游戏初始化") + def on_inject(self): + """Implement the on inject operation.""" + self.cmp_scripts = {} + self.chatbar.add_new_trigger( + [".rw", ".任务"], + [], + "查看正在进行的任务列表", + lambda player, _: self.list_player_quests(player), + ) + self.chatbar.add_new_trigger( + [".addrw", ".添加任务"], + [("任务标签名", str, None)], + "向玩家添加任务", + self.force_add_quest_menu, + op_only=True, + ) + for player in self.frame.get_players().getAllPlayers(): + self.init_player(player) + + @utils.thread_func("初始化玩家剧情任务数据") + def on_player_join(self, player: Player): + """Implement the on player join operation.""" + self.init_player(player) + + def on_quest_ok(self, args: list[str]): + """Implement the on quest ok operation.""" + target_name, quest_name = args + quest = self.get_quest(quest_name) + target = self.frame.get_players().getPlayerByName(target_name) + if target is None: + self.print(f"§6on_quest_ok: 玩家 {target_name} 不存在") + return + if quest is not None: + ok, reason = self.detect_quest( + target, quest, allow_command_block=True) + if not ok: + self.show_fail(target, reason) + return + self.finish_quest(target, quest) + + def on_quest_start(self, args: list[str]): + """Implement the on quest start operation.""" + target_name, quest_name = args + quest = self.get_quest(quest_name) + target = self.frame.get_players().getPlayerByName(target_name) + if target is None: + self.print(f"§6on_quest_ok: 玩家 {target_name} 不存在") + return + if quest is not None: + self.add_quest(target, quest) + + def init_player(self, player: Player): + """Implement the init player operation.""" + quest_path = self.get_player_quest_data_path(player) + if not os.path.isfile(quest_path): + self.write_player_quest_data(player, self.init_quest_file()) + else: + self.read_player_quest_data(player) + + def init_quest_file(self): + """Implement the init quest file operation.""" + return {"in_quests": [], "quests_ok": {}} + + def get_player_quest_data_path(self, player: Player) -> str: + """Return player quest data path data.""" + path = os.path.join(self.QUEST_DATA_PATH, player.xuid + ".json") + self.quest_data_paths.add(path) + return path + + def read_player_quest_data(self, player: Player) -> dict: + """Implement the read player quest data operation.""" + data = self.tmpjson.load_and_read( + self.get_player_quest_data_path(player), + need_file_exists=False, + default=self.init_quest_file(), + ) + if not isinstance(data, dict): + return self.init_quest_file() + if not isinstance(data.get("in_quests"), list): + data["in_quests"] = [] + if not isinstance(data.get("quests_ok"), dict): + data["quests_ok"] = {} + return data + + def write_player_quest_data(self, player: Player, data: dict): + """Implement the write player quest data operation.""" + path = self.get_player_quest_data_path(player) + self.tmpjson.load_and_write(path, data, need_file_exists=False) + self.tmpjson.flush(path) + + def on_frame_exit(self, _): + """Implement the on frame exit operation.""" + self._config_reload_stop.set() + for path in tuple(self.quest_data_paths): + try: + self.tmpjson.unload(path) + except Exception: + pass + + def read_quests(self, player: Player) -> list[Quest]: + """Implement the read quests operation.""" + o = self.read_player_quest_data(player) + output = [] + o = o or {"in_quests": []} + for i in o["in_quests"]: + output.append(self.get_quest(i)) + return output + + def read_quests_finished(self, player: Player) -> dict[Quest, int]: + """Implement the read quests finished operation.""" + o = self.read_player_quest_data(player) + output = {} + for k, v in o["quests_ok"].items(): + quest = self.get_quest(k) + if quest: + output[quest] = v + return output + + @utils.thread_func("管理员向玩家添加任务") + def force_add_quest_menu(self, player: Player, args: tuple): + # with utils.ChatbarLock(player, lambda _: + # print(utils.chatbar_lock_list)): + """Implement the force add quest menu operation.""" + (quest_tagname,) = args + if (quest := self.get_quest(quest_tagname)) is None: + player.show("§c任务标签名不存在") + return + onlines = self.frame.get_players().getAllPlayers() + self.show_inf(player, "§6选择一个玩家以向他添加任务:") + for i, j in enumerate(onlines): + player.show(f" §a{i + 1}§7 - §f{j.name}") + resp = utils.try_int(player.input()) + self.show_inf(player, "§7输入玩家名前的§6序号§7:") + if resp is None: + player.show("§c序号错误, 已退出") + return + if resp not in range(1, len(onlines) + 1): + player.show("§c序号不在范围内, 已退出") + return + getting = onlines[resp - 1] + player.show( + f"§6向玩家{getting}添加任务" + + ["§c失败", "§a成功"][self.add_quest(getting, quest)], + ) + + @utils.thread_func("列出任务列表") + def list_player_quests(self, player: Player): + # with utils.ChatbarLock(player): + """Implement the list player quests operation.""" + player_quests = self.read_quests(player) + if not player_quests: + self.show_fail(player, "你没有正在进行的任务") + return + else: + player.show(self.cfg["任务设置"]["任务列表显示格式"][0]) + for i, quest_data in enumerate(player_quests): + if quest_data is None: + player.show( + utils.simple_fmt( + { + "[任务显示名]": "§c<任务失效>§f", + "[任务描述]": "--", + "[i]": i + 1, + }, + self.cfg["任务设置"]["任务列表显示格式"][1], + ), + ) + else: + player.show( + utils.simple_fmt( + { + "[任务显示名]": quest_data.show_name, + "[任务描述]": quest_data.description, + "[i]": i + 1, + }, + self.cfg["任务设置"]["任务列表显示格式"][1], + ), + ) + resp = player.input( + utils.simple_fmt( + {"[任务数量]": len(player_quests)}, + self.cfg["任务设置"]["任务列表显示格式"][2], + ) + ) + if resp is None: + return + resp = utils.try_int(resp.strip("[]")) + if resp is None: + player.show("§c序号不合法") + return + if resp not in range(1, len(player_quests) + 1): + self.show_fail(player, "序号超出范围") + return + getting_quest = player_quests[resp - 1] + if getting_quest is None: + self.show_fail(player, "无法完成失效的任务") + return + ok, reason = self.detect_quest(player, getting_quest) + if not ok: + player.show( + utils.simple_fmt( + {"[玩家名]": player.name, "[原因]": reason}, + self.cfg["任务设置"]["任务无法提交的显示"]["格式"], + ), + ) + return + else: + self.finish_quest(player, getting_quest) + + def sec_to_timer(self, timesec: int, fmt: str): + """Implement the sec to timer operation.""" + days, left = divmod(timesec, 86400) + hrs, left = divmod(left, 3600) + mins, secs = divmod(left, 60) + if secs > 0 and mins == 0: + mins = 1 + return utils.simple_fmt( + {"%d": days, "%H": hrs, "%M": mins, "%S": secs}, fmt) + + +entry = plugin_entry( + TaskSystemCloudInterop, + "任务系统云链联动版", + (0, 0, 1), +) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index 43eab27a..de9365a8 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -1,260 +1,269 @@ -"""Guild cloud interop ToolDelta plugin entrypoint.""" - -from threading import Event -from typing import Dict - -from tooldelta import plugin_entry, Plugin, ToolDelta, TYPE_CHECKING, utils, Player, FrameExit -from tooldelta.constants import PacketIDS -from tooldelta.utils import tempjson - -from guild_cloud_interop.matchers import ItemNameMatcher -from guild_cloud_interop.handlers import handlers -from guild_cloud_interop.handlers_quick import handlers_quick -from guild_cloud_interop.logic import logic_functions -from guild_cloud_interop.api import guild_api_functions -from guild_cloud_interop.control import GuildManager -from guild_cloud_interop.config import Config, PLUGIN_ENABLED_KEY -from guild_cloud_interop.config_watcher import config_reload_task, refresh_config_file_state -from guild_cloud_interop.ui import wrap_player - - -def _normalize_chatbar_trigger(trigger: object, fallback: str = "公会") -> str: - """Return the trigger token expected by 聊天栏菜单.add_new_trigger.""" - if not isinstance(trigger, str): - return fallback - normalized = trigger.strip() - while normalized.startswith("."): - normalized = normalized[1:].strip() - return normalized or fallback - - -# FIRE 公会插件主类 FIRE -class GuildPlugin(Plugin): - """ToolDelta plugin entrypoint for guild cloud interop.""" - - name = "公会系统云链联动版" - author = "星林 & 夏至" - version = (0, 1, 7) - - def __init__(self, frame: ToolDelta): - super().__init__(frame) - - self.config = Config.load(self.name, self.version) - self.guilds_file = self.format_data_path("公会数据文件.json") - self.guild_manager = GuildManager(self.guilds_file) - self.guild_chat_mode: Dict[str, bool] = {} - self._stop_event = Event() - self._effect_refresh_cache = {} - self._guild_menu_callback = None - self._guild_menu_chatbar_entry = None - self._guild_runtime_events = {} - self.item_matcher = ItemNameMatcher() - self.ListenPreload(self.on_def) - self.ListenActive(self.on_inject) - self.ListenPlayerJoin(self.on_player_join) - self.ListenFrameExit(self.on_frame_exit) - self.exp_thread = utils.createThread( - self.guild_exp_task, usage="公会经验增加任务") - self.online_thread = utils.createThread( - self.update_online_task, usage="在线状态更新任务") - refresh_config_file_state(self) - self.config_thread = utils.createThread( - config_reload_task, args=(self,), usage="公会配置热更新任务") - - def _plugin_enabled(self) -> bool: - return bool(getattr(self, "config", {}).get(PLUGIN_ENABLED_KEY, False)) - - def get_config_file_state(self): - """Return the last observed runtime config file state.""" - return getattr(self, "_config_file_state", None) - - def set_config_file_state(self, state) -> None: - """Store the last observed runtime config file state.""" - self._config_file_state = state - - def reset_effect_refresh_cache(self) -> None: - """Clear cached guild effect refresh timestamps.""" - self._effect_refresh_cache = {} - - def should_stop_runtime_task(self) -> bool: - """Return whether background runtime tasks should exit.""" - return self._stop_event.is_set() - - def wait_runtime_task_or_stopped(self, seconds: float) -> bool: - """Wait for a background interval or until shutdown is requested.""" - return self._stop_event.wait(seconds) - - def on_def(self): - if not self._plugin_enabled(): - return - - self.chatbar = self.GetPluginAPI("聊天栏菜单") - self.xuidm = self.GetPluginAPI("XUID获取") - - if TYPE_CHECKING: - from 前置_聊天栏菜单 import ChatbarMenu - from 前置_玩家XUID获取 import XUIDGetter - - self.chatbar: ChatbarMenu - self.xuidm: XUIDGetter - - def ui_callback(self, callback): - def wrapped(player, args): - return callback(wrap_player(player), args) - - return wrapped - - def _guild_menu_commands(self) -> list[str]: - raw_triggers = getattr(Config, "GUILD_MENU_TRIGGER", ["公会"]) - if isinstance(raw_triggers, str): - raw_triggers = [raw_triggers] - if not isinstance(raw_triggers, list): - raw_triggers = ["公会"] - - commands: list[str] = [] - for trigger in raw_triggers: - command = _normalize_chatbar_trigger(trigger, "") - if command and command not in commands: - commands.append(command) - return commands or ["公会"] - - def _find_guild_menu_chatbar_entry(self): - entry = getattr(self, "_guild_menu_chatbar_entry", None) - if entry is not None: - return entry - - chatbar = getattr(self, "chatbar", None) - chatbar_triggers = getattr(chatbar, "chatbar_triggers", None) - if not isinstance(chatbar_triggers, list): - return None - - callback = getattr(self, "_guild_menu_callback", None) - for candidate in chatbar_triggers: - if getattr(candidate, "usage", None) != "公会系统指令": - continue - if callback is not None and getattr( - candidate, "func", None) is not callback: - continue - self._guild_menu_chatbar_entry = candidate - return candidate - return None - - def sync_runtime_config_bindings(self): - """Apply hot-reloaded config values that are registered outside Config.""" - entry = self._find_guild_menu_chatbar_entry() - if entry is None: - return - - commands = self._guild_menu_commands() - current_commands = list(getattr(entry, "triggers", [])) - if current_commands == commands: - return - - entry.triggers = commands - - def on_inject(self): - if not self._plugin_enabled(): - return - - self.game_ctrl.sendwocmd( - f"/scoreboard objectives add {Config.GUILD_SCOREBOARD} dummy 积分") - self.game_ctrl.sendwocmd( - f"/scoreboard players add @a {Config.GUILD_SCOREBOARD} 0") - guild_menu_commands = self._guild_menu_commands() - self._guild_menu_callback = self.ui_callback(self.guild_menu_cb) - self.chatbar.add_new_trigger( - guild_menu_commands, - [("", str, "")], - "公会系统指令", - self._guild_menu_callback, - ) - chatbar_triggers = getattr(self.chatbar, "chatbar_triggers", None) - if isinstance(chatbar_triggers, list) and chatbar_triggers: - self._guild_menu_chatbar_entry = chatbar_triggers[-1] - - trigger_configs = [ - {"commands": ["gc", "公会聊天"], - "args": [("message", str, "")], - "description": "公会聊天频道", "callback": self.guild_chat_cb, }, - {"commands": ["仓库出售", "出售"], - "args": - [("item_id", str, ""), - ("count", int, 1), - ("price", int, 0)], - "description": "快速出售物品到仓库", "callback": self.quick_vault_sell, }, - {"commands": ["自定义出售"], - "args": - [("item_id", str, ""), - ("count", int, 1), - ("price", int, 0)], - "description": "自定义价格出售物品到仓库", - "callback": self.custom_vault_sell, }, - {"commands": ["物品列表", "支持物品"], - "args": [("", str, "")], - "description": "查看支持的物品名称列表", "callback": self.show_item_list, }, - {"commands": ["清理公会数据"], - "args": [("confirm", str, "")], - "description": "清理所有公会数据 (管理员专用)", - "callback": self.admin_clear_guild_data, }, - {"commands": ["调试公会菜单"], - "args": [("", str, "")], - "description": "调试公会菜单显示问题", "callback": self.debug_guild_menu, }, - {"commands": ["调试据点功能"], - "args": [("", str, "")], - "description": "调试据点功能问题", - "callback": self.debug_base_function, },] - - # 注册数据更新菜单 - self.frame.add_console_cmd_trigger( - ["更新公会数据"], - "", "更新由于版本更新导致的数据丢失", - self.guild_update_data - ) - for trigger in trigger_configs: - self.chatbar.add_new_trigger( - trigger["commands"], - trigger["args"], - trigger["description"], - self.ui_callback(trigger["callback"]), - ) - - self.ListenPacket(PacketIDS.IDText, self.on_chat_packet) - self.ListenPacket(PacketIDS.IDPlayerAction, self.on_player_action) - - def on_player_join(self, player: Player): - if not self._plugin_enabled(): - return - - player_name = getattr(player, "safe_name", player.name) - self.game_ctrl.sendcmd( - f"/scoreboard players add {player_name} {Config.GUILD_SCOREBOARD} 0") - self._apply_guild_effects_to_player( - player.name, force=True, command_delay=0) - - def on_frame_exit(self, _: FrameExit): - self._stop_event.set() - try: - if not self.guild_manager.flush_dirty_guilds(): - self.print_err("保存公会数据失败") - except Exception as err: - self.print_err(f"保存公会数据失败: {err}") - try: - tempjson.flush(self.guilds_file) - except Exception: - pass - for attr in ("exp_thread", "online_thread", "config_thread"): - thread = getattr(self, attr, None) - if thread is None: - continue - try: - thread.stop() - except Exception: - pass - - -for name, func in {**handlers, **handlers_quick, ** - logic_functions, **guild_api_functions}.items(): - setattr(GuildPlugin, name, func) - - -entry = plugin_entry(GuildPlugin, "guild-cloud-interop") +"""Guild cloud interop ToolDelta plugin entrypoint.""" + +from threading import Event +from typing import Dict + +from tooldelta import plugin_entry, Plugin, ToolDelta, TYPE_CHECKING, utils, Player, FrameExit +from tooldelta.constants import PacketIDS +from tooldelta.utils import tempjson + +from guild_cloud_interop.matchers import ItemNameMatcher +from guild_cloud_interop.handlers import handlers +from guild_cloud_interop.handlers_quick import handlers_quick +from guild_cloud_interop.logic import logic_functions +from guild_cloud_interop.api import guild_api_functions +from guild_cloud_interop.control import GuildManager +from guild_cloud_interop.config import Config, PLUGIN_ENABLED_KEY +from guild_cloud_interop.config_watcher import config_reload_task, refresh_config_file_state +from guild_cloud_interop.ui import wrap_player + + +def _normalize_chatbar_trigger(trigger: object, fallback: str = "公会") -> str: + """Return the trigger token expected by 聊天栏菜单.add_new_trigger.""" + if not isinstance(trigger, str): + return fallback + normalized = trigger.strip() + while normalized.startswith("."): + normalized = normalized[1:].strip() + return normalized or fallback + + +# FIRE 公会插件主类 FIRE +class GuildPlugin(Plugin): + """ToolDelta plugin entrypoint for guild cloud interop.""" + + name = "公会系统云链联动版" + author = "星林 & 夏至" + version = (0, 1, 7) + + def __init__(self, frame: ToolDelta): + super().__init__(frame) + + self.config = Config.load(self.name, self.version) + self.guilds_file = self.format_data_path("公会数据文件.json") + self.guild_manager = GuildManager(self.guilds_file) + self.guild_chat_mode: Dict[str, bool] = {} + self._stop_event = Event() + self._effect_refresh_cache = {} + self._guild_menu_callback = None + self._guild_menu_chatbar_entry = None + self._guild_runtime_events = {} + self.item_matcher = ItemNameMatcher() + self.ListenPreload(self.on_def) + self.ListenActive(self.on_inject) + self.ListenPlayerJoin(self.on_player_join) + self.ListenFrameExit(self.on_frame_exit) + self.exp_thread = utils.createThread( + self.guild_exp_task, usage="公会经验增加任务") + self.online_thread = utils.createThread( + self.update_online_task, usage="在线状态更新任务") + refresh_config_file_state(self) + self.config_thread = utils.createThread( + config_reload_task, args=(self,), usage="公会配置热更新任务") + + def _plugin_enabled(self) -> bool: + """Implement the plugin enabled operation.""" + return bool(getattr(self, "config", {}).get(PLUGIN_ENABLED_KEY, False)) + + def get_config_file_state(self): + """Return the last observed runtime config file state.""" + return getattr(self, "_config_file_state", None) + + def set_config_file_state(self, state) -> None: + """Store the last observed runtime config file state.""" + self._config_file_state = state + + def reset_effect_refresh_cache(self) -> None: + """Clear cached guild effect refresh timestamps.""" + self._effect_refresh_cache = {} + + def should_stop_runtime_task(self) -> bool: + """Return whether background runtime tasks should exit.""" + return self._stop_event.is_set() + + def wait_runtime_task_or_stopped(self, seconds: float) -> bool: + """Wait for a background interval or until shutdown is requested.""" + return self._stop_event.wait(seconds) + + def on_def(self): + """Implement the on def operation.""" + if not self._plugin_enabled(): + return + + self.chatbar = self.GetPluginAPI("聊天栏菜单") + self.xuidm = self.GetPluginAPI("XUID获取") + + if TYPE_CHECKING: + from 前置_聊天栏菜单 import ChatbarMenu + from 前置_玩家XUID获取 import XUIDGetter + + self.chatbar: ChatbarMenu + self.xuidm: XUIDGetter + + def ui_callback(self, callback): + """Implement the ui callback operation.""" + def wrapped(player, args): + """Implement the wrapped operation.""" + return callback(wrap_player(player), args) + + return wrapped + + def _guild_menu_commands(self) -> list[str]: + """Implement the guild menu commands operation.""" + raw_triggers = getattr(Config, "GUILD_MENU_TRIGGER", ["公会"]) + if isinstance(raw_triggers, str): + raw_triggers = [raw_triggers] + if not isinstance(raw_triggers, list): + raw_triggers = ["公会"] + + commands: list[str] = [] + for trigger in raw_triggers: + command = _normalize_chatbar_trigger(trigger, "") + if command and command not in commands: + commands.append(command) + return commands or ["公会"] + + def _find_guild_menu_chatbar_entry(self): + """Implement the find guild menu chatbar entry operation.""" + entry = getattr(self, "_guild_menu_chatbar_entry", None) + if entry is not None: + return entry + + chatbar = getattr(self, "chatbar", None) + chatbar_triggers = getattr(chatbar, "chatbar_triggers", None) + if not isinstance(chatbar_triggers, list): + return None + + callback = getattr(self, "_guild_menu_callback", None) + for candidate in chatbar_triggers: + if getattr(candidate, "usage", None) != "公会系统指令": + continue + if callback is not None and getattr( + candidate, "func", None) is not callback: + continue + self._guild_menu_chatbar_entry = candidate + return candidate + return None + + def sync_runtime_config_bindings(self): + """Apply hot-reloaded config values that are registered outside Config.""" + entry = self._find_guild_menu_chatbar_entry() + if entry is None: + return + + commands = self._guild_menu_commands() + current_commands = list(getattr(entry, "triggers", [])) + if current_commands == commands: + return + + entry.triggers = commands + + def on_inject(self): + """Implement the on inject operation.""" + if not self._plugin_enabled(): + return + + self.game_ctrl.sendwocmd( + f"/scoreboard objectives add {Config.GUILD_SCOREBOARD} dummy 积分") + self.game_ctrl.sendwocmd( + f"/scoreboard players add @a {Config.GUILD_SCOREBOARD} 0") + guild_menu_commands = self._guild_menu_commands() + self._guild_menu_callback = self.ui_callback(self.guild_menu_cb) + self.chatbar.add_new_trigger( + guild_menu_commands, + [("", str, "")], + "公会系统指令", + self._guild_menu_callback, + ) + chatbar_triggers = getattr(self.chatbar, "chatbar_triggers", None) + if isinstance(chatbar_triggers, list) and chatbar_triggers: + self._guild_menu_chatbar_entry = chatbar_triggers[-1] + + trigger_configs = [ + {"commands": ["gc", "公会聊天"], + "args": [("message", str, "")], + "description": "公会聊天频道", "callback": self.guild_chat_cb, }, + {"commands": ["仓库出售", "出售"], + "args": + [("item_id", str, ""), + ("count", int, 1), + ("price", int, 0)], + "description": "快速出售物品到仓库", "callback": self.quick_vault_sell, }, + {"commands": ["自定义出售"], + "args": + [("item_id", str, ""), + ("count", int, 1), + ("price", int, 0)], + "description": "自定义价格出售物品到仓库", + "callback": self.custom_vault_sell, }, + {"commands": ["物品列表", "支持物品"], + "args": [("", str, "")], + "description": "查看支持的物品名称列表", "callback": self.show_item_list, }, + {"commands": ["清理公会数据"], + "args": [("confirm", str, "")], + "description": "清理所有公会数据 (管理员专用)", + "callback": self.admin_clear_guild_data, }, + {"commands": ["调试公会菜单"], + "args": [("", str, "")], + "description": "调试公会菜单显示问题", "callback": self.debug_guild_menu, }, + {"commands": ["调试据点功能"], + "args": [("", str, "")], + "description": "调试据点功能问题", + "callback": self.debug_base_function, },] + + # 注册数据更新菜单 + self.frame.add_console_cmd_trigger( + ["更新公会数据"], + "", "更新由于版本更新导致的数据丢失", + self.guild_update_data + ) + for trigger in trigger_configs: + self.chatbar.add_new_trigger( + trigger["commands"], + trigger["args"], + trigger["description"], + self.ui_callback(trigger["callback"]), + ) + + self.ListenPacket(PacketIDS.IDText, self.on_chat_packet) + self.ListenPacket(PacketIDS.IDPlayerAction, self.on_player_action) + + def on_player_join(self, player: Player): + """Implement the on player join operation.""" + if not self._plugin_enabled(): + return + + player_name = getattr(player, "safe_name", player.name) + self.game_ctrl.sendcmd( + f"/scoreboard players add {player_name} {Config.GUILD_SCOREBOARD} 0") + self._apply_guild_effects_to_player( + player.name, force=True, command_delay=0) + + def on_frame_exit(self, _: FrameExit): + """Implement the on frame exit operation.""" + self._stop_event.set() + try: + if not self.guild_manager.flush_dirty_guilds(): + self.print_err("保存公会数据失败") + except Exception as err: + self.print_err(f"保存公会数据失败: {err}") + try: + tempjson.flush(self.guilds_file) + except Exception: + pass + for attr in ("exp_thread", "online_thread", "config_thread"): + thread = getattr(self, attr, None) + if thread is None: + continue + try: + thread.stop() + except Exception: + pass + + +for name, func in {**handlers, **handlers_quick, ** + logic_functions, **guild_api_functions}.items(): + setattr(GuildPlugin, name, func) + + +entry = plugin_entry(GuildPlugin, "guild-cloud-interop") diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/api.cpython-313.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/api.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..6877d05de6aa215527a295cbdaabf8aaee0f8511 GIT binary patch literal 94653 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 SRRSAXoPj=1VVuDXd;6IBz{A=f6Du@-O9 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 L+0LuSlK)mRk4Q%WEZm!6q) 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 literal 0 HcmV?d00001 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/handlers_quick.cpython-313.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/handlers_quick.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..1bda484d648e77836e3f777b54444394bf5ef462 GIT binary patch literal 22658 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

lGMiDTR##A}^s5^B63@q(xE0~VfFh`ImFgP= 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 literal 0 HcmV?d00001 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/matchers.cpython-313.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/matchers.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..1d7f9d9c850678d4d155271ad28a3065a9eb021b GIT binary patch literal 6582 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 literal 0 HcmV?d00001 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/models.cpython-313.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/models.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..60d8081ce32536cdce2049af0f5629d60680ea56 GIT binary patch literal 33073 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 literal 0 HcmV?d00001 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/prompts.cpython-313.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/prompts.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..97f2d327008aeb3154479e4a0cfd768bb66a186d GIT binary patch literal 4213 zcmb7HYfu!~6}~-Bm=Vz(5En!nQFIg#-;Y>U5L8eRi*{0#K-FX%S~5EHke;5cRf?){ z6Wl?i#By17UEGBfE131ct{WfVBjvCB9LiKdb(tzFnV!e5?L_lG=iWQR^uX+o+=A2h z+UjUC(OIQ!ekI}h+~eSqm0O~c&%jp09^$Ii%Bmmv5gm*nvYeqi;r!g=_Z>U_c(Pe-5-gp|iWM2Gv7 z-n)@!H>1}EVq;VB2mOIHu=8r%{m)xL_SLu;c|HPSvFU5`l98!PfikT9^1EpNV@ z*e6e6?R2-_?GyRaK9|@|mgDzFmC&W=t(UR!2QaaubFDnTq4f3B)AxbuneNjruS;d0@A0m3?Q?KsQJR&nzE;K{$Gy>^N9lN~Gt;b*0mA@AYD^nvpafL3 z3sfX+G856+Z4g9j5}btU0z|iFBzrNU0ap98sk(hJ`aM8sdm6oe8NjGqd%QSB^!x=FU!IYoZeA39Vh_C~ng}9OSEF}Fvt!wrVS$!?Auuq6 zwc6l!cA(?B5afad4P+NqB^nw~E`O7*%DRkP;;Lwz2UC1^N5JE4?^&Fb-ihdNKVV$B zemQM!Z-zOn-sN?j=S6bosA=e2^k!(RLzF)~yBNUEXwRoD20L}{3+Ni@V=}Q6e~|8G z^TGs=@oU3M|KR@*@rz(45ezDyPEl8552s_(-;n#CY;{*ZjZBZF9r`e%lvD;CisZ78|w&vSP zDx|>AIGYIeMX%fmtjaK$Y+|9I_~-=n$ZlNZ6HQe*9(;|p@zQ)y`)MfxP4Z_?(U?!~ zlV+gVr@31ge|D(_9=RFtC^hg)!-^2|?gC5(k%MW`?k1&nCcie+P=QcDhfr6!1GVUr z%bE|zICNFObA+}UQ(F%G&ZOO)ppA!snl|F2S>!+9l0@@}Y>nQocsvFl}{N8&sEvRUNeR=Igu@0UcoLC%3ySde(x^pVRO z;Qg}x9DiOmQpNx$voPLEFgdiZt*Nf9oIU81+D`d=-g@3Ex!O*8105cr$t9lSrK(Ps zN650Gy$F%1>xmmL;uk+tE>FbzLT#i)INe@fpxx;aBwqA&wb3a2)m`W1JQ|pjE{V8t zR>H4;H;^EDyJFRl_qz9Uq0bW1&)G_5ZRInz@{ziU^3ldg{b=)#+>^qGzjj1z z>rv7Aw^m!Q`5maDjH=?ild2ZOWDvE^*VKV_Ff~x+dS({`S!XV6&zg0&$U_EM63XHd zz?-cg83jBp;OUkO#;y~W!g*pI5I-(6%Zkh3BNwNniCHJjP%;7d)wdlhd;lbfBGxq{ z`Wb8W$VWiJhH4QnXT+8R3naFWAu4Tbtx047`XI)gNpuRjR$O#4T9WVT6pz0S!0J3E z^$xvYXw6OSJ9Mq&jgI-041ksl7d-16y6hHHSQm^MTgFy+<nosuo-C#t-R3UGDJEr$*^ z*S6H#b7gY}5C2F=qKKg`mlEYePAZr@>2U4w;|FW&j&ZUcgk_7z?-BeGe!R+tc8^;U z0YwN7;-z-}j4R-k$Yr#f(jb=eC`g?Y=5o#%m)CpRDwy<)Rt+>B$uBa^3@YZ0NH5ASm z3I{x6MR!+(w;ukR?seI*zZ;Iv&F^@}j@&&Q-hp@4;hi_um0zr!u~v@!3P{*cNp9H* z2pz{o@Ez9h|zfhzNfbsG8j(Tb#}_0Z`zbP7xzgHc98ZJVFOG zR-C0hR?|F^Qi`jwDU3Q1cLtKeJBav4dr8jzPG5V#%O4Oo!#-|o{xYDdo?)1OptTBG zp`a?&R92>&Gn9l&52$*WzBOzJ+XdAKQ`Llw)!{9_Rn3^mLB@@$j>+X1)r$FL$hb2jMG)rR?E*bm!(t(IVB6*8_3m)EPMm|2ZA5!%$CQ`cagC6fEqGR&-nGqBm+>Uzv> zB8rXbX3SI)!FII@Gu6nrIb7MKZoy0q5#!XYnArxNgiH6R+cC3)?Ce)}VrCa%exdHh t%pSCE@0`7UuDo{64nGI}Q_H||7?r+nU<^#b`=vT&%YQ9Pm@1OO{{g;FkZ%A0 literal 0 HcmV?d00001 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/service.cpython-313.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/service.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..3f725f12c6655aa3256c7fe7dee9d411a0f8d837 GIT binary patch literal 2870 zcmaJ@>u(fQ6u?z8Ox#TMGSLu-OtZ4sfXqD70~2a#&JT`{3?GR)4dtJB@}&J01L zu^5#FX;iRL@c|?lFd9;or~!P`zn~jycAaQqVz-4)HWZ^#KX}f}?Dk>xX7}7P_ndp@ z-gAEEcW17)Hb9^q`LN^hpFD*8f)AC(yUN^+pp23jp(Mt{*qzM%EM-N~MA}GBaFo- zhYVeD7IGxxKFmcy86|^+GO!0W#!xQCQXbz;Jux0+P0Rz*8><27i+Mr%V?G*4`6B_V zemlHv!H%x@U^TN7#PsOc^vJoX3on-spDLezVdmAB%5R+&JH?q(=cc}X*R_5zeEHz{ z>ESbqdq#UtsB`e2+X7;gJR)fJFp3z76RomyvH_o}Gh+-q;XJ#gNQCVhi11de9TjOv z&d4d1TEV@t@`Ra_FiKV+3$c?ilDg&9O+`_4J;GYNu4+k&C(&_rQM_hAw`0rTu6qaD z#cf$*a3Gu2wyT;U4?d)ssdQ$SOrKDV&LKISS!SgC;zv`Lj?a90apvNQsSB@NIri1% z1Fx44pPf0BA9Qx7XjwBMr89;~v$;WCrTfx~+Le3C@<}k7HYCYvS`z85iY18qydK0b zDYbS?`qq}3!+Czv*Hl{DlIM@qml~V%{77gHJcv?_9XtRcojCsu$|$)WqS+7H($-&% z>AVGzdI!dPKKfjY?Bp^`oay(^`&Bepxq|n~5cJ*dZWgW+CpVCFWRG1p=;|f%O@w6F zJ)z3P{KkUG3bz--8w=r$#c)p{+*4{@H_H)m>nvf!_WbtAP|Gi&M({cD>|4*C9VmA86gqpp z3vab8vpym;HG$qcxp6`4jdJ5r59rFm%%hIyoI46)l*AbY&agc0 zkQcEfHrXD_ps?F{qU-JHMu z-t@)ekZlw5G<|Gj>dSW=1l$MALb32HAI)kSa*CZ@+6ZG{LVwGr?pGw^shnzgQQIeL zR*gNhSI6x_s@+^ek*oXvtH>Qo3rx`Qyinx9RfS;qq;ck%GtZPDN4pN~I`W{KtT&~v zydUiT=Fy3p2PT^1lc9%8tC}EDRDhYw*ZyXxLCC|hm0yk6ZsM>*wc24hxAh~7ENRQH zHeZ=wUv4Gul4am=L57iz?bcuAhRO)RyONn-A2P`Shg2B@IHVmogxNE{#_}A(#+k*K z0M-Cb>t#}$(=Xr*-dqpwFWV4tZnJWJ%*D;6;qsZ6%ZEm0j=wN-__OKZ17-*Kx?Z1u z;!OHKcf_FURbiYypd2I^mv?k|pX&0CwTh~`YDm<~80jHZ(ha!!7m-amlcd+-TpOOO zLpxZr?)_#{4n>Up`#R}S zaCc$D-IKyS3oOxs5SKadm$5xDS z6XDH8p}Qb-|24~j&7qiMy{mgW$aqI!TO&8VdCj(!+_x(|pf7?pLd&L@m7u_f6<$-I zIW5ITI1>%iCl6jeSIL(hLn?ern;e} zGioLaX0IeAvWg^8gfU%%LPUWXq*%4qYRQH{T9IVKpy|CPd<;s`2gKfulWr_riUO;` z{(yLi{J`IKmFGPzS3`bJ!`16NAnJUc=x+_Ir`4H?6Z+sdUC>*81HOY2_#Nc1pS|QQ pV3~i7<-5x{)mC~d7%-+f-l~vQhWUYPDwDOp2i7yKR|yDv+CMf%y~+Rp literal 0 HcmV?d00001 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/ui.cpython-313.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/ui.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..9430589f6623435d982fd28e4c2b960b7c3a40ae GIT binary patch literal 11478 zcmd5iZFCdYl{3;v8hb{TjK4oou=xd%%VZlfi4Pfs%S%$g9NDN^l zRx&lSPnn3xg|NBVOw91)nz^Se#PXDtSfSoRY?77ml8xj@Jjs=Eh#=(>Q4)w<5=mZX zUL#x07=KcwWG`o4odD+26wcPK!2!S37-kdF3%Cy2{||*6UEirPh8;>va2r=2==(TgZ+^nS0EIYN&kRr zAQ|o3>k~_GGP|c}!YDt7fg&OuGl;{!Jqlw+DvXLQ zuj~4R{OY%1eAjUR2`xoXimp%iZ@(SIx`>Syw_#C-yE9;lc6R}Ay7l)AAj9zRGcyc( zZxhsBVm2}%6Z}BFLYx;QP)ijt__yjS1*=jDtHNiWMMp=+>o{93wZbl@rJP{|2A=OBr`hJ7 zURM{anYLc_b{K_g`FE{v#tdD5{R(W_p6B4ONs{jOjjB-alRS&_*rflwbHL_#lV8<@Py>+cU9kb_}==hIZjwD`$Oa=8AaKM=}NGxx?XW`F;q&)>iH`L%auKYH`- zg+JVR{o>rY$foIAp zq6d?Z9ZIe!h&InU3q}uI$eD50#+|jFIM)nuHw%hK!xy#>@t;=JzQs;HHe~-)STXu) zykzrKN4#YB$2p0TBS~?^NXu}`SnWIRx7_2066G7F#rkA%>CmBMdBsrkEukPGto+nb zoOBjnw!di~>v<>eR$$VWsNOW~d^}lRIciH*uN)O_mQ|0vJihLevTdW56zXJA+`S`K zz4H_2t}o0?`8WPcEWE`#M)HR9M#Iy5Ws)x#KOHaG80R;h6TVo@3fBPXKYa!)3KSKtC`l?*lmrgnd4>bP3}PM#~d`r=aSLGRcyv?~9}o z;6==EP(hh#0N@gnsqx!!uqDaE8a_fTLmq=h;Lz45gDnjcQbWrG6e^=~AwMijCcA>q zry~N#6%TWqYTOC^6%_6$bJGDrd-?2}XUC5v9P4BJ`iCcgv!fIEdjK*g;DuT(Wtbz@ zFbYebproroeVRN>yVdBIer7c^CW8hjYL}*T`&hSWuC%!_(5)3zDF_$7L3mZUKHtgyo=6a3(dTg z2B?ra%qW*dJ|F1PaG=|VauVo@gk{C&dymnEL}4bd_*#a0PAP=!yUd@smM=NhQvIcs zwbVVZnk}of4v5>WZ*u_AcIq;45P(b>@G#z#5F3JPm@#xhJWMTXc|kP`<#AB~T?vh&+sPZSLH=I!Z?C>@}?a{8#UOKKk8$$UTc+9*!V`a8Mg1g*|K=2pF<~5Nu1lBs@l^?7b-HXBQxZarR^BnZ z1jz`M3Y!?pvdJ1$Msk%b(A^d5)sp`S6YT%O7fVe)rq?Ywt!-9szgm zmt%8RCTVH@%D>DH|6y)uN*iNnbne_cv|;Xdztto?RXR65_r}kR(%oou?t32@rTHI! zF#p3TH>ZkC?d@&tzNg!p4j$=Hg=hD*w;F{!$+LfdlO!2s;YjPjHofqW&^wGJ@@Y#% zjsWM+!%z7EWKrgp%`w`Tu$7EGF&<7-)J>kg+L+k5_v0sSgcJLv*t2pha5C}i$(xRf z7+;ahj=M9~{!C*3GqL9=@^gS(9^=bX$U7%%6BP|pbsrZdHaFd9sAWTagNUcL%`*IR%kvN0I6Dt19{(H< zdAQxP_kW~0gN8sP~|C|3l-kmeJX0c+?TyoGS7#hq}E9k~Pzi#i2>l5Bh_1pdR zy(HLwGW61M_xg2MalFp$ZTRos|KPLe>&H8l_3IkeyK4y#eeU8*j_g~u_wCCg6u)k- z8>j;a_RU*=J9-hxob6QV>fz^J*HGuKjettkjHDI}tj~-EKA=$w=v)t`vo``UWnkC) ze5V4O0fkJelvu0VM$MpVBC=|MV@UT2)!z2>u_J9MePh)YL$&mg{>T7&_y%OB&uCM(`XAwBxnmDk_qfF7+SG%>{vov6>XXo^F|JzI~*%IbfYLC zwndvhwTU+!l{1c-xT9vgX`=bv<|+1{Tc?_S*ZiC2kJT8zb`KkEnzxC`e0NgJA88(L9%Ik9&WNtK=z{SU%b)H)ZXwrwaiZ(fP2VOC4TOD=(wy9>EXJ>L;5ev!QH4j5nns zVe@lo^#>*J2v8wpVr;R2_h=hjopp1BArhDAIX*xM0Jl+pZquLL1V!?}Cdgc$MSpO8 zVq0eL3#7DXNdSz-*hiqDD}u}dLav*mLaMoUVp}^(=aX&i2b$Vd@z{}LN1Jr>2WJ%v zWh?WDBI>e88%QURd>wwuQOGn=^d!WZC>)3LY0PW&I7s5!8F6D=+&E>?-~G+J@}cL) zHeU&jJs+#{#yb5mZ&$3VZ~8|<2iy?Rq zTfiJL9sV)IB+MQNUhn9DP&l~BptrL){ZcBf0kH_!{Mm!XsS!92=S41QLdrc|(1*)v z)lwG4u%A7!g4uDs;M{4+IK-v;Y+kMp2r2sNkW#o1qsmTS;vT{UI}QTOq1(Is>UdK9 zV0C^0)<2I92w6LZ;Fcut9IVhox6UDp$6|2ihlob@_4ncdh9-}ty~u#mPswI*zVrzk zJy^sMD%d3nIO4r{_@XI28-vFbe_$b&;g2_Z8{@l}Lu?DY6>o*nlBNq+HP$W&!DOf9 zu`Z%<3}~Rap(%rg&0{n8>RFanrrZKQgD|Eos}$xMV;VI55CqE3pBKYJ8aFmcka9Q% zLt#dLI`3aCJC11JJ;FFHZ4{)n%s97~9moC`jI-)%#xb>k%q+8Rd4It;wO>2VQ_GIy z_?wRN8jO=w&w1CTc{E1!*p|FsQ_D}ZXsqOHcN_TWrzfS+z{uqfuFY!X^q_2p3#f%a z(5B(_EWH)h?^q#b(&g3fZsk4KC;H&GZ4V8m!R>1VqkNj{NZfmO*HItO4^G%#H$*n} zLUDH;brlCqyX#uXvw%%7m`0w#%mW#v1Rk9Jl&N8L7ZE&z5LCGY^%-^0$hQE3>oR(A zbt(`(L7u~=7R*{PLo-PJ4l*^THxdj|$4?8Pps%J`+a`n#Vs;3#!&m(eMg&5d~^?eVS)!)&0SCAfWJ&fx}3X7Rhcv;+PF#dktt_G|-Mw z?);II4+n<2yfS)twy)w7Bw5aGT^P8F_8^wS;5k5O+&- zUObu*T}em5$ajXnGxq9?qb}~K(~DQm&Nw#29UF|+Gcyi%+~GEgugy3b;*N%-qi~En z|Lt4)C+)<-U&_6UB#zginQHosla?Co5*$JK&n@>f;Ib?nLFD zX>l)h5$q$o&Oto%%Bz!&iItmXgvaB;_D&~{63&IlE8p<=9QMpzXWR;5T!Zk@`XS~0mjUfDP!ZcW-( z%-E~qb_n{<*lXkVS_7|na`hBDWtw!yE4R#ujmzS-PI{&upV~b6t$5|O8Sxv-;yo(8 zC3yNuUum0cN>py1^2955%!oU`@Ucu$`9c>94)nQp$o^ure&esFfNKUtEu|0R;0r@c4RX^KZlejze>!l0 zX3Ub+{W5z@a41eG7S}R(>G2HZe2B66UU-U?p?IyU20>d1EZ4k0zK5#nwsA(52 zo5}Q8JvQ{~Jv?w`He7}DxGjzPcLE%Y2L?Qpv&<-|V1_5h=xsc~c>(Q)Un*pLZ2;Ff zUrQy{@P~;?D+{vU3u`p;%6_ut6a|yms)uw4Tg~-_4i`ffUZ`Hnm zP%QztEk3PUg8nW!s0ux@(hYHHj4;sCw1VQR>qB^Gce}>ng-(rx0OODFQ*J;OW$qU; zIYl>xlA(rJ>ERniH*7bQ7%weY%z4)6!LN9Rck0*XV~$CFd@xqLd79sn;sH zzWFP&IcE#p->=)x-pgh3i-$P9-^CDI(H5`&gs+3v{Nj0=J>Bq3qIknJUysps+`!L3 ztUbn6Y6qvx!wgymx;uXlK<4f=cBF>|i8gPs(0$3R11wC`6|`hKQe~cLAqozA6b=h! z+!b3goLG=n)D270qQ17wy)E&7{)-%t)SSV(tZu~sd?o-Ns-beqa4jM@6Yctge&z*{ z9Y6ibt84b^xx4TFE4=~fz5T-vUEl?%?8XRsgaG&6z8t090Pj5DL6x5ps6*&@1RaDs zf4BfqXDG{FvJc8MG;|y&x@Q0F{N2kReEy?;gV!uST^Y$XTG)10f}>KEJrS`(>o8#kRFQ#c6vAx0B{cP$$Bwa<~+#5Qfk2fe%EDiYiE ztys}>)4Vsy<$Ukx>qjp(kM54Sx?}l0)0}LGvL_)_#JCD7%5E$5j5N1w1q`p6;WHl9 zO5|{aga&hh0VV8(E2`rV)xb-A4e^u#K`lB?>DobXKyn-1? zK~#0pIwe&X21w<);ESk??&V|~R>M~Wy%AUl_=N6(iKbh4FR>rx{ z6cv+>D#)DmQF}74JSrsfN+5HTLS24&G%s0J8DokUa+&f?3zn5^c``45!3@t^1;w;{ zt8w=OtpA?_tjMnZf`RPIYL2bBpJ#$BS71x;SMqGh{o*3ld4Chfo?`FkZDUt1yuvc; zH!N7n*eZxD7L_Dd6ecUGzOX|%Dda7f;d#rx#%On}Sja)lT*mHP5D*d>v1|dc7xEZu v&Q}hEp2%Y>DSPm|RaClQfhSI~V8MnE&j9N=2*G5k7X%HeS@QrPx&i(V5NPP> literal 0 HcmV?d00001 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/validators.cpython-313.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/validators.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..50e8ec164c45d16e19235be4a68fd8bd69bf0083 GIT binary patch literal 2819 zcmb7GYfuwc6u$dN2yZZ;V8sa!AMt^fM+HRe_@LDmr*)yz88h99$r2aC2Ji0L+R;&` zgM#?LQ7Wy@)J|IkpPA8d5Uu*tpP|3v_75}70{It>)Bf$byCEbNespJY?&~|}+LuUwa2qTW-=ufEKG^QP>1XUo8S&BHeo;Hz~ z@mZYKZ`bXTdQ`=6*zFg+Zbb}8jst?fN5Hb9Gk_gFRrGruo|D`7JmtTKjGE0;;|T~yL;4%p2~BImqkr`gTra&Vq6*O{;7 z8F%u9c_jfHwZaC~d=N9wF6q6$MQ@#*xqnjsVMJpkcekKfv7o3}@=%F$kcNbVfnWR# zh#}O0NLa|{1(c84m&A6U`b3|jn-(GDChN?+>ul5I#JXhz;;f%j64gQ3(Z_`dN%WVF z&BQtcw+)hn%iwGsbRMdu>u4FmUxl)f1NkzYSt|HIi4*kRi9G#LA3QgGGw7sS)e>0I zQ|2OL=Vre9Ju)^vJvtsGSwlb-?|^1bKhT30qt{N%+&Ebi8M~$5y)u30MzkmauY3n? z+MIdRJM;W>&Bi3&Y162c8r7^(EgDs!QL8k{rBNF+>S(LFa$Xbq*dXDJsJw4SINyHmXn4u8kfZg5@}e`eye+h( z?T>=C*A`S<5woF!k~kPfX_-v!iu~exLHwUg_5x$(&0{8+$1Ev9DH%C^epJ8rC_N)n z=h8BgY${p+f~4hIXj75Vq38Pe4SoEwer=?7{revzGcEFXAbRB?Ci#gM6J^TC6Pb|E zftP~Rijue9<98nsFmHsWa-q*j(FwoxQqiguc2bZ2xF7e+edV%>v$3 zLCfkC1;5vD~{gQ>`dzeYPejCsnDtrf(2bCR&&Qw8}*_$XPv+AUst%J!Dl&Z;^ zn)}FY8sA(&lIRW^ic%94om35rsli!cHG_C{+Kl387L}>Hzf281oEi$YVsZ>M2SjSv zjA-l6d!s);nHs*IM60=gGpF@mZ$`$?Z*0fKFy*AR3{m!qK2gyaAQI5J5~gt#2#t}{ zZkcQxxNL^5bS`uOSyaTsg26tYfVI^N#_t^@xhDA_2nZrjqBv|X87{w`HGFiWIaJmV zvNwcs8Yc6LE*$GWcHwyc@#J!7?UqnkOUT|5%4vz@6%MW#=;~v_*?EKI0}V;g2w)mR z_Qp_7<7B>lkQ=V(I~KAnGgE<8gm0Aj$yW#aNsB&tCKh@|W}gYh$2u)3^n!w=Up%9q z>ecT(PAT}wxzspCUxG&hA~&6?m|nr>N_ZNj2pv-?Rh*2ATap5*BYhJl`o|S@DaKAO&C|gXg^g55dU_{3Y^q3(A3jS7T%aW0GweyC2>OOtyr}iC9I1 zlZa{%S~1U?io9Dze1B z#A$G19*vx8kCVlu=4e*sNRKG_j7u}mn|=qHt=Wu83nqVFNa>OZ>0=B tuple[Optional[GuildTask], str]: query = str(task_query or "").strip() - if not query: - return None, "任务不能为空" - for task in guild.tasks: - if task.task_id == query or task.name == query: - return task, "" + if not query: + return None, "任务不能为空" + for task in guild.tasks: + if query in (task.task_id, task.name): + return task, "" query_lower = query.casefold() matched = [ task @@ -254,9 +253,9 @@ def _activity_multiplier(self, key: str) -> float: if not isinstance(event, dict): return 1.0 expires_at = float(event.get("expires_at", 0) or 0) - if expires_at > 0 and expires_at <= _now(): - getattr(self, "_guild_runtime_events", {}).pop(key, None) - return 1.0 + if 0 < expires_at <= _now(): + getattr(self, "_guild_runtime_events", {}).pop(key, None) + return 1.0 try: multiplier = float(event.get("multiplier", 1.0)) except (TypeError, ValueError): @@ -329,16 +328,16 @@ def api_get_player_record( member = guild.get_member(str(player_name).strip()) if member is None: return False, "成员数据异常", None - records = [ - log.to_dict() - for log in guild.audit_logs - if log.actor == member.name or log.target == member.name - ] - vault_records = [ - log.to_dict() - for log in guild.vault_trade_logs - if log.actor == member.name or log.seller == member.name or log.buyer == member.name - ] + records = [ + log.to_dict() + for log in guild.audit_logs + if member.name in (log.actor, log.target) + ] + vault_records = [ + log.to_dict() + for log in guild.vault_trade_logs + if member.name in (log.actor, log.seller, log.buyer) + ] return True, "查询成功", { "guild": _guild_summary(guild), "member": _member_summary(member), @@ -481,9 +480,9 @@ def api_leave_guild_as_player( def api_disband_owned_guild_as_player( self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: - """Disband the player's own guild, only when the player is the owner.""" - guilds = _load_guilds(self) - name, guild, member, err = _find_player_context(self, player_name, guilds) + """Disband the player's own guild, only when the player is the owner.""" + guilds = _load_guilds(self) + _, guild, member, err = _find_player_context(self, player_name, guilds) if guild is None or member is None: return False, err, None if member.rank != GuildRank.OWNER: @@ -1696,9 +1695,9 @@ def api_get_guild_activity_status(self) -> tuple[bool, str, dict[str, Any]]: active = {} for key, event in list(events.items()): expires_at = float(event.get("expires_at", 0) or 0) - if expires_at > 0 and expires_at <= now: - events.pop(key, None) - continue + if 0 < expires_at <= now: + events.pop(key, None) + continue active[key] = copy.deepcopy(event) active[key]["remaining_seconds"] = max( 0, int(expires_at - now)) if expires_at > 0 else 0 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" index db834e16..9e22b669 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" @@ -1,362 +1,362 @@ -"""Runtime configuration loader for the guild plugin. - -ToolDelta creates the runtime config file from DEFAULT_CONFIG when the -plugin is loaded. Do not ship a plugin-local ``plugin config`` directory. -""" - -from __future__ import annotations - -import copy -import json -from typing import Any - -from tooldelta import cfg, fmts - - -PLUGIN_ENABLED_KEY = "是否启用插件" - -DEFAULT_CONFIG_JSON = r'''{ - "配置版本": "0.1.7", - "动态载入设置": { - "是否启用动态载入配置文件(仅用于本插件)": true, - "动态载入检测时间间隔(单位:秒)": 5 - }, - "基础配置": { - "是否启用插件": false, - "公会菜单唤醒词": [ - ".公会" - ], - "积分计分板名称": "money", - "缓存有效时间秒": 300, - "批量保存间隔秒": 5, - "批量保存最大数量": 10 - }, - "功能开关": { - "公会仓库": true, - "公会据点": true, - "公会捐献": true, - "公会任务": true, - "公会效果": true, - "公会排行": true - }, - "公会配置": { - "公会最大成员数": 30, - "在线成员每次增加经验": 10, - "在线经验增加间隔秒": 600, - "每日登录经验": 5, - "捐献转经验倍率": 0.1, - "每页显示数量": 6, - "公会各等级升级所需经验": { - "2": 100, - "3": 200, - "4": 400, - "5": 800, - "6": 1600, - "7": 3200, - "8": 6400, - "9": 12800, - "10": 25600 - } - }, - "创建配置": { - "创建公会消耗积分": 5000, - "创建冷却秒": 300, - "最短公会名长度": 2, - "最长公会名长度": 20, - "禁止公会名列表": [ - "管理员", - "系统", - "官方" - ], - "禁止公会名包含词": [ - "admin", - "operator" - ], - "启用同名模糊检测": true, - "模糊检测最小相似度": 0.88, - "创建后全服公告": true - }, - "仓库配置": { - "初始容量": 54, - "每级增加容量": 0, - "交易税率": 0.05, - "市场配置": { - "启用交易日志": true, - "交易日志保留数量": 120, - "启用撤回出售": true, - "只允许撤回自己的物品": false, - "撤回后返还物品": true, - "允许购买自己出售的物品": false, - "单个成员最大上架数量": 18, - "单次出售最大数量": 64, - "单笔价格下限": 1, - "单笔价格上限": 100000, - "建议价最小倍率": 0.2, - "建议价最大倍率": 5.0, - "高价交易审计倍率": 3.0 - } - }, - "经验配置": { - "在线经验": { - "每次增加经验": 10, - "增加间隔秒": 600 - }, - "登录经验": { - "每日登录经验": 5 - }, - "捐献经验": { - "转经验倍率": 0.1 - } - }, - "据点配置": { - "维度名称映射": { - "0": "主世界", - "1": "地狱", - "2": "末地" - } - }, - "效果系统": { - "刷新间隔秒": 3600, - "效果列表": { - "speed": { - "name": "速度提升", - "levels": { - "1": 1, - "2": 2 - }, - "costs": { - "1": 10, - "2": 20 - } - }, - "haste": { - "name": "急迫", - "levels": { - "1": 1, - "2": 2 - }, - "costs": { - "1": 10, - "2": 20 - } - }, - "strength": { - "name": "力量", - "levels": { - "1": 1, - "2": 2 - }, - "costs": { - "1": 10, - "2": 20 - } - }, - "resistance": { - "name": "抗性提升", - "levels": { - "1": 1, - "2": 2 - }, - "costs": { - "1": 10, - "2": 20 - } - }, - "regeneration": { - "name": "生命恢复", - "levels": { - "1": 1, - "2": 2 - }, - "costs": { - "1": 10, - "2": 20 - } - }, - "night_vision": { - "name": "夜视", - "levels": { - "1": 1, - "2": 2 - }, - "costs": { - "1": 10, - "2": 20 - } - }, - "jump_boost": { - "name": "跳跃提升", - "levels": { - "1": 1, - "2": 2 - }, - "costs": { - "1": 10, - "2": 20 - } - } - } - }, - "申请配置": { - "启用离线申请队列": true, - "申请有效期秒": 86400, - "重复申请冷却秒": 300, - "每个玩家最多待处理申请数": 3, - "每个公会最多待处理申请数": 30, - "申请理由最大长度": 60, - "满员时自动拒绝新申请": true, - "申请提交后通知在线管理员": true, - "批准后通知全体在线成员": true, - "拒绝后保留记录": true - }, - "任务系统": { - "启用自动任务模板": true, - "自动任务生成间隔秒": 86400, - "每次生成自动任务数量": 3, - "自动任务最大同时存在数量": 6, - "自动任务默认有效期秒": 172800, - "成员每日可完成任务数量": 5, - "创建任务名称最大长度": 20, - "创建任务描述最大长度": 100, - "创建任务目标数量上限": 10000, - "创建任务贡献奖励上限": 1000, - "创建任务经验奖励上限": 1000, - "自动任务模板列表": [ - { - "name": "每日仓库交易", - "description": "完成一次公会仓库交易", - "task_type": "trade", - "target": "trade_count", - "target_count": 1, - "reward_exp": 30, - "reward_contribution": 10 - }, - { - "name": "钻石补给", - "description": "收集钻石补给公会", - "task_type": "collect", - "target": "minecraft:diamond", - "target_count": 8, - "reward_exp": 80, - "reward_contribution": 25 - }, - { - "name": "基础建材", - "description": "提交圆石作为公会建材", - "task_type": "collect", - "target": "minecraft:cobblestone", - "target_count": 64, - "reward_exp": 40, - "reward_contribution": 12 - } - ] - }, - "权限系统": { - "职位权限配置": { - "会长": { - "踢出成员权限": true, - "处理/同意加入公会申请权限": true, - "设置公会公告权限": true, - "管理公会任务权限": true, - "公会仓库使用权限": true, - "设置公会据点权限": true, - "返回公会据点权限": true, - "购买公会效果权限": true, - "设置仓库物品价值权限": true, - "出售仓库物品权限": true, - "购买仓库物品权限": true, - "撤回自己出售物品权限": true, - "撤回任意仓库物品权限": true, - "处理加入申请队列权限": true, - "查看审计日志权限": true, - "设置成员职位权限": true, - "转让会长权限": true, - "创建公会任务权限": true, - "删除公会任务权限": true, - "强制完成公会任务权限": true - }, - "副会长": { - "踢出成员权限": true, - "处理/同意加入公会申请权限": true, - "设置公会公告权限": true, - "管理公会任务权限": true, - "公会仓库使用权限": true, - "设置公会据点权限": false, - "返回公会据点权限": true, - "购买公会效果权限": true, - "设置仓库物品价值权限": true, - "出售仓库物品权限": true, - "购买仓库物品权限": true, - "撤回自己出售物品权限": true, - "撤回任意仓库物品权限": true, - "处理加入申请队列权限": true, - "查看审计日志权限": true, - "设置成员职位权限": true, - "转让会长权限": false, - "创建公会任务权限": true, - "删除公会任务权限": true, - "强制完成公会任务权限": true - }, - "长老": { - "踢出成员权限": false, - "处理/同意加入公会申请权限": true, - "设置公会公告权限": true, - "管理公会任务权限": true, - "公会仓库使用权限": true, - "设置公会据点权限": false, - "返回公会据点权限": true, - "购买公会效果权限": true, - "设置仓库物品价值权限": false, - "出售仓库物品权限": true, - "购买仓库物品权限": true, - "撤回自己出售物品权限": true, - "撤回任意仓库物品权限": false, - "处理加入申请队列权限": true, - "查看审计日志权限": false, - "设置成员职位权限": false, - "转让会长权限": false, - "创建公会任务权限": true, - "删除公会任务权限": false, - "强制完成公会任务权限": false - }, - "成员": { - "踢出成员权限": false, - "处理/同意加入公会申请权限": false, - "设置公会公告权限": false, - "管理公会任务权限": false, - "公会仓库使用权限": true, - "设置公会据点权限": false, - "返回公会据点权限": true, - "购买公会效果权限": false, - "设置仓库物品价值权限": false, - "出售仓库物品权限": true, - "购买仓库物品权限": true, - "撤回自己出售物品权限": true, - "撤回任意仓库物品权限": false, - "处理加入申请队列权限": false, - "查看审计日志权限": false, - "设置成员职位权限": false, - "转让会长权限": false, - "创建公会任务权限": false, - "删除公会任务权限": false, - "强制完成公会任务权限": false - } - } - }, - "数据安全": { - "启用保存前备份": true, - "备份目录名": "公会数据备份", - "最大备份数量": 10, - "强制保存时也备份": true, - "启用异常数据跳过": true, - "启动时自动修复缺失字段": true, - "修复前写入备份": true, - "审计日志保留数量": 200 - }, - "提示词配置": { - "已有公会提示词": "§c❀ §r你已经有公会了", - "快捷创建缺少名称提示词": "§a❀ §r请输入公会名称,例如: 公会创建 我的公会", - "快捷创建名称长度无效提示词": "§c❀ §r公会名必须在2-16个字符之间", +"""Runtime configuration loader for the guild plugin. + +ToolDelta creates the runtime config file from DEFAULT_CONFIG when the +plugin is loaded. Do not ship a plugin-local ``plugin config`` directory. +""" + +from __future__ import annotations + +import copy +import json +from typing import Any + +from tooldelta import cfg, fmts + + +PLUGIN_ENABLED_KEY = "是否启用插件" + +DEFAULT_CONFIG_JSON = r'''{ + "配置版本": "0.1.7", + "动态载入设置": { + "是否启用动态载入配置文件(仅用于本插件)": true, + "动态载入检测时间间隔(单位:秒)": 5 + }, + "基础配置": { + "是否启用插件": false, + "公会菜单唤醒词": [ + ".公会" + ], + "积分计分板名称": "money", + "缓存有效时间秒": 300, + "批量保存间隔秒": 5, + "批量保存最大数量": 10 + }, + "功能开关": { + "公会仓库": true, + "公会据点": true, + "公会捐献": true, + "公会任务": true, + "公会效果": true, + "公会排行": true + }, + "公会配置": { + "公会最大成员数": 30, + "在线成员每次增加经验": 10, + "在线经验增加间隔秒": 600, + "每日登录经验": 5, + "捐献转经验倍率": 0.1, + "每页显示数量": 6, + "公会各等级升级所需经验": { + "2": 100, + "3": 200, + "4": 400, + "5": 800, + "6": 1600, + "7": 3200, + "8": 6400, + "9": 12800, + "10": 25600 + } + }, + "创建配置": { + "创建公会消耗积分": 5000, + "创建冷却秒": 300, + "最短公会名长度": 2, + "最长公会名长度": 20, + "禁止公会名列表": [ + "管理员", + "系统", + "官方" + ], + "禁止公会名包含词": [ + "admin", + "operator" + ], + "启用同名模糊检测": true, + "模糊检测最小相似度": 0.88, + "创建后全服公告": true + }, + "仓库配置": { + "初始容量": 54, + "每级增加容量": 0, + "交易税率": 0.05, + "市场配置": { + "启用交易日志": true, + "交易日志保留数量": 120, + "启用撤回出售": true, + "只允许撤回自己的物品": false, + "撤回后返还物品": true, + "允许购买自己出售的物品": false, + "单个成员最大上架数量": 18, + "单次出售最大数量": 64, + "单笔价格下限": 1, + "单笔价格上限": 100000, + "建议价最小倍率": 0.2, + "建议价最大倍率": 5.0, + "高价交易审计倍率": 3.0 + } + }, + "经验配置": { + "在线经验": { + "每次增加经验": 10, + "增加间隔秒": 600 + }, + "登录经验": { + "每日登录经验": 5 + }, + "捐献经验": { + "转经验倍率": 0.1 + } + }, + "据点配置": { + "维度名称映射": { + "0": "主世界", + "1": "地狱", + "2": "末地" + } + }, + "效果系统": { + "刷新间隔秒": 3600, + "效果列表": { + "speed": { + "name": "速度提升", + "levels": { + "1": 1, + "2": 2 + }, + "costs": { + "1": 10, + "2": 20 + } + }, + "haste": { + "name": "急迫", + "levels": { + "1": 1, + "2": 2 + }, + "costs": { + "1": 10, + "2": 20 + } + }, + "strength": { + "name": "力量", + "levels": { + "1": 1, + "2": 2 + }, + "costs": { + "1": 10, + "2": 20 + } + }, + "resistance": { + "name": "抗性提升", + "levels": { + "1": 1, + "2": 2 + }, + "costs": { + "1": 10, + "2": 20 + } + }, + "regeneration": { + "name": "生命恢复", + "levels": { + "1": 1, + "2": 2 + }, + "costs": { + "1": 10, + "2": 20 + } + }, + "night_vision": { + "name": "夜视", + "levels": { + "1": 1, + "2": 2 + }, + "costs": { + "1": 10, + "2": 20 + } + }, + "jump_boost": { + "name": "跳跃提升", + "levels": { + "1": 1, + "2": 2 + }, + "costs": { + "1": 10, + "2": 20 + } + } + } + }, + "申请配置": { + "启用离线申请队列": true, + "申请有效期秒": 86400, + "重复申请冷却秒": 300, + "每个玩家最多待处理申请数": 3, + "每个公会最多待处理申请数": 30, + "申请理由最大长度": 60, + "满员时自动拒绝新申请": true, + "申请提交后通知在线管理员": true, + "批准后通知全体在线成员": true, + "拒绝后保留记录": true + }, + "任务系统": { + "启用自动任务模板": true, + "自动任务生成间隔秒": 86400, + "每次生成自动任务数量": 3, + "自动任务最大同时存在数量": 6, + "自动任务默认有效期秒": 172800, + "成员每日可完成任务数量": 5, + "创建任务名称最大长度": 20, + "创建任务描述最大长度": 100, + "创建任务目标数量上限": 10000, + "创建任务贡献奖励上限": 1000, + "创建任务经验奖励上限": 1000, + "自动任务模板列表": [ + { + "name": "每日仓库交易", + "description": "完成一次公会仓库交易", + "task_type": "trade", + "target": "trade_count", + "target_count": 1, + "reward_exp": 30, + "reward_contribution": 10 + }, + { + "name": "钻石补给", + "description": "收集钻石补给公会", + "task_type": "collect", + "target": "minecraft:diamond", + "target_count": 8, + "reward_exp": 80, + "reward_contribution": 25 + }, + { + "name": "基础建材", + "description": "提交圆石作为公会建材", + "task_type": "collect", + "target": "minecraft:cobblestone", + "target_count": 64, + "reward_exp": 40, + "reward_contribution": 12 + } + ] + }, + "权限系统": { + "职位权限配置": { + "会长": { + "踢出成员权限": true, + "处理/同意加入公会申请权限": true, + "设置公会公告权限": true, + "管理公会任务权限": true, + "公会仓库使用权限": true, + "设置公会据点权限": true, + "返回公会据点权限": true, + "购买公会效果权限": true, + "设置仓库物品价值权限": true, + "出售仓库物品权限": true, + "购买仓库物品权限": true, + "撤回自己出售物品权限": true, + "撤回任意仓库物品权限": true, + "处理加入申请队列权限": true, + "查看审计日志权限": true, + "设置成员职位权限": true, + "转让会长权限": true, + "创建公会任务权限": true, + "删除公会任务权限": true, + "强制完成公会任务权限": true + }, + "副会长": { + "踢出成员权限": true, + "处理/同意加入公会申请权限": true, + "设置公会公告权限": true, + "管理公会任务权限": true, + "公会仓库使用权限": true, + "设置公会据点权限": false, + "返回公会据点权限": true, + "购买公会效果权限": true, + "设置仓库物品价值权限": true, + "出售仓库物品权限": true, + "购买仓库物品权限": true, + "撤回自己出售物品权限": true, + "撤回任意仓库物品权限": true, + "处理加入申请队列权限": true, + "查看审计日志权限": true, + "设置成员职位权限": true, + "转让会长权限": false, + "创建公会任务权限": true, + "删除公会任务权限": true, + "强制完成公会任务权限": true + }, + "长老": { + "踢出成员权限": false, + "处理/同意加入公会申请权限": true, + "设置公会公告权限": true, + "管理公会任务权限": true, + "公会仓库使用权限": true, + "设置公会据点权限": false, + "返回公会据点权限": true, + "购买公会效果权限": true, + "设置仓库物品价值权限": false, + "出售仓库物品权限": true, + "购买仓库物品权限": true, + "撤回自己出售物品权限": true, + "撤回任意仓库物品权限": false, + "处理加入申请队列权限": true, + "查看审计日志权限": false, + "设置成员职位权限": false, + "转让会长权限": false, + "创建公会任务权限": true, + "删除公会任务权限": false, + "强制完成公会任务权限": false + }, + "成员": { + "踢出成员权限": false, + "处理/同意加入公会申请权限": false, + "设置公会公告权限": false, + "管理公会任务权限": false, + "公会仓库使用权限": true, + "设置公会据点权限": false, + "返回公会据点权限": true, + "购买公会效果权限": false, + "设置仓库物品价值权限": false, + "出售仓库物品权限": true, + "购买仓库物品权限": true, + "撤回自己出售物品权限": true, + "撤回任意仓库物品权限": false, + "处理加入申请队列权限": false, + "查看审计日志权限": false, + "设置成员职位权限": false, + "转让会长权限": false, + "创建公会任务权限": false, + "删除公会任务权限": false, + "强制完成公会任务权限": false + } + } + }, + "数据安全": { + "启用保存前备份": true, + "备份目录名": "公会数据备份", + "最大备份数量": 10, + "强制保存时也备份": true, + "启用异常数据跳过": true, + "启动时自动修复缺失字段": true, + "修复前写入备份": true, + "审计日志保留数量": 200 + }, + "提示词配置": { + "已有公会提示词": "§c❀ §r你已经有公会了", + "快捷创建缺少名称提示词": "§a❀ §r请输入公会名称,例如: 公会创建 我的公会", + "快捷创建名称长度无效提示词": "§c❀ §r公会名必须在2-16个字符之间", "创建公会余额不足提示词": ( "§c❀ §r创建公会需要 §e{consume}§r 点 " "§b{scoreboard}§r 计分板积分\n" @@ -367,1332 +367,1356 @@ "§a❀ §r当前余额: §f{balance}\n" "§a❀ §r输入 §a确认§7 继续创建,输入 §cq§7 取消" ), - "创建公会回复超时提示词": "§c❀ §r回复超时,已取消创建公会", - "创建公会取消提示词": "§c❀ §r已取消创建公会", - "创建公会输入名称提示词": "§a❀ §r请输入公会名字:\n§a❀ §r要求: 2-20个字符,不能包含特殊符号", - "创建公会名称无效提示词": "§c❀ §r{error}", - "创建公会二次余额不足提示词": "§c❀ §r当前 §b{scoreboard}§r 余额不足,需要 §e{consume}§r,当前 §f{balance}", - "创建公会成功提示词": "§a❀ §r已创建公会 §e{guild}", - "创建公会全服公告提示词": "§a❀ §r§e{player}§r 创建了公会 §e{guild}§r!", - "创建公会名称已存在提示词": "§c❀ §r该公会名已存在", - "菜单回复超时提示词": "§c❀ §r回复超时!已退出公会系统", - "无效指令提示词": "§c❀ §r无效的指令", - "通用分页为空提示词": "§c❀ §r{title}为空", - "通用分页超时提示词": "§c❀ §r操作超时", - "通用分页退出提示词": "§a❀ §r已退出", - "通用分页无效选择提示词": "§c❀ §r无效的选择", - "公会列表为空提示词": "§c❀ §r公会列表为空", - "公会列表分页超时提示词": "§c❀ §r操作超时", - "公会列表分页退出提示词": "§a❀ §r已退出" - }, - "功能列表配置": { - "菜单标题": "公会管理系统", - "游客身份显示": "[§7游客§f]", - "成员身份显示模板": "[{rank}§f] §e{guild}", - "输入数字提示模板": "§a❀ §r输入 §e[1-{count}]§r 之间的数字选择功能", - "输入名称提示词": "§a❀ §r也可输入功能名称,输入 §cq §r退出", - "基础功能": { - "创建": { - "名称": "创建", - "描述": "创建自己的公会" - }, - "列表": { - "名称": "列表", - "描述": "查看所有公会" - }, - "查看": { - "名称": "查看", - "描述": "查看公会详情" - }, - "成员": { - "名称": "成员", - "描述": "查看成员列表" - }, - "日志": { - "名称": "日志", - "描述": "查看公会日志" - }, - "公告": { - "名称": "公告", - "描述": "查看/设置公告" - }, - "加入": { - "名称": "加入", - "描述": "加入一个公会" - }, - "退出": { - "名称": "退出", - "描述": "退出当前公会" - }, - "管理": { - "名称": "管理", - "描述": "管理公会成员" - }, - "解散": { - "名称": "解散", - "描述": "解散公会" - } - }, - "可选功能": { - "仓库": { - "名称": "仓库", - "描述": "公会仓库" - }, - "据点": { - "名称": "据点", - "描述": "据点相关操作" - }, - "捐献": { - "名称": "捐献", - "描述": "捐献物品到公会" - }, - "任务": { - "名称": "任务", - "描述": "公会任务系统" - }, - "效果": { - "名称": "效果", - "描述": "通过钻石获得效果增益" - }, - "排行": { - "名称": "排行", - "描述": "查看公会排行榜" - } - } - }, - "经济系统": { - "默认物品价值": { - "minecraft:diamond": 50, - "minecraft:emerald": 25, - "minecraft:gold_ingot": 10, - "minecraft:iron_ingot": 5, - "minecraft:copper_ingot": 2, - "minecraft:coal": 1, - "minecraft:redstone": 2, - "minecraft:lapis_lazuli": 3, - "minecraft:quartz": 2, - "minecraft:netherite_ingot": 200, - "minecraft:ancient_debris": 150, - "minecraft:ender_pearl": 20, - "minecraft:blaze_rod": 15, - "minecraft:ghast_tear": 25, - "minecraft:shulker_shell": 100, - "minecraft:nautilus_shell": 30, - "minecraft:heart_of_the_sea": 150, - "minecraft:stone": 1, - "minecraft:cobblestone": 1, - "minecraft:dirt": 1, - "minecraft:sand": 1, - "minecraft:gravel": 1, - "minecraft:obsidian": 10, - "minecraft:crying_obsidian": 15, - "minecraft:netherrack": 1, - "minecraft:end_stone": 5, - "minecraft:oak_log": 2, - "minecraft:birch_log": 2, - "minecraft:spruce_log": 2, - "minecraft:jungle_log": 2, - "minecraft:acacia_log": 2, - "minecraft:dark_oak_log": 2, - "minecraft:oak_planks": 1, - "minecraft:birch_planks": 1, - "minecraft:spruce_planks": 1, - "minecraft:jungle_planks": 1, - "minecraft:acacia_planks": 1, - "minecraft:dark_oak_planks": 1, - "minecraft:apple": 2, - "minecraft:golden_apple": 20, - "minecraft:enchanted_golden_apple": 100, - "minecraft:bread": 3, - "minecraft:beef": 3, - "minecraft:cooked_beef": 5, - "minecraft:porkchop": 3, - "minecraft:cooked_porkchop": 5, - "minecraft:chicken": 2, - "minecraft:cooked_chicken": 4, - "minecraft:cod": 2, - "minecraft:cooked_cod": 4, - "minecraft:salmon": 3, - "minecraft:cooked_salmon": 5, - "minecraft:carrot": 1, - "minecraft:golden_carrot": 8, - "minecraft:potato": 1, - "minecraft:baked_potato": 2, - "minecraft:diamond_block": 450, - "minecraft:emerald_block": 225, - "minecraft:gold_block": 90, - "minecraft:iron_block": 45, - "minecraft:copper_block": 18, - "minecraft:coal_block": 9, - "minecraft:redstone_block": 18, - "minecraft:lapis_block": 27, - "minecraft:quartz_block": 18, - "minecraft:netherite_block": 1800, - "minecraft:string": 1, - "minecraft:leather": 3, - "minecraft:feather": 2, - "minecraft:gunpowder": 5, - "minecraft:bone": 2, - "minecraft:bone_meal": 1, - "minecraft:spider_eye": 3, - "minecraft:slime_ball": 8, - "minecraft:magma_cream": 10, - "minecraft:wheat": 1, - "minecraft:wheat_seeds": 1, - "minecraft:sugar_cane": 2, - "minecraft:bamboo": 1, - "minecraft:kelp": 1, - "minecraft:cactus": 2 - }, - "中文物品名称": { - "钻石": "minecraft:diamond", - "绿宝石": "minecraft:emerald", - "金锭": "minecraft:gold_ingot", - "铁锭": "minecraft:iron_ingot", - "铜锭": "minecraft:copper_ingot", - "煤炭": "minecraft:coal", - "木炭": "minecraft:charcoal", - "红石": "minecraft:redstone", - "青金石": "minecraft:lapis_lazuli", - "石英": "minecraft:quartz", - "下界合金锭": "minecraft:netherite_ingot", - "远古残骸": "minecraft:ancient_debris", - "下界石英": "minecraft:nether_quartz", - "紫水晶碎片": "minecraft:amethyst_shard", - "紫水晶": "minecraft:amethyst_shard", - "末影珍珠": "minecraft:ender_pearl", - "烈焰棒": "minecraft:blaze_rod", - "恶魂之泪": "minecraft:ghast_tear", - "潜影贝壳": "minecraft:shulker_shell", - "鹦鹉螺壳": "minecraft:nautilus_shell", - "海洋之心": "minecraft:heart_of_the_sea", - "圆石": "minecraft:cobblestone", - "石头": "minecraft:stone", - "花岗岩": "minecraft:granite", - "闪长岩": "minecraft:diorite", - "安山岩": "minecraft:andesite", - "深板岩": "minecraft:deepslate", - "黑石": "minecraft:blackstone", - "玄武岩": "minecraft:basalt", - "末地石": "minecraft:end_stone", - "下界岩": "minecraft:netherrack", - "灵魂沙": "minecraft:soul_sand", - "灵魂土": "minecraft:soul_soil", - "橡木原木": "minecraft:oak_log", - "白桦原木": "minecraft:birch_log", - "云杉原木": "minecraft:spruce_log", - "丛林原木": "minecraft:jungle_log", - "金合欢原木": "minecraft:acacia_log", - "深色橡木原木": "minecraft:dark_oak_log", - "绯红菌柄": "minecraft:crimson_stem", - "诡异菌柄": "minecraft:warped_stem", - "橡木木板": "minecraft:oak_planks", - "白桦木板": "minecraft:birch_planks", - "云杉木板": "minecraft:spruce_planks", - "丛林木板": "minecraft:jungle_planks", - "金合欢木板": "minecraft:acacia_planks", - "深色橡木木板": "minecraft:dark_oak_planks", - "苹果": "minecraft:apple", - "金苹果": "minecraft:golden_apple", - "附魔金苹果": "minecraft:enchanted_golden_apple", - "面包": "minecraft:bread", - "牛肉": "minecraft:beef", - "熟牛肉": "minecraft:cooked_beef", - "猪肉": "minecraft:porkchop", - "熟猪肉": "minecraft:cooked_porkchop", - "鸡肉": "minecraft:chicken", - "熟鸡肉": "minecraft:cooked_chicken", - "羊肉": "minecraft:mutton", - "熟羊肉": "minecraft:cooked_mutton", - "鱼": "minecraft:cod", - "熟鱼": "minecraft:cooked_cod", - "鲑鱼": "minecraft:salmon", - "熟鲑鱼": "minecraft:cooked_salmon", - "胡萝卜": "minecraft:carrot", - "金胡萝卜": "minecraft:golden_carrot", - "土豆": "minecraft:potato", - "烤土豆": "minecraft:baked_potato", - "甜菜根": "minecraft:beetroot", - "甜菜汤": "minecraft:beetroot_soup", - "蘑菇煲": "minecraft:mushroom_stew", - "兔肉煲": "minecraft:rabbit_stew", - "木棍": "minecraft:stick", - "线": "minecraft:string", - "皮革": "minecraft:leather", - "羽毛": "minecraft:feather", - "火药": "minecraft:gunpowder", - "骨头": "minecraft:bone", - "骨粉": "minecraft:bone_meal", - "蜘蛛眼": "minecraft:spider_eye", - "腐肉": "minecraft:rotten_flesh", - "史莱姆球": "minecraft:slime_ball", - "岩浆膏": "minecraft:magma_cream", - "小麦": "minecraft:wheat", - "小麦种子": "minecraft:wheat_seeds", - "南瓜": "minecraft:pumpkin", - "南瓜种子": "minecraft:pumpkin_seeds", - "西瓜": "minecraft:melon", - "西瓜种子": "minecraft:melon_seeds", - "甘蔗": "minecraft:sugar_cane", - "竹子": "minecraft:bamboo", - "海带": "minecraft:kelp", - "仙人掌": "minecraft:cactus", - "墨囊": "minecraft:ink_sac", - "玫瑰红": "minecraft:red_dye", - "橙色染料": "minecraft:orange_dye", - "黄色染料": "minecraft:yellow_dye", - "黄绿色染料": "minecraft:lime_dye", - "绿色染料": "minecraft:green_dye", - "青色染料": "minecraft:cyan_dye", - "淡蓝色染料": "minecraft:light_blue_dye", - "蓝色染料": "minecraft:blue_dye", - "紫色染料": "minecraft:purple_dye", - "品红色染料": "minecraft:magenta_dye", - "粉红色染料": "minecraft:pink_dye", - "白色染料": "minecraft:white_dye", - "淡灰色染料": "minecraft:light_gray_dye", - "灰色染料": "minecraft:gray_dye", - "黑色染料": "minecraft:black_dye", - "棕色染料": "minecraft:brown_dye", - "泥土": "minecraft:dirt", - "草方块": "minecraft:grass_block", - "沙子": "minecraft:sand", - "红沙": "minecraft:red_sand", - "砂砾": "minecraft:gravel", - "粘土": "minecraft:clay", - "雪球": "minecraft:snowball", - "冰": "minecraft:ice", - "浮冰": "minecraft:packed_ice", - "蓝冰": "minecraft:blue_ice", - "黑曜石": "minecraft:obsidian", - "哭泣的黑曜石": "minecraft:crying_obsidian", - "基岩": "minecraft:bedrock", - "海绵": "minecraft:sponge", - "湿海绵": "minecraft:wet_sponge", - "钻石块": "minecraft:diamond_block", - "绿宝石块": "minecraft:emerald_block", - "金块": "minecraft:gold_block", - "铁块": "minecraft:iron_block", - "铜块": "minecraft:copper_block", - "煤炭块": "minecraft:coal_block", - "红石块": "minecraft:redstone_block", - "青金石块": "minecraft:lapis_block", - "石英块": "minecraft:quartz_block", - "下界合金块": "minecraft:netherite_block" - }, - "物品别名": { - "钻": "钻石", - "diamond": "钻石", - "钻石锭": "钻石", - "绿宝": "绿宝石", - "emerald": "绿宝石", - "村民币": "绿宝石", - "翡翠": "绿宝石", - "金": "金锭", - "gold": "金锭", - "黄金": "金锭", - "金子": "金锭", - "铁": "铁锭", - "iron": "铁锭", - "铜": "铜锭", - "copper": "铜锭", - "煤": "煤炭", - "coal": "煤炭", - "煤块": "煤炭", - "红石粉": "红石", - "redstone": "红石", - "青金": "青金石", - "lapis": "青金石", - "蓝宝石": "青金石", - "石英晶体": "石英", - "quartz": "石英", - "下界合金": "下界合金锭", - "netherite": "下界合金锭", - "合金": "下界合金锭", - "下合金": "下界合金锭", - "残骸": "远古残骸", - "ancient_debris": "远古残骸", - "远古": "远古残骸", - "橡木": "橡木原木", - "oak": "橡木原木", - "白桦": "白桦原木", - "birch": "白桦原木", - "云杉": "云杉原木", - "spruce": "云杉原木", - "丛林": "丛林原木", - "jungle": "丛林原木", - "金合欢": "金合欢原木", - "acacia": "金合欢原木", - "深色橡木": "深色橡木原木", - "dark_oak": "深色橡木原木", - "苹果": "苹果", - "apple": "苹果", - "金苹": "金苹果", - "golden_apple": "金苹果", - "附魔苹果": "附魔金苹果", - "神苹": "附魔金苹果", - "notch苹果": "附魔金苹果", - "石": "石头", - "stone": "石头", - "泥": "泥土", - "dirt": "泥土", - "土": "泥土", - "沙": "沙子", - "sand": "沙子", - "末影珠": "末影珍珠", - "ender_pearl": "末影珍珠", - "传送珠": "末影珍珠", - "烈焰": "烈焰棒", - "blaze_rod": "烈焰棒", - "火棒": "烈焰棒", - "恶魂泪": "恶魂之泪", - "ghast_tear": "恶魂之泪", - "鬼泪": "恶魂之泪" - } - } -}''' -DEFAULT_CONFIG: dict[str, Any] = json.loads(DEFAULT_CONFIG_JSON) - -LEVEL_EXP_CONFIG_KEY = "公会各等级升级所需经验" - -CONFIG_ATTRIBUTE_ALIASES = { - "GUILD_LEVEL_EXP": LEVEL_EXP_CONFIG_KEY, - "GUILD_MENU_TRIGGER": "公会菜单唤醒词", - "GUILD_CREATION_COST": "创建公会消耗积分", - "MAX_GUILD_MEMBERS": "公会最大成员数", - "GUILD_SCOREBOARD": "积分计分板名称", - "GUILD_FUNCTION_VAULT": "公会仓库", - "GUILD_FUNCTION_BASE": "公会据点", - "GUILD_FUNCTION_DONATION": "公会捐献", - "GUILD_FUNCTION_TASKS": "公会任务", - "GUILD_FUNCTION_EFFECT": "公会效果", - "GUILD_FUNCTION_RANKINGS": "公会排行", - "EXP_PER_ONLINE_MEMBER": "在线成员每次增加经验", - "EXP_UPDATE_INTERVAL": "在线经验增加间隔秒", - "DAILY_LOGIN_EXP": "每日登录经验", - "DONATION_EXP_RATE": "捐献转经验倍率", - "ITEMS_PER_PAGE": "每页显示数量", - "VAULT_INITIAL_SLOTS": "初始容量", - "VAULT_SLOTS_PER_LEVEL": "每级增加容量", - "VAULT_TRADE_TAX": "交易税率", - "DIMENSION_NAMES": "维度名称映射", - "CACHE_DURATION": "缓存有效时间秒", - "EFFECT_REFRESH_INTERVAL": "刷新间隔秒", - "EFFECTS_CONFIG": "效果列表", - "BATCH_SAVE_INTERVAL": "批量保存间隔秒", - "MAX_BATCH_SIZE": "批量保存最大数量", - "PERMISSIONS": "职位权限配置", - "GUILD_CREATE_CONFIG": "创建配置", - "GUILD_JOIN_REQUEST_CONFIG": "申请配置", - "GUILD_VAULT_CONFIG": "市场配置", - "GUILD_TASK_CONFIG": "任务系统", - "GUILD_DATA_SAFETY_CONFIG": "数据安全", - "PROMPT_CONFIG": "提示词配置", - "MENU_CONFIG": "功能列表配置", - "DEFAULT_ITEM_VALUES": "默认物品价值", - "CHINESE_ITEM_NAMES": "中文物品名称", - "ITEM_ALIASES": "物品别名", -} - -CONFIG_ATTRIBUTE_KEYS = { - config_key: attr_name for attr_name, - config_key in CONFIG_ATTRIBUTE_ALIASES.items()} - -REMOVED_CONFIG_ATTRIBUTES = ("GUILD_LEVELS",) - -CONFIG_VERSION_KEY = "配置版本" -GROUPED_CONFIG_VERSION = "0.1.7" -CONFIG_FILE_DIR = "插件配置文件" -RUNTIME_CONFIG_RELOAD_INTERVAL = 5 -DYNAMIC_LOAD_SETTINGS_KEY = "动态载入设置" -DYNAMIC_LOAD_ENABLED_KEY = "是否启用动态载入配置文件(仅用于本插件)" -DYNAMIC_LOAD_INTERVAL_KEY = "动态载入检测时间间隔(单位:秒)" -DYNAMIC_LOAD_DEFAULT = { - DYNAMIC_LOAD_ENABLED_KEY: True, - DYNAMIC_LOAD_INTERVAL_KEY: RUNTIME_CONFIG_RELOAD_INTERVAL, -} - - -def _merge_config(raw: Any, default: Any) -> Any: - if isinstance(default, dict): - merged = { - key: _merge_config(raw.get(key) if isinstance(raw, dict) else None, value) - for key, value in default.items() - } - - if isinstance(raw, dict): - for key, value in raw.items(): - if key not in merged: - merged[key] = copy.deepcopy(value) - - return merged - - return copy.deepcopy(raw) if raw is not None else copy.deepcopy(default) - - -def _normalize_bool(value: Any, fallback: bool) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - text = value.strip().lower() - if text in ("true", "1", "yes", "y", "on", "启用", "是", "真"): - return True - if text in ("false", "0", "no", "n", "off", "禁用", "否", "假"): - return False - if isinstance(value, (int, float)) and not isinstance(value, bool): - return bool(value) - return bool(fallback) - - -def _normalize_str( - value: Any, - fallback: str, - *, - allow_empty: bool = False) -> str: - if value is None: - return fallback - text = str(value).strip() - if text or allow_empty: - return text - return fallback - - -def _normalize_number(value: Any, fallback: int | float) -> int | float: - if isinstance(value, bool): - return fallback - if isinstance(value, (int, float)): - return value - try: - number = float(str(value).strip()) - except (TypeError, ValueError): - return fallback - if number.is_integer(): - return int(number) - return number - - -def _normalize_positive_int(value: Any, fallback: int) -> int: - if isinstance(value, bool): - return fallback - try: - result = int(value) - except (TypeError, ValueError): - return fallback - return result if result > 0 else fallback - - -def _normalize_non_negative_int(value: Any, fallback: int) -> int: - if isinstance(value, bool): - return fallback - try: - result = int(value) - except (TypeError, ValueError): - return fallback - return result if result >= 0 else fallback - - -def _normalize_string_list( - value: Any, - fallback: list[str], - *, - allow_empty: bool = False) -> list[str]: - if isinstance(value, str): - candidates = [value] - elif isinstance(value, list): - candidates = value - else: - return copy.deepcopy(fallback) - - result: list[str] = [] - for item in candidates: - if not isinstance(item, str): - continue - text = item.strip() - if text and text not in result: - result.append(text) - if result or allow_empty: - return result - return copy.deepcopy(fallback) - - -def _normalize_any_key_dict( - raw: Any, fallback: dict[str, Any], value_normalizer) -> dict[str, Any]: - source = raw if isinstance(raw, dict) else fallback - default_values = fallback if isinstance(fallback, dict) else {} - result: dict[str, Any] = {} - for key, value in source.items(): - text_key = str(key) - fallback_value = default_values.get(text_key) - if fallback_value is None and default_values: - fallback_value = next(iter(default_values.values())) - result[text_key] = value_normalizer(value, fallback_value) - return result - - -def _normalize_menu_items( - raw: Any, fallback: dict[str, Any]) -> dict[str, Any]: - source = _merge_config(raw, fallback) - result: dict[str, Any] = {} - for key, item_fallback in fallback.items(): - item = source.get(key) if isinstance(source, dict) else None - if not isinstance(item, dict): - item = {} - result[key] = { - "名称": _normalize_str( - item.get("名称"), - item_fallback["名称"]), - "描述": _normalize_str( - item.get("描述"), - item_fallback["描述"], - allow_empty=True), - } - return result - - -def _trim_fixed_keys(raw: dict[str, Any], - default: dict[str, Any]) -> dict[str, Any]: - return { - key: raw.get(key, copy.deepcopy(value)) - for key, value in default.items() - } - - -def _normalize_market_config( - raw: Any, fallback: dict[str, Any]) -> dict[str, Any]: - raw = raw if isinstance(raw, dict) else {} - result = copy.deepcopy(fallback) - bool_keys = ( - "启用交易日志", - "启用撤回出售", - "只允许撤回自己的物品", - "撤回后返还物品", - "允许购买自己出售的物品", - ) - positive_int_keys = ( - "交易日志保留数量", - "单个成员最大上架数量", - "单次出售最大数量", - "单笔价格下限", - "单笔价格上限", - ) - number_keys = ( - "建议价最小倍率", - "建议价最大倍率", - "高价交易审计倍率", - ) - for key in bool_keys: - result[key] = _normalize_bool(raw.get(key, result[key]), result[key]) - for key in positive_int_keys: - result[key] = _normalize_positive_int( - raw.get(key, result[key]), result[key]) - for key in number_keys: - result[key] = _normalize_number(raw.get(key, result[key]), result[key]) - return result - - -def _normalize_effects_config( - raw: Any, fallback: dict[str, Any]) -> dict[str, Any]: - source = raw if isinstance(raw, dict) else fallback - default_effect = next(iter(fallback.values())) if fallback else {} - result: dict[str, Any] = {} - for effect_id, effect_cfg in source.items(): - if not isinstance(effect_cfg, dict): - effect_cfg = {} - default = fallback.get(str(effect_id), default_effect) - result[str(effect_id)] = { - "name": _normalize_str(effect_cfg.get("name"), default.get("name", str(effect_id))), - "levels": _normalize_any_key_dict( - effect_cfg.get("levels"), - default.get("levels", {}), - lambda value, fallback_value: _normalize_non_negative_int( - value, - int(fallback_value or 0), - ), - ), - "costs": _normalize_any_key_dict( - effect_cfg.get("costs"), - default.get("costs", {}), - lambda value, fallback_value: _normalize_non_negative_int( - value, - int(fallback_value or 0), - ), - ), - } - return result - - -def _normalize_permissions( - raw: Any, fallback: dict[str, Any]) -> dict[str, Any]: - source = raw if isinstance(raw, dict) else fallback - default_role = next(iter(fallback.values())) if fallback else {} - result: dict[str, dict[str, bool]] = {} - for role, permissions in source.items(): - if not isinstance(permissions, dict): - permissions = {} - default_permissions = fallback.get(str(role), default_role) - merged_permissions = _merge_config(permissions, default_permissions) - result[str(role)] = { - str(key): _normalize_bool(value, bool(default_permissions.get(key, False))) - for key, value in merged_permissions.items() - } - return result - - -def _normalize_task_templates( - raw: Any, fallback: list[dict[str, Any]]) -> list[dict[str, Any]]: - source = raw if isinstance(raw, list) else fallback - default_template = fallback[0] if fallback else {} - result: list[dict[str, Any]] = [] - for template in source: - if not isinstance(template, dict): - continue - default = _merge_config(template, default_template) - result.append( - { - "name": _normalize_str( - template.get("name"), - default["name"]), - "description": _normalize_str( - template.get("description"), - default["description"]), - "task_type": _normalize_str( - template.get("task_type"), - default["task_type"]), - "target": _normalize_str( - template.get("target"), - default["target"]), - "target_count": _normalize_positive_int( - template.get("target_count"), - default["target_count"], - ), - "reward_exp": _normalize_non_negative_int( - template.get("reward_exp"), - default["reward_exp"], - ), - "reward_contribution": _normalize_non_negative_int( - template.get("reward_contribution"), - default["reward_contribution"], - ), - }) - return result or copy.deepcopy(fallback) - - -def _normalize_grouped_config(raw: dict[str, Any]) -> dict[str, Any]: - default = DEFAULT_CONFIG - config = _merge_config(raw, default) - config = {key: copy.deepcopy(config[key]) for key in default} - config[CONFIG_VERSION_KEY] = GROUPED_CONFIG_VERSION - - config[DYNAMIC_LOAD_SETTINGS_KEY] = _trim_fixed_keys( - config[DYNAMIC_LOAD_SETTINGS_KEY], - default[DYNAMIC_LOAD_SETTINGS_KEY], - ) - dynamic = config[DYNAMIC_LOAD_SETTINGS_KEY] - dynamic_default = default[DYNAMIC_LOAD_SETTINGS_KEY] - dynamic[DYNAMIC_LOAD_ENABLED_KEY] = _normalize_bool( - dynamic.get(DYNAMIC_LOAD_ENABLED_KEY), - dynamic_default[DYNAMIC_LOAD_ENABLED_KEY], - ) - dynamic[DYNAMIC_LOAD_INTERVAL_KEY] = _normalize_positive_int( - dynamic.get(DYNAMIC_LOAD_INTERVAL_KEY), - dynamic_default[DYNAMIC_LOAD_INTERVAL_KEY], - ) - - config["基础配置"] = _trim_fixed_keys(config["基础配置"], default["基础配置"]) - base = config["基础配置"] - base_default = default["基础配置"] - base["是否启用插件"] = _normalize_bool( - base.get("是否启用插件"), base_default["是否启用插件"]) - base["公会菜单唤醒词"] = _normalize_string_list( - base.get("公会菜单唤醒词"), - base_default["公会菜单唤醒词"], - ) - base["积分计分板名称"] = _normalize_str( - base.get("积分计分板名称"), - base_default["积分计分板名称"]) - for key in ("缓存有效时间秒", "批量保存间隔秒", "批量保存最大数量"): - base[key] = _normalize_positive_int(base.get(key), base_default[key]) - - config["公会配置"] = _trim_fixed_keys(config["公会配置"], default["公会配置"]) - guild = config["公会配置"] - guild_default = default["公会配置"] - for key in ( - "公会最大成员数", - "在线经验增加间隔秒", - "每页显示数量", - ): - guild[key] = _normalize_positive_int( - guild.get(key), guild_default[key]) - for key in ("在线成员每次增加经验", "每日登录经验"): - guild[key] = _normalize_non_negative_int( - guild.get(key), guild_default[key]) - guild["捐献转经验倍率"] = _normalize_number( - guild.get("捐献转经验倍率"), - guild_default["捐献转经验倍率"], - ) - guild[LEVEL_EXP_CONFIG_KEY] = _normalize_any_key_dict( - guild.get(LEVEL_EXP_CONFIG_KEY), - guild_default[LEVEL_EXP_CONFIG_KEY], - lambda value, fallback_value: _normalize_positive_int( - value, int(fallback_value or 1)), - ) - - config["功能开关"] = _trim_fixed_keys(config["功能开关"], default["功能开关"]) - feature_switches = config["功能开关"] - for key, fallback in default["功能开关"].items(): - feature_switches[key] = _normalize_bool( - feature_switches.get(key), fallback) - - config["仓库配置"] = _trim_fixed_keys(config["仓库配置"], default["仓库配置"]) - vault = config["仓库配置"] - vault_default = default["仓库配置"] - vault["初始容量"] = _normalize_positive_int( - vault.get("初始容量"), vault_default["初始容量"]) - vault["每级增加容量"] = _normalize_non_negative_int( - vault.get("每级增加容量"), - vault_default["每级增加容量"], - ) - vault["交易税率"] = _normalize_number(vault.get("交易税率"), vault_default["交易税率"]) - vault["市场配置"] = _normalize_market_config( - vault.get("市场配置"), vault_default["市场配置"]) - - config["经验配置"] = _trim_fixed_keys(config["经验配置"], default["经验配置"]) - config["经验配置"]["在线经验"] = _trim_fixed_keys( - config["经验配置"]["在线经验"], - default["经验配置"]["在线经验"], - ) - config["经验配置"]["登录经验"] = _trim_fixed_keys( - config["经验配置"]["登录经验"], - default["经验配置"]["登录经验"], - ) - config["经验配置"]["捐献经验"] = _trim_fixed_keys( - config["经验配置"]["捐献经验"], - default["经验配置"]["捐献经验"], - ) - exp = config["经验配置"] - exp_default = default["经验配置"] - exp["在线经验"]["每次增加经验"] = _normalize_non_negative_int( - exp["在线经验"].get("每次增加经验"), - exp_default["在线经验"]["每次增加经验"], - ) - exp["在线经验"]["增加间隔秒"] = _normalize_positive_int( - exp["在线经验"].get("增加间隔秒"), - exp_default["在线经验"]["增加间隔秒"], - ) - exp["登录经验"]["每日登录经验"] = _normalize_non_negative_int( - exp["登录经验"].get("每日登录经验"), - exp_default["登录经验"]["每日登录经验"], - ) - exp["捐献经验"]["转经验倍率"] = _normalize_number( - exp["捐献经验"].get("转经验倍率"), - exp_default["捐献经验"]["转经验倍率"], - ) - - config["据点配置"] = _trim_fixed_keys(config["据点配置"], default["据点配置"]) - config["据点配置"]["维度名称映射"] = _normalize_any_key_dict( - config["据点配置"].get("维度名称映射"), - default["据点配置"]["维度名称映射"], - lambda value, - fallback_value: _normalize_str(value, str(fallback_value or "")),) - - config["效果系统"] = _trim_fixed_keys(config["效果系统"], default["效果系统"]) - effects = config["效果系统"] - effects_default = default["效果系统"] - effects["刷新间隔秒"] = _normalize_positive_int( - effects.get("刷新间隔秒"), - effects_default["刷新间隔秒"], - ) - effects["效果列表"] = _normalize_effects_config( - effects.get("效果列表"), - effects_default["效果列表"], - ) - - config["创建配置"] = _trim_fixed_keys(config["创建配置"], default["创建配置"]) - create_cfg = config["创建配置"] - create_default = default["创建配置"] - create_cfg["创建公会消耗积分"] = _normalize_non_negative_int( - create_cfg.get("创建公会消耗积分"), - create_default["创建公会消耗积分"], - ) - for key in ("创建冷却秒", "最短公会名长度", "最长公会名长度"): - create_cfg[key] = _normalize_positive_int( - create_cfg.get(key), create_default[key]) - create_cfg["禁止公会名列表"] = _normalize_string_list( - create_cfg.get("禁止公会名列表"), - create_default["禁止公会名列表"], - allow_empty=True, - ) - create_cfg["禁止公会名包含词"] = _normalize_string_list( - create_cfg.get("禁止公会名包含词"), - create_default["禁止公会名包含词"], - allow_empty=True, - ) - create_cfg["启用同名模糊检测"] = _normalize_bool( - create_cfg.get("启用同名模糊检测"), - create_default["启用同名模糊检测"], - ) - create_cfg["模糊检测最小相似度"] = _normalize_number( - create_cfg.get("模糊检测最小相似度"), - create_default["模糊检测最小相似度"], - ) - create_cfg["创建后全服公告"] = _normalize_bool( - create_cfg.get("创建后全服公告"), - create_default["创建后全服公告"], - ) - - config["申请配置"] = _trim_fixed_keys(config["申请配置"], default["申请配置"]) - join_cfg = config["申请配置"] - join_default = default["申请配置"] - for key, fallback in join_default.items(): - if isinstance(fallback, bool): - join_cfg[key] = _normalize_bool(join_cfg.get(key), fallback) - else: - join_cfg[key] = _normalize_positive_int( - join_cfg.get(key), fallback) - - config["任务系统"] = _trim_fixed_keys(config["任务系统"], default["任务系统"]) - task_cfg = config["任务系统"] - task_default = default["任务系统"] - task_cfg["启用自动任务模板"] = _normalize_bool( - task_cfg.get("启用自动任务模板"), - task_default["启用自动任务模板"], - ) - for key in ( - "自动任务生成间隔秒", - "每次生成自动任务数量", - "自动任务最大同时存在数量", - "自动任务默认有效期秒", - "成员每日可完成任务数量", - "创建任务名称最大长度", - "创建任务描述最大长度", - "创建任务目标数量上限", - ): - task_cfg[key] = _normalize_positive_int( - task_cfg.get(key), task_default[key]) - for key in ("创建任务贡献奖励上限", "创建任务经验奖励上限"): - task_cfg[key] = _normalize_non_negative_int( - task_cfg.get(key), task_default[key]) - task_cfg["自动任务模板列表"] = _normalize_task_templates( - task_cfg.get("自动任务模板列表"), - task_default["自动任务模板列表"], - ) - - config["权限系统"] = _trim_fixed_keys(config["权限系统"], default["权限系统"]) - config["权限系统"]["职位权限配置"] = _normalize_permissions( - config["权限系统"].get("职位权限配置"), - default["权限系统"]["职位权限配置"], - ) - - config["数据安全"] = _trim_fixed_keys(config["数据安全"], default["数据安全"]) - safety = config["数据安全"] - safety_default = default["数据安全"] - safety["备份目录名"] = _normalize_str( - safety.get("备份目录名"), - safety_default["备份目录名"]) - for key in ( - "启用保存前备份", - "强制保存时也备份", - "启用异常数据跳过", - "启动时自动修复缺失字段", - "修复前写入备份", - ): - safety[key] = _normalize_bool(safety.get(key), safety_default[key]) - safety["最大备份数量"] = _normalize_positive_int( - safety.get("最大备份数量"), - safety_default["最大备份数量"], - ) - safety["审计日志保留数量"] = _normalize_positive_int( - safety.get("审计日志保留数量"), - safety_default["审计日志保留数量"], - ) - - prompt_config = _merge_config(config.get("提示词配置"), default["提示词配置"]) - config["提示词配置"] = _normalize_any_key_dict( - prompt_config, - prompt_config, - lambda value, fallback_value: _normalize_str( - value, - str(fallback_value or ""), - allow_empty=True, - ), - ) - - config["功能列表配置"] = _trim_fixed_keys(config["功能列表配置"], default["功能列表配置"]) - menu_config = config["功能列表配置"] - menu_default = default["功能列表配置"] - for key in ( - "菜单标题", - "游客身份显示", - "成员身份显示模板", - "输入数字提示模板", - "输入名称提示词", - ): - menu_config[key] = _normalize_str(menu_config.get( - key), menu_default[key], allow_empty=True) - menu_config["基础功能"] = _normalize_menu_items( - menu_config.get("基础功能"), - menu_default["基础功能"], - ) - menu_config["可选功能"] = _normalize_menu_items( - menu_config.get("可选功能"), - menu_default["可选功能"], - ) - - config["经济系统"] = _trim_fixed_keys(config["经济系统"], default["经济系统"]) - economy = config["经济系统"] - economy_default = default["经济系统"] - economy["默认物品价值"] = _normalize_any_key_dict( - economy.get("默认物品价值"), - economy_default["默认物品价值"], - lambda value, - fallback_value: _normalize_number( - value, - fallback_value or 1), - ) - for key in ("中文物品名称", "物品别名"): - economy[key] = _normalize_any_key_dict( - economy.get(key), - economy_default[key], - lambda value, fallback_value: _normalize_str( - value, str(fallback_value or "")), - ) - - return config - - -def grouped_config_std() -> dict[str, Any]: - """Return the ToolDelta schema for the grouped guild config.""" - number = (int, float) - string_map = cfg.AnyKeyValue(str) - number_map = cfg.AnyKeyValue(number) - int_map = cfg.AnyKeyValue(cfg.NNInt) - menu_item_std = { - "名称": str, - "描述": str, - } - effect_std = cfg.AnyKeyValue( - { - "name": str, - "levels": int_map, - "costs": int_map, - } - ) - permission_std = cfg.AnyKeyValue(cfg.AnyKeyValue(bool)) - task_template_std = { - "name": str, - "description": str, - "task_type": str, - "target": str, - "target_count": cfg.PInt, - "reward_exp": cfg.NNInt, - "reward_contribution": cfg.NNInt, - } - - return { - CONFIG_VERSION_KEY: str, - DYNAMIC_LOAD_SETTINGS_KEY: { - DYNAMIC_LOAD_ENABLED_KEY: bool, - DYNAMIC_LOAD_INTERVAL_KEY: cfg.PInt, - }, - "基础配置": { - "是否启用插件": bool, - "公会菜单唤醒词": cfg.JsonList(str, -1), - "积分计分板名称": str, - "缓存有效时间秒": cfg.PInt, - "批量保存间隔秒": cfg.PInt, - "批量保存最大数量": cfg.PInt, - }, - "功能开关": { - "公会仓库": bool, - "公会据点": bool, - "公会捐献": bool, - "公会任务": bool, - "公会效果": bool, - "公会排行": bool, - }, - "公会配置": { - "公会最大成员数": cfg.PInt, - "在线成员每次增加经验": cfg.NNInt, - "在线经验增加间隔秒": cfg.PInt, - "每日登录经验": cfg.NNInt, - "捐献转经验倍率": number, - "每页显示数量": cfg.PInt, - LEVEL_EXP_CONFIG_KEY: cfg.AnyKeyValue(cfg.PInt), - }, - "创建配置": { - "创建公会消耗积分": cfg.NNInt, - "创建冷却秒": cfg.PInt, - "最短公会名长度": cfg.PInt, - "最长公会名长度": cfg.PInt, - "禁止公会名列表": cfg.JsonList(str, -1), - "禁止公会名包含词": cfg.JsonList(str, -1), - "启用同名模糊检测": bool, - "模糊检测最小相似度": number, - "创建后全服公告": bool, - }, - "仓库配置": { - "初始容量": cfg.PInt, - "每级增加容量": cfg.NNInt, - "交易税率": number, - "市场配置": { - "启用交易日志": bool, - "交易日志保留数量": cfg.PInt, - "启用撤回出售": bool, - "只允许撤回自己的物品": bool, - "撤回后返还物品": bool, - "允许购买自己出售的物品": bool, - "单个成员最大上架数量": cfg.PInt, - "单次出售最大数量": cfg.PInt, - "单笔价格下限": cfg.PInt, - "单笔价格上限": cfg.PInt, - "建议价最小倍率": number, - "建议价最大倍率": number, - "高价交易审计倍率": number, - }, - }, - "经验配置": { - "在线经验": { - "每次增加经验": cfg.NNInt, - "增加间隔秒": cfg.PInt, - }, - "登录经验": { - "每日登录经验": cfg.NNInt, - }, - "捐献经验": { - "转经验倍率": number, - }, - }, - "据点配置": { - "维度名称映射": string_map, - }, - "效果系统": { - "刷新间隔秒": cfg.PInt, - "效果列表": effect_std, - }, - "申请配置": { - "启用离线申请队列": bool, - "申请有效期秒": cfg.PInt, - "重复申请冷却秒": cfg.PInt, - "每个玩家最多待处理申请数": cfg.PInt, - "每个公会最多待处理申请数": cfg.PInt, - "申请理由最大长度": cfg.PInt, - "满员时自动拒绝新申请": bool, - "申请提交后通知在线管理员": bool, - "批准后通知全体在线成员": bool, - "拒绝后保留记录": bool, - }, - "任务系统": { - "启用自动任务模板": bool, - "自动任务生成间隔秒": cfg.PInt, - "每次生成自动任务数量": cfg.PInt, - "自动任务最大同时存在数量": cfg.PInt, - "自动任务默认有效期秒": cfg.PInt, - "成员每日可完成任务数量": cfg.PInt, - "创建任务名称最大长度": cfg.PInt, - "创建任务描述最大长度": cfg.PInt, - "创建任务目标数量上限": cfg.PInt, - "创建任务贡献奖励上限": cfg.NNInt, - "创建任务经验奖励上限": cfg.NNInt, - "自动任务模板列表": cfg.JsonList(task_template_std, -1), - }, - "权限系统": { - "职位权限配置": permission_std, - }, - "数据安全": { - "启用保存前备份": bool, - "备份目录名": str, - "最大备份数量": cfg.PInt, - "强制保存时也备份": bool, - "启用异常数据跳过": bool, - "启动时自动修复缺失字段": bool, - "修复前写入备份": bool, - "审计日志保留数量": cfg.PInt, - }, - "提示词配置": string_map, - "功能列表配置": { - "菜单标题": str, - "游客身份显示": str, - "成员身份显示模板": str, - "输入数字提示模板": str, - "输入名称提示词": str, - "基础功能": cfg.AnyKeyValue(menu_item_std), - "可选功能": cfg.AnyKeyValue(menu_item_std), - }, - "经济系统": { - "默认物品价值": number_map, - "中文物品名称": string_map, - "物品别名": string_map, - }, - } - - -def _int_keyed_dict(raw: Any) -> Any: - if not isinstance(raw, dict): - return raw - - result = {} - for key, value in raw.items(): - try: - normalized_key = int(key) - except (TypeError, ValueError): - normalized_key = key - result[normalized_key] = value - return result - - -def _require_current_config_format(raw_config: Any) -> None: - if not isinstance(raw_config, dict): - raise ValueError("配置项必须是对象") - - -def _normalize_effect_runtime_config(raw: Any) -> Any: - effects = copy.deepcopy(raw) - if not isinstance(effects, dict): - return effects - - for effect in effects.values(): - if not isinstance(effect, dict): - continue - for key in ("levels", "costs"): - if key in effect: - effect[key] = _int_keyed_dict(effect[key]) - return effects - - -def _build_runtime_config(grouped_config: dict[str, Any]) -> dict[str, Any]: - base = grouped_config["基础配置"] - features = grouped_config["功能开关"] - guild = grouped_config["公会配置"] - create = grouped_config["创建配置"] - vault = grouped_config["仓库配置"] - exp = grouped_config["经验配置"] - base_point = grouped_config["据点配置"] - effects = grouped_config["效果系统"] - permissions = grouped_config["权限系统"] - economy = grouped_config["经济系统"] - - return { - "是否启用插件": copy.deepcopy(base["是否启用插件"]), - "公会菜单唤醒词": copy.deepcopy(base["公会菜单唤醒词"]), - "积分计分板名称": copy.deepcopy(base["积分计分板名称"]), - "缓存有效时间秒": copy.deepcopy(base["缓存有效时间秒"]), - "批量保存间隔秒": copy.deepcopy(base["批量保存间隔秒"]), - "批量保存最大数量": copy.deepcopy(base["批量保存最大数量"]), - "公会仓库": copy.deepcopy(features["公会仓库"]), - "公会据点": copy.deepcopy(features["公会据点"]), - "公会捐献": copy.deepcopy(features["公会捐献"]), - "公会任务": copy.deepcopy(features["公会任务"]), - "公会效果": copy.deepcopy(features["公会效果"]), - "公会排行": copy.deepcopy(features["公会排行"]), - "公会最大成员数": copy.deepcopy(guild["公会最大成员数"]), - "在线成员每次增加经验": copy.deepcopy(exp["在线经验"]["每次增加经验"]), - "在线经验增加间隔秒": copy.deepcopy(exp["在线经验"]["增加间隔秒"]), - "每日登录经验": copy.deepcopy(exp["登录经验"]["每日登录经验"]), - "捐献转经验倍率": copy.deepcopy(exp["捐献经验"]["转经验倍率"]), - "每页显示数量": copy.deepcopy(guild["每页显示数量"]), - LEVEL_EXP_CONFIG_KEY: _int_keyed_dict(guild[LEVEL_EXP_CONFIG_KEY]), - "创建公会消耗积分": copy.deepcopy(create["创建公会消耗积分"]), - "创建配置": copy.deepcopy(create), - "申请配置": copy.deepcopy(grouped_config["申请配置"]), - "初始容量": copy.deepcopy(vault["初始容量"]), - "每级增加容量": copy.deepcopy(vault["每级增加容量"]), - "交易税率": copy.deepcopy(vault["交易税率"]), - "市场配置": copy.deepcopy(vault["市场配置"]), - "维度名称映射": _int_keyed_dict(base_point["维度名称映射"]), - "刷新间隔秒": copy.deepcopy(effects["刷新间隔秒"]), - "效果列表": _normalize_effect_runtime_config(effects["效果列表"]), - "任务系统": copy.deepcopy(grouped_config["任务系统"]), - "职位权限配置": copy.deepcopy(permissions["职位权限配置"]), - "数据安全": copy.deepcopy(grouped_config["数据安全"]), - "提示词配置": copy.deepcopy(grouped_config["提示词配置"]), - "功能列表配置": copy.deepcopy(grouped_config["功能列表配置"]), - "默认物品价值": copy.deepcopy(economy["默认物品价值"]), - "中文物品名称": copy.deepcopy(economy["中文物品名称"]), - "物品别名": copy.deepcopy(economy["物品别名"]), - } - - -def _set_config_attributes( - config_cls: type, normalized_config: dict[str, Any]) -> None: - for attr_name in REMOVED_CONFIG_ATTRIBUTES: - if hasattr(config_cls, attr_name): - delattr(config_cls, attr_name) - - for key, value in normalized_config.items(): - attr_name = CONFIG_ATTRIBUTE_KEYS.get(key, key) - setattr(config_cls, attr_name, value) - - -class Config: - """Runtime facade exposing loaded config as Config.X attributes.""" - - _loaded = False - _config: dict[str, Any] = {} - _grouped_config: dict[str, Any] = copy.deepcopy(DEFAULT_CONFIG) - - @classmethod - def grouped_config_std(cls) -> dict[str, Any]: - return grouped_config_std() - - @classmethod - def is_dynamic_load_enabled(cls) -> bool: - settings = cls._grouped_config.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) - if not isinstance(settings, dict): - return DYNAMIC_LOAD_DEFAULT[DYNAMIC_LOAD_ENABLED_KEY] - return bool( - settings.get( - DYNAMIC_LOAD_ENABLED_KEY, - DYNAMIC_LOAD_DEFAULT[DYNAMIC_LOAD_ENABLED_KEY])) - - @classmethod - def dynamic_load_interval(cls) -> int: - settings = cls._grouped_config.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) - if not isinstance(settings, dict): - return RUNTIME_CONFIG_RELOAD_INTERVAL - return _normalize_positive_int( - settings.get( - DYNAMIC_LOAD_INTERVAL_KEY, - RUNTIME_CONFIG_RELOAD_INTERVAL), - RUNTIME_CONFIG_RELOAD_INTERVAL, - ) - - @classmethod - def load(cls, - plugin_name: str, - version: tuple[int, - int, - int]) -> dict[str, - Any]: - default_config = copy.deepcopy(DEFAULT_CONFIG) - - try: - raw_config, _ = cfg.get_plugin_config_and_version( - plugin_name, - {}, - default_config, - version, - ) - except Exception as err: - fmts.print_err( - f"{plugin_name} config load failed, using defaults: {err}") - raw_config = default_config - - try: - _require_current_config_format(raw_config) - grouped_config = _normalize_grouped_config(raw_config) - cfg.check_auto(cls.grouped_config_std(), grouped_config) - except Exception as err: - fmts.print_err( - f"{plugin_name} config validation failed, using defaults: {err}") - grouped_config = _normalize_grouped_config({}) - cfg.check_auto(cls.grouped_config_std(), grouped_config) - cfg.upgrade_plugin_config(plugin_name, grouped_config, version) - - runtime_config = _build_runtime_config(grouped_config) - _set_config_attributes(cls, runtime_config) - - cls._config = runtime_config - cls._grouped_config = grouped_config - cls._loaded = True - - return copy.deepcopy(runtime_config) + "创建公会回复超时提示词": "§c❀ §r回复超时,已取消创建公会", + "创建公会取消提示词": "§c❀ §r已取消创建公会", + "创建公会输入名称提示词": "§a❀ §r请输入公会名字:\n§a❀ §r要求: 2-20个字符,不能包含特殊符号", + "创建公会名称无效提示词": "§c❀ §r{error}", + "创建公会二次余额不足提示词": "§c❀ §r当前 §b{scoreboard}§r 余额不足,需要 §e{consume}§r,当前 §f{balance}", + "创建公会成功提示词": "§a❀ §r已创建公会 §e{guild}", + "创建公会全服公告提示词": "§a❀ §r§e{player}§r 创建了公会 §e{guild}§r!", + "创建公会名称已存在提示词": "§c❀ §r该公会名已存在", + "菜单回复超时提示词": "§c❀ §r回复超时!已退出公会系统", + "无效指令提示词": "§c❀ §r无效的指令", + "通用分页为空提示词": "§c❀ §r{title}为空", + "通用分页超时提示词": "§c❀ §r操作超时", + "通用分页退出提示词": "§a❀ §r已退出", + "通用分页无效选择提示词": "§c❀ §r无效的选择", + "公会列表为空提示词": "§c❀ §r公会列表为空", + "公会列表分页超时提示词": "§c❀ §r操作超时", + "公会列表分页退出提示词": "§a❀ §r已退出" + }, + "功能列表配置": { + "菜单标题": "公会管理系统", + "游客身份显示": "[§7游客§f]", + "成员身份显示模板": "[{rank}§f] §e{guild}", + "输入数字提示模板": "§a❀ §r输入 §e[1-{count}]§r 之间的数字选择功能", + "输入名称提示词": "§a❀ §r也可输入功能名称,输入 §cq §r退出", + "基础功能": { + "创建": { + "名称": "创建", + "描述": "创建自己的公会" + }, + "列表": { + "名称": "列表", + "描述": "查看所有公会" + }, + "查看": { + "名称": "查看", + "描述": "查看公会详情" + }, + "成员": { + "名称": "成员", + "描述": "查看成员列表" + }, + "日志": { + "名称": "日志", + "描述": "查看公会日志" + }, + "公告": { + "名称": "公告", + "描述": "查看/设置公告" + }, + "加入": { + "名称": "加入", + "描述": "加入一个公会" + }, + "退出": { + "名称": "退出", + "描述": "退出当前公会" + }, + "管理": { + "名称": "管理", + "描述": "管理公会成员" + }, + "解散": { + "名称": "解散", + "描述": "解散公会" + } + }, + "可选功能": { + "仓库": { + "名称": "仓库", + "描述": "公会仓库" + }, + "据点": { + "名称": "据点", + "描述": "据点相关操作" + }, + "捐献": { + "名称": "捐献", + "描述": "捐献物品到公会" + }, + "任务": { + "名称": "任务", + "描述": "公会任务系统" + }, + "效果": { + "名称": "效果", + "描述": "通过钻石获得效果增益" + }, + "排行": { + "名称": "排行", + "描述": "查看公会排行榜" + } + } + }, + "经济系统": { + "默认物品价值": { + "minecraft:diamond": 50, + "minecraft:emerald": 25, + "minecraft:gold_ingot": 10, + "minecraft:iron_ingot": 5, + "minecraft:copper_ingot": 2, + "minecraft:coal": 1, + "minecraft:redstone": 2, + "minecraft:lapis_lazuli": 3, + "minecraft:quartz": 2, + "minecraft:netherite_ingot": 200, + "minecraft:ancient_debris": 150, + "minecraft:ender_pearl": 20, + "minecraft:blaze_rod": 15, + "minecraft:ghast_tear": 25, + "minecraft:shulker_shell": 100, + "minecraft:nautilus_shell": 30, + "minecraft:heart_of_the_sea": 150, + "minecraft:stone": 1, + "minecraft:cobblestone": 1, + "minecraft:dirt": 1, + "minecraft:sand": 1, + "minecraft:gravel": 1, + "minecraft:obsidian": 10, + "minecraft:crying_obsidian": 15, + "minecraft:netherrack": 1, + "minecraft:end_stone": 5, + "minecraft:oak_log": 2, + "minecraft:birch_log": 2, + "minecraft:spruce_log": 2, + "minecraft:jungle_log": 2, + "minecraft:acacia_log": 2, + "minecraft:dark_oak_log": 2, + "minecraft:oak_planks": 1, + "minecraft:birch_planks": 1, + "minecraft:spruce_planks": 1, + "minecraft:jungle_planks": 1, + "minecraft:acacia_planks": 1, + "minecraft:dark_oak_planks": 1, + "minecraft:apple": 2, + "minecraft:golden_apple": 20, + "minecraft:enchanted_golden_apple": 100, + "minecraft:bread": 3, + "minecraft:beef": 3, + "minecraft:cooked_beef": 5, + "minecraft:porkchop": 3, + "minecraft:cooked_porkchop": 5, + "minecraft:chicken": 2, + "minecraft:cooked_chicken": 4, + "minecraft:cod": 2, + "minecraft:cooked_cod": 4, + "minecraft:salmon": 3, + "minecraft:cooked_salmon": 5, + "minecraft:carrot": 1, + "minecraft:golden_carrot": 8, + "minecraft:potato": 1, + "minecraft:baked_potato": 2, + "minecraft:diamond_block": 450, + "minecraft:emerald_block": 225, + "minecraft:gold_block": 90, + "minecraft:iron_block": 45, + "minecraft:copper_block": 18, + "minecraft:coal_block": 9, + "minecraft:redstone_block": 18, + "minecraft:lapis_block": 27, + "minecraft:quartz_block": 18, + "minecraft:netherite_block": 1800, + "minecraft:string": 1, + "minecraft:leather": 3, + "minecraft:feather": 2, + "minecraft:gunpowder": 5, + "minecraft:bone": 2, + "minecraft:bone_meal": 1, + "minecraft:spider_eye": 3, + "minecraft:slime_ball": 8, + "minecraft:magma_cream": 10, + "minecraft:wheat": 1, + "minecraft:wheat_seeds": 1, + "minecraft:sugar_cane": 2, + "minecraft:bamboo": 1, + "minecraft:kelp": 1, + "minecraft:cactus": 2 + }, + "中文物品名称": { + "钻石": "minecraft:diamond", + "绿宝石": "minecraft:emerald", + "金锭": "minecraft:gold_ingot", + "铁锭": "minecraft:iron_ingot", + "铜锭": "minecraft:copper_ingot", + "煤炭": "minecraft:coal", + "木炭": "minecraft:charcoal", + "红石": "minecraft:redstone", + "青金石": "minecraft:lapis_lazuli", + "石英": "minecraft:quartz", + "下界合金锭": "minecraft:netherite_ingot", + "远古残骸": "minecraft:ancient_debris", + "下界石英": "minecraft:nether_quartz", + "紫水晶碎片": "minecraft:amethyst_shard", + "紫水晶": "minecraft:amethyst_shard", + "末影珍珠": "minecraft:ender_pearl", + "烈焰棒": "minecraft:blaze_rod", + "恶魂之泪": "minecraft:ghast_tear", + "潜影贝壳": "minecraft:shulker_shell", + "鹦鹉螺壳": "minecraft:nautilus_shell", + "海洋之心": "minecraft:heart_of_the_sea", + "圆石": "minecraft:cobblestone", + "石头": "minecraft:stone", + "花岗岩": "minecraft:granite", + "闪长岩": "minecraft:diorite", + "安山岩": "minecraft:andesite", + "深板岩": "minecraft:deepslate", + "黑石": "minecraft:blackstone", + "玄武岩": "minecraft:basalt", + "末地石": "minecraft:end_stone", + "下界岩": "minecraft:netherrack", + "灵魂沙": "minecraft:soul_sand", + "灵魂土": "minecraft:soul_soil", + "橡木原木": "minecraft:oak_log", + "白桦原木": "minecraft:birch_log", + "云杉原木": "minecraft:spruce_log", + "丛林原木": "minecraft:jungle_log", + "金合欢原木": "minecraft:acacia_log", + "深色橡木原木": "minecraft:dark_oak_log", + "绯红菌柄": "minecraft:crimson_stem", + "诡异菌柄": "minecraft:warped_stem", + "橡木木板": "minecraft:oak_planks", + "白桦木板": "minecraft:birch_planks", + "云杉木板": "minecraft:spruce_planks", + "丛林木板": "minecraft:jungle_planks", + "金合欢木板": "minecraft:acacia_planks", + "深色橡木木板": "minecraft:dark_oak_planks", + "苹果": "minecraft:apple", + "金苹果": "minecraft:golden_apple", + "附魔金苹果": "minecraft:enchanted_golden_apple", + "面包": "minecraft:bread", + "牛肉": "minecraft:beef", + "熟牛肉": "minecraft:cooked_beef", + "猪肉": "minecraft:porkchop", + "熟猪肉": "minecraft:cooked_porkchop", + "鸡肉": "minecraft:chicken", + "熟鸡肉": "minecraft:cooked_chicken", + "羊肉": "minecraft:mutton", + "熟羊肉": "minecraft:cooked_mutton", + "鱼": "minecraft:cod", + "熟鱼": "minecraft:cooked_cod", + "鲑鱼": "minecraft:salmon", + "熟鲑鱼": "minecraft:cooked_salmon", + "胡萝卜": "minecraft:carrot", + "金胡萝卜": "minecraft:golden_carrot", + "土豆": "minecraft:potato", + "烤土豆": "minecraft:baked_potato", + "甜菜根": "minecraft:beetroot", + "甜菜汤": "minecraft:beetroot_soup", + "蘑菇煲": "minecraft:mushroom_stew", + "兔肉煲": "minecraft:rabbit_stew", + "木棍": "minecraft:stick", + "线": "minecraft:string", + "皮革": "minecraft:leather", + "羽毛": "minecraft:feather", + "火药": "minecraft:gunpowder", + "骨头": "minecraft:bone", + "骨粉": "minecraft:bone_meal", + "蜘蛛眼": "minecraft:spider_eye", + "腐肉": "minecraft:rotten_flesh", + "史莱姆球": "minecraft:slime_ball", + "岩浆膏": "minecraft:magma_cream", + "小麦": "minecraft:wheat", + "小麦种子": "minecraft:wheat_seeds", + "南瓜": "minecraft:pumpkin", + "南瓜种子": "minecraft:pumpkin_seeds", + "西瓜": "minecraft:melon", + "西瓜种子": "minecraft:melon_seeds", + "甘蔗": "minecraft:sugar_cane", + "竹子": "minecraft:bamboo", + "海带": "minecraft:kelp", + "仙人掌": "minecraft:cactus", + "墨囊": "minecraft:ink_sac", + "玫瑰红": "minecraft:red_dye", + "橙色染料": "minecraft:orange_dye", + "黄色染料": "minecraft:yellow_dye", + "黄绿色染料": "minecraft:lime_dye", + "绿色染料": "minecraft:green_dye", + "青色染料": "minecraft:cyan_dye", + "淡蓝色染料": "minecraft:light_blue_dye", + "蓝色染料": "minecraft:blue_dye", + "紫色染料": "minecraft:purple_dye", + "品红色染料": "minecraft:magenta_dye", + "粉红色染料": "minecraft:pink_dye", + "白色染料": "minecraft:white_dye", + "淡灰色染料": "minecraft:light_gray_dye", + "灰色染料": "minecraft:gray_dye", + "黑色染料": "minecraft:black_dye", + "棕色染料": "minecraft:brown_dye", + "泥土": "minecraft:dirt", + "草方块": "minecraft:grass_block", + "沙子": "minecraft:sand", + "红沙": "minecraft:red_sand", + "砂砾": "minecraft:gravel", + "粘土": "minecraft:clay", + "雪球": "minecraft:snowball", + "冰": "minecraft:ice", + "浮冰": "minecraft:packed_ice", + "蓝冰": "minecraft:blue_ice", + "黑曜石": "minecraft:obsidian", + "哭泣的黑曜石": "minecraft:crying_obsidian", + "基岩": "minecraft:bedrock", + "海绵": "minecraft:sponge", + "湿海绵": "minecraft:wet_sponge", + "钻石块": "minecraft:diamond_block", + "绿宝石块": "minecraft:emerald_block", + "金块": "minecraft:gold_block", + "铁块": "minecraft:iron_block", + "铜块": "minecraft:copper_block", + "煤炭块": "minecraft:coal_block", + "红石块": "minecraft:redstone_block", + "青金石块": "minecraft:lapis_block", + "石英块": "minecraft:quartz_block", + "下界合金块": "minecraft:netherite_block" + }, + "物品别名": { + "钻": "钻石", + "diamond": "钻石", + "钻石锭": "钻石", + "绿宝": "绿宝石", + "emerald": "绿宝石", + "村民币": "绿宝石", + "翡翠": "绿宝石", + "金": "金锭", + "gold": "金锭", + "黄金": "金锭", + "金子": "金锭", + "铁": "铁锭", + "iron": "铁锭", + "铜": "铜锭", + "copper": "铜锭", + "煤": "煤炭", + "coal": "煤炭", + "煤块": "煤炭", + "红石粉": "红石", + "redstone": "红石", + "青金": "青金石", + "lapis": "青金石", + "蓝宝石": "青金石", + "石英晶体": "石英", + "quartz": "石英", + "下界合金": "下界合金锭", + "netherite": "下界合金锭", + "合金": "下界合金锭", + "下合金": "下界合金锭", + "残骸": "远古残骸", + "ancient_debris": "远古残骸", + "远古": "远古残骸", + "橡木": "橡木原木", + "oak": "橡木原木", + "白桦": "白桦原木", + "birch": "白桦原木", + "云杉": "云杉原木", + "spruce": "云杉原木", + "丛林": "丛林原木", + "jungle": "丛林原木", + "金合欢": "金合欢原木", + "acacia": "金合欢原木", + "深色橡木": "深色橡木原木", + "dark_oak": "深色橡木原木", + "苹果": "苹果", + "apple": "苹果", + "金苹": "金苹果", + "golden_apple": "金苹果", + "附魔苹果": "附魔金苹果", + "神苹": "附魔金苹果", + "notch苹果": "附魔金苹果", + "石": "石头", + "stone": "石头", + "泥": "泥土", + "dirt": "泥土", + "土": "泥土", + "沙": "沙子", + "sand": "沙子", + "末影珠": "末影珍珠", + "ender_pearl": "末影珍珠", + "传送珠": "末影珍珠", + "烈焰": "烈焰棒", + "blaze_rod": "烈焰棒", + "火棒": "烈焰棒", + "恶魂泪": "恶魂之泪", + "ghast_tear": "恶魂之泪", + "鬼泪": "恶魂之泪" + } + } +}''' +DEFAULT_CONFIG: dict[str, Any] = json.loads(DEFAULT_CONFIG_JSON) + +LEVEL_EXP_CONFIG_KEY = "公会各等级升级所需经验" + +CONFIG_ATTRIBUTE_ALIASES = { + "GUILD_LEVEL_EXP": LEVEL_EXP_CONFIG_KEY, + "GUILD_MENU_TRIGGER": "公会菜单唤醒词", + "GUILD_CREATION_COST": "创建公会消耗积分", + "MAX_GUILD_MEMBERS": "公会最大成员数", + "GUILD_SCOREBOARD": "积分计分板名称", + "GUILD_FUNCTION_VAULT": "公会仓库", + "GUILD_FUNCTION_BASE": "公会据点", + "GUILD_FUNCTION_DONATION": "公会捐献", + "GUILD_FUNCTION_TASKS": "公会任务", + "GUILD_FUNCTION_EFFECT": "公会效果", + "GUILD_FUNCTION_RANKINGS": "公会排行", + "EXP_PER_ONLINE_MEMBER": "在线成员每次增加经验", + "EXP_UPDATE_INTERVAL": "在线经验增加间隔秒", + "DAILY_LOGIN_EXP": "每日登录经验", + "DONATION_EXP_RATE": "捐献转经验倍率", + "ITEMS_PER_PAGE": "每页显示数量", + "VAULT_INITIAL_SLOTS": "初始容量", + "VAULT_SLOTS_PER_LEVEL": "每级增加容量", + "VAULT_TRADE_TAX": "交易税率", + "DIMENSION_NAMES": "维度名称映射", + "CACHE_DURATION": "缓存有效时间秒", + "EFFECT_REFRESH_INTERVAL": "刷新间隔秒", + "EFFECTS_CONFIG": "效果列表", + "BATCH_SAVE_INTERVAL": "批量保存间隔秒", + "MAX_BATCH_SIZE": "批量保存最大数量", + "PERMISSIONS": "职位权限配置", + "GUILD_CREATE_CONFIG": "创建配置", + "GUILD_JOIN_REQUEST_CONFIG": "申请配置", + "GUILD_VAULT_CONFIG": "市场配置", + "GUILD_TASK_CONFIG": "任务系统", + "GUILD_DATA_SAFETY_CONFIG": "数据安全", + "PROMPT_CONFIG": "提示词配置", + "MENU_CONFIG": "功能列表配置", + "DEFAULT_ITEM_VALUES": "默认物品价值", + "CHINESE_ITEM_NAMES": "中文物品名称", + "ITEM_ALIASES": "物品别名", +} + +CONFIG_ATTRIBUTE_KEYS = { + config_key: attr_name for attr_name, + config_key in CONFIG_ATTRIBUTE_ALIASES.items()} + +REMOVED_CONFIG_ATTRIBUTES = ("GUILD_LEVELS",) + +CONFIG_VERSION_KEY = "配置版本" +GROUPED_CONFIG_VERSION = "0.1.7" +CONFIG_FILE_DIR = "插件配置文件" +RUNTIME_CONFIG_RELOAD_INTERVAL = 5 +DYNAMIC_LOAD_SETTINGS_KEY = "动态载入设置" +DYNAMIC_LOAD_ENABLED_KEY = "是否启用动态载入配置文件(仅用于本插件)" +DYNAMIC_LOAD_INTERVAL_KEY = "动态载入检测时间间隔(单位:秒)" +DYNAMIC_LOAD_DEFAULT = { + DYNAMIC_LOAD_ENABLED_KEY: True, + DYNAMIC_LOAD_INTERVAL_KEY: RUNTIME_CONFIG_RELOAD_INTERVAL, +} + + +def _merge_config(raw: Any, default: Any) -> Any: + """Implement the merge config operation.""" + if isinstance(default, dict): + merged = { + key: _merge_config(raw.get(key) if isinstance(raw, dict) else None, value) + for key, value in default.items() + } + + if isinstance(raw, dict): + for key, value in raw.items(): + if key not in merged: + merged[key] = copy.deepcopy(value) + + return merged + + return copy.deepcopy(raw) if raw is not None else copy.deepcopy(default) + + +def _normalize_bool(value: Any, fallback: bool) -> bool: + """Normalize bool values.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in ("true", "1", "yes", "y", "on", "启用", "是", "真"): + return True + if text in ("false", "0", "no", "n", "off", "禁用", "否", "假"): + return False + if isinstance(value, (int, float)) and not isinstance(value, bool): + return bool(value) + return bool(fallback) + + +def _normalize_str( + value: Any, + fallback: str, + *, + allow_empty: bool = False) -> str: + """Normalize str values.""" + if value is None: + return fallback + text = str(value).strip() + if text or allow_empty: + return text + return fallback + + +def _normalize_number(value: Any, fallback: int | float) -> int | float: + """Normalize number values.""" + if isinstance(value, bool): + return fallback + if isinstance(value, (int, float)): + return value + try: + number = float(str(value).strip()) + except (TypeError, ValueError): + return fallback + if number.is_integer(): + return int(number) + return number + + +def _normalize_positive_int(value: Any, fallback: int) -> int: + """Normalize positive int values.""" + if isinstance(value, bool): + return fallback + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + +def _normalize_non_negative_int(value: Any, fallback: int) -> int: + """Normalize non negative int values.""" + if isinstance(value, bool): + return fallback + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result >= 0 else fallback + + +def _normalize_string_list( + value: Any, + fallback: list[str], + *, + allow_empty: bool = False) -> list[str]: + """Normalize string list values.""" + if isinstance(value, str): + candidates = [value] + elif isinstance(value, list): + candidates = value + else: + return copy.deepcopy(fallback) + + result: list[str] = [] + for item in candidates: + if not isinstance(item, str): + continue + text = item.strip() + if text and text not in result: + result.append(text) + if result or allow_empty: + return result + return copy.deepcopy(fallback) + + +def _normalize_any_key_dict( + raw: Any, fallback: dict[str, Any], value_normalizer) -> dict[str, Any]: + """Normalize any key dict values.""" + source = raw if isinstance(raw, dict) else fallback + default_values = fallback if isinstance(fallback, dict) else {} + result: dict[str, Any] = {} + for key, value in source.items(): + text_key = str(key) + fallback_value = default_values.get(text_key) + if fallback_value is None and default_values: + fallback_value = next(iter(default_values.values())) + result[text_key] = value_normalizer(value, fallback_value) + return result + + +def _normalize_menu_items( + raw: Any, fallback: dict[str, Any]) -> dict[str, Any]: + """Normalize menu items values.""" + source = _merge_config(raw, fallback) + result: dict[str, Any] = {} + for key, item_fallback in fallback.items(): + item = source.get(key) if isinstance(source, dict) else None + if not isinstance(item, dict): + item = {} + result[key] = { + "名称": _normalize_str( + item.get("名称"), + item_fallback["名称"]), + "描述": _normalize_str( + item.get("描述"), + item_fallback["描述"], + allow_empty=True), + } + return result + + +def _trim_fixed_keys(raw: dict[str, Any], + default: dict[str, Any]) -> dict[str, Any]: + """Implement the trim fixed keys operation.""" + return { + key: raw.get(key, copy.deepcopy(value)) + for key, value in default.items() + } + + +def _normalize_market_config( + raw: Any, fallback: dict[str, Any]) -> dict[str, Any]: + """Normalize market config values.""" + raw = raw if isinstance(raw, dict) else {} + result = copy.deepcopy(fallback) + bool_keys = ( + "启用交易日志", + "启用撤回出售", + "只允许撤回自己的物品", + "撤回后返还物品", + "允许购买自己出售的物品", + ) + positive_int_keys = ( + "交易日志保留数量", + "单个成员最大上架数量", + "单次出售最大数量", + "单笔价格下限", + "单笔价格上限", + ) + number_keys = ( + "建议价最小倍率", + "建议价最大倍率", + "高价交易审计倍率", + ) + for key in bool_keys: + result[key] = _normalize_bool(raw.get(key, result[key]), result[key]) + for key in positive_int_keys: + result[key] = _normalize_positive_int( + raw.get(key, result[key]), result[key]) + for key in number_keys: + result[key] = _normalize_number(raw.get(key, result[key]), result[key]) + return result + + +def _normalize_effects_config( + raw: Any, fallback: dict[str, Any]) -> dict[str, Any]: + """Normalize effects config values.""" + source = raw if isinstance(raw, dict) else fallback + default_effect = next(iter(fallback.values())) if fallback else {} + result: dict[str, Any] = {} + for effect_id, effect_cfg in source.items(): + if not isinstance(effect_cfg, dict): + effect_cfg = {} + default = fallback.get(str(effect_id), default_effect) + result[str(effect_id)] = { + "name": _normalize_str(effect_cfg.get("name"), default.get("name", str(effect_id))), + "levels": _normalize_any_key_dict( + effect_cfg.get("levels"), + default.get("levels", {}), + lambda value, fallback_value: _normalize_non_negative_int( + value, + int(fallback_value or 0), + ), + ), + "costs": _normalize_any_key_dict( + effect_cfg.get("costs"), + default.get("costs", {}), + lambda value, fallback_value: _normalize_non_negative_int( + value, + int(fallback_value or 0), + ), + ), + } + return result + + +def _normalize_permissions( + raw: Any, fallback: dict[str, Any]) -> dict[str, Any]: + """Normalize permissions values.""" + source = raw if isinstance(raw, dict) else fallback + default_role = next(iter(fallback.values())) if fallback else {} + result: dict[str, dict[str, bool]] = {} + for role, permissions in source.items(): + if not isinstance(permissions, dict): + permissions = {} + default_permissions = fallback.get(str(role), default_role) + merged_permissions = _merge_config(permissions, default_permissions) + result[str(role)] = { + str(key): _normalize_bool(value, bool(default_permissions.get(key, False))) + for key, value in merged_permissions.items() + } + return result + + +def _normalize_task_templates( + raw: Any, fallback: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Normalize task templates values.""" + source = raw if isinstance(raw, list) else fallback + default_template = fallback[0] if fallback else {} + result: list[dict[str, Any]] = [] + for template in source: + if not isinstance(template, dict): + continue + default = _merge_config(template, default_template) + result.append( + { + "name": _normalize_str( + template.get("name"), + default["name"]), + "description": _normalize_str( + template.get("description"), + default["description"]), + "task_type": _normalize_str( + template.get("task_type"), + default["task_type"]), + "target": _normalize_str( + template.get("target"), + default["target"]), + "target_count": _normalize_positive_int( + template.get("target_count"), + default["target_count"], + ), + "reward_exp": _normalize_non_negative_int( + template.get("reward_exp"), + default["reward_exp"], + ), + "reward_contribution": _normalize_non_negative_int( + template.get("reward_contribution"), + default["reward_contribution"], + ), + }) + return result or copy.deepcopy(fallback) + + +def _normalize_grouped_config(raw: dict[str, Any]) -> dict[str, Any]: + """Normalize grouped config values.""" + default = DEFAULT_CONFIG + config = _merge_config(raw, default) + config = {key: copy.deepcopy(config[key]) for key in default} + config[CONFIG_VERSION_KEY] = GROUPED_CONFIG_VERSION + + config[DYNAMIC_LOAD_SETTINGS_KEY] = _trim_fixed_keys( + config[DYNAMIC_LOAD_SETTINGS_KEY], + default[DYNAMIC_LOAD_SETTINGS_KEY], + ) + dynamic = config[DYNAMIC_LOAD_SETTINGS_KEY] + dynamic_default = default[DYNAMIC_LOAD_SETTINGS_KEY] + dynamic[DYNAMIC_LOAD_ENABLED_KEY] = _normalize_bool( + dynamic.get(DYNAMIC_LOAD_ENABLED_KEY), + dynamic_default[DYNAMIC_LOAD_ENABLED_KEY], + ) + dynamic[DYNAMIC_LOAD_INTERVAL_KEY] = _normalize_positive_int( + dynamic.get(DYNAMIC_LOAD_INTERVAL_KEY), + dynamic_default[DYNAMIC_LOAD_INTERVAL_KEY], + ) + + config["基础配置"] = _trim_fixed_keys(config["基础配置"], default["基础配置"]) + base = config["基础配置"] + base_default = default["基础配置"] + base["是否启用插件"] = _normalize_bool( + base.get("是否启用插件"), base_default["是否启用插件"]) + base["公会菜单唤醒词"] = _normalize_string_list( + base.get("公会菜单唤醒词"), + base_default["公会菜单唤醒词"], + ) + base["积分计分板名称"] = _normalize_str( + base.get("积分计分板名称"), + base_default["积分计分板名称"]) + for key in ("缓存有效时间秒", "批量保存间隔秒", "批量保存最大数量"): + base[key] = _normalize_positive_int(base.get(key), base_default[key]) + + config["公会配置"] = _trim_fixed_keys(config["公会配置"], default["公会配置"]) + guild = config["公会配置"] + guild_default = default["公会配置"] + for key in ( + "公会最大成员数", + "在线经验增加间隔秒", + "每页显示数量", + ): + guild[key] = _normalize_positive_int( + guild.get(key), guild_default[key]) + for key in ("在线成员每次增加经验", "每日登录经验"): + guild[key] = _normalize_non_negative_int( + guild.get(key), guild_default[key]) + guild["捐献转经验倍率"] = _normalize_number( + guild.get("捐献转经验倍率"), + guild_default["捐献转经验倍率"], + ) + guild[LEVEL_EXP_CONFIG_KEY] = _normalize_any_key_dict( + guild.get(LEVEL_EXP_CONFIG_KEY), + guild_default[LEVEL_EXP_CONFIG_KEY], + lambda value, fallback_value: _normalize_positive_int( + value, int(fallback_value or 1)), + ) + + config["功能开关"] = _trim_fixed_keys(config["功能开关"], default["功能开关"]) + feature_switches = config["功能开关"] + for key, fallback in default["功能开关"].items(): + feature_switches[key] = _normalize_bool( + feature_switches.get(key), fallback) + + config["仓库配置"] = _trim_fixed_keys(config["仓库配置"], default["仓库配置"]) + vault = config["仓库配置"] + vault_default = default["仓库配置"] + vault["初始容量"] = _normalize_positive_int( + vault.get("初始容量"), vault_default["初始容量"]) + vault["每级增加容量"] = _normalize_non_negative_int( + vault.get("每级增加容量"), + vault_default["每级增加容量"], + ) + vault["交易税率"] = _normalize_number(vault.get("交易税率"), vault_default["交易税率"]) + vault["市场配置"] = _normalize_market_config( + vault.get("市场配置"), vault_default["市场配置"]) + + config["经验配置"] = _trim_fixed_keys(config["经验配置"], default["经验配置"]) + config["经验配置"]["在线经验"] = _trim_fixed_keys( + config["经验配置"]["在线经验"], + default["经验配置"]["在线经验"], + ) + config["经验配置"]["登录经验"] = _trim_fixed_keys( + config["经验配置"]["登录经验"], + default["经验配置"]["登录经验"], + ) + config["经验配置"]["捐献经验"] = _trim_fixed_keys( + config["经验配置"]["捐献经验"], + default["经验配置"]["捐献经验"], + ) + exp = config["经验配置"] + exp_default = default["经验配置"] + exp["在线经验"]["每次增加经验"] = _normalize_non_negative_int( + exp["在线经验"].get("每次增加经验"), + exp_default["在线经验"]["每次增加经验"], + ) + exp["在线经验"]["增加间隔秒"] = _normalize_positive_int( + exp["在线经验"].get("增加间隔秒"), + exp_default["在线经验"]["增加间隔秒"], + ) + exp["登录经验"]["每日登录经验"] = _normalize_non_negative_int( + exp["登录经验"].get("每日登录经验"), + exp_default["登录经验"]["每日登录经验"], + ) + exp["捐献经验"]["转经验倍率"] = _normalize_number( + exp["捐献经验"].get("转经验倍率"), + exp_default["捐献经验"]["转经验倍率"], + ) + + config["据点配置"] = _trim_fixed_keys(config["据点配置"], default["据点配置"]) + config["据点配置"]["维度名称映射"] = _normalize_any_key_dict( + config["据点配置"].get("维度名称映射"), + default["据点配置"]["维度名称映射"], + lambda value, + fallback_value: _normalize_str(value, str(fallback_value or "")),) + + config["效果系统"] = _trim_fixed_keys(config["效果系统"], default["效果系统"]) + effects = config["效果系统"] + effects_default = default["效果系统"] + effects["刷新间隔秒"] = _normalize_positive_int( + effects.get("刷新间隔秒"), + effects_default["刷新间隔秒"], + ) + effects["效果列表"] = _normalize_effects_config( + effects.get("效果列表"), + effects_default["效果列表"], + ) + + config["创建配置"] = _trim_fixed_keys(config["创建配置"], default["创建配置"]) + create_cfg = config["创建配置"] + create_default = default["创建配置"] + create_cfg["创建公会消耗积分"] = _normalize_non_negative_int( + create_cfg.get("创建公会消耗积分"), + create_default["创建公会消耗积分"], + ) + for key in ("创建冷却秒", "最短公会名长度", "最长公会名长度"): + create_cfg[key] = _normalize_positive_int( + create_cfg.get(key), create_default[key]) + create_cfg["禁止公会名列表"] = _normalize_string_list( + create_cfg.get("禁止公会名列表"), + create_default["禁止公会名列表"], + allow_empty=True, + ) + create_cfg["禁止公会名包含词"] = _normalize_string_list( + create_cfg.get("禁止公会名包含词"), + create_default["禁止公会名包含词"], + allow_empty=True, + ) + create_cfg["启用同名模糊检测"] = _normalize_bool( + create_cfg.get("启用同名模糊检测"), + create_default["启用同名模糊检测"], + ) + create_cfg["模糊检测最小相似度"] = _normalize_number( + create_cfg.get("模糊检测最小相似度"), + create_default["模糊检测最小相似度"], + ) + create_cfg["创建后全服公告"] = _normalize_bool( + create_cfg.get("创建后全服公告"), + create_default["创建后全服公告"], + ) + + config["申请配置"] = _trim_fixed_keys(config["申请配置"], default["申请配置"]) + join_cfg = config["申请配置"] + join_default = default["申请配置"] + for key, fallback in join_default.items(): + if isinstance(fallback, bool): + join_cfg[key] = _normalize_bool(join_cfg.get(key), fallback) + else: + join_cfg[key] = _normalize_positive_int( + join_cfg.get(key), fallback) + + config["任务系统"] = _trim_fixed_keys(config["任务系统"], default["任务系统"]) + task_cfg = config["任务系统"] + task_default = default["任务系统"] + task_cfg["启用自动任务模板"] = _normalize_bool( + task_cfg.get("启用自动任务模板"), + task_default["启用自动任务模板"], + ) + for key in ( + "自动任务生成间隔秒", + "每次生成自动任务数量", + "自动任务最大同时存在数量", + "自动任务默认有效期秒", + "成员每日可完成任务数量", + "创建任务名称最大长度", + "创建任务描述最大长度", + "创建任务目标数量上限", + ): + task_cfg[key] = _normalize_positive_int( + task_cfg.get(key), task_default[key]) + for key in ("创建任务贡献奖励上限", "创建任务经验奖励上限"): + task_cfg[key] = _normalize_non_negative_int( + task_cfg.get(key), task_default[key]) + task_cfg["自动任务模板列表"] = _normalize_task_templates( + task_cfg.get("自动任务模板列表"), + task_default["自动任务模板列表"], + ) + + config["权限系统"] = _trim_fixed_keys(config["权限系统"], default["权限系统"]) + config["权限系统"]["职位权限配置"] = _normalize_permissions( + config["权限系统"].get("职位权限配置"), + default["权限系统"]["职位权限配置"], + ) + + config["数据安全"] = _trim_fixed_keys(config["数据安全"], default["数据安全"]) + safety = config["数据安全"] + safety_default = default["数据安全"] + safety["备份目录名"] = _normalize_str( + safety.get("备份目录名"), + safety_default["备份目录名"]) + for key in ( + "启用保存前备份", + "强制保存时也备份", + "启用异常数据跳过", + "启动时自动修复缺失字段", + "修复前写入备份", + ): + safety[key] = _normalize_bool(safety.get(key), safety_default[key]) + safety["最大备份数量"] = _normalize_positive_int( + safety.get("最大备份数量"), + safety_default["最大备份数量"], + ) + safety["审计日志保留数量"] = _normalize_positive_int( + safety.get("审计日志保留数量"), + safety_default["审计日志保留数量"], + ) + + prompt_config = _merge_config(config.get("提示词配置"), default["提示词配置"]) + config["提示词配置"] = _normalize_any_key_dict( + prompt_config, + prompt_config, + lambda value, fallback_value: _normalize_str( + value, + str(fallback_value or ""), + allow_empty=True, + ), + ) + + config["功能列表配置"] = _trim_fixed_keys(config["功能列表配置"], default["功能列表配置"]) + menu_config = config["功能列表配置"] + menu_default = default["功能列表配置"] + for key in ( + "菜单标题", + "游客身份显示", + "成员身份显示模板", + "输入数字提示模板", + "输入名称提示词", + ): + menu_config[key] = _normalize_str(menu_config.get( + key), menu_default[key], allow_empty=True) + menu_config["基础功能"] = _normalize_menu_items( + menu_config.get("基础功能"), + menu_default["基础功能"], + ) + menu_config["可选功能"] = _normalize_menu_items( + menu_config.get("可选功能"), + menu_default["可选功能"], + ) + + config["经济系统"] = _trim_fixed_keys(config["经济系统"], default["经济系统"]) + economy = config["经济系统"] + economy_default = default["经济系统"] + economy["默认物品价值"] = _normalize_any_key_dict( + economy.get("默认物品价值"), + economy_default["默认物品价值"], + lambda value, + fallback_value: _normalize_number( + value, + fallback_value or 1), + ) + for key in ("中文物品名称", "物品别名"): + economy[key] = _normalize_any_key_dict( + economy.get(key), + economy_default[key], + lambda value, fallback_value: _normalize_str( + value, str(fallback_value or "")), + ) + + return config + + +def grouped_config_std() -> dict[str, Any]: + """Return the ToolDelta schema for the grouped guild config.""" + number = (int, float) + string_map = cfg.AnyKeyValue(str) + number_map = cfg.AnyKeyValue(number) + int_map = cfg.AnyKeyValue(cfg.NNInt) + menu_item_std = { + "名称": str, + "描述": str, + } + effect_std = cfg.AnyKeyValue( + { + "name": str, + "levels": int_map, + "costs": int_map, + } + ) + permission_std = cfg.AnyKeyValue(cfg.AnyKeyValue(bool)) + task_template_std = { + "name": str, + "description": str, + "task_type": str, + "target": str, + "target_count": cfg.PInt, + "reward_exp": cfg.NNInt, + "reward_contribution": cfg.NNInt, + } + + return { + CONFIG_VERSION_KEY: str, + DYNAMIC_LOAD_SETTINGS_KEY: { + DYNAMIC_LOAD_ENABLED_KEY: bool, + DYNAMIC_LOAD_INTERVAL_KEY: cfg.PInt, + }, + "基础配置": { + "是否启用插件": bool, + "公会菜单唤醒词": cfg.JsonList(str, -1), + "积分计分板名称": str, + "缓存有效时间秒": cfg.PInt, + "批量保存间隔秒": cfg.PInt, + "批量保存最大数量": cfg.PInt, + }, + "功能开关": { + "公会仓库": bool, + "公会据点": bool, + "公会捐献": bool, + "公会任务": bool, + "公会效果": bool, + "公会排行": bool, + }, + "公会配置": { + "公会最大成员数": cfg.PInt, + "在线成员每次增加经验": cfg.NNInt, + "在线经验增加间隔秒": cfg.PInt, + "每日登录经验": cfg.NNInt, + "捐献转经验倍率": number, + "每页显示数量": cfg.PInt, + LEVEL_EXP_CONFIG_KEY: cfg.AnyKeyValue(cfg.PInt), + }, + "创建配置": { + "创建公会消耗积分": cfg.NNInt, + "创建冷却秒": cfg.PInt, + "最短公会名长度": cfg.PInt, + "最长公会名长度": cfg.PInt, + "禁止公会名列表": cfg.JsonList(str, -1), + "禁止公会名包含词": cfg.JsonList(str, -1), + "启用同名模糊检测": bool, + "模糊检测最小相似度": number, + "创建后全服公告": bool, + }, + "仓库配置": { + "初始容量": cfg.PInt, + "每级增加容量": cfg.NNInt, + "交易税率": number, + "市场配置": { + "启用交易日志": bool, + "交易日志保留数量": cfg.PInt, + "启用撤回出售": bool, + "只允许撤回自己的物品": bool, + "撤回后返还物品": bool, + "允许购买自己出售的物品": bool, + "单个成员最大上架数量": cfg.PInt, + "单次出售最大数量": cfg.PInt, + "单笔价格下限": cfg.PInt, + "单笔价格上限": cfg.PInt, + "建议价最小倍率": number, + "建议价最大倍率": number, + "高价交易审计倍率": number, + }, + }, + "经验配置": { + "在线经验": { + "每次增加经验": cfg.NNInt, + "增加间隔秒": cfg.PInt, + }, + "登录经验": { + "每日登录经验": cfg.NNInt, + }, + "捐献经验": { + "转经验倍率": number, + }, + }, + "据点配置": { + "维度名称映射": string_map, + }, + "效果系统": { + "刷新间隔秒": cfg.PInt, + "效果列表": effect_std, + }, + "申请配置": { + "启用离线申请队列": bool, + "申请有效期秒": cfg.PInt, + "重复申请冷却秒": cfg.PInt, + "每个玩家最多待处理申请数": cfg.PInt, + "每个公会最多待处理申请数": cfg.PInt, + "申请理由最大长度": cfg.PInt, + "满员时自动拒绝新申请": bool, + "申请提交后通知在线管理员": bool, + "批准后通知全体在线成员": bool, + "拒绝后保留记录": bool, + }, + "任务系统": { + "启用自动任务模板": bool, + "自动任务生成间隔秒": cfg.PInt, + "每次生成自动任务数量": cfg.PInt, + "自动任务最大同时存在数量": cfg.PInt, + "自动任务默认有效期秒": cfg.PInt, + "成员每日可完成任务数量": cfg.PInt, + "创建任务名称最大长度": cfg.PInt, + "创建任务描述最大长度": cfg.PInt, + "创建任务目标数量上限": cfg.PInt, + "创建任务贡献奖励上限": cfg.NNInt, + "创建任务经验奖励上限": cfg.NNInt, + "自动任务模板列表": cfg.JsonList(task_template_std, -1), + }, + "权限系统": { + "职位权限配置": permission_std, + }, + "数据安全": { + "启用保存前备份": bool, + "备份目录名": str, + "最大备份数量": cfg.PInt, + "强制保存时也备份": bool, + "启用异常数据跳过": bool, + "启动时自动修复缺失字段": bool, + "修复前写入备份": bool, + "审计日志保留数量": cfg.PInt, + }, + "提示词配置": string_map, + "功能列表配置": { + "菜单标题": str, + "游客身份显示": str, + "成员身份显示模板": str, + "输入数字提示模板": str, + "输入名称提示词": str, + "基础功能": cfg.AnyKeyValue(menu_item_std), + "可选功能": cfg.AnyKeyValue(menu_item_std), + }, + "经济系统": { + "默认物品价值": number_map, + "中文物品名称": string_map, + "物品别名": string_map, + }, + } + + +def _int_keyed_dict(raw: Any) -> Any: + """Implement the int keyed dict operation.""" + if not isinstance(raw, dict): + return raw + + result = {} + for key, value in raw.items(): + try: + normalized_key = int(key) + except (TypeError, ValueError): + normalized_key = key + result[normalized_key] = value + return result + + +def _require_current_config_format(raw_config: Any) -> None: + """Implement the require current config format operation.""" + if not isinstance(raw_config, dict): + raise ValueError("配置项必须是对象") + + +def _normalize_effect_runtime_config(raw: Any) -> Any: + """Normalize effect runtime config values.""" + effects = copy.deepcopy(raw) + if not isinstance(effects, dict): + return effects + + for effect in effects.values(): + if not isinstance(effect, dict): + continue + for key in ("levels", "costs"): + if key in effect: + effect[key] = _int_keyed_dict(effect[key]) + return effects + + +def _build_runtime_config(grouped_config: dict[str, Any]) -> dict[str, Any]: + """Implement the build runtime config operation.""" + base = grouped_config["基础配置"] + features = grouped_config["功能开关"] + guild = grouped_config["公会配置"] + create = grouped_config["创建配置"] + vault = grouped_config["仓库配置"] + exp = grouped_config["经验配置"] + base_point = grouped_config["据点配置"] + effects = grouped_config["效果系统"] + permissions = grouped_config["权限系统"] + economy = grouped_config["经济系统"] + + return { + "是否启用插件": copy.deepcopy(base["是否启用插件"]), + "公会菜单唤醒词": copy.deepcopy(base["公会菜单唤醒词"]), + "积分计分板名称": copy.deepcopy(base["积分计分板名称"]), + "缓存有效时间秒": copy.deepcopy(base["缓存有效时间秒"]), + "批量保存间隔秒": copy.deepcopy(base["批量保存间隔秒"]), + "批量保存最大数量": copy.deepcopy(base["批量保存最大数量"]), + "公会仓库": copy.deepcopy(features["公会仓库"]), + "公会据点": copy.deepcopy(features["公会据点"]), + "公会捐献": copy.deepcopy(features["公会捐献"]), + "公会任务": copy.deepcopy(features["公会任务"]), + "公会效果": copy.deepcopy(features["公会效果"]), + "公会排行": copy.deepcopy(features["公会排行"]), + "公会最大成员数": copy.deepcopy(guild["公会最大成员数"]), + "在线成员每次增加经验": copy.deepcopy(exp["在线经验"]["每次增加经验"]), + "在线经验增加间隔秒": copy.deepcopy(exp["在线经验"]["增加间隔秒"]), + "每日登录经验": copy.deepcopy(exp["登录经验"]["每日登录经验"]), + "捐献转经验倍率": copy.deepcopy(exp["捐献经验"]["转经验倍率"]), + "每页显示数量": copy.deepcopy(guild["每页显示数量"]), + LEVEL_EXP_CONFIG_KEY: _int_keyed_dict(guild[LEVEL_EXP_CONFIG_KEY]), + "创建公会消耗积分": copy.deepcopy(create["创建公会消耗积分"]), + "创建配置": copy.deepcopy(create), + "申请配置": copy.deepcopy(grouped_config["申请配置"]), + "初始容量": copy.deepcopy(vault["初始容量"]), + "每级增加容量": copy.deepcopy(vault["每级增加容量"]), + "交易税率": copy.deepcopy(vault["交易税率"]), + "市场配置": copy.deepcopy(vault["市场配置"]), + "维度名称映射": _int_keyed_dict(base_point["维度名称映射"]), + "刷新间隔秒": copy.deepcopy(effects["刷新间隔秒"]), + "效果列表": _normalize_effect_runtime_config(effects["效果列表"]), + "任务系统": copy.deepcopy(grouped_config["任务系统"]), + "职位权限配置": copy.deepcopy(permissions["职位权限配置"]), + "数据安全": copy.deepcopy(grouped_config["数据安全"]), + "提示词配置": copy.deepcopy(grouped_config["提示词配置"]), + "功能列表配置": copy.deepcopy(grouped_config["功能列表配置"]), + "默认物品价值": copy.deepcopy(economy["默认物品价值"]), + "中文物品名称": copy.deepcopy(economy["中文物品名称"]), + "物品别名": copy.deepcopy(economy["物品别名"]), + } + + +def _set_config_attributes( + config_cls: type, normalized_config: dict[str, Any]) -> None: + """Set config attributes data.""" + for attr_name in REMOVED_CONFIG_ATTRIBUTES: + if hasattr(config_cls, attr_name): + delattr(config_cls, attr_name) + + for key, value in normalized_config.items(): + attr_name = CONFIG_ATTRIBUTE_KEYS.get(key, key) + setattr(config_cls, attr_name, value) + + +class Config: + """Runtime facade exposing loaded config as Config.X attributes.""" + + _loaded = False + _config: dict[str, Any] = {} + _grouped_config: dict[str, Any] = copy.deepcopy(DEFAULT_CONFIG) + + @classmethod + def grouped_config_std(cls) -> dict[str, Any]: + """Implement the grouped config std operation.""" + return grouped_config_std() + + @classmethod + def is_dynamic_load_enabled(cls) -> bool: + """Implement the is dynamic load enabled operation.""" + settings = cls._grouped_config.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return DYNAMIC_LOAD_DEFAULT[DYNAMIC_LOAD_ENABLED_KEY] + return bool( + settings.get( + DYNAMIC_LOAD_ENABLED_KEY, + DYNAMIC_LOAD_DEFAULT[DYNAMIC_LOAD_ENABLED_KEY])) + + @classmethod + def dynamic_load_interval(cls) -> int: + """Implement the dynamic load interval operation.""" + settings = cls._grouped_config.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return RUNTIME_CONFIG_RELOAD_INTERVAL + return _normalize_positive_int( + settings.get( + DYNAMIC_LOAD_INTERVAL_KEY, + RUNTIME_CONFIG_RELOAD_INTERVAL), + RUNTIME_CONFIG_RELOAD_INTERVAL, + ) + + @classmethod + def load(cls, + plugin_name: str, + version: tuple[int, + int, + int]) -> dict[str, + Any]: + """Load load data.""" + default_config = copy.deepcopy(DEFAULT_CONFIG) + + try: + raw_config, _ = cfg.get_plugin_config_and_version( + plugin_name, + {}, + default_config, + version, + ) + except Exception as err: + fmts.print_err( + f"{plugin_name} config load failed, using defaults: {err}") + raw_config = default_config + + try: + _require_current_config_format(raw_config) + grouped_config = _normalize_grouped_config(raw_config) + cfg.check_auto(cls.grouped_config_std(), grouped_config) + except Exception as err: + fmts.print_err( + f"{plugin_name} config validation failed, using defaults: {err}") + grouped_config = _normalize_grouped_config({}) + cfg.check_auto(cls.grouped_config_std(), grouped_config) + cfg.upgrade_plugin_config(plugin_name, grouped_config, version) + + runtime_config = _build_runtime_config(grouped_config) + _set_config_attributes(cls, runtime_config) + + cls._config = runtime_config + cls._grouped_config = grouped_config + cls._loaded = True + + return copy.deepcopy(runtime_config) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config_watcher.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config_watcher.py" index 4cebea11..a1cf8483 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config_watcher.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config_watcher.py" @@ -12,10 +12,12 @@ def get_config_path(plugin_name: str) -> str: + """Return config path data.""" return os.path.join(CONFIG_FILE_DIR, f"{plugin_name}.json") def get_file_state(path: str) -> tuple[int, int] | None: + """Return file state data.""" try: stat = os.stat(path) except OSError: @@ -24,11 +26,13 @@ def get_file_state(path: str) -> tuple[int, int] | None: def refresh_config_file_state(plugin: Any) -> None: + """Implement the refresh config file state operation.""" config_path = get_config_path(plugin.name) plugin.set_config_file_state(get_file_state(config_path)) def apply_runtime_config(plugin: Any, *, announce: bool = False) -> None: + """Implement the apply runtime config operation.""" plugin.config = Config.load(plugin.name, plugin.version) guild_manager = getattr(plugin, "guild_manager", None) @@ -48,6 +52,7 @@ def apply_runtime_config(plugin: Any, *, announce: bool = False) -> None: def config_reload_task(plugin: Any) -> None: + """Implement the config reload task operation.""" config_path = get_config_path(plugin.name) plugin.set_config_file_state(get_file_state(config_path)) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" index ef0ba507..54750f00 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" @@ -3,10 +3,17 @@ import json import time import uuid -from datetime import datetime - -from tooldelta import Player, game_utils, fmts -from guild_cloud_interop.models import GuildData, GuildMember, GuildTask, GuildBase, GuildRank, VaultItem +from datetime import datetime + +from tooldelta import Player, game_utils, fmts +from guild_cloud_interop.models import ( + GuildBase, + GuildData, + GuildMember, + GuildRank, + GuildTask, + VaultItem, +) from guild_cloud_interop.config import Config from guild_cloud_interop.prompts import render_config_prompt, render_create_guild_prompt from guild_cloud_interop.ui import ORION_BORDER, TITLE_PREFIX, format_page_footer @@ -25,10 +32,8 @@ def _handle_effect(self, player: Player) -> bool: player.show("§l§a公会 §d>> §r你没有购买公会效果权限") return True - level = guild.level - - # 显示可选效果 - player.show("§l§a公会 §d>> §r可用效果列表:") + # 显示可选效果 + player.show("§l§a公会 §d>> §r可用效果列表:") for key, val in Config.EFFECTS_CONFIG.items(): try: @@ -659,9 +664,7 @@ def _handle_create_task(self, player: Player, guild: GuildData) -> bool: player.show(f"§c经验奖励必须在0-{max_exp_reward}之间") return True - # 创建任务 - import uuid - task_id = uuid.uuid4().hex[:8] + task_id = uuid.uuid4().hex[:8] new_task = GuildTask( task_id=task_id, name=task_name, @@ -720,9 +723,9 @@ def _handle_generate_auto_tasks( if not task.completed and task.task_id.startswith("auto-") ] max_active = int(task_config.get("自动任务最大同时存在数量", 6)) - if max_active > 0 and len(active_auto_tasks) >= max_active: - player.show(f"§l§a公会任务 §d>> §r自动任务数量已达上限 {max_active}") - return True + if len(active_auto_tasks) >= max_active > 0: + player.show(f"§l§a公会任务 §d>> §r自动任务数量已达上限 {max_active}") + return True generate_count = int(task_config.get("每次生成自动任务数量", 3)) if max_active > 0: @@ -1096,13 +1099,12 @@ def formatter(i, m): return f"§e{i}. {m.rank.display_name} §f{m.name}\n" "3": GuildRank.MEMBER} new_rank = rank_map.get(rank_choice) - if new_rank: - if self.guild_manager.set_member_rank( - guild, target_member.name, new_rank): - player.show( - f"§l§a公会 §d>> §r已将 { - target_member.name} 的职位设置为 { - new_rank.display_name}") + if new_rank and self.guild_manager.set_member_rank( + guild, target_member.name, new_rank): + player.show( + f"§l§a公会 §d>> §r已将 { + target_member.name} 的职位设置为 { + new_rank.display_name}") return True @@ -1501,9 +1503,9 @@ def _handle_vault_sell(self, player: Player, guild: GuildData) -> bool: {}).get( "单次出售最大数量", 64)) - if max_sell_count > 0 and count > max_sell_count: - player.show(f"§l§a公会仓库 §d>> §r单次最多出售 {max_sell_count} 个") - return True + if count > max_sell_count > 0: + player.show(f"§l§a公会仓库 §d>> §r单次最多出售 {max_sell_count} 个") + return True # 获取建议价格 suggested_price = guild.get_item_value(item_id) * count diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" index b205855b..381cd779 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" @@ -1,566 +1,573 @@ -"""Shared guild runtime and gameplay logic.""" - -import os -import time -from typing import List, Optional, Tuple, Any - - -from tooldelta import Player, game_utils, fmts -from tooldelta.utils import tempjson - -from guild_cloud_interop.config import Config -from guild_cloud_interop.models import GuildData, GuildMember, GuildRank, VaultItem -from guild_cloud_interop.prompts import render_config_prompt -from guild_cloud_interop.ui import format_page_footer, format_panel - - -def _format_template(template: str, **values: Any) -> str: - result = str(template) - for key, value in values.items(): - result = result.replace("{" + key + "}", str(value)) - return result - - -def _menu_config() -> dict[str, Any]: - config = getattr(Config, "MENU_CONFIG", {}) - return config if isinstance(config, dict) else {} - - -def _menu_item(group: str, key: str, fallback_name: str, - fallback_desc: str) -> tuple[str, str]: - menu_config = _menu_config() - group_config = menu_config.get(group, {}) - item_config = group_config.get( - key, {}) if isinstance( - group_config, dict) else {} - if not isinstance(item_config, dict): - item_config = {} - name = item_config.get("名称") - desc = item_config.get("描述") - return ( - str(name) if isinstance(name, str) and name else fallback_name, - str(desc) if isinstance(desc, str) else fallback_desc, - ) - - -def _menu_item_name(group: str, key: str, fallback_name: str) -> str: - return _menu_item(group, key, fallback_name, "")[0] - - -def _show_menu( - self, - player: Player, - guild: Optional[GuildData], - member: Optional[GuildMember]) -> Optional[str]: - """显示公会菜单并返回用户选择 - 增强版本""" - - # 数据完整性检查 - if guild and not member: - # 公会存在但成员不存在,尝试重新获取 - fmts.print_err(f"数据不一致:玩家 {player.name} 在公会 {guild.name} 中但成员信息缺失") - member = guild.get_member(player.name) - if not member: - player.show("§c错误:公会数据不一致,请尝试重新加入公会或联系管理员") - fmts.print_err(f"严重错误:无法找到玩家 {player.name} 的成员信息") - return None - - is_member = guild is not None - is_owner = member and member.rank == GuildRank.OWNER - can_manage_members = bool( - guild and member and any( - guild.has_permission( - player.name, - permission) for permission in ( - "kick", - "set_rank", - "transfer_owner", - "join_queue"))) - - # 调试日志 - if guild: - fmts.print_inf( - f"菜单显示 - 玩家: { - player.name}, 公会: { - guild.name}, 成员职位: { - member.rank.value if member else 'None'}, 是否会长: {is_owner}") - - menu_config = _menu_config() - base_items = [ - ("创建", "创建自己的公会", not is_member), - ("列表", "查看所有公会", True), - ("查看", "查看公会详情", is_member), - ("成员", "查看成员列表", is_member), - ("日志", "查看公会日志", is_member), - ("公告", "查看/设置公告", is_member), - ("加入", "加入一个公会", not is_member), - ("退出", "退出当前公会", is_member and not is_owner), - ("管理", "管理公会成员", can_manage_members), - ("解散", "解散公会", is_owner), - ] - menu_items = [ - (*_menu_item("基础功能", key, default_name, default_desc), condition) - for key, default_desc, condition in base_items - for default_name in (key,) - ] - - optional_menu_config = [ - (Config.GUILD_FUNCTION_VAULT, "仓库", "公会仓库", is_member), - (Config.GUILD_FUNCTION_BASE, "据点", "据点相关操作", is_member), - (Config.GUILD_FUNCTION_DONATION, "捐献", "捐献物品到公会", is_member), - (Config.GUILD_FUNCTION_TASKS, "任务", "公会任务系统", is_member), - (Config.GUILD_FUNCTION_EFFECT, "效果", "通过钻石获得效果增益", is_member), - (Config.GUILD_FUNCTION_RANKINGS, "排行", "查看公会排行榜", True), - ] - - for enabled, cmd, desc, condition in optional_menu_config: - if enabled: - menu_items.append((*_menu_item("可选功能", cmd, cmd, desc), condition)) - - available_items = [(cmd, desc) for cmd, desc, cond in menu_items if cond] - - if member and guild: - subtitle_template = menu_config.get("成员身份显示模板", "[{rank}§f] §e{guild}") - subtitle = _format_template( - subtitle_template, rank=member.rank.display_name, guild=guild.name) - else: - subtitle = str(menu_config.get("游客身份显示", "[§7游客§f]")) - - number_prompt = _format_template( - menu_config.get("输入数字提示模板", "§a❀ §r输入 §e[1-{count}]§r 之间的数字选择功能"), - count=len(available_items), - ) - name_prompt = str(menu_config.get("输入名称提示词", "§a❀ §r也可输入功能名称,输入 §cq §r退出")) - player.show(format_panel( - str(menu_config.get("菜单标题", "公会管理系统")), - available_items, - subtitle=subtitle, - footer=f"{number_prompt}\n{name_prompt}", - )) - choice_map = {str(index): cmd for index, (cmd, _desc) - in enumerate(available_items, start=1)} - choice_map.update({cmd: cmd for cmd, _desc in available_items}) - user_input = game_utils.waitMsg(player.name, timeout=30) - if user_input is None: - player.show(render_config_prompt("菜单回复超时提示词")) - return None - return choice_map.get(user_input, user_input) - - -def guild_update_data(self, args: list[str]): - """更新过去的数据,确保所有公会都有 guild_id,且与外层 id 一致""" - updated = False - - # 使用统一接口加载所有公会数据 - guilds = self.guild_manager.load_guilds(force_reload=True) - - for outer_id, guild in guilds.items(): - inner_id = guild.guild_id - if inner_id != outer_id: - guild.guild_id = outer_id - fmts.print_inf(f"已更新 guild_id: {outer_id}(原: {inner_id})") - updated = True - - if updated: - try: - self.guild_manager.save_guilds(guilds) - fmts.print_inf("公会数据文件已更新完成") - except Exception as e: - fmts.print_err(f"保存公会数据时出错: {e}") - else: - fmts.print_inf("无需更新,所有 数据 已正确") - - -def guild_menu_cb(self, player: Player, args: tuple): - """公会菜单回调函数 - 增强版本""" - player_xuid = self.xuidm.get_xuid_by_name(player.name, allow_offline=True) - - # 强制刷新缓存以确保数据最新 - guild = self.guild_manager.get_guild_by_player( - player.name, force_reload=True) - member = guild.get_member(player.name) if guild else None - - # 额外的数据验证 - if guild and not member: - fmts.print_err(f"数据不一致警告:玩家 {player.name} 在公会 {guild.name} 中但找不到成员记录") - # 尝试重新加载数据 - guild = self.guild_manager.get_guild_by_player( - player.name, force_reload=True) - member = guild.get_member(player.name) if guild else None - - subcommand = self._show_menu(player, guild, member) - - if subcommand is None or subcommand in ("q", "Q", ".", "。"): - return True - - if guild and member and self.guild_is_frozen(guild): - frozen_readonly_items = { - _menu_item_name("基础功能", "列表", "列表"), - _menu_item_name("基础功能", "查看", "查看"), - _menu_item_name("基础功能", "成员", "成员"), - _menu_item_name("基础功能", "日志", "日志"), - _menu_item_name("可选功能", "排行", "排行"), - } - if subcommand not in frozen_readonly_items: - self.show_guild_frozen(player, guild) - return True - - # 路由到对应的处理函数 - # 功能项配置:功能是否启用、菜单标题、处理函数 - optional_handlers_config = [ - (Config.GUILD_FUNCTION_VAULT, "仓库", self._handle_vault), - (Config.GUILD_FUNCTION_BASE, "据点", self._handle_base_menu), - (Config.GUILD_FUNCTION_DONATION, "捐献", self._handle_donation), - (Config.GUILD_FUNCTION_TASKS, "任务", self._handle_tasks), - (Config.GUILD_FUNCTION_EFFECT, "效果", self._handle_effect), - (Config.GUILD_FUNCTION_RANKINGS, "排行", self._handle_rankings), - ] - - # 基础菜单项(总是存在) - handlers = { - _menu_item_name("基础功能", "创建", "创建"): lambda: self._handle_create_guild(player, player_xuid), - _menu_item_name("基础功能", "列表", "列表"): lambda: self._handle_list_guilds(player), - _menu_item_name("基础功能", "查看", "查看"): lambda: self._handle_view_guild(player), - _menu_item_name("基础功能", "成员", "成员"): lambda: self._handle_view_members(player), - _menu_item_name("基础功能", "日志", "日志"): lambda: self._handle_view_logs(player), - _menu_item_name("基础功能", "公告", "公告"): lambda: self._handle_announcement(player), - _menu_item_name("基础功能", "加入", "加入"): lambda: self._handle_join_guild(player), - _menu_item_name("基础功能", "退出", "退出"): lambda: self._handle_leave_guild(player), - _menu_item_name("基础功能", "管理", "管理"): lambda: self._handle_manage_members(player), - _menu_item_name("基础功能", "解散", "解散"): lambda: self._handle_dissolve_guild(player, player_xuid), - } - - # 追加可选功能项 - for enabled, title, func in optional_handlers_config: - if enabled: - handlers[_menu_item_name("可选功能", title, title) - ] = lambda f=func: f(player) - - handler = handlers.get(subcommand) - if handler: - return handler() - else: - player.show(render_config_prompt("无效指令提示词")) - - return True - - -def _create_progress_bar( - self, - current: int, - total: int, - length: int = 10) -> str: - """创建进度条""" - if total == 0: - return "§7[§c无效§7]" - - progress = min(current / total, 1.0) - filled = int(progress * length) - empty = length - filled - - bar = "§a" + "█" * filled + "§7" + "░" * empty - percentage = int(progress * 100) - - return f"§7[{bar}§7] §f{percentage}%" - - -def _format_time_duration(self, seconds: float) -> str: - """格式化时间长度""" - if seconds < 60: - return f"{int(seconds)}秒" - elif seconds < 3600: - return f"{int(seconds // 60)}分钟" - elif seconds < 86400: - return f"{int(seconds // 3600)}小时" - else: - return f"{int(seconds // 86400)}天" - - -def _get_item_display_name(self, item_id: str) -> str: - """获取物品显示名称""" - return self.item_matcher.get_chinese_name(item_id) - - -def _has_inventory_space( - self, - player: Player, - item_id: str, - count: int) -> bool: - """检查玩家背包是否有足够空间""" - return True - - -def _handle_base_menu(self, player: Player) -> bool: - """据点菜单""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - can_return = guild.has_permission(player.name, "return_base") - can_set = guild.has_permission(player.name, "setbase") - - player.show("§r========== §a据点菜单§r ==========") - - if guild.base: - base = guild.base - dim_name = Config.DIMENSION_NAMES.get( - base.dimension, f"维度{base.dimension}") - player.show( - f"§7当前据点: §f{dim_name} ({ - base.x:.1f}, { - base.y:.1f}, { - base.z:.1f})") - if can_return: - player.show("§e1. §f传送到据点") - else: - player.show("§7当前据点: §c未设置") - - if can_set: - player.show("§e2. §f设置据点") - - player.show("§7输入选项序号,q 返回") - - choice = game_utils.waitMsg(player.name, timeout=30) - - if choice == "1" and guild.base and can_return: - return self._handle_return_base(player) - elif choice == "2" and can_set: - return self._handle_set_base(player) - - return True - - -def guild_chat_cb(self, player: Player, args: tuple): - """公会聊天回调""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - message = args[0] if args and args[0] else None - - if not message: - # 切换聊天模式 - current_mode = self.guild_chat_mode.get(player.name, False) - self.guild_chat_mode[player.name] = not current_mode - - if self.guild_chat_mode[player.name]: - player.show("§l§a公会 §d>> §r已切换到公会聊天模式") - else: - player.show("§l§a公会 §d>> §r已切换到公共聊天模式") - else: - # 发送公会消息 - self._send_guild_message(guild, player.name, message) - - return True - - -def _send_guild_message(self, guild: GuildData, sender: str, message: str): - """发送公会聊天消息""" - if self.guild_is_frozen(guild): - return - member = guild.get_member(sender) - if not member: - return - - # 构建消息 - chat_msg = f"§d✧§b[公会]§d✦ { - member.rank.display_name} §e{sender}§7: §f{message}" - - # 发送给所有在线的公会成员 - for member in guild.members: - if member.name in self.game_ctrl.allplayers: - self.game_ctrl.sendcmd( - f'/tellraw {member.name} {{"rawtext":[{{"text":"{chat_msg}"}}]}}' - ) - - -def on_chat_packet(self, packet): - """处理聊天数据包""" - try: - # 检查是否是玩家聊天 - if packet.get("type") != 1: # 1 表示玩家聊天 - return False - - sender = packet.get("source_name", "") - message = packet.get("message", "") - - # 检查是否在公会聊天模式 - if self.guild_chat_mode.get(sender, False): - guild = self.guild_manager.get_guild_by_player(sender) - if guild: - self._send_guild_message(guild, sender, message) - return True # 阻止原始消息 - except BaseException: - pass - - return False - - -def _should_stop(self) -> bool: - stop_event = getattr(self, "_stop_event", None) - return bool(stop_event and stop_event.is_set()) - - -def _wait_or_stopped(self, seconds: float) -> bool: - stop_event = getattr(self, "_stop_event", None) - if stop_event is None: - time.sleep(seconds) - return False - return stop_event.wait(seconds) - - -def _apply_guild_effects_to_player( - self, - player_name: str, - guild: Optional[GuildData] = None, - force: bool = False, - command_delay: float = 0.2, - effect_names: Optional[set] = None, -) -> int: - """按需给单个在线玩家补发公会增益,避免周期性全量刷命令压租赁服。""" - if not Config.GUILD_FUNCTION_EFFECT or player_name not in self.game_ctrl.allplayers: - return 0 - - if guild is None: - guild = self.guild_manager.get_guild_by_player(player_name) - if guild is None: - return 0 - - effects = getattr(guild, "purchased_effects", {}) or {} - if not effects: - return 0 - - now = time.time() - cache = getattr(self, "_effect_refresh_cache", None) - if cache is None: - cache = {} - self._effect_refresh_cache = cache - - refresh_interval = getattr(Config, "EFFECT_REFRESH_INTERVAL", 3600) - sent_count = 0 - - for effect_name, raw_level in effects.items(): - if _should_stop(self): - break - if effect_names is not None and effect_name not in effect_names: - continue - - try: - amplifier = max(int(raw_level) - 1, 0) - except (TypeError, ValueError): - fmts.print_err( - f"公会 { - guild.name} 的效果 {effect_name} 等级无效: {raw_level}") - continue - - cache_key = (player_name, effect_name) - cached = cache.get(cache_key) - if not force and cached: - cached_amplifier, cached_time = cached - if cached_amplifier == amplifier and now - cached_time < refresh_interval: - continue - - if sent_count and command_delay > 0 and _wait_or_stopped( - self, command_delay): - break - - try: - self.game_ctrl.sendwocmd( - f"effect {player_name} {effect_name} 100000 {amplifier} true" - ) - cache[cache_key] = (amplifier, time.time()) - sent_count += 1 - except Exception as e: - fmts.print_err(f"为玩家 {player_name} 添加效果 {effect_name} 时出错: {e}") - - return sent_count - - -def _refresh_online_effects( - self, - online_players: List[str], - guilds: Optional[dict] = None) -> int: - if not Config.GUILD_FUNCTION_EFFECT: - return 0 - - online_set = set(online_players) - cache = getattr(self, "_effect_refresh_cache", None) - if cache: - for cache_key in list(cache): - if cache_key[0] not in online_set: - del cache[cache_key] - - sent_count = 0 - for player_name in online_players: - if _should_stop(self): - break - - guild = None - if guilds is not None: - guild_id = self.guild_manager.get_cached_guild_id(player_name) - if guild_id: - guild = guilds.get(guild_id) - - sent_count += _apply_guild_effects_to_player( - self, player_name, guild=guild) - - return sent_count - - -def guild_exp_task(self): - """公会经验更新任务""" - while not _should_stop(self): - if _wait_or_stopped(self, Config.EXP_UPDATE_INTERVAL): - break - if not self._plugin_enabled(): - continue - - try: - fmts.print_inf("正在更新公会经验...") - - online_players = list(self.game_ctrl.allplayers) - guilds = self.guild_manager.load_guilds(force_reload=True) - guild_online_count = {} - level_ups = [] - - # 统计每个公会在线人数 - for player_name in online_players: - guild = self.guild_manager.get_guild_by_player(player_name) - if guild: - guild_online_count[guild.guild_id] = guild_online_count.get( - guild.guild_id, 0) + 1 - - # 更新经验和等级 - for gid, count in guild_online_count.items(): - if count == 0: - continue - - guild = guilds[gid] - exp_add, _ = self.guild_apply_reward_multipliers( - count * Config.EXP_PER_ONLINE_MEMBER, - 0, - ) - guild.exp += exp_add - - # 处理升级 - level = guild.level - next_required_exp = Config.GUILD_LEVEL_EXP.get(level + 1) - while next_required_exp and guild.exp >= next_required_exp: - guild.exp -= next_required_exp - level += 1 - next_required_exp = Config.GUILD_LEVEL_EXP.get(level + 1) - level_ups.append((guild.name, level)) - guild.add_log(f"公会升级到 {level} 级") - - guild.level = level - - self.guild_manager.save_guilds(guilds) - +"""Shared guild runtime and gameplay logic.""" + +import os +import time +from typing import List, Optional, Tuple, Any + + +from tooldelta import Player, game_utils, fmts +from tooldelta.utils import tempjson + +from guild_cloud_interop.config import Config +from guild_cloud_interop.models import GuildData, GuildMember, GuildRank, VaultItem +from guild_cloud_interop.prompts import render_config_prompt +from guild_cloud_interop.ui import format_page_footer, format_panel + + +def _format_template(template: str, **values: Any) -> str: + """Implement the format template operation.""" + result = str(template) + for key, value in values.items(): + result = result.replace("{" + key + "}", str(value)) + return result + + +def _menu_config() -> dict[str, Any]: + """Implement the menu config operation.""" + config = getattr(Config, "MENU_CONFIG", {}) + return config if isinstance(config, dict) else {} + + +def _menu_item(group: str, key: str, fallback_name: str, + fallback_desc: str) -> tuple[str, str]: + """Implement the menu item operation.""" + menu_config = _menu_config() + group_config = menu_config.get(group, {}) + item_config = group_config.get( + key, {}) if isinstance( + group_config, dict) else {} + if not isinstance(item_config, dict): + item_config = {} + name = item_config.get("名称") + desc = item_config.get("描述") + return ( + str(name) if isinstance(name, str) and name else fallback_name, + str(desc) if isinstance(desc, str) else fallback_desc, + ) + + +def _menu_item_name(group: str, key: str, fallback_name: str) -> str: + """Implement the menu item name operation.""" + return _menu_item(group, key, fallback_name, "")[0] + + +def _show_menu( + self, + player: Player, + guild: Optional[GuildData], + member: Optional[GuildMember]) -> Optional[str]: + """显示公会菜单并返回用户选择 - 增强版本""" + + # 数据完整性检查 + if guild and not member: + # 公会存在但成员不存在,尝试重新获取 + fmts.print_err(f"数据不一致:玩家 {player.name} 在公会 {guild.name} 中但成员信息缺失") + member = guild.get_member(player.name) + if not member: + player.show("§c错误:公会数据不一致,请尝试重新加入公会或联系管理员") + fmts.print_err(f"严重错误:无法找到玩家 {player.name} 的成员信息") + return None + + is_member = guild is not None + is_owner = member and member.rank == GuildRank.OWNER + can_manage_members = bool( + guild and member and any( + guild.has_permission( + player.name, + permission) for permission in ( + "kick", + "set_rank", + "transfer_owner", + "join_queue"))) + + # 调试日志 + if guild: + fmts.print_inf( + f"菜单显示 - 玩家: { + player.name}, 公会: { + guild.name}, 成员职位: { + member.rank.value if member else 'None'}, 是否会长: {is_owner}") + + menu_config = _menu_config() + base_items = [ + ("创建", "创建自己的公会", not is_member), + ("列表", "查看所有公会", True), + ("查看", "查看公会详情", is_member), + ("成员", "查看成员列表", is_member), + ("日志", "查看公会日志", is_member), + ("公告", "查看/设置公告", is_member), + ("加入", "加入一个公会", not is_member), + ("退出", "退出当前公会", is_member and not is_owner), + ("管理", "管理公会成员", can_manage_members), + ("解散", "解散公会", is_owner), + ] + menu_items = [ + (*_menu_item("基础功能", key, default_name, default_desc), condition) + for key, default_desc, condition in base_items + for default_name in (key,) + ] + + optional_menu_config = [ + (Config.GUILD_FUNCTION_VAULT, "仓库", "公会仓库", is_member), + (Config.GUILD_FUNCTION_BASE, "据点", "据点相关操作", is_member), + (Config.GUILD_FUNCTION_DONATION, "捐献", "捐献物品到公会", is_member), + (Config.GUILD_FUNCTION_TASKS, "任务", "公会任务系统", is_member), + (Config.GUILD_FUNCTION_EFFECT, "效果", "通过钻石获得效果增益", is_member), + (Config.GUILD_FUNCTION_RANKINGS, "排行", "查看公会排行榜", True), + ] + + for enabled, cmd, desc, condition in optional_menu_config: + if enabled: + menu_items.append((*_menu_item("可选功能", cmd, cmd, desc), condition)) + + available_items = [(cmd, desc) for cmd, desc, cond in menu_items if cond] + + if member and guild: + subtitle_template = menu_config.get("成员身份显示模板", "[{rank}§f] §e{guild}") + subtitle = _format_template( + subtitle_template, rank=member.rank.display_name, guild=guild.name) + else: + subtitle = str(menu_config.get("游客身份显示", "[§7游客§f]")) + + number_prompt = _format_template( + menu_config.get("输入数字提示模板", "§a❀ §r输入 §e[1-{count}]§r 之间的数字选择功能"), + count=len(available_items), + ) + name_prompt = str(menu_config.get("输入名称提示词", "§a❀ §r也可输入功能名称,输入 §cq §r退出")) + player.show(format_panel( + str(menu_config.get("菜单标题", "公会管理系统")), + available_items, + subtitle=subtitle, + footer=f"{number_prompt}\n{name_prompt}", + )) + choice_map = {str(index): cmd for index, (cmd, _desc) + in enumerate(available_items, start=1)} + choice_map.update({cmd: cmd for cmd, _desc in available_items}) + user_input = game_utils.waitMsg(player.name, timeout=30) + if user_input is None: + player.show(render_config_prompt("菜单回复超时提示词")) + return None + return choice_map.get(user_input, user_input) + + +def guild_update_data(self, args: list[str]): + """更新过去的数据,确保所有公会都有 guild_id,且与外层 id 一致""" + updated = False + + # 使用统一接口加载所有公会数据 + guilds = self.guild_manager.load_guilds(force_reload=True) + + for outer_id, guild in guilds.items(): + inner_id = guild.guild_id + if inner_id != outer_id: + guild.guild_id = outer_id + fmts.print_inf(f"已更新 guild_id: {outer_id}(原: {inner_id})") + updated = True + + if updated: + try: + self.guild_manager.save_guilds(guilds) + fmts.print_inf("公会数据文件已更新完成") + except Exception as e: + fmts.print_err(f"保存公会数据时出错: {e}") + else: + fmts.print_inf("无需更新,所有 数据 已正确") + + +def guild_menu_cb(self, player: Player, args: tuple): + """公会菜单回调函数 - 增强版本""" + player_xuid = self.xuidm.get_xuid_by_name(player.name, allow_offline=True) + + # 强制刷新缓存以确保数据最新 + guild = self.guild_manager.get_guild_by_player( + player.name, force_reload=True) + member = guild.get_member(player.name) if guild else None + + # 额外的数据验证 + if guild and not member: + fmts.print_err(f"数据不一致警告:玩家 {player.name} 在公会 {guild.name} 中但找不到成员记录") + # 尝试重新加载数据 + guild = self.guild_manager.get_guild_by_player( + player.name, force_reload=True) + member = guild.get_member(player.name) if guild else None + + subcommand = self._show_menu(player, guild, member) + + if subcommand is None or subcommand in ("q", "Q", ".", "。"): + return True + + if guild and member and self.guild_is_frozen(guild): + frozen_readonly_items = { + _menu_item_name("基础功能", "列表", "列表"), + _menu_item_name("基础功能", "查看", "查看"), + _menu_item_name("基础功能", "成员", "成员"), + _menu_item_name("基础功能", "日志", "日志"), + _menu_item_name("可选功能", "排行", "排行"), + } + if subcommand not in frozen_readonly_items: + self.show_guild_frozen(player, guild) + return True + + # 路由到对应的处理函数 + # 功能项配置:功能是否启用、菜单标题、处理函数 + optional_handlers_config = [ + (Config.GUILD_FUNCTION_VAULT, "仓库", self._handle_vault), + (Config.GUILD_FUNCTION_BASE, "据点", self._handle_base_menu), + (Config.GUILD_FUNCTION_DONATION, "捐献", self._handle_donation), + (Config.GUILD_FUNCTION_TASKS, "任务", self._handle_tasks), + (Config.GUILD_FUNCTION_EFFECT, "效果", self._handle_effect), + (Config.GUILD_FUNCTION_RANKINGS, "排行", self._handle_rankings), + ] + + # 基础菜单项(总是存在) + handlers = { + _menu_item_name("基础功能", "创建", "创建"): lambda: self._handle_create_guild(player, player_xuid), + _menu_item_name("基础功能", "列表", "列表"): lambda: self._handle_list_guilds(player), + _menu_item_name("基础功能", "查看", "查看"): lambda: self._handle_view_guild(player), + _menu_item_name("基础功能", "成员", "成员"): lambda: self._handle_view_members(player), + _menu_item_name("基础功能", "日志", "日志"): lambda: self._handle_view_logs(player), + _menu_item_name("基础功能", "公告", "公告"): lambda: self._handle_announcement(player), + _menu_item_name("基础功能", "加入", "加入"): lambda: self._handle_join_guild(player), + _menu_item_name("基础功能", "退出", "退出"): lambda: self._handle_leave_guild(player), + _menu_item_name("基础功能", "管理", "管理"): lambda: self._handle_manage_members(player), + _menu_item_name("基础功能", "解散", "解散"): lambda: self._handle_dissolve_guild(player, player_xuid), + } + + # 追加可选功能项 + for enabled, title, func in optional_handlers_config: + if enabled: + handlers[_menu_item_name("可选功能", title, title) + ] = lambda f=func: f(player) + + handler = handlers.get(subcommand) + if handler: + return handler() + else: + player.show(render_config_prompt("无效指令提示词")) + + return True + + +def _create_progress_bar( + self, + current: int, + total: int, + length: int = 10) -> str: + """创建进度条""" + if total == 0: + return "§7[§c无效§7]" + + progress = min(current / total, 1.0) + filled = int(progress * length) + empty = length - filled + + bar = "§a" + "█" * filled + "§7" + "░" * empty + percentage = int(progress * 100) + + return f"§7[{bar}§7] §f{percentage}%" + + +def _format_time_duration(self, seconds: float) -> str: + """格式化时间长度""" + if seconds < 60: + return f"{int(seconds)}秒" + elif seconds < 3600: + return f"{int(seconds // 60)}分钟" + elif seconds < 86400: + return f"{int(seconds // 3600)}小时" + else: + return f"{int(seconds // 86400)}天" + + +def _get_item_display_name(self, item_id: str) -> str: + """获取物品显示名称""" + return self.item_matcher.get_chinese_name(item_id) + + +def _has_inventory_space( + self, + player: Player, + item_id: str, + count: int) -> bool: + """检查玩家背包是否有足够空间""" + return True + + +def _handle_base_menu(self, player: Player) -> bool: + """据点菜单""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + can_return = guild.has_permission(player.name, "return_base") + can_set = guild.has_permission(player.name, "setbase") + + player.show("§r========== §a据点菜单§r ==========") + + if guild.base: + base = guild.base + dim_name = Config.DIMENSION_NAMES.get( + base.dimension, f"维度{base.dimension}") + player.show( + f"§7当前据点: §f{dim_name} ({ + base.x:.1f}, { + base.y:.1f}, { + base.z:.1f})") + if can_return: + player.show("§e1. §f传送到据点") + else: + player.show("§7当前据点: §c未设置") + + if can_set: + player.show("§e2. §f设置据点") + + player.show("§7输入选项序号,q 返回") + + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "1" and guild.base and can_return: + return self._handle_return_base(player) + elif choice == "2" and can_set: + return self._handle_set_base(player) + + return True + + +def guild_chat_cb(self, player: Player, args: tuple): + """公会聊天回调""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + message = args[0] if args and args[0] else None + + if not message: + # 切换聊天模式 + current_mode = self.guild_chat_mode.get(player.name, False) + self.guild_chat_mode[player.name] = not current_mode + + if self.guild_chat_mode[player.name]: + player.show("§l§a公会 §d>> §r已切换到公会聊天模式") + else: + player.show("§l§a公会 §d>> §r已切换到公共聊天模式") + else: + # 发送公会消息 + self._send_guild_message(guild, player.name, message) + + return True + + +def _send_guild_message(self, guild: GuildData, sender: str, message: str): + """发送公会聊天消息""" + if self.guild_is_frozen(guild): + return + member = guild.get_member(sender) + if not member: + return + + # 构建消息 + chat_msg = f"§d✧§b[公会]§d✦ { + member.rank.display_name} §e{sender}§7: §f{message}" + + # 发送给所有在线的公会成员 + for member in guild.members: + if member.name in self.game_ctrl.allplayers: + self.game_ctrl.sendcmd( + f'/tellraw {member.name} {{"rawtext":[{{"text":"{chat_msg}"}}]}}' + ) + + +def on_chat_packet(self, packet): + """处理聊天数据包""" + try: + # 检查是否是玩家聊天 + if packet.get("type") != 1: # 1 表示玩家聊天 + return False + + sender = packet.get("source_name", "") + message = packet.get("message", "") + + # 检查是否在公会聊天模式 + if self.guild_chat_mode.get(sender, False): + guild = self.guild_manager.get_guild_by_player(sender) + if guild: + self._send_guild_message(guild, sender, message) + return True # 阻止原始消息 + except BaseException: + pass + + return False + + +def _should_stop(self) -> bool: + """Implement the should stop operation.""" + stop_event = getattr(self, "_stop_event", None) + return bool(stop_event and stop_event.is_set()) + + +def _wait_or_stopped(self, seconds: float) -> bool: + """Implement the wait or stopped operation.""" + stop_event = getattr(self, "_stop_event", None) + if stop_event is None: + time.sleep(seconds) + return False + return stop_event.wait(seconds) + + +def _apply_guild_effects_to_player( + self, + player_name: str, + guild: Optional[GuildData] = None, + force: bool = False, + command_delay: float = 0.2, + effect_names: Optional[set] = None, +) -> int: + """按需给单个在线玩家补发公会增益,避免周期性全量刷命令压租赁服。""" + if not Config.GUILD_FUNCTION_EFFECT or player_name not in self.game_ctrl.allplayers: + return 0 + + if guild is None: + guild = self.guild_manager.get_guild_by_player(player_name) + if guild is None: + return 0 + + effects = getattr(guild, "purchased_effects", {}) or {} + if not effects: + return 0 + + now = time.time() + cache = getattr(self, "_effect_refresh_cache", None) + if cache is None: + cache = {} + self._effect_refresh_cache = cache + + refresh_interval = getattr(Config, "EFFECT_REFRESH_INTERVAL", 3600) + sent_count = 0 + + for effect_name, raw_level in effects.items(): + if _should_stop(self): + break + if effect_names is not None and effect_name not in effect_names: + continue + + try: + amplifier = max(int(raw_level) - 1, 0) + except (TypeError, ValueError): + fmts.print_err( + f"公会 { + guild.name} 的效果 {effect_name} 等级无效: {raw_level}") + continue + + cache_key = (player_name, effect_name) + cached = cache.get(cache_key) + if not force and cached: + cached_amplifier, cached_time = cached + if cached_amplifier == amplifier and now - cached_time < refresh_interval: + continue + + if sent_count and command_delay > 0 and _wait_or_stopped( + self, command_delay): + break + + try: + self.game_ctrl.sendwocmd( + f"effect {player_name} {effect_name} 100000 {amplifier} true" + ) + cache[cache_key] = (amplifier, time.time()) + sent_count += 1 + except Exception as e: + fmts.print_err(f"为玩家 {player_name} 添加效果 {effect_name} 时出错: {e}") + + return sent_count + + +def _refresh_online_effects( + self, + online_players: List[str], + guilds: Optional[dict] = None) -> int: + """Implement the refresh online effects operation.""" + if not Config.GUILD_FUNCTION_EFFECT: + return 0 + + online_set = set(online_players) + cache = getattr(self, "_effect_refresh_cache", None) + if cache: + for cache_key in list(cache): + if cache_key[0] not in online_set: + del cache[cache_key] + + sent_count = 0 + for player_name in online_players: + if _should_stop(self): + break + + guild = None + if guilds is not None: + guild_id = self.guild_manager.get_cached_guild_id(player_name) + if guild_id: + guild = guilds.get(guild_id) + + sent_count += _apply_guild_effects_to_player( + self, player_name, guild=guild) + + return sent_count + + +def guild_exp_task(self): + """公会经验更新任务""" + while not _should_stop(self): + if _wait_or_stopped(self, Config.EXP_UPDATE_INTERVAL): + break + if not self._plugin_enabled(): + continue + + try: + fmts.print_inf("正在更新公会经验...") + + online_players = list(self.game_ctrl.allplayers) + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_online_count = {} + level_ups = [] + + # 统计每个公会在线人数 + for player_name in online_players: + guild = self.guild_manager.get_guild_by_player(player_name) + if guild: + guild_online_count[guild.guild_id] = guild_online_count.get( + guild.guild_id, 0) + 1 + + # 更新经验和等级 + for gid, count in guild_online_count.items(): + if count == 0: + continue + + guild = guilds[gid] + exp_add, _ = self.guild_apply_reward_multipliers( + count * Config.EXP_PER_ONLINE_MEMBER, + 0, + ) + guild.exp += exp_add + + # 处理升级 + level = guild.level + next_required_exp = Config.GUILD_LEVEL_EXP.get(level + 1) + while next_required_exp and guild.exp >= next_required_exp: + guild.exp -= next_required_exp + level += 1 + next_required_exp = Config.GUILD_LEVEL_EXP.get(level + 1) + level_ups.append((guild.name, level)) + guild.add_log(f"公会升级到 {level} 级") + + guild.level = level + + self.guild_manager.save_guilds(guilds) + for guild_name, new_level in level_ups: if _should_stop(self): break @@ -568,77 +575,77 @@ def guild_exp_task(self): self.game_ctrl.sendcmd( f'/tellraw @a {{"rawtext":[{{"text":"{message}"}}]}}' ) - except Exception as e: - fmts.print_err(f"更新公会经验出错: {e}") - - -def update_online_task(self): - """更新在线状态任务""" - while not _should_stop(self): - try: - if _wait_or_stopped(self, 300): - break - if not self._plugin_enabled(): - continue - online_players = list(self.game_ctrl.allplayers) # 这里是 List[str] - - self.guild_manager.update_online_status(online_players) # 传名字列表 - guilds = self.guild_manager.load_guilds() - sent_count = _refresh_online_effects( - self, online_players, guilds=guilds) - - if sent_count: - fmts.print_inf(f"已低频补发 {sent_count} 条公会增益命令") - except Exception as e: - fmts.print_err(f"更新在线状态出错: {e}") - - -def on_player_action(self, packet): - """监听玩家行为,用于任务进度跟踪""" - try: - # TODO 等待具体的数据包格式 - pass - except Exception as e: - fmts.print_err(f"处理玩家行为事件出错: {e}") - - -def update_task_progress( - self, - player_name: str, - task_type: str, - target: str, - amount: int = 1): - """更新任务进度""" - try: - guild = self.guild_manager.get_guild_by_player(player_name) - if not guild: - return - if self.guild_is_frozen(guild): - return - - updated = False - for task in guild.tasks: - if (task.completed or - task.task_type != task_type or - task.target != target or - player_name not in task.participants): - continue - - task.current_count += amount - if task.current_count >= task.target_count: - # 任务完成 - task.completed = True - task.current_count = task.target_count - - # 发放奖励 - reward_exp, reward_contribution = self.guild_apply_reward_multipliers( - task.reward_exp, task.reward_contribution, ) - member = guild.get_member(player_name) - if member: - member.contribution += reward_contribution - guild.exp += reward_exp - guild.stats.total_contribution += reward_contribution - + except Exception as e: + fmts.print_err(f"更新公会经验出错: {e}") + + +def update_online_task(self): + """更新在线状态任务""" + while not _should_stop(self): + try: + if _wait_or_stopped(self, 300): + break + if not self._plugin_enabled(): + continue + online_players = list(self.game_ctrl.allplayers) # 这里是 List[str] + + self.guild_manager.update_online_status(online_players) # 传名字列表 + guilds = self.guild_manager.load_guilds() + sent_count = _refresh_online_effects( + self, online_players, guilds=guilds) + + if sent_count: + fmts.print_inf(f"已低频补发 {sent_count} 条公会增益命令") + except Exception as e: + fmts.print_err(f"更新在线状态出错: {e}") + + +def on_player_action(self, packet): + """监听玩家行为,用于任务进度跟踪""" + try: + # TODO 等待具体的数据包格式 + pass + except Exception as e: + fmts.print_err(f"处理玩家行为事件出错: {e}") + + +def update_task_progress( + self, + player_name: str, + task_type: str, + target: str, + amount: int = 1): + """更新任务进度""" + try: + guild = self.guild_manager.get_guild_by_player(player_name) + if not guild: + return + if self.guild_is_frozen(guild): + return + + updated = False + for task in guild.tasks: + if (task.completed or + task.task_type != task_type or + task.target != target or + player_name not in task.participants): + continue + + task.current_count += amount + if task.current_count >= task.target_count: + # 任务完成 + task.completed = True + task.current_count = task.target_count + + # 发放奖励 + reward_exp, reward_contribution = self.guild_apply_reward_multipliers( + task.reward_exp, task.reward_contribution, ) + member = guild.get_member(player_name) + if member: + member.contribution += reward_contribution + guild.exp += reward_exp + guild.stats.total_contribution += reward_contribution + # 通知玩家 if player_name in self.game_ctrl.allplayers: message = ( @@ -648,505 +655,505 @@ def update_task_progress( self.game_ctrl.sendcmd( f'/tellraw {player_name} {{"rawtext":[{{"text":"{message}"}}]}}' ) - - guild.add_log(f"{player_name} 完成了任务: {task.name}") - updated = True - else: - updated = True - - if updated: - self.guild_manager.mark_guild_dirty(guild.guild_id) - - except Exception as e: - fmts.print_err(f"更新任务进度出错: {e}") - - -def check_and_complete_trade_tasks(self, player_name: str): - """检查并完成贸易任务""" - self.update_task_progress(player_name, "trade", "trade_count", 1) - - -def get_guild_rankings( - self, sort_by: str = "level") -> List[Tuple[GuildData, Any]]: - """获取公会排行榜""" - guilds = self.guild_manager.load_guilds() - guild_list = list(guilds.values()) - - if sort_by == "level": - guild_list.sort(key=lambda g: (g.level, g.exp), reverse=True) - return [(g, g.level) for g in guild_list] - elif sort_by == "members": - guild_list.sort(key=lambda g: len(g.members), reverse=True) - return [(g, len(g.members)) for g in guild_list] - elif sort_by == "contribution": - guild_list.sort(key=lambda g: g.stats.total_contribution, reverse=True) - return [(g, g.stats.total_contribution) for g in guild_list] - elif sort_by == "activity": - # 基于最近活跃度排序 - current_time = time.time() - guild_list.sort(key=lambda g: max( - [m.last_online for m in g.members] + [0]), reverse=True) - return [(g, max([m.last_online for m in g.members] + [0])) - for g in guild_list] - else: - return [(g, 0) for g in guild_list] - - -def get_member_rankings( - self, guild_id: str, sort_by: str = "contribution") -> List[Tuple[GuildMember, Any]]: - """获取公会成员排行榜""" - guilds = self.guild_manager.load_guilds() - guild = guilds.get(guild_id) - - if not guild: - return [] - - members = guild.members.copy() - - if sort_by == "contribution": - members.sort(key=lambda m: m.contribution, reverse=True) - return [(m, m.contribution) for m in members] - elif sort_by == "online_time": - current_time = time.time() - members.sort(key=lambda m: current_time - m.last_online) - return [(m, current_time - m.last_online) for m in members] - elif sort_by == "join_time": - members.sort(key=lambda m: m.join_time) - return [(m, m.join_time) for m in members] - else: - return [(m, 0) for m in members] - - -def _paginate_display( - self, - player: Player, - items: List[Any], - title: str, - formatter, - allow_selection: bool = False) -> Optional[int]: - """分页显示通用函数""" - if not items: - player.show(render_config_prompt("通用分页为空提示词", title=title)) - return None - - page = 1 - max_page = (len(items) + Config.ITEMS_PER_PAGE - - 1) // Config.ITEMS_PER_PAGE - - while True: - page = max(1, min(page, max_page)) - start = (page - 1) * Config.ITEMS_PER_PAGE - end = start + Config.ITEMS_PER_PAGE - - msg = format_panel(title, subtitle=f"第 {page}/{max_page} 页") - - for i, item in enumerate(items[start:end], start=1): - msg += "\n" + formatter(start + i, item).rstrip() - - msg += "\n" + format_page_footer(page, max_page, - start + 1, end, allow_selection) - - player.show(msg) - - choice = game_utils.waitMsg(player.name, timeout=20) - if choice is None: - player.show(render_config_prompt("通用分页超时提示词")) - return None - elif choice == "+": - page = min(page + 1, max_page) - elif choice == "-": - page = max(page - 1, 1) - elif choice == "q": - player.show(render_config_prompt("通用分页退出提示词")) - return None - elif allow_selection and choice.isdigit(): - idx = int(choice) - if 1 <= idx <= len(items): - return idx - 1 - else: - player.show(render_config_prompt("通用分页无效选择提示词")) - - -def custom_vault_sell(self, player: Player, args: tuple): - """自定义价格出售物品到仓库""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - user_input = args[0] if args and args[0] else None - count = args[1] if len(args) > 1 and args[1] else 1 - custom_price = args[2] if len(args) > 2 and args[2] else 0 - - if not user_input: - player.show("§l§a公会仓库 §d>> §r请输入物品名称,例如: .自定义出售 钻石 10 800") - player.show("§7格式: 自定义出售 [物品名称] [数量] [自定义价格]") - player.show("§7支持中文: 自定义出售 钻石 10 800") - player.show("§7支持英文: 自定义出售 minecraft:diamond 10 800") - return True - - # 使用智能匹配查找物品ID - item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) - - if not item_id: - player.show("§l§a公会仓库 §d>> §r未找到匹配的物品") - if suggestions: - player.show("§7您是否想要:") - for i, suggestion in enumerate(suggestions[:3], 1): - player.show(f"§e{i}. §f{suggestion}") - return True - - # 检查仓库容量 - max_slots = Config.VAULT_INITIAL_SLOTS - if len(guild.vault_items) >= max_slots: - player.show("§l§a公会仓库 §d>> §r仓库已满") - return True - - # 检查玩家是否有该物品 - item_count = player.getItemCount(item_id) - if item_count < count: - player.show(f"§l§a公会仓库 §d>> §r你只有 {item_count} 个该物品,无法出售 {count} 个") - return True - - # 如果没有指定价格,询问自定义价格 - if custom_price <= 0: - suggested_price = guild.get_item_value(item_id) * count - player.show(f"§l§a公会仓库 §d>> §r建议价格: {suggested_price} 贡献点") - player.show("§7请输入你的自定义价格 (贡献点):") - - price_input = game_utils.waitMsg(player.name, timeout=30) - if not price_input or not price_input.isdigit(): - player.show("§l§a公会仓库 §d>> §r无效的价格") - return True - - custom_price = int(price_input) - if custom_price <= 0: - player.show("§l§a公会仓库 §d>> §r价格必须大于0") - return True - - # 确认出售 - item_name = self._get_item_display_name(item_id) - player.show(f"§l§a公会仓库 §d>> §r确认以自定义价格出售?") - player.show(f"§7物品: §f{item_name} x{count}") - player.show(f"§7自定义价格: §e{custom_price}贡献点") - player.show("§7输入 '确认' 继续出售,其他任意键取消") - - confirm = game_utils.waitMsg(player.name, timeout=20) - if confirm != "确认": - player.show("§l§a公会仓库 §d>> §r出售已取消") - return True - - # 执行出售 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if not guild: - player.show("§l§a公会仓库 §d>> §r公会数据异常") - return True - - # 再次检查仓库容量 - if len(guild.vault_items) >= max_slots: - player.show("§l§a公会仓库 §d>> §r仓库已满") - return True - - # 扣除物品 - self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {count}") - - # 添加到仓库 - vault_item = VaultItem( - item_id=item_id, - count=count, - price=custom_price, - seller=player.name - ) - - guild.vault_items.append(vault_item) - guild.add_log( - f"{player.name} 自定义价格出售了 {item_name} x{count} (价格{custom_price}贡献点)") - - # 保存数据 - self.guild_manager.save_guilds(guilds) - - player.show( - f"§l§a公会仓库 §d>> §r自定义价格出售成功!{item_name} x{count} 已上架,价格 {custom_price} 贡献点") - - # 更新贸易任务进度 - self.check_and_complete_trade_tasks(player.name) - - return True - - -def show_item_list(self, player: Player, args: tuple): - """显示支持的物品名称列表""" - player.show("§r========== §a支持的物品名称§r ==========") - player.show("§7以下是系统支持的物品名称,您可以在各种功能中使用:") - player.show("") - - # 按类别显示物品 - categories = { - "§e基础材料": ["钻石", "绿宝石", "金锭", "铁锭", "铜锭", "煤炭", "红石", "青金石", "石英"], - "§a稀有材料": ["下界合金锭", "远古残骸", "末影珍珠", "烈焰棒", "恶魂之泪"], - "§b方块材料": ["石头", "圆石", "泥土", "沙子", "砂砾", "黑曜石", "下界岩"], - "§6木材": ["橡木原木", "白桦原木", "云杉原木", "丛林原木", "金合欢原木"], - "§c食物": ["苹果", "金苹果", "面包", "牛肉", "熟牛肉", "胡萝卜", "土豆"], - "§d染料": ["墨囊", "玫瑰红", "橙色染料", "黄色染料", "绿色染料", "蓝色染料"] - } - - for category, items in categories.items(): - player.show(f"{category}:") - item_line = " " - for i, item in enumerate(items): - if i > 0 and i % 4 == 0: # 每行显示4个物品 - player.show(item_line) - item_line = " " - item_line += f"§f{item}§7, " - if item_line.strip() != "": - player.show(item_line.rstrip(", ")) - player.show("") - - player.show("§7提示:") - player.show("§7- 支持中文名称输入,如: §f钻石§7、§f铁锭§7、§f金块") - player.show("§7- 支持模糊匹配,如: 输入 §f铁§7 可匹配 §f铁锭§7、§f铁块") - player.show("§7- 支持别名,如: §f钻§7 可匹配 §f钻石") - player.show("§7- 也支持英文ID,如: §fminecraft:diamond") - player.show("§7- 使用 §e.物品列表§7 随时查看此列表") - - return True - - -def admin_clear_guild_data(self, player: Player, args: tuple): - """管理员清理公会数据功能""" - confirm = args[0] if args and args[0] else "" - - # 简单的管理员验证 (可以根据需要修改) - if player.name not in ["Admin", "管理员", "op"]: # 根据实际情况修改管理员名单 - player.show("§c权限不足:此功能仅限管理员使用") - return True - - if confirm != "确认清理": - player.show("§l§c公会数据清理工具§r") - player.show("§c警告:此操作将删除所有公会数据,包括:") - player.show("§7- 所有公会记录") - player.show("§7- 成员信息") - player.show("§7- 仓库物品") - player.show("§7- 任务记录") - player.show("§7- 公会日志") - player.show("§c此操作不可撤销!") - player.show("§e如果确认要清理,请使用:§f.清理公会数据 确认清理") - return True - - try: - # 备份当前数据 - import shutil - import time - - backup_file = f"{self.guilds_file}.backup_{int(time.time())}" - if os.path.exists(self.guilds_file): - shutil.copy2(self.guilds_file, backup_file) - player.show(f"§a已创建数据备份:{backup_file}") - - # 清理内存中的数据 - self.guild_manager.reset_runtime_state() - - # 清理数据文件 - empty_data = {} - tempjson.load_and_write( - self.guilds_file, - empty_data, - need_file_exists=False) - tempjson.flush(self.guilds_file) - - player.show("§l§a公会数据清理完成§r") - player.show("§a✅ 已清空所有公会记录") - player.show("§a✅ 已重置缓存数据") - player.show("§a✅ 已清空数据文件") - player.show(f"§7备份文件:{backup_file}") - player.show("§7系统已回到初始状态,可以重新创建公会") - - # 记录操作日志 - fmts.print_inf(f"管理员 {player.name} 清理了所有公会数据") - - except Exception as e: - player.show(f"§c清理失败:{str(e)}") - fmts.print_err(f"清理公会数据时出错:{e}") - - return True - - -def debug_guild_menu(self, player: Player, args: tuple): - """调试公会菜单显示问题""" - player.show("§l§c公会菜单调试信息§r") - player.show("=" * 40) - - # 获取公会和成员信息 - guild = self.guild_manager.get_guild_by_player(player.name) - member = guild.get_member(player.name) if guild else None - - player.show(f"§7玩家名称: §f{player.name}") - player.show(f"§7公会存在: §f{guild is not None}") - - if guild: - player.show(f"§7公会名称: §f{guild.name}") - player.show(f"§7公会ID: §f{guild.guild_id}") - player.show(f"§7成员数量: §f{len(guild.members)}") - - if member: - player.show(f"§7成员存在: §f{member is not None}") - player.show(f"§7成员名称: §f{member.name}") - player.show(f"§7成员职位: §f{member.rank}") - player.show(f"§7职位值: §f{member.rank.value}") - player.show(f"§7是否会长: §f{member.rank == GuildRank.OWNER}") - player.show(f"§7是否副会长: §f{member.rank == GuildRank.DEPUTY}") - - # 测试菜单条件 - is_member = guild is not None - is_owner = member and member.rank == GuildRank.OWNER - is_deputy_or_above = member and member.rank in [ - GuildRank.OWNER, GuildRank.DEPUTY] - - player.show("§7菜单条件测试:") - player.show(f" is_member: §f{is_member}") - player.show(f" is_owner: §f{is_owner}") - player.show(f" is_deputy_or_above: §f{is_deputy_or_above}") - - # 测试具体菜单项 - menu_tests = [ - ("查看", is_member), - ("成员", is_member), - ("日志", is_member), - ("公告", is_member), - ("仓库", Config.GUILD_FUNCTION_VAULT and is_member), - ("管理", is_deputy_or_above), - ("解散", is_owner), - ("据点", Config.GUILD_FUNCTION_BASE and is_member), - ("捐献", Config.GUILD_FUNCTION_DONATION and is_member), - ("任务", Config.GUILD_FUNCTION_TASKS and is_member), - ("效果", Config.GUILD_FUNCTION_EFFECT and is_member), - ] - - player.show("§7菜单项显示测试:") - for menu_name, condition in menu_tests: - status = "✅显示" if condition else "❌隐藏" - player.show(f" {menu_name}: §f{status}") - else: - player.show("§c成员信息不存在!") - else: - player.show("§c公会信息不存在!") - - player.show("=" * 40) - return True - - -def debug_base_function(self, player: Player, args: tuple): - """调试据点功能问题""" - player.show("§l§c据点功能调试信息§r") - player.show("=" * 50) - - # 获取公会和成员信息 - guild = self.guild_manager.get_guild_by_player(player.name) - member = guild.get_member(player.name) if guild else None - - player.show(f"§7玩家名称: §f{player.name}") - player.show(f"§7公会存在: §f{guild is not None}") - - if guild: - player.show(f"§7公会名称: §f{guild.name}") - player.show(f"§7公会ID: §f{guild.guild_id}") - - if member: - player.show(f"§7成员职位: §f{member.rank.value}") - player.show(f"§7是否会长: §f{member.rank == GuildRank.OWNER}") - - # 权限检查测试 - has_setbase_old = guild.has_permission(player.name, "setbase") - is_owner_new = member.rank == GuildRank.OWNER - - player.show("§7权限检查结果:") - player.show(f" 旧方式(has_permission): §f{has_setbase_old}") - player.show(f" 新方式(直接检查会长): §f{is_owner_new}") - - if has_setbase_old != is_owner_new: - player.show("§c⚠️ 权限检查结果不一致!") - else: - player.show("§a✅ 权限检查一致") - - # 据点信息检查 - player.show("§7据点信息:") - if guild.base: - base = guild.base - player.show(f" 据点存在: §a是") - player.show(f" 维度: §f{base.dimension}") - player.show(f" 坐标: §f({base.x}, {base.y}, {base.z})") - player.show( - f" 坐标类型: §f{ - type( - base.x).__name__}, { - type( - base.y).__name__}, { - type( - base.z).__name__}") - - # 验证坐标有效性 - try: - x, y, z = float(base.x), float(base.y), float(base.z) - player.show(f" 坐标转换: §a成功 ({x}, {y}, {z})") - except Exception as e: - player.show(f" 坐标转换: §c失败 - {e}") - - # 验证维度有效性 - valid_dimensions = [0, -1, 1] - if base.dimension in valid_dimensions: - player.show(f" 维度有效性: §a有效") - else: - player.show(f" 维度有效性: §c无效 (应为 {valid_dimensions})") - - # 测试传送指令格式和方法 - player.show("§7传送方法测试:") - tp_methods = [ - ("sendwocmd", f"tp {player.name} {base.x} {base.y} {base.z}"), - ] - for i, (method, cmd) in enumerate(tp_methods): - player.show(f" 方法{i + 1}({method}): §f{cmd}") - - else: - player.show(f" 据点存在: §c否") - player.show(f" 原因: 公会未设置据点") - else: - player.show("§c公会信息不存在!") - - player.show("=" * 50) - return True - - -logic_functions = { - "_show_menu": _show_menu, - "guild_update_data": guild_update_data, - "guild_menu_cb": guild_menu_cb, - "_create_progress_bar": _create_progress_bar, - "_format_time_duration": _format_time_duration, - "_get_item_display_name": _get_item_display_name, - "_has_inventory_space": _has_inventory_space, - "_handle_base_menu": _handle_base_menu, - "_send_guild_message": _send_guild_message, - "on_chat_packet": on_chat_packet, - "_should_stop": _should_stop, - "_wait_or_stopped": _wait_or_stopped, - "_apply_guild_effects_to_player": _apply_guild_effects_to_player, - "_refresh_online_effects": _refresh_online_effects, - "guild_exp_task": guild_exp_task, - "update_online_task": update_online_task, - "on_player_action": on_player_action, - "update_task_progress": update_task_progress, - "check_and_complete_trade_tasks": check_and_complete_trade_tasks, - "get_member_rankings": get_member_rankings, - "_paginate_display": _paginate_display, - "custom_vault_sell": custom_vault_sell, - "show_item_list": show_item_list, - "admin_clear_guild_data": admin_clear_guild_data, - "guild_chat_cb": guild_chat_cb, - "debug_guild_menu": debug_guild_menu, - "debug_base_function": debug_base_function, - "get_guild_rankings": get_guild_rankings -} + + guild.add_log(f"{player_name} 完成了任务: {task.name}") + updated = True + else: + updated = True + + if updated: + self.guild_manager.mark_guild_dirty(guild.guild_id) + + except Exception as e: + fmts.print_err(f"更新任务进度出错: {e}") + + +def check_and_complete_trade_tasks(self, player_name: str): + """检查并完成贸易任务""" + self.update_task_progress(player_name, "trade", "trade_count", 1) + + +def get_guild_rankings( + self, sort_by: str = "level") -> List[Tuple[GuildData, Any]]: + """获取公会排行榜""" + guilds = self.guild_manager.load_guilds() + guild_list = list(guilds.values()) + + if sort_by == "level": + guild_list.sort(key=lambda g: (g.level, g.exp), reverse=True) + return [(g, g.level) for g in guild_list] + elif sort_by == "members": + guild_list.sort(key=lambda g: len(g.members), reverse=True) + return [(g, len(g.members)) for g in guild_list] + elif sort_by == "contribution": + guild_list.sort(key=lambda g: g.stats.total_contribution, reverse=True) + return [(g, g.stats.total_contribution) for g in guild_list] + elif sort_by == "activity": + # 基于最近活跃度排序 + current_time = time.time() + guild_list.sort(key=lambda g: max( + [m.last_online for m in g.members] + [0]), reverse=True) + return [(g, max([m.last_online for m in g.members] + [0])) + for g in guild_list] + else: + return [(g, 0) for g in guild_list] + + +def get_member_rankings( + self, guild_id: str, sort_by: str = "contribution") -> List[Tuple[GuildMember, Any]]: + """获取公会成员排行榜""" + guilds = self.guild_manager.load_guilds() + guild = guilds.get(guild_id) + + if not guild: + return [] + + members = guild.members.copy() + + if sort_by == "contribution": + members.sort(key=lambda m: m.contribution, reverse=True) + return [(m, m.contribution) for m in members] + elif sort_by == "online_time": + current_time = time.time() + members.sort(key=lambda m: current_time - m.last_online) + return [(m, current_time - m.last_online) for m in members] + elif sort_by == "join_time": + members.sort(key=lambda m: m.join_time) + return [(m, m.join_time) for m in members] + else: + return [(m, 0) for m in members] + + +def _paginate_display( + self, + player: Player, + items: List[Any], + title: str, + formatter, + allow_selection: bool = False) -> Optional[int]: + """分页显示通用函数""" + if not items: + player.show(render_config_prompt("通用分页为空提示词", title=title)) + return None + + page = 1 + max_page = (len(items) + Config.ITEMS_PER_PAGE - + 1) // Config.ITEMS_PER_PAGE + + while True: + page = max(1, min(page, max_page)) + start = (page - 1) * Config.ITEMS_PER_PAGE + end = start + Config.ITEMS_PER_PAGE + + msg = format_panel(title, subtitle=f"第 {page}/{max_page} 页") + + for i, item in enumerate(items[start:end], start=1): + msg += "\n" + formatter(start + i, item).rstrip() + + msg += "\n" + format_page_footer(page, max_page, + start + 1, end, allow_selection) + + player.show(msg) + + choice = game_utils.waitMsg(player.name, timeout=20) + if choice is None: + player.show(render_config_prompt("通用分页超时提示词")) + return None + elif choice == "+": + page = min(page + 1, max_page) + elif choice == "-": + page = max(page - 1, 1) + elif choice == "q": + player.show(render_config_prompt("通用分页退出提示词")) + return None + elif allow_selection and choice.isdigit(): + idx = int(choice) + if 1 <= idx <= len(items): + return idx - 1 + else: + player.show(render_config_prompt("通用分页无效选择提示词")) + + +def custom_vault_sell(self, player: Player, args: tuple): + """自定义价格出售物品到仓库""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + user_input = args[0] if args and args[0] else None + count = args[1] if len(args) > 1 and args[1] else 1 + custom_price = args[2] if len(args) > 2 and args[2] else 0 + + if not user_input: + player.show("§l§a公会仓库 §d>> §r请输入物品名称,例如: .自定义出售 钻石 10 800") + player.show("§7格式: 自定义出售 [物品名称] [数量] [自定义价格]") + player.show("§7支持中文: 自定义出售 钻石 10 800") + player.show("§7支持英文: 自定义出售 minecraft:diamond 10 800") + return True + + # 使用智能匹配查找物品ID + item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) + + if not item_id: + player.show("§l§a公会仓库 §d>> §r未找到匹配的物品") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + return True + + # 检查仓库容量 + max_slots = Config.VAULT_INITIAL_SLOTS + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + + # 检查玩家是否有该物品 + item_count = player.getItemCount(item_id) + if item_count < count: + player.show(f"§l§a公会仓库 §d>> §r你只有 {item_count} 个该物品,无法出售 {count} 个") + return True + + # 如果没有指定价格,询问自定义价格 + if custom_price <= 0: + suggested_price = guild.get_item_value(item_id) * count + player.show(f"§l§a公会仓库 §d>> §r建议价格: {suggested_price} 贡献点") + player.show("§7请输入你的自定义价格 (贡献点):") + + price_input = game_utils.waitMsg(player.name, timeout=30) + if not price_input or not price_input.isdigit(): + player.show("§l§a公会仓库 §d>> §r无效的价格") + return True + + custom_price = int(price_input) + if custom_price <= 0: + player.show("§l§a公会仓库 §d>> §r价格必须大于0") + return True + + # 确认出售 + item_name = self._get_item_display_name(item_id) + player.show(f"§l§a公会仓库 §d>> §r确认以自定义价格出售?") + player.show(f"§7物品: §f{item_name} x{count}") + player.show(f"§7自定义价格: §e{custom_price}贡献点") + player.show("§7输入 '确认' 继续出售,其他任意键取消") + + confirm = game_utils.waitMsg(player.name, timeout=20) + if confirm != "确认": + player.show("§l§a公会仓库 §d>> §r出售已取消") + return True + + # 执行出售 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if not guild: + player.show("§l§a公会仓库 §d>> §r公会数据异常") + return True + + # 再次检查仓库容量 + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + + # 扣除物品 + self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {count}") + + # 添加到仓库 + vault_item = VaultItem( + item_id=item_id, + count=count, + price=custom_price, + seller=player.name + ) + + guild.vault_items.append(vault_item) + guild.add_log( + f"{player.name} 自定义价格出售了 {item_name} x{count} (价格{custom_price}贡献点)") + + # 保存数据 + self.guild_manager.save_guilds(guilds) + + player.show( + f"§l§a公会仓库 §d>> §r自定义价格出售成功!{item_name} x{count} 已上架,价格 {custom_price} 贡献点") + + # 更新贸易任务进度 + self.check_and_complete_trade_tasks(player.name) + + return True + + +def show_item_list(self, player: Player, args: tuple): + """显示支持的物品名称列表""" + player.show("§r========== §a支持的物品名称§r ==========") + player.show("§7以下是系统支持的物品名称,您可以在各种功能中使用:") + player.show("") + + # 按类别显示物品 + categories = { + "§e基础材料": ["钻石", "绿宝石", "金锭", "铁锭", "铜锭", "煤炭", "红石", "青金石", "石英"], + "§a稀有材料": ["下界合金锭", "远古残骸", "末影珍珠", "烈焰棒", "恶魂之泪"], + "§b方块材料": ["石头", "圆石", "泥土", "沙子", "砂砾", "黑曜石", "下界岩"], + "§6木材": ["橡木原木", "白桦原木", "云杉原木", "丛林原木", "金合欢原木"], + "§c食物": ["苹果", "金苹果", "面包", "牛肉", "熟牛肉", "胡萝卜", "土豆"], + "§d染料": ["墨囊", "玫瑰红", "橙色染料", "黄色染料", "绿色染料", "蓝色染料"] + } + + for category, items in categories.items(): + player.show(f"{category}:") + item_line = " " + for i, item in enumerate(items): + if i > 0 and i % 4 == 0: # 每行显示4个物品 + player.show(item_line) + item_line = " " + item_line += f"§f{item}§7, " + if item_line.strip() != "": + player.show(item_line.rstrip(", ")) + player.show("") + + player.show("§7提示:") + player.show("§7- 支持中文名称输入,如: §f钻石§7、§f铁锭§7、§f金块") + player.show("§7- 支持模糊匹配,如: 输入 §f铁§7 可匹配 §f铁锭§7、§f铁块") + player.show("§7- 支持别名,如: §f钻§7 可匹配 §f钻石") + player.show("§7- 也支持英文ID,如: §fminecraft:diamond") + player.show("§7- 使用 §e.物品列表§7 随时查看此列表") + + return True + + +def admin_clear_guild_data(self, player: Player, args: tuple): + """管理员清理公会数据功能""" + confirm = args[0] if args and args[0] else "" + + # 简单的管理员验证 (可以根据需要修改) + if player.name not in ["Admin", "管理员", "op"]: # 根据实际情况修改管理员名单 + player.show("§c权限不足:此功能仅限管理员使用") + return True + + if confirm != "确认清理": + player.show("§l§c公会数据清理工具§r") + player.show("§c警告:此操作将删除所有公会数据,包括:") + player.show("§7- 所有公会记录") + player.show("§7- 成员信息") + player.show("§7- 仓库物品") + player.show("§7- 任务记录") + player.show("§7- 公会日志") + player.show("§c此操作不可撤销!") + player.show("§e如果确认要清理,请使用:§f.清理公会数据 确认清理") + return True + + try: + # 备份当前数据 + import shutil + import time + + backup_file = f"{self.guilds_file}.backup_{int(time.time())}" + if os.path.exists(self.guilds_file): + shutil.copy2(self.guilds_file, backup_file) + player.show(f"§a已创建数据备份:{backup_file}") + + # 清理内存中的数据 + self.guild_manager.reset_runtime_state() + + # 清理数据文件 + empty_data = {} + tempjson.load_and_write( + self.guilds_file, + empty_data, + need_file_exists=False) + tempjson.flush(self.guilds_file) + + player.show("§l§a公会数据清理完成§r") + player.show("§a✅ 已清空所有公会记录") + player.show("§a✅ 已重置缓存数据") + player.show("§a✅ 已清空数据文件") + player.show(f"§7备份文件:{backup_file}") + player.show("§7系统已回到初始状态,可以重新创建公会") + + # 记录操作日志 + fmts.print_inf(f"管理员 {player.name} 清理了所有公会数据") + + except Exception as e: + player.show(f"§c清理失败:{str(e)}") + fmts.print_err(f"清理公会数据时出错:{e}") + + return True + + +def debug_guild_menu(self, player: Player, args: tuple): + """调试公会菜单显示问题""" + player.show("§l§c公会菜单调试信息§r") + player.show("=" * 40) + + # 获取公会和成员信息 + guild = self.guild_manager.get_guild_by_player(player.name) + member = guild.get_member(player.name) if guild else None + + player.show(f"§7玩家名称: §f{player.name}") + player.show(f"§7公会存在: §f{guild is not None}") + + if guild: + player.show(f"§7公会名称: §f{guild.name}") + player.show(f"§7公会ID: §f{guild.guild_id}") + player.show(f"§7成员数量: §f{len(guild.members)}") + + if member: + player.show(f"§7成员存在: §f{member is not None}") + player.show(f"§7成员名称: §f{member.name}") + player.show(f"§7成员职位: §f{member.rank}") + player.show(f"§7职位值: §f{member.rank.value}") + player.show(f"§7是否会长: §f{member.rank == GuildRank.OWNER}") + player.show(f"§7是否副会长: §f{member.rank == GuildRank.DEPUTY}") + + # 测试菜单条件 + is_member = guild is not None + is_owner = member and member.rank == GuildRank.OWNER + is_deputy_or_above = member and member.rank in [ + GuildRank.OWNER, GuildRank.DEPUTY] + + player.show("§7菜单条件测试:") + player.show(f" is_member: §f{is_member}") + player.show(f" is_owner: §f{is_owner}") + player.show(f" is_deputy_or_above: §f{is_deputy_or_above}") + + # 测试具体菜单项 + menu_tests = [ + ("查看", is_member), + ("成员", is_member), + ("日志", is_member), + ("公告", is_member), + ("仓库", Config.GUILD_FUNCTION_VAULT and is_member), + ("管理", is_deputy_or_above), + ("解散", is_owner), + ("据点", Config.GUILD_FUNCTION_BASE and is_member), + ("捐献", Config.GUILD_FUNCTION_DONATION and is_member), + ("任务", Config.GUILD_FUNCTION_TASKS and is_member), + ("效果", Config.GUILD_FUNCTION_EFFECT and is_member), + ] + + player.show("§7菜单项显示测试:") + for menu_name, condition in menu_tests: + status = "✅显示" if condition else "❌隐藏" + player.show(f" {menu_name}: §f{status}") + else: + player.show("§c成员信息不存在!") + else: + player.show("§c公会信息不存在!") + + player.show("=" * 40) + return True + + +def debug_base_function(self, player: Player, args: tuple): + """调试据点功能问题""" + player.show("§l§c据点功能调试信息§r") + player.show("=" * 50) + + # 获取公会和成员信息 + guild = self.guild_manager.get_guild_by_player(player.name) + member = guild.get_member(player.name) if guild else None + + player.show(f"§7玩家名称: §f{player.name}") + player.show(f"§7公会存在: §f{guild is not None}") + + if guild: + player.show(f"§7公会名称: §f{guild.name}") + player.show(f"§7公会ID: §f{guild.guild_id}") + + if member: + player.show(f"§7成员职位: §f{member.rank.value}") + player.show(f"§7是否会长: §f{member.rank == GuildRank.OWNER}") + + # 权限检查测试 + has_setbase_old = guild.has_permission(player.name, "setbase") + is_owner_new = member.rank == GuildRank.OWNER + + player.show("§7权限检查结果:") + player.show(f" 旧方式(has_permission): §f{has_setbase_old}") + player.show(f" 新方式(直接检查会长): §f{is_owner_new}") + + if has_setbase_old != is_owner_new: + player.show("§c⚠️ 权限检查结果不一致!") + else: + player.show("§a✅ 权限检查一致") + + # 据点信息检查 + player.show("§7据点信息:") + if guild.base: + base = guild.base + player.show(f" 据点存在: §a是") + player.show(f" 维度: §f{base.dimension}") + player.show(f" 坐标: §f({base.x}, {base.y}, {base.z})") + player.show( + f" 坐标类型: §f{ + type( + base.x).__name__}, { + type( + base.y).__name__}, { + type( + base.z).__name__}") + + # 验证坐标有效性 + try: + x, y, z = float(base.x), float(base.y), float(base.z) + player.show(f" 坐标转换: §a成功 ({x}, {y}, {z})") + except Exception as e: + player.show(f" 坐标转换: §c失败 - {e}") + + # 验证维度有效性 + valid_dimensions = [0, -1, 1] + if base.dimension in valid_dimensions: + player.show(f" 维度有效性: §a有效") + else: + player.show(f" 维度有效性: §c无效 (应为 {valid_dimensions})") + + # 测试传送指令格式和方法 + player.show("§7传送方法测试:") + tp_methods = [ + ("sendwocmd", f"tp {player.name} {base.x} {base.y} {base.z}"), + ] + for i, (method, cmd) in enumerate(tp_methods): + player.show(f" 方法{i + 1}({method}): §f{cmd}") + + else: + player.show(f" 据点存在: §c否") + player.show(f" 原因: 公会未设置据点") + else: + player.show("§c公会信息不存在!") + + player.show("=" * 50) + return True + + +logic_functions = { + "_show_menu": _show_menu, + "guild_update_data": guild_update_data, + "guild_menu_cb": guild_menu_cb, + "_create_progress_bar": _create_progress_bar, + "_format_time_duration": _format_time_duration, + "_get_item_display_name": _get_item_display_name, + "_has_inventory_space": _has_inventory_space, + "_handle_base_menu": _handle_base_menu, + "_send_guild_message": _send_guild_message, + "on_chat_packet": on_chat_packet, + "_should_stop": _should_stop, + "_wait_or_stopped": _wait_or_stopped, + "_apply_guild_effects_to_player": _apply_guild_effects_to_player, + "_refresh_online_effects": _refresh_online_effects, + "guild_exp_task": guild_exp_task, + "update_online_task": update_online_task, + "on_player_action": on_player_action, + "update_task_progress": update_task_progress, + "check_and_complete_trade_tasks": check_and_complete_trade_tasks, + "get_member_rankings": get_member_rankings, + "_paginate_display": _paginate_display, + "custom_vault_sell": custom_vault_sell, + "show_item_list": show_item_list, + "admin_clear_guild_data": admin_clear_guild_data, + "guild_chat_cb": guild_chat_cb, + "debug_guild_menu": debug_guild_menu, + "debug_base_function": debug_base_function, + "get_guild_rankings": get_guild_rankings +} diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" index 6cb8112e..5a01df4d 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" @@ -1,736 +1,753 @@ -"""Data models for the guild cloud interop plugin.""" - -import uuid -import time - -from dataclasses import dataclass, field -from enum import Enum -from typing import Dict, List, Optional, Tuple, Any -from tooldelta import fmts -from datetime import datetime -from guild_cloud_interop.config import Config - -# FIRE 枚举类型 FIRE - - -class GuildRank(Enum): - """Guild membership roles.""" - - OWNER = "owner" - DEPUTY = "deputy" - ELDER = "elder" - MEMBER = "member" - - @property - def display_name(self): - return { - "owner": "§c会长", - "deputy": "§6副会长", - "elder": "§e长老", - "member": "§a成员" - }[self.value] - - @property - def config_key(self): - return { - "owner": "会长", - "deputy": "副会长", - "elder": "长老", - "member": "成员" - }[self.value] - - -PERMISSION_CONFIG_KEYS = { - "kick": "踢出成员权限", - "invite": "处理/同意加入公会申请权限", - "announce": "设置公会公告权限", - "task_manage": "管理公会任务权限", - "vault": "公会仓库使用权限", - "setbase": "设置公会据点权限", - "return_base": "返回公会据点权限", - "effect_buy": "购买公会效果权限", - "vault_settings": "设置仓库物品价值权限", - "vault_sell": "出售仓库物品权限", - "vault_buy": "购买仓库物品权限", - "vault_cancel_own": "撤回自己出售物品权限", - "vault_cancel_any": "撤回任意仓库物品权限", - "join_queue": "处理加入申请队列权限", - "audit_log": "查看审计日志权限", - "set_rank": "设置成员职位权限", - "transfer_owner": "转让会长权限", - "task_create": "创建公会任务权限", - "task_delete": "删除公会任务权限", - "task_complete": "强制完成公会任务权限", -} - - -# FIRE 数据类 FIRE -@dataclass -class GuildBase: - """公会据点信息""" - dimension: int - x: float - y: float - z: float - - def to_dict(self): - return { - "dimension": self.dimension, - "x": self.x, - "y": self.y, - "z": self.z} - - -@dataclass -class VaultItem: - """仓库物品信息""" - item_id: str - count: int - price: int # 贡献点价格 - seller: str - timestamp: float = field(default_factory=time.time) - - def to_dict(self): - return { - "item_id": self.item_id, - "count": self.count, - "price": self.price, - "seller": self.seller, - "timestamp": self.timestamp - } - - @classmethod - def from_dict(cls, data): - return cls( - item_id=data["item_id"], - count=data["count"], - price=data["price"], - seller=data["seller"], - timestamp=data.get("timestamp", time.time()) - ) - - -@dataclass -class GuildMember: - """公会成员信息""" - name: str - rank: GuildRank - join_time: float - contribution: int = 0 - last_online: float = field(default_factory=time.time) - - def to_dict(self): - return { - "name": self.name, - "rank": self.rank.value, - "join_time": self.join_time, - "contribution": self.contribution, - "last_online": self.last_online - } - - @classmethod - def from_dict(cls, data): - return cls( - name=data["name"], - rank=GuildRank(data.get("rank", "member")), - join_time=data.get("join_time", time.time()), - contribution=data.get("contribution", 0), - last_online=data.get("last_online", time.time()) - ) - - -@dataclass -class GuildJoinRequest: - """公会加入申请记录""" - player_name: str - reason: str = "" - create_time: float = field(default_factory=time.time) - status: str = "pending" - handler: str = "" - handle_time: float = 0 - result_reason: str = "" - - def to_dict(self): - return { - "player_name": self.player_name, - "reason": self.reason, - "create_time": self.create_time, - "status": self.status, - "handler": self.handler, - "handle_time": self.handle_time, - "result_reason": self.result_reason, - } - - @classmethod - def from_dict(cls, data): - return cls( - player_name=data["player_name"], - reason=data.get("reason", ""), - create_time=data.get("create_time", time.time()), - status=data.get("status", "pending"), - handler=data.get("handler", ""), - handle_time=data.get("handle_time", 0), - result_reason=data.get("result_reason", ""), - ) - - -@dataclass -class VaultTradeLog: - """公会仓库交易日志""" - action: str - item_id: str - count: int - price: int - actor: str - seller: str = "" - buyer: str = "" - timestamp: float = field(default_factory=time.time) - detail: str = "" - - def to_dict(self): - return { - "action": self.action, - "item_id": self.item_id, - "count": self.count, - "price": self.price, - "actor": self.actor, - "seller": self.seller, - "buyer": self.buyer, - "timestamp": self.timestamp, - "detail": self.detail, - } - - @classmethod - def from_dict(cls, data): - return cls( - action=data["action"], - item_id=data["item_id"], - count=data.get("count", 0), - price=data.get("price", 0), - actor=data.get("actor", ""), - seller=data.get("seller", ""), - buyer=data.get("buyer", ""), - timestamp=data.get("timestamp", time.time()), - detail=data.get("detail", ""), - ) - - -@dataclass -class GuildAuditLog: - """公会审计日志""" - action: str - actor: str - target: str = "" - detail: str = "" - result: str = "success" - timestamp: float = field(default_factory=time.time) - - def to_dict(self): - return { - "action": self.action, - "actor": self.actor, - "target": self.target, - "detail": self.detail, - "result": self.result, - "timestamp": self.timestamp, - } - - @classmethod - def from_dict(cls, data): - return cls( - action=data["action"], - actor=data.get("actor", ""), - target=data.get("target", ""), - detail=data.get("detail", ""), - result=data.get("result", "success"), - timestamp=data.get("timestamp", time.time()), - ) - - -@dataclass -class GuildTask: - """公会任务""" - task_id: str - name: str - description: str - task_type: str # "collect", "kill", "build", "trade" - target: str # 目标物品/怪物等 - target_count: int - current_count: int = 0 - reward_exp: int = 0 - reward_contribution: int = 0 - create_time: float = field(default_factory=time.time) - deadline: float = 0 # 截止时间,0表示无限期 - completed: bool = False - participants: List[str] = field(default_factory=list) # 参与者列表 - - def to_dict(self): - return { - "task_id": self.task_id, - "name": self.name, - "description": self.description, - "task_type": self.task_type, - "target": self.target, - "target_count": self.target_count, - "current_count": self.current_count, - "reward_exp": self.reward_exp, - "reward_contribution": self.reward_contribution, - "create_time": self.create_time, - "deadline": self.deadline, - "completed": self.completed, - "participants": self.participants - } - - @classmethod - def from_dict(cls, data): - return cls( - task_id=data["task_id"], - name=data["name"], - description=data["description"], - task_type=data["task_type"], - target=data["target"], - target_count=data["target_count"], - current_count=data.get("current_count", 0), - reward_exp=data.get("reward_exp", 0), - reward_contribution=data.get("reward_contribution", 0), - create_time=data.get("create_time", time.time()), - deadline=data.get("deadline", 0), - completed=data.get("completed", False), - participants=data.get("participants", []) - ) - - -@dataclass -class GuildStats: - """公会统计数据""" - total_contribution: int = 0 - total_trades: int = 0 - total_online_time: int = 0 - member_count_history: List[Tuple[float, int]] = field(default_factory=list) - level_up_history: List[Tuple[float, int]] = field(default_factory=list) - - def to_dict(self): - return { - "total_contribution": self.total_contribution, - "total_trades": self.total_trades, - "total_online_time": self.total_online_time, - "member_count_history": self.member_count_history, - "level_up_history": self.level_up_history - } - - @classmethod - def from_dict(cls, data): - return cls( - total_contribution=data.get("total_contribution", 0), - total_trades=data.get("total_trades", 0), - total_online_time=data.get("total_online_time", 0), - member_count_history=data.get("member_count_history", []), - level_up_history=data.get("level_up_history", []) - ) - - -@dataclass -class GuildData: - """公会完整数据""" - guild_id: str - name: str - owner: str - level: int = 1 - exp: int = 0 - create_time: float = field(default_factory=time.time) - base: Optional[GuildBase] = None - vault: Dict[str, int] = field(default_factory=dict) # 保留原有仓库格式兼容性 - vault_items: List[VaultItem] = field(default_factory=list) # 新的仓库物品列表 - custom_item_values: Dict[str, int] = field(default_factory=dict) # 自定义物品价值 - announcement: str = "" - members: List[GuildMember] = field(default_factory=list) - logs: List[str] = field(default_factory=list) - purchased_effects: Dict[str, int] = field(default_factory=dict) - stats: GuildStats = field(default_factory=GuildStats) # 新增统计数据 - settings: Dict[str, Any] = field(default_factory=dict) # 公会设置 - tasks: List[GuildTask] = field(default_factory=list) # 公会任务列表 - join_requests: List[GuildJoinRequest] = field(default_factory=list) - vault_trade_logs: List[VaultTradeLog] = field(default_factory=list) - audit_logs: List[GuildAuditLog] = field(default_factory=list) - - def add_log(self, message: str): - """添加日志""" - timestamp = datetime.now().strftime("%m-%d %H:%M") - self.logs.append(f"[{timestamp}] {message}") - # 保留最近50条日志 - if len(self.logs) > 50: - self.logs = self.logs[-50:] - - def add_audit_log( - self, - action: str, - actor: str, - target: str = "", - detail: str = "", - result: str = "success", - now: Optional[float] = None, - ): - """添加审计日志""" - self.audit_logs.append(GuildAuditLog( - action=action, - actor=actor, - target=target, - detail=detail, - result=result, - timestamp=time.time() if now is None else now, - )) - max_logs = int( - getattr( - Config, - "GUILD_DATA_SAFETY_CONFIG", - {}).get( - "审计日志保留数量", - 200)) - if max_logs > 0 and len(self.audit_logs) > max_logs: - self.audit_logs = self.audit_logs[-max_logs:] - - def get_member(self, name: str) -> Optional[GuildMember]: - """获取成员信息""" - for member in self.members: - if member.name == name: - return member - return None - - def pending_join_requests( - self, - now: Optional[float] = None) -> List[GuildJoinRequest]: - """获取未过期的待处理加入申请""" - current_time = time.time() if now is None else now - expire_seconds = int( - getattr( - Config, - "GUILD_JOIN_REQUEST_CONFIG", - {}).get( - "申请有效期秒", - 86400)) - return [ - request - for request in self.join_requests - if request.status == "pending" - and (expire_seconds <= 0 or current_time - request.create_time <= expire_seconds) - ] - - def add_join_request( - self, - player_name: str, - reason: str = "", - now: Optional[float] = None) -> bool: - """添加离线加入申请""" - if self.get_member(player_name): - return False - - current_time = time.time() if now is None else now - join_config = getattr(Config, "GUILD_JOIN_REQUEST_CONFIG", {}) - max_reason_len = int(join_config.get("申请理由最大长度", 60)) - reason = (reason or "")[:max_reason_len] - - for request in self.pending_join_requests(now=current_time): - if request.player_name == player_name: - return False - - max_pending = int(join_config.get("每个公会最多待处理申请数", 30)) - if max_pending > 0 and len( - self.pending_join_requests( - now=current_time)) >= max_pending: - return False - - self.join_requests.append(GuildJoinRequest( - player_name=player_name, - reason=reason, - create_time=current_time, - )) - self.add_log(f"{player_name} 提交了加入申请") - self.add_audit_log("join_request_create", player_name, - detail=reason, now=current_time) - return True - - def resolve_join_request( - self, - player_name: str, - handler: str, - approved: bool, - result_reason: str = "", - now: Optional[float] = None, - ) -> bool: - """处理加入申请""" - current_time = time.time() if now is None else now - for request in self.pending_join_requests(now=current_time): - if request.player_name != player_name: - continue - request.status = "approved" if approved else "rejected" - request.handler = handler - request.handle_time = current_time - request.result_reason = result_reason - self.add_log( - f"{handler} { - '批准' if approved else '拒绝'}了 {player_name} 的加入申请") - self.add_audit_log( - "join_request_approve" if approved else "join_request_reject", - handler, - target=player_name, - detail=result_reason, - now=current_time, - ) - return True - return False - - def has_permission(self, player_name: str, permission: str) -> bool: - """检查成员权限 - 优化版本""" - if not player_name or not permission: - return False - - member = self.get_member(player_name) - if not member: - return False - - rank_permissions = Config.PERMISSIONS.get(member.rank.config_key, {}) - permission_key = PERMISSION_CONFIG_KEYS.get(permission) - - has_perm = ( - isinstance(rank_permissions, dict) - and permission_key is not None - and bool(rank_permissions.get(permission_key, False)) - ) - - if not has_perm: - self.add_log(f"权限检查失败: {player_name} 尝试使用 {permission} 权限") - - return has_perm - - def get_member_permissions(self, player_name: str) -> List[str]: - """获取成员的所有权限列表""" - member = self.get_member(player_name) - if not member: - return [] - - rank_permissions = Config.PERMISSIONS.get(member.rank.config_key, {}) - if not isinstance(rank_permissions, dict): - return [] - - return [ - permission - for permission, config_key in PERMISSION_CONFIG_KEYS.items() - if bool(rank_permissions.get(config_key, False)) - ] - - def can_manage_member(self, manager_name: str, target_name: str) -> bool: - """检查是否可以管理指定成员""" - manager = self.get_member(manager_name) - target = self.get_member(target_name) - - if not manager or not target: - return False - - if manager_name == target_name: - return False - - if manager.rank == GuildRank.OWNER: - return True - - if manager.rank == GuildRank.DEPUTY: - return target.rank in [GuildRank.ELDER, GuildRank.MEMBER] - - return False - - def get_item_value(self, item_id: str) -> int: - """获取物品的贡献点价值""" - # 优先使用自定义价值,否则使用默认价值 - return self.custom_item_values.get( - item_id, Config.DEFAULT_ITEM_VALUES.get(item_id, 1)) - - def add_vault_item(self, item: VaultItem) -> bool: - """添加物品到仓库""" - max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 - if len(self.vault_items) >= max_slots: - return False - self.vault_items.append(item) - return True - - def remove_vault_item(self, index: int) -> Optional[VaultItem]: - """从仓库移除物品""" - if 0 <= index < len(self.vault_items): - return self.vault_items.pop(index) - return None - - def add_vault_trade_log( - self, - action: str, - item: VaultItem, - actor: str, - buyer: str = "", - detail: str = "", - now: Optional[float] = None, - ): - """添加仓库交易日志""" - self.vault_trade_logs.append(VaultTradeLog( - action=action, - item_id=item.item_id, - count=item.count, - price=item.price, - actor=actor, - seller=item.seller, - buyer=buyer, - timestamp=time.time() if now is None else now, - detail=detail, - )) - max_logs = int( - getattr( - Config, - "GUILD_VAULT_CONFIG", - {}).get( - "交易日志保留数量", - 120)) - if max_logs > 0 and len(self.vault_trade_logs) > max_logs: - self.vault_trade_logs = self.vault_trade_logs[-max_logs:] - - def cancel_vault_item( - self, - actor: str, - index: int, - now: Optional[float] = None) -> Optional[VaultItem]: - """撤回仓库上架物品""" - item = self.remove_vault_item(index) - if not item: - return None - - self.add_vault_trade_log( - "cancel", item, actor, detail="撤回上架物品", now=now) - self.add_audit_log("vault_cancel", actor, target=item.seller, - detail=item.item_id, now=now) - self.add_log( - f"{actor} 撤回了 { - item.seller} 上架的 { - item.item_id} x{ - item.count}") - return item - - def to_dict(self): - return { - "guild_id": self.guild_id, - "name": self.name, - "owner": self.owner, - "level": self.level, - "exp": self.exp, - "create_time": self.create_time, - "base": self.base.to_dict() if self.base else None, - "vault": self.vault, - "vault_items": [ - item.to_dict() for item in self.vault_items], - "custom_item_values": self.custom_item_values, - "announcement": self.announcement, - "purchased_effects": self.purchased_effects, - "members": [ - m.to_dict() for m in self.members], - "logs": self.logs, - "stats": self.stats.to_dict(), - "settings": self.settings, - "tasks": [ - task.to_dict() for task in self.tasks], - "join_requests": [ - request.to_dict() for request in self.join_requests], - "vault_trade_logs": [ - log.to_dict() for log in self.vault_trade_logs], - "audit_logs": [ - log.to_dict() for log in self.audit_logs], - } - - @classmethod - def from_dict(cls, data, outer_key=None): - base = None - try: - if data.get("base") and isinstance(data["base"], dict): - base_data = data["base"] - if all( - key in base_data for key in [ - "dimension", - "x", - "y", - "z"]): - base = GuildBase( - dimension=base_data["dimension"], - x=float(base_data["x"]), - y=float(base_data["y"]), - z=float(base_data["z"]) - ) - elif data.get("base"): - fmts.print_err(f"据点数据格式不正确: {data['base']}") - except Exception as e: - fmts.print_err(f"解析据点数据出错: {e}") - import traceback - fmts.print_err(traceback.format_exc()) - - guild_id = data.get("guild_id") - if not guild_id: - guild_id = outer_key if outer_key else uuid.uuid4().hex[:8] - - # 兼容 members 为字符串列表 - members = [] - for m in data.get("members", []): - if isinstance(m, dict): - members.append(GuildMember.from_dict(m)) - elif isinstance(m, str): - members.append(GuildMember( - name=m, - rank=GuildRank.MEMBER, - join_time=time.time() - )) - - # 处理仓库物品 - vault_items = [] - for item_data in data.get("vault_items", []): - if isinstance(item_data, dict): - vault_items.append(VaultItem.from_dict(item_data)) - - # 处理统计数据 - stats_data = data.get("stats", {}) - stats = GuildStats.from_dict(stats_data) if isinstance( - stats_data, dict) else GuildStats() - - # 处理任务数据 - tasks = [] - for task_data in data.get("tasks", []): - if isinstance(task_data, dict): - tasks.append(GuildTask.from_dict(task_data)) - - join_requests = [] - for request_data in data.get("join_requests", []): - if isinstance(request_data, dict): - join_requests.append(GuildJoinRequest.from_dict(request_data)) - - vault_trade_logs = [] - for log_data in data.get("vault_trade_logs", []): - if isinstance(log_data, dict): - vault_trade_logs.append(VaultTradeLog.from_dict(log_data)) - - audit_logs = [] - for log_data in data.get("audit_logs", []): - if isinstance(log_data, dict): - audit_logs.append(GuildAuditLog.from_dict(log_data)) - - return cls( - guild_id=guild_id, - name=data.get("name", ""), - owner=data.get("owner", ""), - level=data.get("level", 1), - exp=data.get("exp", 0), - create_time=data.get("create_time", time.time()), - base=base, - vault=data.get("vault", {}), - vault_items=vault_items, - custom_item_values=data.get("custom_item_values", {}), - announcement=data.get("announcement", ""), - members=members, - purchased_effects=data.get("purchased_effects", {}), - logs=data.get("logs", []), - stats=stats, - settings=data.get("settings", {}), - tasks=tasks, - join_requests=join_requests, - vault_trade_logs=vault_trade_logs, - audit_logs=audit_logs, - ) +"""Data models for the guild cloud interop plugin.""" + +import uuid +import time + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional, Tuple, Any +from tooldelta import fmts +from datetime import datetime +from guild_cloud_interop.config import Config + +# FIRE 枚举类型 FIRE + + +class GuildRank(Enum): + """Guild membership roles.""" + + OWNER = "owner" + DEPUTY = "deputy" + ELDER = "elder" + MEMBER = "member" + + @property + def display_name(self): + return { + "owner": "§c会长", + "deputy": "§6副会长", + "elder": "§e长老", + "member": "§a成员" + }[self.value] + + @property + def config_key(self): + return { + "owner": "会长", + "deputy": "副会长", + "elder": "长老", + "member": "成员" + }[self.value] + + +PERMISSION_CONFIG_KEYS = { + "kick": "踢出成员权限", + "invite": "处理/同意加入公会申请权限", + "announce": "设置公会公告权限", + "task_manage": "管理公会任务权限", + "vault": "公会仓库使用权限", + "setbase": "设置公会据点权限", + "return_base": "返回公会据点权限", + "effect_buy": "购买公会效果权限", + "vault_settings": "设置仓库物品价值权限", + "vault_sell": "出售仓库物品权限", + "vault_buy": "购买仓库物品权限", + "vault_cancel_own": "撤回自己出售物品权限", + "vault_cancel_any": "撤回任意仓库物品权限", + "join_queue": "处理加入申请队列权限", + "audit_log": "查看审计日志权限", + "set_rank": "设置成员职位权限", + "transfer_owner": "转让会长权限", + "task_create": "创建公会任务权限", + "task_delete": "删除公会任务权限", + "task_complete": "强制完成公会任务权限", +} + + +# FIRE 数据类 FIRE +@dataclass +class GuildBase: + """公会据点信息""" + dimension: int + x: float + y: float + z: float + + def to_dict(self): + """Implement the to dict operation.""" + return { + "dimension": self.dimension, + "x": self.x, + "y": self.y, + "z": self.z} + + +@dataclass +class VaultItem: + """仓库物品信息""" + item_id: str + count: int + price: int # 贡献点价格 + seller: str + timestamp: float = field(default_factory=time.time) + + def to_dict(self): + """Implement the to dict operation.""" + return { + "item_id": self.item_id, + "count": self.count, + "price": self.price, + "seller": self.seller, + "timestamp": self.timestamp + } + + @classmethod + def from_dict(cls, data): + """Implement the from dict operation.""" + return cls( + item_id=data["item_id"], + count=data["count"], + price=data["price"], + seller=data["seller"], + timestamp=data.get("timestamp", time.time()) + ) + + +@dataclass +class GuildMember: + """公会成员信息""" + name: str + rank: GuildRank + join_time: float + contribution: int = 0 + last_online: float = field(default_factory=time.time) + + def to_dict(self): + """Implement the to dict operation.""" + return { + "name": self.name, + "rank": self.rank.value, + "join_time": self.join_time, + "contribution": self.contribution, + "last_online": self.last_online + } + + @classmethod + def from_dict(cls, data): + """Implement the from dict operation.""" + return cls( + name=data["name"], + rank=GuildRank(data.get("rank", "member")), + join_time=data.get("join_time", time.time()), + contribution=data.get("contribution", 0), + last_online=data.get("last_online", time.time()) + ) + + +@dataclass +class GuildJoinRequest: + """公会加入申请记录""" + player_name: str + reason: str = "" + create_time: float = field(default_factory=time.time) + status: str = "pending" + handler: str = "" + handle_time: float = 0 + result_reason: str = "" + + def to_dict(self): + """Implement the to dict operation.""" + return { + "player_name": self.player_name, + "reason": self.reason, + "create_time": self.create_time, + "status": self.status, + "handler": self.handler, + "handle_time": self.handle_time, + "result_reason": self.result_reason, + } + + @classmethod + def from_dict(cls, data): + """Implement the from dict operation.""" + return cls( + player_name=data["player_name"], + reason=data.get("reason", ""), + create_time=data.get("create_time", time.time()), + status=data.get("status", "pending"), + handler=data.get("handler", ""), + handle_time=data.get("handle_time", 0), + result_reason=data.get("result_reason", ""), + ) + + +@dataclass +class VaultTradeLog: + """公会仓库交易日志""" + action: str + item_id: str + count: int + price: int + actor: str + seller: str = "" + buyer: str = "" + timestamp: float = field(default_factory=time.time) + detail: str = "" + + def to_dict(self): + """Implement the to dict operation.""" + return { + "action": self.action, + "item_id": self.item_id, + "count": self.count, + "price": self.price, + "actor": self.actor, + "seller": self.seller, + "buyer": self.buyer, + "timestamp": self.timestamp, + "detail": self.detail, + } + + @classmethod + def from_dict(cls, data): + """Implement the from dict operation.""" + return cls( + action=data["action"], + item_id=data["item_id"], + count=data.get("count", 0), + price=data.get("price", 0), + actor=data.get("actor", ""), + seller=data.get("seller", ""), + buyer=data.get("buyer", ""), + timestamp=data.get("timestamp", time.time()), + detail=data.get("detail", ""), + ) + + +@dataclass +class GuildAuditLog: + """公会审计日志""" + action: str + actor: str + target: str = "" + detail: str = "" + result: str = "success" + timestamp: float = field(default_factory=time.time) + + def to_dict(self): + """Implement the to dict operation.""" + return { + "action": self.action, + "actor": self.actor, + "target": self.target, + "detail": self.detail, + "result": self.result, + "timestamp": self.timestamp, + } + + @classmethod + def from_dict(cls, data): + """Implement the from dict operation.""" + return cls( + action=data["action"], + actor=data.get("actor", ""), + target=data.get("target", ""), + detail=data.get("detail", ""), + result=data.get("result", "success"), + timestamp=data.get("timestamp", time.time()), + ) + + +@dataclass +class GuildTask: + """公会任务""" + task_id: str + name: str + description: str + task_type: str # "collect", "kill", "build", "trade" + target: str # 目标物品/怪物等 + target_count: int + current_count: int = 0 + reward_exp: int = 0 + reward_contribution: int = 0 + create_time: float = field(default_factory=time.time) + deadline: float = 0 # 截止时间,0表示无限期 + completed: bool = False + participants: List[str] = field(default_factory=list) # 参与者列表 + + def to_dict(self): + """Implement the to dict operation.""" + return { + "task_id": self.task_id, + "name": self.name, + "description": self.description, + "task_type": self.task_type, + "target": self.target, + "target_count": self.target_count, + "current_count": self.current_count, + "reward_exp": self.reward_exp, + "reward_contribution": self.reward_contribution, + "create_time": self.create_time, + "deadline": self.deadline, + "completed": self.completed, + "participants": self.participants + } + + @classmethod + def from_dict(cls, data): + """Implement the from dict operation.""" + return cls( + task_id=data["task_id"], + name=data["name"], + description=data["description"], + task_type=data["task_type"], + target=data["target"], + target_count=data["target_count"], + current_count=data.get("current_count", 0), + reward_exp=data.get("reward_exp", 0), + reward_contribution=data.get("reward_contribution", 0), + create_time=data.get("create_time", time.time()), + deadline=data.get("deadline", 0), + completed=data.get("completed", False), + participants=data.get("participants", []) + ) + + +@dataclass +class GuildStats: + """公会统计数据""" + total_contribution: int = 0 + total_trades: int = 0 + total_online_time: int = 0 + member_count_history: List[Tuple[float, int]] = field(default_factory=list) + level_up_history: List[Tuple[float, int]] = field(default_factory=list) + + def to_dict(self): + """Implement the to dict operation.""" + return { + "total_contribution": self.total_contribution, + "total_trades": self.total_trades, + "total_online_time": self.total_online_time, + "member_count_history": self.member_count_history, + "level_up_history": self.level_up_history + } + + @classmethod + def from_dict(cls, data): + """Implement the from dict operation.""" + return cls( + total_contribution=data.get("total_contribution", 0), + total_trades=data.get("total_trades", 0), + total_online_time=data.get("total_online_time", 0), + member_count_history=data.get("member_count_history", []), + level_up_history=data.get("level_up_history", []) + ) + + +@dataclass +class GuildData: + """公会完整数据""" + guild_id: str + name: str + owner: str + level: int = 1 + exp: int = 0 + create_time: float = field(default_factory=time.time) + base: Optional[GuildBase] = None + vault: Dict[str, int] = field(default_factory=dict) # 保留原有仓库格式兼容性 + vault_items: List[VaultItem] = field(default_factory=list) # 新的仓库物品列表 + custom_item_values: Dict[str, int] = field(default_factory=dict) # 自定义物品价值 + announcement: str = "" + members: List[GuildMember] = field(default_factory=list) + logs: List[str] = field(default_factory=list) + purchased_effects: Dict[str, int] = field(default_factory=dict) + stats: GuildStats = field(default_factory=GuildStats) # 新增统计数据 + settings: Dict[str, Any] = field(default_factory=dict) # 公会设置 + tasks: List[GuildTask] = field(default_factory=list) # 公会任务列表 + join_requests: List[GuildJoinRequest] = field(default_factory=list) + vault_trade_logs: List[VaultTradeLog] = field(default_factory=list) + audit_logs: List[GuildAuditLog] = field(default_factory=list) + + def add_log(self, message: str): + """添加日志""" + timestamp = datetime.now().strftime("%m-%d %H:%M") + self.logs.append(f"[{timestamp}] {message}") + # 保留最近50条日志 + if len(self.logs) > 50: + self.logs = self.logs[-50:] + + def add_audit_log( + self, + action: str, + actor: str, + target: str = "", + detail: str = "", + result: str = "success", + now: Optional[float] = None, + ): + """添加审计日志""" + self.audit_logs.append(GuildAuditLog( + action=action, + actor=actor, + target=target, + detail=detail, + result=result, + timestamp=time.time() if now is None else now, + )) + max_logs = int( + getattr( + Config, + "GUILD_DATA_SAFETY_CONFIG", + {}).get( + "审计日志保留数量", + 200)) + if max_logs > 0 and len(self.audit_logs) > max_logs: + self.audit_logs = self.audit_logs[-max_logs:] + + def get_member(self, name: str) -> Optional[GuildMember]: + """获取成员信息""" + for member in self.members: + if member.name == name: + return member + return None + + def pending_join_requests( + self, + now: Optional[float] = None) -> List[GuildJoinRequest]: + """获取未过期的待处理加入申请""" + current_time = time.time() if now is None else now + expire_seconds = int( + getattr( + Config, + "GUILD_JOIN_REQUEST_CONFIG", + {}).get( + "申请有效期秒", + 86400)) + return [ + request + for request in self.join_requests + if request.status == "pending" + and (expire_seconds <= 0 or current_time - request.create_time <= expire_seconds) + ] + + def add_join_request( + self, + player_name: str, + reason: str = "", + now: Optional[float] = None) -> bool: + """添加离线加入申请""" + if self.get_member(player_name): + return False + + current_time = time.time() if now is None else now + join_config = getattr(Config, "GUILD_JOIN_REQUEST_CONFIG", {}) + max_reason_len = int(join_config.get("申请理由最大长度", 60)) + reason = (reason or "")[:max_reason_len] + + for request in self.pending_join_requests(now=current_time): + if request.player_name == player_name: + return False + + max_pending = int(join_config.get("每个公会最多待处理申请数", 30)) + if max_pending > 0 and len( + self.pending_join_requests( + now=current_time)) >= max_pending: + return False + + self.join_requests.append(GuildJoinRequest( + player_name=player_name, + reason=reason, + create_time=current_time, + )) + self.add_log(f"{player_name} 提交了加入申请") + self.add_audit_log("join_request_create", player_name, + detail=reason, now=current_time) + return True + + def resolve_join_request( + self, + player_name: str, + handler: str, + approved: bool, + result_reason: str = "", + now: Optional[float] = None, + ) -> bool: + """处理加入申请""" + current_time = time.time() if now is None else now + for request in self.pending_join_requests(now=current_time): + if request.player_name != player_name: + continue + request.status = "approved" if approved else "rejected" + request.handler = handler + request.handle_time = current_time + request.result_reason = result_reason + self.add_log( + f"{handler} { + '批准' if approved else '拒绝'}了 {player_name} 的加入申请") + self.add_audit_log( + "join_request_approve" if approved else "join_request_reject", + handler, + target=player_name, + detail=result_reason, + now=current_time, + ) + return True + return False + + def has_permission(self, player_name: str, permission: str) -> bool: + """检查成员权限 - 优化版本""" + if not player_name or not permission: + return False + + member = self.get_member(player_name) + if not member: + return False + + rank_permissions = Config.PERMISSIONS.get(member.rank.config_key, {}) + permission_key = PERMISSION_CONFIG_KEYS.get(permission) + + has_perm = ( + isinstance(rank_permissions, dict) + and permission_key is not None + and bool(rank_permissions.get(permission_key, False)) + ) + + if not has_perm: + self.add_log(f"权限检查失败: {player_name} 尝试使用 {permission} 权限") + + return has_perm + + def get_member_permissions(self, player_name: str) -> List[str]: + """获取成员的所有权限列表""" + member = self.get_member(player_name) + if not member: + return [] + + rank_permissions = Config.PERMISSIONS.get(member.rank.config_key, {}) + if not isinstance(rank_permissions, dict): + return [] + + return [ + permission + for permission, config_key in PERMISSION_CONFIG_KEYS.items() + if bool(rank_permissions.get(config_key, False)) + ] + + def can_manage_member(self, manager_name: str, target_name: str) -> bool: + """检查是否可以管理指定成员""" + manager = self.get_member(manager_name) + target = self.get_member(target_name) + + if not manager or not target: + return False + + if manager_name == target_name: + return False + + if manager.rank == GuildRank.OWNER: + return True + + if manager.rank == GuildRank.DEPUTY: + return target.rank in [GuildRank.ELDER, GuildRank.MEMBER] + + return False + + def get_item_value(self, item_id: str) -> int: + """获取物品的贡献点价值""" + # 优先使用自定义价值,否则使用默认价值 + return self.custom_item_values.get( + item_id, Config.DEFAULT_ITEM_VALUES.get(item_id, 1)) + + def add_vault_item(self, item: VaultItem) -> bool: + """添加物品到仓库""" + max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 + if len(self.vault_items) >= max_slots: + return False + self.vault_items.append(item) + return True + + def remove_vault_item(self, index: int) -> Optional[VaultItem]: + """从仓库移除物品""" + if 0 <= index < len(self.vault_items): + return self.vault_items.pop(index) + return None + + def add_vault_trade_log( + self, + action: str, + item: VaultItem, + actor: str, + buyer: str = "", + detail: str = "", + now: Optional[float] = None, + ): + """添加仓库交易日志""" + self.vault_trade_logs.append(VaultTradeLog( + action=action, + item_id=item.item_id, + count=item.count, + price=item.price, + actor=actor, + seller=item.seller, + buyer=buyer, + timestamp=time.time() if now is None else now, + detail=detail, + )) + max_logs = int( + getattr( + Config, + "GUILD_VAULT_CONFIG", + {}).get( + "交易日志保留数量", + 120)) + if max_logs > 0 and len(self.vault_trade_logs) > max_logs: + self.vault_trade_logs = self.vault_trade_logs[-max_logs:] + + def cancel_vault_item( + self, + actor: str, + index: int, + now: Optional[float] = None) -> Optional[VaultItem]: + """撤回仓库上架物品""" + item = self.remove_vault_item(index) + if not item: + return None + + self.add_vault_trade_log( + "cancel", item, actor, detail="撤回上架物品", now=now) + self.add_audit_log("vault_cancel", actor, target=item.seller, + detail=item.item_id, now=now) + self.add_log( + f"{actor} 撤回了 { + item.seller} 上架的 { + item.item_id} x{ + item.count}") + return item + + def to_dict(self): + """Implement the to dict operation.""" + return { + "guild_id": self.guild_id, + "name": self.name, + "owner": self.owner, + "level": self.level, + "exp": self.exp, + "create_time": self.create_time, + "base": self.base.to_dict() if self.base else None, + "vault": self.vault, + "vault_items": [ + item.to_dict() for item in self.vault_items], + "custom_item_values": self.custom_item_values, + "announcement": self.announcement, + "purchased_effects": self.purchased_effects, + "members": [ + m.to_dict() for m in self.members], + "logs": self.logs, + "stats": self.stats.to_dict(), + "settings": self.settings, + "tasks": [ + task.to_dict() for task in self.tasks], + "join_requests": [ + request.to_dict() for request in self.join_requests], + "vault_trade_logs": [ + log.to_dict() for log in self.vault_trade_logs], + "audit_logs": [ + log.to_dict() for log in self.audit_logs], + } + + @classmethod + def from_dict(cls, data, outer_key=None): + """Implement the from dict operation.""" + base = None + try: + if data.get("base") and isinstance(data["base"], dict): + base_data = data["base"] + if all( + key in base_data for key in [ + "dimension", + "x", + "y", + "z"]): + base = GuildBase( + dimension=base_data["dimension"], + x=float(base_data["x"]), + y=float(base_data["y"]), + z=float(base_data["z"]) + ) + elif data.get("base"): + fmts.print_err(f"据点数据格式不正确: {data['base']}") + except Exception as e: + fmts.print_err(f"解析据点数据出错: {e}") + import traceback + fmts.print_err(traceback.format_exc()) + + guild_id = data.get("guild_id") + if not guild_id: + guild_id = outer_key if outer_key else uuid.uuid4().hex[:8] + + # 兼容 members 为字符串列表 + members = [] + for m in data.get("members", []): + if isinstance(m, dict): + members.append(GuildMember.from_dict(m)) + elif isinstance(m, str): + members.append(GuildMember( + name=m, + rank=GuildRank.MEMBER, + join_time=time.time() + )) + + # 处理仓库物品 + vault_items = [] + for item_data in data.get("vault_items", []): + if isinstance(item_data, dict): + vault_items.append(VaultItem.from_dict(item_data)) + + # 处理统计数据 + stats_data = data.get("stats", {}) + stats = GuildStats.from_dict(stats_data) if isinstance( + stats_data, dict) else GuildStats() + + # 处理任务数据 + tasks = [] + for task_data in data.get("tasks", []): + if isinstance(task_data, dict): + tasks.append(GuildTask.from_dict(task_data)) + + join_requests = [] + for request_data in data.get("join_requests", []): + if isinstance(request_data, dict): + join_requests.append(GuildJoinRequest.from_dict(request_data)) + + vault_trade_logs = [] + for log_data in data.get("vault_trade_logs", []): + if isinstance(log_data, dict): + vault_trade_logs.append(VaultTradeLog.from_dict(log_data)) + + audit_logs = [] + for log_data in data.get("audit_logs", []): + if isinstance(log_data, dict): + audit_logs.append(GuildAuditLog.from_dict(log_data)) + + return cls( + guild_id=guild_id, + name=data.get("name", ""), + owner=data.get("owner", ""), + level=data.get("level", 1), + exp=data.get("exp", 0), + create_time=data.get("create_time", time.time()), + base=base, + vault=data.get("vault", {}), + vault_items=vault_items, + custom_item_values=data.get("custom_item_values", {}), + announcement=data.get("announcement", ""), + members=members, + purchased_effects=data.get("purchased_effects", {}), + logs=data.get("logs", []), + stats=stats, + settings=data.get("settings", {}), + tasks=tasks, + join_requests=join_requests, + vault_trade_logs=vault_trade_logs, + audit_logs=audit_logs, + ) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/prompts.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/prompts.py" index d02ccf0f..e576504f 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/prompts.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/prompts.py" @@ -42,16 +42,19 @@ def render_prompt(template: str, **values: Any) -> str: + """Implement the render prompt operation.""" for key, value in values.items(): template = template.replace("{" + key + "}", str(value)) return template def render_create_guild_prompt(key: str, **values: Any) -> str: + """Implement the render create guild prompt operation.""" return render_config_prompt(key, **values) def render_config_prompt(key: str, **values: Any) -> str: + """Implement the render config prompt operation.""" prompt_config = getattr(Config, "PROMPT_CONFIG", {}) fallback = CREATE_GUILD_PROMPT_FALLBACKS.get(key, "") template = fallback diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" index 52e91b8b..71842f8c 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" @@ -1,225 +1,235 @@ -"""Orion-style UI helpers for the guild cloud interop plugin.""" - -from __future__ import annotations - -import re -from typing import Iterable, Sequence - - -ORION_BORDER = "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧" -TITLE_PREFIX = "§l§d❐§f" -OPTION_PREFIX = "§l§b[ §e{}§b ] §r§e{}" -INFO_PREFIX = "§a❀ §r" -WARN_PREFIX = "§6❀ " -ERROR_PREFIX = "§c❀ " -SUCCESS_PREFIX = "§a❀ " -MAX_CHAT_MESSAGE_CHARS = 240 - - -def split_chat_chunks( - text: str, - max_chars: int = MAX_CHAT_MESSAGE_CHARS) -> list[str]: - """Split rich-text chat output into line-preserving chunks for rental servers.""" - chunks: list[str] = [] - current: list[str] = [] - current_len = 0 - - for line in str(text).splitlines(): - line_len = len(line) - separator_len = 1 if current else 0 - if current and current_len + separator_len + line_len > max_chars: - chunks.append("\n".join(current)) - current = [] - current_len = 0 - - if line_len > max_chars: - if current: - chunks.append("\n".join(current)) - current = [] - current_len = 0 - for start in range(0, line_len, max_chars): - chunks.append(line[start:start + max_chars]) - continue - - current.append(line) - current_len += separator_len + line_len - - if current: - chunks.append("\n".join(current)) - - return chunks or [""] - - -class OrionPlayerView: - """Player proxy that renders plugin messages in the Orion panel style.""" - - def __init__(self, player): - self._player = player - - def __getattr__(self, name): - return getattr(self._player, name) - - def show(self, text): - for chunk in split_chat_chunks(format_message(str(text))): - self._player.show(chunk) - - -def wrap_player(player): - if isinstance(player, OrionPlayerView): - return player - return OrionPlayerView(player) - - -def strip_reset(text: str) -> str: - return text.replace("§r", "") - - -def normalize_inline(text: str) -> str: - text = strip_reset(text) - text = text.replace("§l§a公会仓库 §d>> ", "") - text = text.replace("§l§a公会任务 §d>> ", "") - text = text.replace("§l§a任务管理 §d>> ", "") - text = text.replace("§l§a创建任务 §d>> ", "") - text = text.replace("§l§a公会 §d>> ", "") - text = text.replace("§r§7>> ", "") - text = text.replace("§7>> ", "") - text = text.replace(">>>", "-") - return text.strip() - - -def classify_prefix(text: str) -> str: - if "错误" in text or "失败" in text or "无效" in text or "不足" in text or "权限不足" in text: - return ERROR_PREFIX - if "警告" in text or "超时" in text or "已满" in text or "不存在" in text or "未找到" in text: - return WARN_PREFIX - if "成功" in text or "已创建" in text or "已加入" in text or "已退出" in text or "已更新" in text: - return SUCCESS_PREFIX - return INFO_PREFIX - - -def format_option(line: str) -> str | None: - clean = normalize_inline(line) - - match = re.match(r"^(?:§[0-9a-frlomnk])*([0-9]+)[..、]\s*(.*)$", clean) - if match: - return OPTION_PREFIX.format(match.group(1), match.group(2).strip()) - - match = re.match( - r"^(?:§[0-9a-frlomnk])*●\s*(.+?)(?:\s*§7[-—]\s*|\s+-\s+)(.+)$", clean) - if match: - return f"§l§b[ §e-§b ] §r§e{ - match.group(1).strip()} §7- §f{ - match.group(2).strip()}" - - match = re.match(r"^([^\s]+)\s+§7[-—]\s*(.+)$", clean) - if match: - return f"§l§b[ §e-§b ] §r§e{ - match.group(1).strip()} §7- §f{ - match.group(2).strip()}" - - return None - - -def format_title(title: str) -> str: - title = normalize_inline(title) - title = title.replace("§a", "§6").replace("§c", "§c") - return f"{ORION_BORDER}\n{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b{title}§d\n{ORION_BORDER}" - - -def format_message(text: str) -> str: - lines = str(text).splitlines() - rendered: list[str] = [] - - for raw_line in lines: - if raw_line == "": - rendered.append("") - continue - - line = raw_line.strip() - if ( - line == ORION_BORDER - or line.startswith("§d✧✦") - or line.startswith(TITLE_PREFIX) - or line.startswith("§l§b[") - or line.startswith("§e>§g>§6>") - or "❀" in line - ): - rendered.append(line) - continue - - title_match = re.match(r"^(?:§r)?=+\s*§a(.+?)§r?\s*=+", line) - if title_match: - rendered.append(format_title(title_match.group(1))) - continue - - if re.fullmatch(r"=+", strip_reset(line)): - rendered.append(ORION_BORDER) - continue - - option = format_option(line) - if option is not None: - rendered.append(option) - continue - - clean = normalize_inline(line) - if not clean: - rendered.append("") - continue - - if clean.startswith("§c"): - rendered.append(ERROR_PREFIX + clean[2:]) - elif clean.startswith("§6"): - rendered.append(WARN_PREFIX + clean[2:]) - elif clean.startswith("§a"): - rendered.append(SUCCESS_PREFIX + clean[2:]) - elif clean.startswith("§7"): - rendered.append(INFO_PREFIX + clean[2:]) - elif clean.startswith("§e"): - rendered.append(INFO_PREFIX + clean[2:]) - else: - rendered.append(classify_prefix(clean) + clean) - - return "\n".join(rendered) - - -def format_panel( - title: str, - options: Sequence[tuple[str, str]] = (), - *, - subtitle: str = "", - footer: str = "", - lines: Iterable[str] = (), -) -> str: - output = [ - ORION_BORDER, - f"{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b{title}§d", - ] - if subtitle: - output.append(f"§e>§g>§6>§f{subtitle}") - output.append(ORION_BORDER) - for index, (label, description) in enumerate(options, start=1): - output.append( - OPTION_PREFIX.format( - index, - f"{label} §7- §f{description}")) - for line in lines: - output.append(format_message(line)) - if footer: - output.append(format_message(footer)) - return "\n".join(output) - - -def format_page_footer( - page: int, - total_pages: int, - start: int, - end: int, - allow_selection: bool) -> str: - lines = [ - ORION_BORDER, - f"§l§a[ §e-§a ] §b上页§r§f▶ §7{page}/{total_pages} §f◀§l§b下页 §a[ §e+ §a]", - ] - if allow_selection: - lines.append(f"{INFO_PREFIX}输入 §e[{start}-{end}]§r 之间的数字以选择") - lines.append(f"{INFO_PREFIX}输入 §d-§e 上一页 §r| §d+§e 下一页 §r| §cq§r 退出") - return "\n".join(lines) +"""Orion-style UI helpers for the guild cloud interop plugin.""" + +from __future__ import annotations + +import re +from typing import Iterable, Sequence + + +ORION_BORDER = "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧" +TITLE_PREFIX = "§l§d❐§f" +OPTION_PREFIX = "§l§b[ §e{}§b ] §r§e{}" +INFO_PREFIX = "§a❀ §r" +WARN_PREFIX = "§6❀ " +ERROR_PREFIX = "§c❀ " +SUCCESS_PREFIX = "§a❀ " +MAX_CHAT_MESSAGE_CHARS = 240 + + +def split_chat_chunks( + text: str, + max_chars: int = MAX_CHAT_MESSAGE_CHARS) -> list[str]: + """Split rich-text chat output into line-preserving chunks for rental servers.""" + chunks: list[str] = [] + current: list[str] = [] + current_len = 0 + + for line in str(text).splitlines(): + line_len = len(line) + separator_len = 1 if current else 0 + if current and current_len + separator_len + line_len > max_chars: + chunks.append("\n".join(current)) + current = [] + current_len = 0 + + if line_len > max_chars: + if current: + chunks.append("\n".join(current)) + current = [] + current_len = 0 + for start in range(0, line_len, max_chars): + chunks.append(line[start:start + max_chars]) + continue + + current.append(line) + current_len += separator_len + line_len + + if current: + chunks.append("\n".join(current)) + + return chunks or [""] + + +class OrionPlayerView: + """Player proxy that renders plugin messages in the Orion panel style.""" + + def __init__(self, player): + self._player = player + + def __getattr__(self, name): + return getattr(self._player, name) + + def show(self, text): + """Implement the show operation.""" + for chunk in split_chat_chunks(format_message(str(text))): + self._player.show(chunk) + + +def wrap_player(player): + """Implement the wrap player operation.""" + if isinstance(player, OrionPlayerView): + return player + return OrionPlayerView(player) + + +def strip_reset(text: str) -> str: + """Implement the strip reset operation.""" + return text.replace("§r", "") + + +def normalize_inline(text: str) -> str: + """Implement the normalize inline operation.""" + text = strip_reset(text) + text = text.replace("§l§a公会仓库 §d>> ", "") + text = text.replace("§l§a公会任务 §d>> ", "") + text = text.replace("§l§a任务管理 §d>> ", "") + text = text.replace("§l§a创建任务 §d>> ", "") + text = text.replace("§l§a公会 §d>> ", "") + text = text.replace("§r§7>> ", "") + text = text.replace("§7>> ", "") + text = text.replace(">>>", "-") + return text.strip() + + +def classify_prefix(text: str) -> str: + """Implement the classify prefix operation.""" + if "错误" in text or "失败" in text or "无效" in text or "不足" in text or "权限不足" in text: + return ERROR_PREFIX + if "警告" in text or "超时" in text or "已满" in text or "不存在" in text or "未找到" in text: + return WARN_PREFIX + if "成功" in text or "已创建" in text or "已加入" in text or "已退出" in text or "已更新" in text: + return SUCCESS_PREFIX + return INFO_PREFIX + + +def format_option(line: str) -> str | None: + """Implement the format option operation.""" + clean = normalize_inline(line) + + match = re.match(r"^(?:§[0-9a-frlomnk])*([0-9]+)[..、]\s*(.*)$", clean) + if match: + return OPTION_PREFIX.format(match.group(1), match.group(2).strip()) + + match = re.match( + r"^(?:§[0-9a-frlomnk])*●\s*(.+?)(?:\s*§7[-—]\s*|\s+-\s+)(.+)$", clean) + if match: + return f"§l§b[ §e-§b ] §r§e{ + match.group(1).strip()} §7- §f{ + match.group(2).strip()}" + + match = re.match(r"^([^\s]+)\s+§7[-—]\s*(.+)$", clean) + if match: + return f"§l§b[ §e-§b ] §r§e{ + match.group(1).strip()} §7- §f{ + match.group(2).strip()}" + + return None + + +def format_title(title: str) -> str: + """Implement the format title operation.""" + title = normalize_inline(title) + title = title.replace("§a", "§6").replace("§c", "§c") + return f"{ORION_BORDER}\n{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b{title}§d\n{ORION_BORDER}" + + +def format_message(text: str) -> str: + """Implement the format message operation.""" + lines = str(text).splitlines() + rendered: list[str] = [] + + for raw_line in lines: + if raw_line == "": + rendered.append("") + continue + + line = raw_line.strip() + if ( + line == ORION_BORDER + or line.startswith("§d✧✦") + or line.startswith(TITLE_PREFIX) + or line.startswith("§l§b[") + or line.startswith("§e>§g>§6>") + or "❀" in line + ): + rendered.append(line) + continue + + title_match = re.match(r"^(?:§r)?=+\s*§a(.+?)§r?\s*=+", line) + if title_match: + rendered.append(format_title(title_match.group(1))) + continue + + if re.fullmatch(r"=+", strip_reset(line)): + rendered.append(ORION_BORDER) + continue + + option = format_option(line) + if option is not None: + rendered.append(option) + continue + + clean = normalize_inline(line) + if not clean: + rendered.append("") + continue + + if clean.startswith("§c"): + rendered.append(ERROR_PREFIX + clean[2:]) + elif clean.startswith("§6"): + rendered.append(WARN_PREFIX + clean[2:]) + elif clean.startswith("§a"): + rendered.append(SUCCESS_PREFIX + clean[2:]) + elif clean.startswith("§7"): + rendered.append(INFO_PREFIX + clean[2:]) + elif clean.startswith("§e"): + rendered.append(INFO_PREFIX + clean[2:]) + else: + rendered.append(classify_prefix(clean) + clean) + + return "\n".join(rendered) + + +def format_panel( + title: str, + options: Sequence[tuple[str, str]] = (), + *, + subtitle: str = "", + footer: str = "", + lines: Iterable[str] = (), +) -> str: + """Implement the format panel operation.""" + output = [ + ORION_BORDER, + f"{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b{title}§d", + ] + if subtitle: + output.append(f"§e>§g>§6>§f{subtitle}") + output.append(ORION_BORDER) + for index, (label, description) in enumerate(options, start=1): + output.append( + OPTION_PREFIX.format( + index, + f"{label} §7- §f{description}")) + for line in lines: + output.append(format_message(line)) + if footer: + output.append(format_message(footer)) + return "\n".join(output) + + +def format_page_footer( + page: int, + total_pages: int, + start: int, + end: int, + allow_selection: bool) -> str: + """Implement the format page footer operation.""" + lines = [ + ORION_BORDER, + f"§l§a[ §e-§a ] §b上页§r§f▶ §7{page}/{total_pages} §f◀§l§b下页 §a[ §e+ §a]", + ] + if allow_selection: + lines.append(f"{INFO_PREFIX}输入 §e[{start}-{end}]§r 之间的数字以选择") + lines.append(f"{INFO_PREFIX}输入 §d-§e 上一页 §r| §d+§e 下一页 §r| §cq§r 退出") + return "\n".join(lines) diff --git "a/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index 836973de..451e9ef5 100644 --- "a/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -1,571 +1,587 @@ -"""Whitelist and operator-status checker plugin.""" - -import copy -import os -import time -import threading -from typing import Any - -from tooldelta import Player, Plugin, cfg, fmts, game_utils, plugin_entry, utils - - -CONFIG_FILE_DIR = "插件配置文件" -DYNAMIC_LOAD_SETTINGS_KEY = "动态载入设置" -DYNAMIC_LOAD_ENABLED_KEY = "是否启用动态载入配置文件(仅用于本插件)" -DYNAMIC_LOAD_INTERVAL_KEY = "动态载入检测时间间隔(单位:秒)" -DYNAMIC_LOAD_DEFAULT_INTERVAL = 5 - - -class WhitelistAndOpCheck(Plugin): - """负责白名单与 OP 状态校验,并对外暴露管理接口。""" - - name = "白名单&管理员检测云链联动版" - author = "猫七街" - version = (1, 1, 4) - description = "白名单与管理员状态检测,并向其他插件暴露可复用的管理 API。" - - DEFAULT_CFG = { - DYNAMIC_LOAD_SETTINGS_KEY: { - DYNAMIC_LOAD_ENABLED_KEY: True, - DYNAMIC_LOAD_INTERVAL_KEY: DYNAMIC_LOAD_DEFAULT_INTERVAL, - }, - "检查时间(秒)": 60.0, - "白名单": { - "开启状态": False, - "踢出提示词": "请先加入白名单", - "白名单玩家": {"xuid1": "player_name1", "xuid2": "player_name2"}, - }, - "管理员检测": { - "开启状态": False, - "提示词": "你没有管理员权限", - "管理员列表": {"xuid1": "player_name1", "xuid2": "player_name2"}, - }, - } - - STD_CFG = { - DYNAMIC_LOAD_SETTINGS_KEY: { - DYNAMIC_LOAD_ENABLED_KEY: bool, - DYNAMIC_LOAD_INTERVAL_KEY: cfg.PInt, - }, - "检查时间(秒)": float, - "白名单": {"开启状态": bool, "踢出提示词": str, "白名单玩家": {}}, - "管理员检测": {"开启状态": bool, "提示词": str, "管理员列表": {}}, - } - - def __init__(self, frame): - """初始化运行时状态并注册插件生命周期回调。""" - super().__init__(frame) - self.get_xuid = None - self.bot_name = "" - self._stop_event = threading.Event() - self._config_file_state = None - self._cfg = self.load_config() - self.refresh_config_file_state() - self.config_thread = utils.createThread( - self.config_reload_task, - usage="白名单&管理员检测配置热更新任务", - ) - - self.ListenPreload(self.on_preload) - self.ListenActive(self.on_active) - self.ListenPlayerJoin(self.on_player_join) - self.ListenFrameExit(self.on_frame_exit) - - @classmethod - def merge_with_default(cls, raw: Any, default: Any): - """递归合并用户配置和默认配置。""" - if isinstance(default, dict): - result = { - key: cls.merge_with_default( - raw.get(key) if isinstance(raw, dict) else None, - value, - ) - for key, value in default.items() - } - if isinstance(raw, dict): - for key, value in raw.items(): - if key not in result: - result[key] = copy.deepcopy(value) - return result - return copy.deepcopy( - raw) if raw is not None else copy.deepcopy(default) - - @staticmethod - def trim_fixed_keys(raw: Any, default: dict[str, Any]) -> dict[str, Any]: - raw = raw if isinstance(raw, dict) else {} - return { - key: copy.deepcopy(raw.get(key, value)) - for key, value in default.items() - } - - @staticmethod - def normalize_bool(value: Any, fallback: bool) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - text = value.strip().lower() - if text in ("true", "1", "yes", "y", "on", "启用", "是", "真"): - return True - if text in ("false", "0", "no", "n", "off", "禁用", "否", "假"): - return False - if isinstance(value, (int, float)) and not isinstance(value, bool): - return bool(value) - return bool(fallback) - - @staticmethod - def normalize_positive_int(value: Any, fallback: int) -> int: - if isinstance(value, bool): - return fallback - try: - result = int(value) - except (TypeError, ValueError): - return fallback - return result if result > 0 else fallback - - @staticmethod - def normalize_positive_float(value: Any, fallback: float) -> float: - if isinstance(value, bool): - return fallback - try: - result = float(value) - except (TypeError, ValueError): - return fallback - return result if result > 0 else fallback - - @staticmethod - def normalize_str( - value: Any, - fallback: str, - *, - allow_empty: bool = False) -> str: - if value is None: - return fallback - text = str(value) - if text or allow_empty: - return text - return fallback - - @classmethod - def normalize_player_mapping( - cls, raw: Any, fallback: dict[str, str]) -> dict[str, str]: - source = raw if isinstance(raw, dict) else fallback - result: dict[str, str] = {} - for xuid, player_name in source.items(): - xuid_text = str(xuid).strip() - if not xuid_text: - continue - result[xuid_text] = cls.normalize_str( - player_name, "", allow_empty=True) - return result - - @classmethod - def normalize_config(cls, raw_cfg: Any) -> dict[str, Any]: - merged_cfg = cls.merge_with_default(raw_cfg, cls.DEFAULT_CFG) - normalized = cls.trim_fixed_keys(merged_cfg, cls.DEFAULT_CFG) - - dynamic_default = cls.DEFAULT_CFG[DYNAMIC_LOAD_SETTINGS_KEY] - dynamic = cls.trim_fixed_keys( - normalized.get(DYNAMIC_LOAD_SETTINGS_KEY), - dynamic_default, - ) - dynamic[DYNAMIC_LOAD_ENABLED_KEY] = cls.normalize_bool( - dynamic.get(DYNAMIC_LOAD_ENABLED_KEY), - dynamic_default[DYNAMIC_LOAD_ENABLED_KEY], - ) - dynamic[DYNAMIC_LOAD_INTERVAL_KEY] = cls.normalize_positive_int( - dynamic.get(DYNAMIC_LOAD_INTERVAL_KEY), - dynamic_default[DYNAMIC_LOAD_INTERVAL_KEY], - ) - normalized[DYNAMIC_LOAD_SETTINGS_KEY] = dynamic - - normalized["检查时间(秒)"] = cls.normalize_positive_float( - normalized.get("检查时间(秒)"), - cls.DEFAULT_CFG["检查时间(秒)"], - ) - - whitelist_default = cls.DEFAULT_CFG["白名单"] - whitelist = cls.trim_fixed_keys( - normalized.get("白名单"), whitelist_default) - whitelist["开启状态"] = cls.normalize_bool( - whitelist.get("开启状态"), - whitelist_default["开启状态"], - ) - whitelist["踢出提示词"] = cls.normalize_str( - whitelist.get("踢出提示词"), - whitelist_default["踢出提示词"], - ) - whitelist["白名单玩家"] = cls.normalize_player_mapping( - whitelist.get("白名单玩家"), - whitelist_default["白名单玩家"], - ) - normalized["白名单"] = whitelist - - admin_default = cls.DEFAULT_CFG["管理员检测"] - admin_check = cls.trim_fixed_keys( - normalized.get("管理员检测"), admin_default) - admin_check["开启状态"] = cls.normalize_bool( - admin_check.get("开启状态"), - admin_default["开启状态"], - ) - admin_check["提示词"] = cls.normalize_str( - admin_check.get("提示词"), - admin_default["提示词"], - ) - admin_check["管理员列表"] = cls.normalize_player_mapping( - admin_check.get("管理员列表"), - admin_default["管理员列表"], - ) - normalized["管理员检测"] = admin_check - - return normalized - - def load_config(self) -> dict[str, Any]: - """读取配置文件并做结构校验,失败时退回默认值。""" - try: - raw_cfg, _ = cfg.get_plugin_config_and_version( - self.name, - {}, - self.DEFAULT_CFG, - self.version, - ) - merged_cfg = self.normalize_config(raw_cfg) - cfg.check_auto(self.STD_CFG, merged_cfg) - except Exception as err: - fmts.print_err(f"加载配置文件出错: {err}") - merged_cfg = self.normalize_config({}) - cfg.check_auto(self.STD_CFG, merged_cfg) - cfg.upgrade_plugin_config(self.name, merged_cfg, self.version) - return merged_cfg - - def apply_runtime_config(self, config_items: dict[str, Any]) -> None: - """Replace runtime config with already parsed config-center data.""" - merged_cfg = self.normalize_config(config_items) - cfg.check_auto(self.STD_CFG, merged_cfg) - self._cfg = merged_cfg - self.refresh_config_file_state() - - def save_cfg(self): - """把当前内存中的配置写回插件配置文件。""" - cfg.upgrade_plugin_config(self.name, self._cfg, self.version) - self.refresh_config_file_state() - - def config_file_path(self) -> str: - return os.path.join(CONFIG_FILE_DIR, f"{self.name}.json") - - @staticmethod - def file_state(path: str) -> tuple[int, int] | None: - try: - stat = os.stat(path) - except OSError: - return None - return stat.st_mtime_ns, stat.st_size - - def refresh_config_file_state(self): - self._config_file_state = self.file_state(self.config_file_path()) - - def is_dynamic_config_reload_enabled(self) -> bool: - settings = self._cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) - if not isinstance(settings, dict): - return True - return bool(settings.get(DYNAMIC_LOAD_ENABLED_KEY, True)) - - def dynamic_config_reload_interval(self) -> int: - settings = self._cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) - if not isinstance(settings, dict): - return DYNAMIC_LOAD_DEFAULT_INTERVAL - try: - interval = int(settings.get(DYNAMIC_LOAD_INTERVAL_KEY, - DYNAMIC_LOAD_DEFAULT_INTERVAL)) - except (TypeError, ValueError): - return DYNAMIC_LOAD_DEFAULT_INTERVAL - return interval if interval > 0 else DYNAMIC_LOAD_DEFAULT_INTERVAL - - def reload_runtime_config(self, announce: bool = False): - self._cfg = self.load_config() - self.refresh_config_file_state() - if announce: - fmts.print_suc(f"{self.name} 配置文件已热更新") - - def config_reload_task(self): - while not self._stop_event.wait(self.dynamic_config_reload_interval()): - if not self.is_dynamic_config_reload_enabled(): - self.refresh_config_file_state() - continue - current_state = self.file_state(self.config_file_path()) - if current_state == self._config_file_state: - continue - try: - self.reload_runtime_config(announce=True) - except Exception as err: - self._config_file_state = current_state - fmts.print_err(f"{self.name} 配置文件热更新失败: {err}") - - def api_reload_checker_config( - self) -> tuple[bool, str, dict[str, int | float | bool]]: - try: - self.reload_runtime_config(announce=False) - except Exception as err: - return False, f"白名单&管理员检测配置重载失败: {err}", self.get_runtime_status() - return True, "白名单&管理员检测配置已重载", self.get_runtime_status() - - def on_preload(self): - """在 preload 阶段获取 XUID 查询前置插件。""" - self.get_xuid = self.GetPluginAPI("XUID获取") - - def on_active(self): - """在插件激活后挂载控制台入口并启动周期检测。""" - self.bot_name = self.resolve_bot_name() - self.frame.add_console_cmd_trigger( - ["白名单"], - None, - "在控制台修改白名单(需要玩家先登录一次服务器)", - self.console_manage_whitelist, - ) - self.start_periodic_check() - - def on_frame_exit(self, _): - self._stop_event.set() - - def resolve_bot_name(self) -> str: - """尽量用通用 ToolDelta 能力识别机器人名,避免绑定 NeOmega 接入点。""" - bot_name = getattr(self.game_ctrl, "bot_name", "") - if bot_name: - return bot_name - - get_bot_name = getattr(self.frame.launcher, "get_bot_name", None) - if callable(get_bot_name): - try: - bot_name = get_bot_name() - except Exception: - bot_name = "" - if bot_name: - return bot_name - - omega = getattr(self.frame.launcher, "omega", None) - if omega is None: - return "" - try: - return getattr(omega.get_bot_basic_info(), "BotName", "") or "" - except Exception: - return "" - - def on_player_join(self, player: Player): - """玩家进服时按当前配置执行白名单和管理员状态检查。""" - if self._is_bot_player(player.name): - return - if self._cfg["白名单"]["开启状态"]: - self.enforce_whitelist(player.name, player.xuid) - if self._cfg["管理员检测"]["开启状态"]: - self.enforce_admin_state(player.name, player.xuid) - - def _is_bot_player(self, player_name: str) -> bool: - """判断给定玩家名是否就是当前机器人自己。""" - return bool(self.bot_name) and player_name == self.bot_name - - def resolve_player_xuid(self, player_name: str) -> tuple[str | None, str]: - """根据玩家名解析 XUID,失败时返回错误信息。""" - try: - player_xuid = self.get_xuid.get_xuid_by_name( - player_name, - allow_offline=True, - ) - except Exception: - return None, "玩家未加入过服务器或无法获取 XUID" - return player_xuid, "" - - def add_whitelist_player(self, player_name: str) -> tuple[bool, str]: - """把玩家加入白名单。""" - return self.add_player_mapping( - player_name, - "白名单", - "白名单玩家", - "玩家已存在白名单中", - "已添加玩家 {player_name} 到白名单", - ) - - def remove_whitelist_player(self, player_name: str) -> tuple[bool, str]: - """把玩家从白名单中移除。""" - return self.remove_player_mapping( - player_name, - "白名单", - "白名单玩家", - "玩家不存在白名单中", - "已从白名单中移除玩家 {player_name}", - ) - - def add_admin_player(self, player_name: str) -> tuple[bool, str]: - """把玩家登记为服务器管理员。""" - return self.add_player_mapping( - player_name, - "管理员检测", - "管理员列表", - "玩家已经是服务器管理员", - "已添加玩家 {player_name} 为服务器管理员", - ) - - def remove_admin_player(self, player_name: str) -> tuple[bool, str]: - """把玩家从服务器管理员名单中移除。""" - return self.remove_player_mapping( - player_name, - "管理员检测", - "管理员列表", - "玩家不是服务器管理员", - "已将玩家 {player_name} 从服务器管理员中移除", - ) - - def add_player_mapping( - self, - player_name: str, - section: str, - key: str, - duplicate_message: str, - success_message: str, - ) -> tuple[bool, str]: - """向指定映射表添加一个以 XUID 为键的玩家条目。""" - player_xuid, error = self.resolve_player_xuid(player_name) - if player_xuid is None: - return False, error - mapping = self._cfg[section][key] - if player_xuid in mapping: - return False, duplicate_message - mapping[player_xuid] = player_name - self.save_cfg() - return True, success_message.format(player_name=player_name) - - def remove_player_mapping( - self, - player_name: str, - section: str, - key: str, - missing_message: str, - success_message: str, - ) -> tuple[bool, str]: - """从指定映射表移除一个以 XUID 为键的玩家条目。""" - player_xuid, error = self.resolve_player_xuid(player_name) - if player_xuid is None: - return False, error - mapping = self._cfg[section][key] - if player_xuid not in mapping: - return False, missing_message - mapping.pop(player_xuid) - self.save_cfg() - return True, success_message.format(player_name=player_name) - - def set_whitelist_enabled(self, enabled: bool) -> tuple[bool, str]: - """切换白名单检测开关。""" - self._cfg["白名单"]["开启状态"] = enabled - self.save_cfg() - return True, f"白名单检测已{'开启' if enabled else '关闭'}" - - def set_admin_check_enabled(self, enabled: bool) -> tuple[bool, str]: - """切换管理员检测开关。""" - self._cfg["管理员检测"]["开启状态"] = enabled - self.save_cfg() - return True, f"管理员检测已{'开启' if enabled else '关闭'}" - - def set_check_interval(self, seconds: float) -> tuple[bool, str]: - """更新周期检测的轮询间隔。""" - if seconds <= 0: - return False, "检测周期必须大于 0" - self._cfg["检查时间(秒)"] = float(seconds) - self.save_cfg() - return True, f"检测周期已设置为 {seconds} 秒" - - def get_runtime_status(self) -> dict[str, int | float | bool]: - """返回给其他插件使用的当前运行状态摘要。""" - return { - "check_interval": self._cfg["检查时间(秒)"], - "whitelist_enabled": self._cfg["白名单"]["开启状态"], - "whitelist_count": len(self._cfg["白名单"]["白名单玩家"]), - "admin_check_enabled": self._cfg["管理员检测"]["开启状态"], - "admin_count": len(self._cfg["管理员检测"]["管理员列表"]), - } - - def enforce_whitelist(self, player_name: str, player_xuid: str): - """对白名单未命中的玩家执行踢出。""" - if player_xuid in self._cfg["白名单"]["白名单玩家"]: - return - self.game_ctrl.sendwocmd( - f"kick {player_xuid} {self._cfg['白名单']['踢出提示词']}" - ) - - def enforce_admin_state(self, player_name: str, player_xuid: str): - """同步服务器 OP 状态与插件配置中的管理员登记状态。""" - is_registered_admin = player_xuid in self._cfg["管理员检测"]["管理员列表"] - is_server_op = game_utils.is_op(player_name) - - if is_server_op and not is_registered_admin: - self.game_ctrl.sendwocmd(f"/say 检测到存在非法管理员:{player_name}") - self.game_ctrl.sendwocmd(f"/deop {player_name}") - self.game_ctrl.sendwocmd( - f"/tell {player_name} {self._cfg['管理员检测']['提示词']}" - ) - return - - if not is_server_op and is_registered_admin: - self.game_ctrl.sendwocmd(f"/op {player_name}") - - def console_manage_whitelist(self, _args: list[str]): - """打开控制台白名单管理菜单。""" - self.console_manage_whitelist_mapping( - title="白名单", - add_action=self.add_whitelist_player, - remove_action=self.remove_whitelist_player, - add_prompt="请输入要添加的玩家昵称:", - remove_prompt="请输入要移除的玩家昵称:", - ) - - def console_manage_whitelist_mapping( - self, - title: str, - add_action, - remove_action, - add_prompt: str, - remove_prompt: str, - ): - """控制台白名单增删交互。""" - option_add = f"添加{title}" - option_remove = f"移除{title}" - while True: - fmts.print_inf("选择你要进行的操作:") - fmts.print_inf(f"1. {option_add}") - fmts.print_inf(f"2. {option_remove}") - fmts.print_inf("q. 退出操作") - choice = input().strip().lower() - if choice == "q": - fmts.print_inf("已退出操作") - return - if choice == "1": - player_name = input(fmts.fmt_info(add_prompt)).strip() - ok, message = add_action(player_name) - self.print_console_result(ok, message) - return - if choice == "2": - player_name = input(fmts.fmt_info(remove_prompt)).strip() - ok, message = remove_action(player_name) - self.print_console_result(ok, message) - return - fmts.print_err("无效的选项") - - @staticmethod - def print_console_result(ok: bool, message: str): - """统一输出控制台操作结果,减少重复分支。""" - if ok: - fmts.print_suc(message) - else: - fmts.print_err(message) - - @utils.thread_func("循环检测白名单和管理员") - def start_periodic_check(self): - """按配置周期轮询在线玩家,补做白名单和管理员状态校验。""" - while not self._stop_event.wait(float(self._cfg["检查时间(秒)"])): - for player in self.frame.get_players().getAllPlayers(): - if self._is_bot_player(player.name): - continue - if self._cfg["白名单"]["开启状态"]: - self.enforce_whitelist(player.name, player.xuid) - if self._cfg["管理员检测"]["开启状态"]: - self.enforce_admin_state(player.name, player.xuid) - - -entry = plugin_entry(WhitelistAndOpCheck, "白名单&管理员检测云链联动版") +"""Whitelist and operator-status checker plugin.""" + +import copy +import os +import time +import threading +from typing import Any + +from tooldelta import Player, Plugin, cfg, fmts, game_utils, plugin_entry, utils + + +CONFIG_FILE_DIR = "插件配置文件" +DYNAMIC_LOAD_SETTINGS_KEY = "动态载入设置" +DYNAMIC_LOAD_ENABLED_KEY = "是否启用动态载入配置文件(仅用于本插件)" +DYNAMIC_LOAD_INTERVAL_KEY = "动态载入检测时间间隔(单位:秒)" +DYNAMIC_LOAD_DEFAULT_INTERVAL = 5 + + +class WhitelistAndOpCheck(Plugin): + """负责白名单与 OP 状态校验,并对外暴露管理接口。""" + + name = "白名单&管理员检测云链联动版" + author = "猫七街" + version = (1, 1, 4) + description = "白名单与管理员状态检测,并向其他插件暴露可复用的管理 API。" + + DEFAULT_CFG = { + DYNAMIC_LOAD_SETTINGS_KEY: { + DYNAMIC_LOAD_ENABLED_KEY: True, + DYNAMIC_LOAD_INTERVAL_KEY: DYNAMIC_LOAD_DEFAULT_INTERVAL, + }, + "检查时间(秒)": 60.0, + "白名单": { + "开启状态": False, + "踢出提示词": "请先加入白名单", + "白名单玩家": {"xuid1": "player_name1", "xuid2": "player_name2"}, + }, + "管理员检测": { + "开启状态": False, + "提示词": "你没有管理员权限", + "管理员列表": {"xuid1": "player_name1", "xuid2": "player_name2"}, + }, + } + + STD_CFG = { + DYNAMIC_LOAD_SETTINGS_KEY: { + DYNAMIC_LOAD_ENABLED_KEY: bool, + DYNAMIC_LOAD_INTERVAL_KEY: cfg.PInt, + }, + "检查时间(秒)": float, + "白名单": {"开启状态": bool, "踢出提示词": str, "白名单玩家": {}}, + "管理员检测": {"开启状态": bool, "提示词": str, "管理员列表": {}}, + } + + def __init__(self, frame): + """初始化运行时状态并注册插件生命周期回调。""" + super().__init__(frame) + self.get_xuid = None + self.bot_name = "" + self._stop_event = threading.Event() + self._config_file_state = None + self._cfg = self.load_config() + self.refresh_config_file_state() + self.config_thread = utils.createThread( + self.config_reload_task, + usage="白名单&管理员检测配置热更新任务", + ) + + self.ListenPreload(self.on_preload) + self.ListenActive(self.on_active) + self.ListenPlayerJoin(self.on_player_join) + self.ListenFrameExit(self.on_frame_exit) + + @classmethod + def merge_with_default(cls, raw: Any, default: Any): + """递归合并用户配置和默认配置。""" + if isinstance(default, dict): + result = { + key: cls.merge_with_default( + raw.get(key) if isinstance(raw, dict) else None, + value, + ) + for key, value in default.items() + } + if isinstance(raw, dict): + for key, value in raw.items(): + if key not in result: + result[key] = copy.deepcopy(value) + return result + return copy.deepcopy( + raw) if raw is not None else copy.deepcopy(default) + + @staticmethod + def trim_fixed_keys(raw: Any, default: dict[str, Any]) -> dict[str, Any]: + """Implement the trim fixed keys operation.""" + raw = raw if isinstance(raw, dict) else {} + return { + key: copy.deepcopy(raw.get(key, value)) + for key, value in default.items() + } + + @staticmethod + def normalize_bool(value: Any, fallback: bool) -> bool: + """Implement the normalize bool operation.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in ("true", "1", "yes", "y", "on", "启用", "是", "真"): + return True + if text in ("false", "0", "no", "n", "off", "禁用", "否", "假"): + return False + if isinstance(value, (int, float)) and not isinstance(value, bool): + return bool(value) + return bool(fallback) + + @staticmethod + def normalize_positive_int(value: Any, fallback: int) -> int: + """Implement the normalize positive int operation.""" + if isinstance(value, bool): + return fallback + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + @staticmethod + def normalize_positive_float(value: Any, fallback: float) -> float: + """Implement the normalize positive float operation.""" + if isinstance(value, bool): + return fallback + try: + result = float(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + @staticmethod + def normalize_str( + value: Any, + fallback: str, + *, + allow_empty: bool = False) -> str: + """Implement the normalize str operation.""" + if value is None: + return fallback + text = str(value) + if text or allow_empty: + return text + return fallback + + @classmethod + def normalize_player_mapping( + cls, raw: Any, fallback: dict[str, str]) -> dict[str, str]: + """Implement the normalize player mapping operation.""" + source = raw if isinstance(raw, dict) else fallback + result: dict[str, str] = {} + for xuid, player_name in source.items(): + xuid_text = str(xuid).strip() + if not xuid_text: + continue + result[xuid_text] = cls.normalize_str( + player_name, "", allow_empty=True) + return result + + @classmethod + def normalize_config(cls, raw_cfg: Any) -> dict[str, Any]: + """Implement the normalize config operation.""" + merged_cfg = cls.merge_with_default(raw_cfg, cls.DEFAULT_CFG) + normalized = cls.trim_fixed_keys(merged_cfg, cls.DEFAULT_CFG) + + dynamic_default = cls.DEFAULT_CFG[DYNAMIC_LOAD_SETTINGS_KEY] + dynamic = cls.trim_fixed_keys( + normalized.get(DYNAMIC_LOAD_SETTINGS_KEY), + dynamic_default, + ) + dynamic[DYNAMIC_LOAD_ENABLED_KEY] = cls.normalize_bool( + dynamic.get(DYNAMIC_LOAD_ENABLED_KEY), + dynamic_default[DYNAMIC_LOAD_ENABLED_KEY], + ) + dynamic[DYNAMIC_LOAD_INTERVAL_KEY] = cls.normalize_positive_int( + dynamic.get(DYNAMIC_LOAD_INTERVAL_KEY), + dynamic_default[DYNAMIC_LOAD_INTERVAL_KEY], + ) + normalized[DYNAMIC_LOAD_SETTINGS_KEY] = dynamic + + normalized["检查时间(秒)"] = cls.normalize_positive_float( + normalized.get("检查时间(秒)"), + cls.DEFAULT_CFG["检查时间(秒)"], + ) + + whitelist_default = cls.DEFAULT_CFG["白名单"] + whitelist = cls.trim_fixed_keys( + normalized.get("白名单"), whitelist_default) + whitelist["开启状态"] = cls.normalize_bool( + whitelist.get("开启状态"), + whitelist_default["开启状态"], + ) + whitelist["踢出提示词"] = cls.normalize_str( + whitelist.get("踢出提示词"), + whitelist_default["踢出提示词"], + ) + whitelist["白名单玩家"] = cls.normalize_player_mapping( + whitelist.get("白名单玩家"), + whitelist_default["白名单玩家"], + ) + normalized["白名单"] = whitelist + + admin_default = cls.DEFAULT_CFG["管理员检测"] + admin_check = cls.trim_fixed_keys( + normalized.get("管理员检测"), admin_default) + admin_check["开启状态"] = cls.normalize_bool( + admin_check.get("开启状态"), + admin_default["开启状态"], + ) + admin_check["提示词"] = cls.normalize_str( + admin_check.get("提示词"), + admin_default["提示词"], + ) + admin_check["管理员列表"] = cls.normalize_player_mapping( + admin_check.get("管理员列表"), + admin_default["管理员列表"], + ) + normalized["管理员检测"] = admin_check + + return normalized + + def load_config(self) -> dict[str, Any]: + """读取配置文件并做结构校验,失败时退回默认值。""" + try: + raw_cfg, _ = cfg.get_plugin_config_and_version( + self.name, + {}, + self.DEFAULT_CFG, + self.version, + ) + merged_cfg = self.normalize_config(raw_cfg) + cfg.check_auto(self.STD_CFG, merged_cfg) + except Exception as err: + fmts.print_err(f"加载配置文件出错: {err}") + merged_cfg = self.normalize_config({}) + cfg.check_auto(self.STD_CFG, merged_cfg) + cfg.upgrade_plugin_config(self.name, merged_cfg, self.version) + return merged_cfg + + def apply_runtime_config(self, config_items: dict[str, Any]) -> None: + """Replace runtime config with already parsed config-center data.""" + merged_cfg = self.normalize_config(config_items) + cfg.check_auto(self.STD_CFG, merged_cfg) + self._cfg = merged_cfg + self.refresh_config_file_state() + + def save_cfg(self): + """把当前内存中的配置写回插件配置文件。""" + cfg.upgrade_plugin_config(self.name, self._cfg, self.version) + self.refresh_config_file_state() + + def config_file_path(self) -> str: + """Implement the config file path operation.""" + return os.path.join(CONFIG_FILE_DIR, f"{self.name}.json") + + @staticmethod + def file_state(path: str) -> tuple[int, int] | None: + """Implement the file state operation.""" + try: + stat = os.stat(path) + except OSError: + return None + return stat.st_mtime_ns, stat.st_size + + def refresh_config_file_state(self): + """Implement the refresh config file state operation.""" + self._config_file_state = self.file_state(self.config_file_path()) + + def is_dynamic_config_reload_enabled(self) -> bool: + """Implement the is dynamic config reload enabled operation.""" + settings = self._cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return True + return bool(settings.get(DYNAMIC_LOAD_ENABLED_KEY, True)) + + def dynamic_config_reload_interval(self) -> int: + """Implement the dynamic config reload interval operation.""" + settings = self._cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return DYNAMIC_LOAD_DEFAULT_INTERVAL + try: + interval = int(settings.get(DYNAMIC_LOAD_INTERVAL_KEY, + DYNAMIC_LOAD_DEFAULT_INTERVAL)) + except (TypeError, ValueError): + return DYNAMIC_LOAD_DEFAULT_INTERVAL + return interval if interval > 0 else DYNAMIC_LOAD_DEFAULT_INTERVAL + + def reload_runtime_config(self, announce: bool = False): + """Implement the reload runtime config operation.""" + self._cfg = self.load_config() + self.refresh_config_file_state() + if announce: + fmts.print_suc(f"{self.name} 配置文件已热更新") + + def config_reload_task(self): + """Implement the config reload task operation.""" + while not self._stop_event.wait(self.dynamic_config_reload_interval()): + if not self.is_dynamic_config_reload_enabled(): + self.refresh_config_file_state() + continue + current_state = self.file_state(self.config_file_path()) + if current_state == self._config_file_state: + continue + try: + self.reload_runtime_config(announce=True) + except Exception as err: + self._config_file_state = current_state + fmts.print_err(f"{self.name} 配置文件热更新失败: {err}") + + def api_reload_checker_config( + self) -> tuple[bool, str, dict[str, int | float | bool]]: + """Expose the api reload checker config API operation.""" + try: + self.reload_runtime_config(announce=False) + except Exception as err: + return False, f"白名单&管理员检测配置重载失败: {err}", self.get_runtime_status() + return True, "白名单&管理员检测配置已重载", self.get_runtime_status() + + def on_preload(self): + """在 preload 阶段获取 XUID 查询前置插件。""" + self.get_xuid = self.GetPluginAPI("XUID获取") + + def on_active(self): + """在插件激活后挂载控制台入口并启动周期检测。""" + self.bot_name = self.resolve_bot_name() + self.frame.add_console_cmd_trigger( + ["白名单"], + None, + "在控制台修改白名单(需要玩家先登录一次服务器)", + self.console_manage_whitelist, + ) + self.start_periodic_check() + + def on_frame_exit(self, _): + """Implement the on frame exit operation.""" + self._stop_event.set() + + def resolve_bot_name(self) -> str: + """尽量用通用 ToolDelta 能力识别机器人名,避免绑定 NeOmega 接入点。""" + bot_name = getattr(self.game_ctrl, "bot_name", "") + if bot_name: + return bot_name + + get_bot_name = getattr(self.frame.launcher, "get_bot_name", None) + if callable(get_bot_name): + try: + bot_name = get_bot_name() + except Exception: + bot_name = "" + if bot_name: + return bot_name + + omega = getattr(self.frame.launcher, "omega", None) + if omega is None: + return "" + try: + return getattr(omega.get_bot_basic_info(), "BotName", "") or "" + except Exception: + return "" + + def on_player_join(self, player: Player): + """玩家进服时按当前配置执行白名单和管理员状态检查。""" + if self._is_bot_player(player.name): + return + if self._cfg["白名单"]["开启状态"]: + self.enforce_whitelist(player.name, player.xuid) + if self._cfg["管理员检测"]["开启状态"]: + self.enforce_admin_state(player.name, player.xuid) + + def _is_bot_player(self, player_name: str) -> bool: + """判断给定玩家名是否就是当前机器人自己。""" + return bool(self.bot_name) and player_name == self.bot_name + + def resolve_player_xuid(self, player_name: str) -> tuple[str | None, str]: + """根据玩家名解析 XUID,失败时返回错误信息。""" + try: + player_xuid = self.get_xuid.get_xuid_by_name( + player_name, + allow_offline=True, + ) + except Exception: + return None, "玩家未加入过服务器或无法获取 XUID" + return player_xuid, "" + + def add_whitelist_player(self, player_name: str) -> tuple[bool, str]: + """把玩家加入白名单。""" + return self.add_player_mapping( + player_name, + "白名单", + "白名单玩家", + "玩家已存在白名单中", + "已添加玩家 {player_name} 到白名单", + ) + + def remove_whitelist_player(self, player_name: str) -> tuple[bool, str]: + """把玩家从白名单中移除。""" + return self.remove_player_mapping( + player_name, + "白名单", + "白名单玩家", + "玩家不存在白名单中", + "已从白名单中移除玩家 {player_name}", + ) + + def add_admin_player(self, player_name: str) -> tuple[bool, str]: + """把玩家登记为服务器管理员。""" + return self.add_player_mapping( + player_name, + "管理员检测", + "管理员列表", + "玩家已经是服务器管理员", + "已添加玩家 {player_name} 为服务器管理员", + ) + + def remove_admin_player(self, player_name: str) -> tuple[bool, str]: + """把玩家从服务器管理员名单中移除。""" + return self.remove_player_mapping( + player_name, + "管理员检测", + "管理员列表", + "玩家不是服务器管理员", + "已将玩家 {player_name} 从服务器管理员中移除", + ) + + def add_player_mapping( + self, + player_name: str, + section: str, + key: str, + duplicate_message: str, + success_message: str, + ) -> tuple[bool, str]: + """向指定映射表添加一个以 XUID 为键的玩家条目。""" + player_xuid, error = self.resolve_player_xuid(player_name) + if player_xuid is None: + return False, error + mapping = self._cfg[section][key] + if player_xuid in mapping: + return False, duplicate_message + mapping[player_xuid] = player_name + self.save_cfg() + return True, success_message.format(player_name=player_name) + + def remove_player_mapping( + self, + player_name: str, + section: str, + key: str, + missing_message: str, + success_message: str, + ) -> tuple[bool, str]: + """从指定映射表移除一个以 XUID 为键的玩家条目。""" + player_xuid, error = self.resolve_player_xuid(player_name) + if player_xuid is None: + return False, error + mapping = self._cfg[section][key] + if player_xuid not in mapping: + return False, missing_message + mapping.pop(player_xuid) + self.save_cfg() + return True, success_message.format(player_name=player_name) + + def set_whitelist_enabled(self, enabled: bool) -> tuple[bool, str]: + """切换白名单检测开关。""" + self._cfg["白名单"]["开启状态"] = enabled + self.save_cfg() + return True, f"白名单检测已{'开启' if enabled else '关闭'}" + + def set_admin_check_enabled(self, enabled: bool) -> tuple[bool, str]: + """切换管理员检测开关。""" + self._cfg["管理员检测"]["开启状态"] = enabled + self.save_cfg() + return True, f"管理员检测已{'开启' if enabled else '关闭'}" + + def set_check_interval(self, seconds: float) -> tuple[bool, str]: + """更新周期检测的轮询间隔。""" + if seconds <= 0: + return False, "检测周期必须大于 0" + self._cfg["检查时间(秒)"] = float(seconds) + self.save_cfg() + return True, f"检测周期已设置为 {seconds} 秒" + + def get_runtime_status(self) -> dict[str, int | float | bool]: + """返回给其他插件使用的当前运行状态摘要。""" + return { + "check_interval": self._cfg["检查时间(秒)"], + "whitelist_enabled": self._cfg["白名单"]["开启状态"], + "whitelist_count": len(self._cfg["白名单"]["白名单玩家"]), + "admin_check_enabled": self._cfg["管理员检测"]["开启状态"], + "admin_count": len(self._cfg["管理员检测"]["管理员列表"]), + } + + def enforce_whitelist(self, player_name: str, player_xuid: str): + """对白名单未命中的玩家执行踢出。""" + if player_xuid in self._cfg["白名单"]["白名单玩家"]: + return + self.game_ctrl.sendwocmd( + f"kick {player_xuid} {self._cfg['白名单']['踢出提示词']}" + ) + + def enforce_admin_state(self, player_name: str, player_xuid: str): + """同步服务器 OP 状态与插件配置中的管理员登记状态。""" + is_registered_admin = player_xuid in self._cfg["管理员检测"]["管理员列表"] + is_server_op = game_utils.is_op(player_name) + + if is_server_op and not is_registered_admin: + self.game_ctrl.sendwocmd(f"/say 检测到存在非法管理员:{player_name}") + self.game_ctrl.sendwocmd(f"/deop {player_name}") + self.game_ctrl.sendwocmd( + f"/tell {player_name} {self._cfg['管理员检测']['提示词']}" + ) + return + + if not is_server_op and is_registered_admin: + self.game_ctrl.sendwocmd(f"/op {player_name}") + + def console_manage_whitelist(self, _args: list[str]): + """打开控制台白名单管理菜单。""" + self.console_manage_whitelist_mapping( + title="白名单", + add_action=self.add_whitelist_player, + remove_action=self.remove_whitelist_player, + add_prompt="请输入要添加的玩家昵称:", + remove_prompt="请输入要移除的玩家昵称:", + ) + + def console_manage_whitelist_mapping( + self, + title: str, + add_action, + remove_action, + add_prompt: str, + remove_prompt: str, + ): + """控制台白名单增删交互。""" + option_add = f"添加{title}" + option_remove = f"移除{title}" + while True: + fmts.print_inf("选择你要进行的操作:") + fmts.print_inf(f"1. {option_add}") + fmts.print_inf(f"2. {option_remove}") + fmts.print_inf("q. 退出操作") + choice = input().strip().lower() + if choice == "q": + fmts.print_inf("已退出操作") + return + if choice == "1": + player_name = input(fmts.fmt_info(add_prompt)).strip() + ok, message = add_action(player_name) + self.print_console_result(ok, message) + return + if choice == "2": + player_name = input(fmts.fmt_info(remove_prompt)).strip() + ok, message = remove_action(player_name) + self.print_console_result(ok, message) + return + fmts.print_err("无效的选项") + + @staticmethod + def print_console_result(ok: bool, message: str): + """统一输出控制台操作结果,减少重复分支。""" + if ok: + fmts.print_suc(message) + else: + fmts.print_err(message) + + @utils.thread_func("循环检测白名单和管理员") + def start_periodic_check(self): + """按配置周期轮询在线玩家,补做白名单和管理员状态校验。""" + while not self._stop_event.wait(float(self._cfg["检查时间(秒)"])): + for player in self.frame.get_players().getAllPlayers(): + if self._is_bot_player(player.name): + continue + if self._cfg["白名单"]["开启状态"]: + self.enforce_whitelist(player.name, player.xuid) + if self._cfg["管理员检测"]["开启状态"]: + self.enforce_admin_state(player.name, player.xuid) + + +entry = plugin_entry(WhitelistAndOpCheck, "白名单&管理员检测云链联动版") diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" index 409f55c6..8e379a46 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" @@ -1,665 +1,696 @@ -"""QQ account and game XUID binding support.""" - -from __future__ import annotations - -import json -import os -import secrets -import threading -import time -from copy import deepcopy -from typing import Any - - -class QQLinkerBindingMixin: - """Handles QQ-to-game binding verification and persistence.""" - - BINDING_TIMEOUT_MINUTES_DEFAULT = 10 - - def init_binding_state(self): - self.binding_data_path = self.format_data_path("QQ绑定数据.json") - self.pending_bindings: dict[str, dict[str, Any]] = {} - self.pending_binding_timers: dict[str, threading.Timer] = {} - self.pending_bindings_lock = threading.RLock() - self._ensure_binding_data() - - @staticmethod - def _binding_default_data() -> dict[str, dict[str, Any]]: - return {"qq_to_xuids": {}, "xuid_to_qqs": {}, "xuid_names": {}} - - def _ensure_binding_data(self): - data = self.read_binding_data() - self.save_binding_data(data) - - def read_binding_data(self) -> dict[str, dict[str, Any]]: - if not os.path.isfile(self.binding_data_path): - return self._binding_default_data() - try: - with open(self.binding_data_path, "r", encoding="utf-8") as file: - raw = json.load(file) - except Exception: - raw = {} - - data = self._binding_default_data() - if isinstance(raw, dict): - data["qq_to_xuids"] = self._normalize_binding_map( - raw.get("qq_to_xuids")) - data["xuid_to_qqs"] = self._normalize_binding_map( - raw.get("xuid_to_qqs")) - names = raw.get("xuid_names", {}) - if isinstance(names, dict): - data["xuid_names"] = { - str(xuid): str(name) - for xuid, name in names.items() - if str(xuid).strip() and str(name).strip() - } - return data - - @staticmethod - def _normalize_binding_map(raw: Any) -> dict[str, list[str]]: - result: dict[str, list[str]] = {} - if not isinstance(raw, dict): - return result - for key, values in raw.items(): - skey = str(key).strip() - if not skey: - continue - if not isinstance(values, list): - values = [values] - normalized: list[str] = [] - for value in values: - svalue = str(value).strip() - if svalue and svalue not in normalized: - normalized.append(svalue) - result[skey] = normalized - return result - - def save_binding_data(self, data: dict[str, dict[str, Any]]): - with open(self.binding_data_path, "w", encoding="utf-8") as file: - json.dump(data, file, ensure_ascii=False, indent=2) - - @staticmethod - def _binding_qq_key(qqid: int | str) -> str: - text = str(qqid).strip() - if not text: - return "" - try: - value = int(text) - except ValueError: - return text - return str(value) if value > 0 else "" - - @staticmethod - def _binding_xuid_key(xuid: str) -> str: - return str(xuid).strip() - - @staticmethod - def _binding_qq_values(values: list[str]) -> list[int]: - result: list[int] = [] - for value in values: - try: - qqid = int(str(value).strip()) - except ValueError: - continue - if qqid > 0 and qqid not in result: - result.append(qqid) - return result - - def _binding_api_group_id(self, group_id: int | None = None) -> int: - if group_id is not None: - return int(group_id) - return int(self.linked_group or 0) - - def api_get_binding_data(self) -> dict[str, dict[str, Any]]: - """Return a normalized copy of all QQ/XUID binding data.""" - return deepcopy(self.read_binding_data()) - - def api_get_all_bindings(self) -> list[dict[str, Any]]: - """Return binding relations as flat records for easier iteration.""" - data = self.read_binding_data() - records: list[dict[str, Any]] = [] - for qq_key, xuids in data["qq_to_xuids"].items(): - try: - qq_value: int | str = int(qq_key) - except ValueError: - qq_value = qq_key - for xuid in xuids: - records.append( - { - "qq": qq_value, - "xuid": xuid, - "player_name": data["xuid_names"].get(xuid, ""), - } - ) - return records - - def api_get_xuids_by_qq(self, qqid: int | str) -> list[str]: - """Return all XUIDs bound to a QQ number.""" - qq_key = self._binding_qq_key(qqid) - if not qq_key: - return [] - return list(self.read_binding_data()["qq_to_xuids"].get(qq_key, [])) - - def api_get_qqs_by_xuid(self, xuid: str) -> list[int]: - """Return all QQ numbers bound to an XUID.""" - xuid_key = self._binding_xuid_key(xuid) - if not xuid_key: - return [] - values = self.read_binding_data()["xuid_to_qqs"].get(xuid_key, []) - return self._binding_qq_values(values) - - def api_get_player_name_by_xuid(self, xuid: str) -> str | None: - """Return the latest recorded player name for an XUID.""" - xuid_key = self._binding_xuid_key(xuid) - if not xuid_key: - return None - return self.read_binding_data()["xuid_names"].get(xuid_key) - - def api_get_bound_players_by_qq( - self, qqid: int | str) -> list[dict[str, str]]: - """Return player records bound to a QQ number.""" - data = self.read_binding_data() - result: list[dict[str, str]] = [] - for xuid in data["qq_to_xuids"].get(self._binding_qq_key(qqid), []): - result.append( - { - "xuid": xuid, - "player_name": data["xuid_names"].get(xuid, ""), - } - ) - return result - - def api_get_bound_qqs_by_xuid(self, xuid: str) -> list[dict[str, Any]]: - """Return QQ records bound to an XUID.""" - xuid_key = self._binding_xuid_key(xuid) - if not xuid_key: - return [] - data = self.read_binding_data() - player_name = data["xuid_names"].get(xuid_key, "") - return [ - {"qq": qqid, "xuid": xuid_key, "player_name": player_name} - for qqid in self._binding_qq_values(data["xuid_to_qqs"].get(xuid_key, [])) - ] - - def api_get_xuids_by_player_name( - self, - player_name: str, - ignore_case: bool = True, - ) -> list[str]: - """Return XUIDs whose latest recorded player name matches.""" - name = str(player_name).strip() - if not name: - return [] - data = self.read_binding_data() - if ignore_case: - name = name.lower() - return [ - xuid - for xuid, stored_name in data["xuid_names"].items() - if stored_name.lower() == name - ] - return [ - xuid - for xuid, stored_name in data["xuid_names"].items() - if stored_name == name - ] - - def api_get_qqs_by_player_name( - self, - player_name: str, - ignore_case: bool = True, - ) -> list[int]: - """Return QQ numbers bound to matching player names.""" - qqids: list[int] = [] - for xuid in self.api_get_xuids_by_player_name( - player_name, ignore_case): - for qqid in self.api_get_qqs_by_xuid(xuid): - if qqid not in qqids: - qqids.append(qqid) - return qqids - - def api_is_binding_enabled(self, group_id: int | None = None) -> bool: - """Return whether QQ/game binding is enabled in config.""" - return self._binding_enabled(self._binding_api_group_id(group_id)) - - def api_is_qq_bound(self, qqid: int | str) -> bool: - """Return whether a QQ number has any binding.""" - return bool(self.api_get_xuids_by_qq(qqid)) - - def api_is_xuid_bound(self, xuid: str) -> bool: - """Return whether an XUID has any binding.""" - return bool(self.api_get_qqs_by_xuid(xuid)) - - def api_is_qq_bound_to_xuid(self, qqid: int | str, xuid: str) -> bool: - """Return whether the exact QQ/XUID relation exists.""" - return self._binding_xuid_key(xuid) in self.api_get_xuids_by_qq(qqid) - - def api_bind_qq_to_xuid( - self, - qqid: int | str, - xuid: str, - player_name: str = "", - group_id: int | None = None, - ) -> tuple[bool, str]: - """Create or refresh a QQ/XUID binding, respecting multi-bind config.""" - qq_key = self._binding_qq_key(qqid) - xuid_key = self._binding_xuid_key(xuid) - if not qq_key or not qq_key.isdigit(): - return False, "QQ号无效" - if not xuid_key: - return False, "XUID不能为空" - name = str(player_name).strip() or xuid_key - return self._bind_qq_to_xuid( - self._binding_api_group_id(group_id), - int(qq_key), - xuid_key, - name, - ) - - def api_unbind_qq_from_xuid( - self, qqid: int | str, xuid: str) -> tuple[bool, str]: - """Remove one exact QQ/XUID binding relation.""" - qq_key = self._binding_qq_key(qqid) - xuid_key = self._binding_xuid_key(xuid) - if not qq_key or not qq_key.isdigit(): - return False, "QQ号无效" - if not xuid_key: - return False, "XUID不能为空" - data = self.read_binding_data() - if not self._remove_binding_relation(data, qq_key, xuid_key): - return False, "绑定关系不存在" - self.save_binding_data(data) - return True, "已解绑" - - def api_unbind_all_by_qq(self, qqid: int | str) -> tuple[bool, str]: - """Remove all bindings owned by one QQ number.""" - qq_key = self._binding_qq_key(qqid) - if not qq_key or not qq_key.isdigit(): - return False, "QQ号无效" - data = self.read_binding_data() - xuids = list(data["qq_to_xuids"].get(qq_key, [])) - if not xuids: - return False, "该 QQ 没有绑定记录" - for xuid in xuids: - self._remove_binding_relation(data, qq_key, xuid) - self.save_binding_data(data) - return True, f"已解绑 {len(xuids)} 个游戏ID" - - def api_unbind_all_by_xuid(self, xuid: str) -> tuple[bool, str]: - """Remove all QQ bindings owned by one XUID.""" - xuid_key = self._binding_xuid_key(xuid) - if not xuid_key: - return False, "XUID不能为空" - data = self.read_binding_data() - qqs = list(data["xuid_to_qqs"].get(xuid_key, [])) - if not qqs: - return False, "该 XUID 没有绑定记录" - for qq_key in qqs: - self._remove_binding_relation(data, qq_key, xuid_key) - self.save_binding_data(data) - return True, f"已解绑 {len(qqs)} 个QQ号" - - def api_update_xuid_player_name( - self, - xuid: str, - player_name: str, - ) -> tuple[bool, str]: - """Update the latest recorded player name for a bound XUID.""" - xuid_key = self._binding_xuid_key(xuid) - name = str(player_name).strip() - if not xuid_key: - return False, "XUID不能为空" - if not name: - return False, "玩家名不能为空" - data = self.read_binding_data() - if xuid_key not in data["xuid_to_qqs"]: - return False, "该 XUID 没有绑定记录" - data["xuid_names"][xuid_key] = name - self.save_binding_data(data) - return True, "已更新玩家名" - - def api_start_binding_request( - self, - group_id: int, - qqid: int | str, - ) -> tuple[bool, str]: - """Start the normal QQ binding verification flow for a group member.""" - qq_key = self._binding_qq_key(qqid) - if not qq_key or not qq_key.isdigit(): - return False, "QQ号无效" - try: - group_id = int(group_id) - except (TypeError, ValueError): - return False, "群号无效" - if group_id not in self.group_cfgs: - return False, "该群未配置群服互通" - try: - ok, message = self._start_binding_request( - group_id, - int(qq_key), - ) - except Exception as err: - return False, f"绑定请求创建失败: {err}" - return ok, message - - def _remove_binding_relation( - self, - data: dict[str, dict[str, Any]], - qq_key: str, - xuid: str, - ) -> bool: - changed = False - qq_xuids = data["qq_to_xuids"].get(qq_key, []) - if xuid in qq_xuids: - qq_xuids.remove(xuid) - changed = True - if qq_xuids: - data["qq_to_xuids"][qq_key] = qq_xuids - else: - data["qq_to_xuids"].pop(qq_key, None) - - xuid_qqs = data["xuid_to_qqs"].get(xuid, []) - if qq_key in xuid_qqs: - xuid_qqs.remove(qq_key) - changed = True - if xuid_qqs: - data["xuid_to_qqs"][xuid] = xuid_qqs - else: - data["xuid_to_qqs"].pop(xuid, None) - data["xuid_names"].pop(xuid, None) - return changed - - def _cleanup_pending_bindings(self): - now = time.time() - with self.pending_bindings_lock: - expired_codes = [ - code - for code, pending in self.pending_bindings.items() - if now >= float(pending.get("expire_at", 0)) - ] - for code in expired_codes: - pending = self._pop_pending_binding(code) - if pending is not None: - self._send_binding_timeout_notice(pending) - - def _new_binding_code(self) -> str: - self._cleanup_pending_bindings() - with self.pending_bindings_lock: - for _ in range(20): - code = f"{secrets.randbelow(1000000):06d}" - if code not in self.pending_bindings: - return code - raise RuntimeError("无法生成唯一绑定验证码") - - def _remove_pending_bindings_by_qq(self, group_id: int, qqid: int): - with self.pending_bindings_lock: - codes = [ - code - for code, pending in self.pending_bindings.items() - if pending.get("group_id") == group_id and pending.get("qqid") == qqid - ] - for code in codes: - self._pop_pending_binding(code) - - def _pop_pending_binding(self, code: str, cancel_timer: bool = True): - with self.pending_bindings_lock: - pending = self.pending_bindings.pop(code, None) - timer = self.pending_binding_timers.pop(code, None) - if cancel_timer and timer is not None: - timer.cancel() - return pending - - def _schedule_binding_timeout(self, code: str, timeout_seconds: int): - timer = threading.Timer( - timeout_seconds, self._handle_binding_timeout, args=(code,)) - timer.daemon = True - with self.pending_bindings_lock: - self.pending_binding_timers[code] = timer - timer.start() - - def _handle_binding_timeout(self, code: str): - pending = self._pop_pending_binding(code, cancel_timer=False) - if pending is not None: - self._send_binding_timeout_notice(pending) - - def _send_binding_timeout_notice(self, pending: dict[str, Any]): - group_id = int(pending["group_id"]) - qqid = int(pending["qqid"]) - timeout_text = self._binding_text( - group_id, - "绑定超时提示文本", - "绑定超时,请重新获取验证码绑定", - ) - try: - self._reply_to_qq(group_id, qqid, timeout_text) - except Exception as err: - self.print_console_warn(f"绑定超时提示发送失败: {err}") - - def cleanup_binding_state(self): - with self.pending_bindings_lock: - timers = list(self.pending_binding_timers.values()) - self.pending_binding_timers.clear() - self.pending_bindings.clear() - for timer in timers: - timer.cancel() - - def _binding_cfg(self) -> dict[str, Any]: - cfg = self.cfg.get("绑定设置", {}) - if isinstance(cfg, dict): - return cfg - return self.binding_default() - - def _binding_enabled(self, group_id: int) -> bool: - return bool(self._binding_cfg().get("是否开启QQ号与游戏ID绑定功能", False)) - - def _binding_text(self, group_id: int, key: str, fallback: str) -> str: - value = self._binding_cfg().get(key, fallback) - text = str(value).strip() - return text or fallback - - def _binding_reject_text(self, group_id: int) -> str: - return self._binding_text( - group_id, - "拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)", - "您已有绑定账号,请解绑后再绑定", - ) - - def _binding_timeout_minutes(self, group_id: int) -> int: - return self._normalize_positive_int( - self._binding_cfg().get( - "绑定超时时间(单位:分钟)", - self.BINDING_TIMEOUT_MINUTES_DEFAULT, - ), - self.BINDING_TIMEOUT_MINUTES_DEFAULT, - ) - - def _render_binding_text( - self, - text: str, - code: str, - timeout_minutes: int) -> str: - return ( - text - .replace("{auth_code}", code) - .replace("{time}", str(timeout_minutes)) - ) - - def _qq_has_bound_xuid(self, qqid: int) -> bool: - data = self.read_binding_data() - return bool(data["qq_to_xuids"].get(str(qqid))) - - def get_group_binding_triggers(self, group_id: int) -> list[str]: - raw = self._binding_cfg().get("绑定触发词", ["绑定"]) - return self.normalize_string_triggers(raw, ["绑定"]) - - def _start_binding_request( - self, group_id: int, qqid: int) -> tuple[bool, str]: - if not self._binding_enabled(group_id): - return False, "QQ绑定功能当前已关闭" - - cfg = self._binding_cfg() - if ( - not bool(cfg.get("是否允许单QQ号可绑定多游戏ID", False)) - and self._qq_has_bound_xuid(qqid) - ): - return False, self._binding_reject_text(group_id) - - code = self._new_binding_code() - timeout_minutes = self._binding_timeout_minutes(group_id) - self._remove_pending_bindings_by_qq(group_id, qqid) - timeout_seconds = timeout_minutes * 60 - with self.pending_bindings_lock: - self.pending_bindings[code] = { - "group_id": group_id, - "qqid": qqid, - "expire_at": time.time() + timeout_seconds, - } - self._schedule_binding_timeout(code, timeout_seconds) - - self._reply_to_qq( - group_id, - qqid, - self._render_binding_text( - self._binding_text( - group_id, - "绑定验证码群聊提示文本", - "已将验证码发送至您的私信,请在{time}分钟内在游戏中发送验证码以完成绑定。", - ), - code, - timeout_minutes, - ), - ) - self.send_private_msg( - qqid, - self._render_binding_text( - self._binding_text( - group_id, - "绑定验证码私信提示文本", - "您的绑定验证码是:{auth_code}。请在{time}分钟内在游戏中发送该验证码已完成绑定。", - ), - code, - timeout_minutes, - ), - ) - return True, "绑定验证码已发送" - - def _handle_binding_trigger( - self, - group_id: int, - qqid: int, - clean_msg: str) -> bool: - if not self._binding_enabled(group_id): - return False - if clean_msg not in self.get_group_binding_triggers(group_id): - return False - - ok, message = self._start_binding_request(group_id, qqid) - if not ok: - self._reply_to_qq(group_id, qqid, message) - return True - - def send_private_msg(self, qqid: int, msg: str): - """向指定 QQ 发送私信。""" - if self.ws is None: - raise RuntimeError("WebSocket 尚未初始化") - if not self.available: - self._print_cloud_status( - "群服互通 云链连接", - "忽略发送", - ["当前未连接云链", f"已忽略发送到 QQ {qqid} 的私信"], - level="warn", - ) - return - payload = { - "action": "send_private_msg", - "params": {"user_id": qqid, "message": msg}, - } - self.ws.send(json.dumps(payload)) - - def consume_game_binding_code(self, chat) -> bool: - msg = str(chat.msg).strip() - if len(msg) != 6 or not msg.isdigit(): - return False - - pending = self._pop_pending_binding(msg) - if pending is None: - self._cleanup_pending_bindings() - return False - - group_id = int(pending["group_id"]) - qqid = int(pending["qqid"]) - if time.time() >= float(pending.get("expire_at", 0)): - timeout_text = self._binding_text( - group_id, - "绑定超时提示文本", - "绑定超时,请重新获取验证码绑定", - ) - chat.player.show(f"§c{timeout_text}") - self._reply_to_qq(group_id, qqid, timeout_text) - return True - - if not self._binding_enabled(group_id): - chat.player.show("§cQQ绑定功能当前已关闭") - return True - - player_name = chat.player.name - xuid = str(getattr(chat.player, "xuid", "")).strip() - if not xuid: - chat.player.show("§c无法获取你的 XUID,绑定失败") - self._reply_to_qq(group_id, qqid, "绑定失败:无法获取玩家 XUID") - return True - - ok, message = self._bind_qq_to_xuid(group_id, qqid, xuid, player_name) - if not ok: - chat.player.show(f"§c{message}") - self._reply_to_qq(group_id, qqid, message) - return True - - chat.player.show("§aQQ绑定成功") - success_text = self._binding_text( - group_id, - "绑定成功提示文本", - "恭喜你绑定成功,您的游戏ID为:{player_name}。", - ) - self._reply_to_qq( - group_id, - qqid, - success_text - .replace("{player_name}", player_name) - .replace("{xuid}", xuid) - .replace("{qq}", str(qqid)), - ) - return True - - def _bind_qq_to_xuid( - self, - group_id: int, - qqid: int, - xuid: str, - player_name: str): - cfg = self._binding_cfg() - allow_multi_xuid = bool(cfg.get("是否允许单QQ号可绑定多游戏ID", False)) - allow_multi_qq = bool(cfg.get("是否允许单游戏ID可绑定多QQ号", False)) - - data = self.read_binding_data() - qq_key = str(qqid) - qq_xuids = data["qq_to_xuids"].setdefault(qq_key, []) - xuid_qqs = data["xuid_to_qqs"].setdefault(xuid, []) - - if xuid in qq_xuids and qq_key in xuid_qqs: - data["xuid_names"][xuid] = player_name - self.save_binding_data(data) - return True, "绑定关系已存在,已刷新玩家名" - - if not allow_multi_xuid and qq_xuids and xuid not in qq_xuids: - return False, self._binding_reject_text(group_id) - if not allow_multi_qq and xuid_qqs and qq_key not in xuid_qqs: - return False, "绑定失败:该游戏ID已绑定其他 QQ 号" - - if xuid not in qq_xuids: - qq_xuids.append(xuid) - if qq_key not in xuid_qqs: - xuid_qqs.append(qq_key) - data["xuid_names"][xuid] = player_name - self.save_binding_data(data) - return True, "绑定成功" +"""QQ account and game XUID binding support.""" + +from __future__ import annotations + +import json +import os +import secrets +import threading +import time +from copy import deepcopy +from typing import Any + + +class QQLinkerBindingMixin: + """Handles QQ-to-game binding verification and persistence.""" + + BINDING_TIMEOUT_MINUTES_DEFAULT = 10 + + def init_binding_state(self): + """Implement the init binding state operation.""" + self.binding_data_path = self.format_data_path("QQ绑定数据.json") + self.pending_bindings: dict[str, dict[str, Any]] = {} + self.pending_binding_timers: dict[str, threading.Timer] = {} + self.pending_bindings_lock = threading.RLock() + self._ensure_binding_data() + + @staticmethod + def _binding_default_data() -> dict[str, dict[str, Any]]: + """Implement the binding default data operation.""" + return {"qq_to_xuids": {}, "xuid_to_qqs": {}, "xuid_names": {}} + + def _ensure_binding_data(self): + """Implement the ensure binding data operation.""" + data = self.read_binding_data() + self.save_binding_data(data) + + def read_binding_data(self) -> dict[str, dict[str, Any]]: + """Implement the read binding data operation.""" + if not os.path.isfile(self.binding_data_path): + return self._binding_default_data() + try: + with open(self.binding_data_path, "r", encoding="utf-8") as file: + raw = json.load(file) + except Exception: + raw = {} + + data = self._binding_default_data() + if isinstance(raw, dict): + data["qq_to_xuids"] = self._normalize_binding_map( + raw.get("qq_to_xuids")) + data["xuid_to_qqs"] = self._normalize_binding_map( + raw.get("xuid_to_qqs")) + names = raw.get("xuid_names", {}) + if isinstance(names, dict): + data["xuid_names"] = { + str(xuid): str(name) + for xuid, name in names.items() + if str(xuid).strip() and str(name).strip() + } + return data + + @staticmethod + def _normalize_binding_map(raw: Any) -> dict[str, list[str]]: + """Normalize binding map values.""" + result: dict[str, list[str]] = {} + if not isinstance(raw, dict): + return result + for key, values in raw.items(): + skey = str(key).strip() + if not skey: + continue + if not isinstance(values, list): + values = [values] + normalized: list[str] = [] + for value in values: + svalue = str(value).strip() + if svalue and svalue not in normalized: + normalized.append(svalue) + result[skey] = normalized + return result + + def save_binding_data(self, data: dict[str, dict[str, Any]]): + """Save binding data data.""" + with open(self.binding_data_path, "w", encoding="utf-8") as file: + json.dump(data, file, ensure_ascii=False, indent=2) + + @staticmethod + def _binding_qq_key(qqid: int | str) -> str: + """Implement the binding qq key operation.""" + text = str(qqid).strip() + if not text: + return "" + try: + value = int(text) + except ValueError: + return text + return str(value) if value > 0 else "" + + @staticmethod + def _binding_xuid_key(xuid: str) -> str: + """Implement the binding xuid key operation.""" + return str(xuid).strip() + + @staticmethod + def _binding_qq_values(values: list[str]) -> list[int]: + """Implement the binding qq values operation.""" + result: list[int] = [] + for value in values: + try: + qqid = int(str(value).strip()) + except ValueError: + continue + if qqid > 0 and qqid not in result: + result.append(qqid) + return result + + def _binding_api_group_id(self, group_id: int | None = None) -> int: + """Implement the binding api group id operation.""" + if group_id is not None: + return int(group_id) + return int(self.linked_group or 0) + + def api_get_binding_data(self) -> dict[str, dict[str, Any]]: + """Return a normalized copy of all QQ/XUID binding data.""" + return deepcopy(self.read_binding_data()) + + def api_get_all_bindings(self) -> list[dict[str, Any]]: + """Return binding relations as flat records for easier iteration.""" + data = self.read_binding_data() + records: list[dict[str, Any]] = [] + for qq_key, xuids in data["qq_to_xuids"].items(): + try: + qq_value: int | str = int(qq_key) + except ValueError: + qq_value = qq_key + for xuid in xuids: + records.append( + { + "qq": qq_value, + "xuid": xuid, + "player_name": data["xuid_names"].get(xuid, ""), + } + ) + return records + + def api_get_xuids_by_qq(self, qqid: int | str) -> list[str]: + """Return all XUIDs bound to a QQ number.""" + qq_key = self._binding_qq_key(qqid) + if not qq_key: + return [] + return list(self.read_binding_data()["qq_to_xuids"].get(qq_key, [])) + + def api_get_qqs_by_xuid(self, xuid: str) -> list[int]: + """Return all QQ numbers bound to an XUID.""" + xuid_key = self._binding_xuid_key(xuid) + if not xuid_key: + return [] + values = self.read_binding_data()["xuid_to_qqs"].get(xuid_key, []) + return self._binding_qq_values(values) + + def api_get_player_name_by_xuid(self, xuid: str) -> str | None: + """Return the latest recorded player name for an XUID.""" + xuid_key = self._binding_xuid_key(xuid) + if not xuid_key: + return None + return self.read_binding_data()["xuid_names"].get(xuid_key) + + def api_get_bound_players_by_qq( + self, qqid: int | str) -> list[dict[str, str]]: + """Return player records bound to a QQ number.""" + data = self.read_binding_data() + result: list[dict[str, str]] = [] + for xuid in data["qq_to_xuids"].get(self._binding_qq_key(qqid), []): + result.append( + { + "xuid": xuid, + "player_name": data["xuid_names"].get(xuid, ""), + } + ) + return result + + def api_get_bound_qqs_by_xuid(self, xuid: str) -> list[dict[str, Any]]: + """Return QQ records bound to an XUID.""" + xuid_key = self._binding_xuid_key(xuid) + if not xuid_key: + return [] + data = self.read_binding_data() + player_name = data["xuid_names"].get(xuid_key, "") + return [ + {"qq": qqid, "xuid": xuid_key, "player_name": player_name} + for qqid in self._binding_qq_values(data["xuid_to_qqs"].get(xuid_key, [])) + ] + + def api_get_xuids_by_player_name( + self, + player_name: str, + ignore_case: bool = True, + ) -> list[str]: + """Return XUIDs whose latest recorded player name matches.""" + name = str(player_name).strip() + if not name: + return [] + data = self.read_binding_data() + if ignore_case: + name = name.lower() + return [ + xuid + for xuid, stored_name in data["xuid_names"].items() + if stored_name.lower() == name + ] + return [ + xuid + for xuid, stored_name in data["xuid_names"].items() + if stored_name == name + ] + + def api_get_qqs_by_player_name( + self, + player_name: str, + ignore_case: bool = True, + ) -> list[int]: + """Return QQ numbers bound to matching player names.""" + qqids: list[int] = [] + for xuid in self.api_get_xuids_by_player_name( + player_name, ignore_case): + for qqid in self.api_get_qqs_by_xuid(xuid): + if qqid not in qqids: + qqids.append(qqid) + return qqids + + def api_is_binding_enabled(self, group_id: int | None = None) -> bool: + """Return whether QQ/game binding is enabled in config.""" + return self._binding_enabled(self._binding_api_group_id(group_id)) + + def api_is_qq_bound(self, qqid: int | str) -> bool: + """Return whether a QQ number has any binding.""" + return bool(self.api_get_xuids_by_qq(qqid)) + + def api_is_xuid_bound(self, xuid: str) -> bool: + """Return whether an XUID has any binding.""" + return bool(self.api_get_qqs_by_xuid(xuid)) + + def api_is_qq_bound_to_xuid(self, qqid: int | str, xuid: str) -> bool: + """Return whether the exact QQ/XUID relation exists.""" + return self._binding_xuid_key(xuid) in self.api_get_xuids_by_qq(qqid) + + def api_bind_qq_to_xuid( + self, + qqid: int | str, + xuid: str, + player_name: str = "", + group_id: int | None = None, + ) -> tuple[bool, str]: + """Create or refresh a QQ/XUID binding, respecting multi-bind config.""" + qq_key = self._binding_qq_key(qqid) + xuid_key = self._binding_xuid_key(xuid) + if not qq_key or not qq_key.isdigit(): + return False, "QQ号无效" + if not xuid_key: + return False, "XUID不能为空" + name = str(player_name).strip() or xuid_key + return self._bind_qq_to_xuid( + self._binding_api_group_id(group_id), + int(qq_key), + xuid_key, + name, + ) + + def api_unbind_qq_from_xuid( + self, qqid: int | str, xuid: str) -> tuple[bool, str]: + """Remove one exact QQ/XUID binding relation.""" + qq_key = self._binding_qq_key(qqid) + xuid_key = self._binding_xuid_key(xuid) + if not qq_key or not qq_key.isdigit(): + return False, "QQ号无效" + if not xuid_key: + return False, "XUID不能为空" + data = self.read_binding_data() + if not self._remove_binding_relation(data, qq_key, xuid_key): + return False, "绑定关系不存在" + self.save_binding_data(data) + return True, "已解绑" + + def api_unbind_all_by_qq(self, qqid: int | str) -> tuple[bool, str]: + """Remove all bindings owned by one QQ number.""" + qq_key = self._binding_qq_key(qqid) + if not qq_key or not qq_key.isdigit(): + return False, "QQ号无效" + data = self.read_binding_data() + xuids = list(data["qq_to_xuids"].get(qq_key, [])) + if not xuids: + return False, "该 QQ 没有绑定记录" + for xuid in xuids: + self._remove_binding_relation(data, qq_key, xuid) + self.save_binding_data(data) + return True, f"已解绑 {len(xuids)} 个游戏ID" + + def api_unbind_all_by_xuid(self, xuid: str) -> tuple[bool, str]: + """Remove all QQ bindings owned by one XUID.""" + xuid_key = self._binding_xuid_key(xuid) + if not xuid_key: + return False, "XUID不能为空" + data = self.read_binding_data() + qqs = list(data["xuid_to_qqs"].get(xuid_key, [])) + if not qqs: + return False, "该 XUID 没有绑定记录" + for qq_key in qqs: + self._remove_binding_relation(data, qq_key, xuid_key) + self.save_binding_data(data) + return True, f"已解绑 {len(qqs)} 个QQ号" + + def api_update_xuid_player_name( + self, + xuid: str, + player_name: str, + ) -> tuple[bool, str]: + """Update the latest recorded player name for a bound XUID.""" + xuid_key = self._binding_xuid_key(xuid) + name = str(player_name).strip() + if not xuid_key: + return False, "XUID不能为空" + if not name: + return False, "玩家名不能为空" + data = self.read_binding_data() + if xuid_key not in data["xuid_to_qqs"]: + return False, "该 XUID 没有绑定记录" + data["xuid_names"][xuid_key] = name + self.save_binding_data(data) + return True, "已更新玩家名" + + def api_start_binding_request( + self, + group_id: int, + qqid: int | str, + ) -> tuple[bool, str]: + """Start the normal QQ binding verification flow for a group member.""" + qq_key = self._binding_qq_key(qqid) + if not qq_key or not qq_key.isdigit(): + return False, "QQ号无效" + try: + group_id = int(group_id) + except (TypeError, ValueError): + return False, "群号无效" + if group_id not in self.group_cfgs: + return False, "该群未配置群服互通" + try: + ok, message = self._start_binding_request( + group_id, + int(qq_key), + ) + except Exception as err: + return False, f"绑定请求创建失败: {err}" + return ok, message + + def _remove_binding_relation( + self, + data: dict[str, dict[str, Any]], + qq_key: str, + xuid: str, + ) -> bool: + """Implement the remove binding relation operation.""" + changed = False + qq_xuids = data["qq_to_xuids"].get(qq_key, []) + if xuid in qq_xuids: + qq_xuids.remove(xuid) + changed = True + if qq_xuids: + data["qq_to_xuids"][qq_key] = qq_xuids + else: + data["qq_to_xuids"].pop(qq_key, None) + + xuid_qqs = data["xuid_to_qqs"].get(xuid, []) + if qq_key in xuid_qqs: + xuid_qqs.remove(qq_key) + changed = True + if xuid_qqs: + data["xuid_to_qqs"][xuid] = xuid_qqs + else: + data["xuid_to_qqs"].pop(xuid, None) + data["xuid_names"].pop(xuid, None) + return changed + + def _cleanup_pending_bindings(self): + """Implement the cleanup pending bindings operation.""" + now = time.time() + with self.pending_bindings_lock: + expired_codes = [ + code + for code, pending in self.pending_bindings.items() + if now >= float(pending.get("expire_at", 0)) + ] + for code in expired_codes: + pending = self._pop_pending_binding(code) + if pending is not None: + self._send_binding_timeout_notice(pending) + + def _new_binding_code(self) -> str: + """Implement the new binding code operation.""" + self._cleanup_pending_bindings() + with self.pending_bindings_lock: + for _ in range(20): + code = f"{secrets.randbelow(1000000):06d}" + if code not in self.pending_bindings: + return code + raise RuntimeError("无法生成唯一绑定验证码") + + def _remove_pending_bindings_by_qq(self, group_id: int, qqid: int): + """Implement the remove pending bindings by qq operation.""" + with self.pending_bindings_lock: + codes = [ + code + for code, pending in self.pending_bindings.items() + if pending.get("group_id") == group_id and pending.get("qqid") == qqid + ] + for code in codes: + self._pop_pending_binding(code) + + def _pop_pending_binding(self, code: str, cancel_timer: bool = True): + """Implement the pop pending binding operation.""" + with self.pending_bindings_lock: + pending = self.pending_bindings.pop(code, None) + timer = self.pending_binding_timers.pop(code, None) + if cancel_timer and timer is not None: + timer.cancel() + return pending + + def _schedule_binding_timeout(self, code: str, timeout_seconds: int): + """Implement the schedule binding timeout operation.""" + timer = threading.Timer( + timeout_seconds, self._handle_binding_timeout, args=(code,)) + timer.daemon = True + with self.pending_bindings_lock: + self.pending_binding_timers[code] = timer + timer.start() + + def _handle_binding_timeout(self, code: str): + """Handle the binding timeout workflow.""" + pending = self._pop_pending_binding(code, cancel_timer=False) + if pending is not None: + self._send_binding_timeout_notice(pending) + + def _send_binding_timeout_notice(self, pending: dict[str, Any]): + """Implement the send binding timeout notice operation.""" + group_id = int(pending["group_id"]) + qqid = int(pending["qqid"]) + timeout_text = self._binding_text( + group_id, + "绑定超时提示文本", + "绑定超时,请重新获取验证码绑定", + ) + try: + self._reply_to_qq(group_id, qqid, timeout_text) + except Exception as err: + self.print_console_warn(f"绑定超时提示发送失败: {err}") + + def cleanup_binding_state(self): + """Implement the cleanup binding state operation.""" + with self.pending_bindings_lock: + timers = list(self.pending_binding_timers.values()) + self.pending_binding_timers.clear() + self.pending_bindings.clear() + for timer in timers: + timer.cancel() + + def _binding_cfg(self) -> dict[str, Any]: + """Implement the binding cfg operation.""" + cfg = self.cfg.get("绑定设置", {}) + if isinstance(cfg, dict): + return cfg + return self.binding_default() + + def _binding_enabled(self, group_id: int) -> bool: + """Implement the binding enabled operation.""" + return bool(self._binding_cfg().get("是否开启QQ号与游戏ID绑定功能", False)) + + def _binding_text(self, group_id: int, key: str, fallback: str) -> str: + """Implement the binding text operation.""" + value = self._binding_cfg().get(key, fallback) + text = str(value).strip() + return text or fallback + + def _binding_reject_text(self, group_id: int) -> str: + """Implement the binding reject text operation.""" + return self._binding_text( + group_id, + "拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)", + "您已有绑定账号,请解绑后再绑定", + ) + + def _binding_timeout_minutes(self, group_id: int) -> int: + """Implement the binding timeout minutes operation.""" + return self._normalize_positive_int( + self._binding_cfg().get( + "绑定超时时间(单位:分钟)", + self.BINDING_TIMEOUT_MINUTES_DEFAULT, + ), + self.BINDING_TIMEOUT_MINUTES_DEFAULT, + ) + + def _render_binding_text( + self, + text: str, + code: str, + timeout_minutes: int) -> str: + """Implement the render binding text operation.""" + return ( + text + .replace("{auth_code}", code) + .replace("{time}", str(timeout_minutes)) + ) + + def _qq_has_bound_xuid(self, qqid: int) -> bool: + """Implement the qq has bound xuid operation.""" + data = self.read_binding_data() + return bool(data["qq_to_xuids"].get(str(qqid))) + + def get_group_binding_triggers(self, group_id: int) -> list[str]: + """Return group binding triggers data.""" + raw = self._binding_cfg().get("绑定触发词", ["绑定"]) + return self.normalize_string_triggers(raw, ["绑定"]) + + def _start_binding_request( + self, group_id: int, qqid: int) -> tuple[bool, str]: + """Implement the start binding request operation.""" + if not self._binding_enabled(group_id): + return False, "QQ绑定功能当前已关闭" + + cfg = self._binding_cfg() + if ( + not bool(cfg.get("是否允许单QQ号可绑定多游戏ID", False)) + and self._qq_has_bound_xuid(qqid) + ): + return False, self._binding_reject_text(group_id) + + code = self._new_binding_code() + timeout_minutes = self._binding_timeout_minutes(group_id) + self._remove_pending_bindings_by_qq(group_id, qqid) + timeout_seconds = timeout_minutes * 60 + with self.pending_bindings_lock: + self.pending_bindings[code] = { + "group_id": group_id, + "qqid": qqid, + "expire_at": time.time() + timeout_seconds, + } + self._schedule_binding_timeout(code, timeout_seconds) + + self._reply_to_qq( + group_id, + qqid, + self._render_binding_text( + self._binding_text( + group_id, + "绑定验证码群聊提示文本", + "已将验证码发送至您的私信,请在{time}分钟内在游戏中发送验证码以完成绑定。", + ), + code, + timeout_minutes, + ), + ) + self.send_private_msg( + qqid, + self._render_binding_text( + self._binding_text( + group_id, + "绑定验证码私信提示文本", + "您的绑定验证码是:{auth_code}。请在{time}分钟内在游戏中发送该验证码已完成绑定。", + ), + code, + timeout_minutes, + ), + ) + return True, "绑定验证码已发送" + + def _handle_binding_trigger( + self, + group_id: int, + qqid: int, + clean_msg: str) -> bool: + """Handle the binding trigger workflow.""" + if not self._binding_enabled(group_id): + return False + if clean_msg not in self.get_group_binding_triggers(group_id): + return False + + ok, message = self._start_binding_request(group_id, qqid) + if not ok: + self._reply_to_qq(group_id, qqid, message) + return True + + def send_private_msg(self, qqid: int, msg: str): + """向指定 QQ 发送私信。""" + if self.ws is None: + raise RuntimeError("WebSocket 尚未初始化") + if not self.available: + self._print_cloud_status( + "群服互通 云链连接", + "忽略发送", + ["当前未连接云链", f"已忽略发送到 QQ {qqid} 的私信"], + level="warn", + ) + return + payload = { + "action": "send_private_msg", + "params": {"user_id": qqid, "message": msg}, + } + self.ws.send(json.dumps(payload)) + + def consume_game_binding_code(self, chat) -> bool: + """Implement the consume game binding code operation.""" + msg = str(chat.msg).strip() + if len(msg) != 6 or not msg.isdigit(): + return False + + pending = self._pop_pending_binding(msg) + if pending is None: + self._cleanup_pending_bindings() + return False + + group_id = int(pending["group_id"]) + qqid = int(pending["qqid"]) + if time.time() >= float(pending.get("expire_at", 0)): + timeout_text = self._binding_text( + group_id, + "绑定超时提示文本", + "绑定超时,请重新获取验证码绑定", + ) + chat.player.show(f"§c{timeout_text}") + self._reply_to_qq(group_id, qqid, timeout_text) + return True + + if not self._binding_enabled(group_id): + chat.player.show("§cQQ绑定功能当前已关闭") + return True + + player_name = chat.player.name + xuid = str(getattr(chat.player, "xuid", "")).strip() + if not xuid: + chat.player.show("§c无法获取你的 XUID,绑定失败") + self._reply_to_qq(group_id, qqid, "绑定失败:无法获取玩家 XUID") + return True + + ok, message = self._bind_qq_to_xuid(group_id, qqid, xuid, player_name) + if not ok: + chat.player.show(f"§c{message}") + self._reply_to_qq(group_id, qqid, message) + return True + + chat.player.show("§aQQ绑定成功") + success_text = self._binding_text( + group_id, + "绑定成功提示文本", + "恭喜你绑定成功,您的游戏ID为:{player_name}。", + ) + self._reply_to_qq( + group_id, + qqid, + success_text + .replace("{player_name}", player_name) + .replace("{xuid}", xuid) + .replace("{qq}", str(qqid)), + ) + return True + + def _bind_qq_to_xuid( + self, + group_id: int, + qqid: int, + xuid: str, + player_name: str): + """Implement the bind qq to xuid operation.""" + cfg = self._binding_cfg() + allow_multi_xuid = bool(cfg.get("是否允许单QQ号可绑定多游戏ID", False)) + allow_multi_qq = bool(cfg.get("是否允许单游戏ID可绑定多QQ号", False)) + + data = self.read_binding_data() + qq_key = str(qqid) + qq_xuids = data["qq_to_xuids"].setdefault(qq_key, []) + xuid_qqs = data["xuid_to_qqs"].setdefault(xuid, []) + + if xuid in qq_xuids and qq_key in xuid_qqs: + data["xuid_names"][xuid] = player_name + self.save_binding_data(data) + return True, "绑定关系已存在,已刷新玩家名" + + if not allow_multi_xuid and qq_xuids and xuid not in qq_xuids: + return False, self._binding_reject_text(group_id) + if not allow_multi_qq and xuid_qqs and qq_key not in xuid_qqs: + return False, "绑定失败:该游戏ID已绑定其他 QQ 号" + + if xuid not in qq_xuids: + qq_xuids.append(xuid) + if qq_key not in xuid_qqs: + xuid_qqs.append(qq_key) + data["xuid_names"][xuid] = player_name + self.save_binding_data(data) + return True, "绑定成功" diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" index 815544de..dfbdcf54 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" @@ -1,611 +1,641 @@ -"""Q 群与控制台配置编辑中心。""" - -import json -import os -import shutil -import time -from typing import Any - -from tooldelta import cfg, fmts - - -class QQLinkerConfigEditorMixin: - """提供 Ultra 版和联动插件配置的菜单化编辑能力。""" - - CONFIG_MENU_TRIGGERS = ["配置中心", "配置菜单", "群服配置"] - CONFIG_FILE_DIR = "插件配置文件" - CONFIG_BACKUP_DIR = "配置文件备份" - CONFIG_BACKUP_INDEX = "backups.json" - CONFIG_EXIT = object() - CONFIG_BACK = object() - - def get_group_config_menu_triggers(self, group_id: int): - group_cfg = self.group_cfgs.get(group_id) - if not group_cfg: - return list(self.CONFIG_MENU_TRIGGERS) - raw = group_cfg["指令设置"].get("配置中心唤醒词", self.CONFIG_MENU_TRIGGERS) - return self.normalize_string_triggers(raw, self.CONFIG_MENU_TRIGGERS) - - def qq_config_center_menu(self, group_id: int, qqid: int): - """Q 群侧配置中心入口。""" - if not self._can_use_group_permission(group_id, qqid, "配置配置文件权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - self._config_center_menu( - {"mode": "qq", "group_id": group_id, "qqid": qqid}) - - def on_console_config_center(self, _args: list[str]): - """控制台侧配置中心入口。""" - self._config_center_menu({"mode": "console"}) - - def _config_group_id(self, ctx: dict[str, Any]) -> int | None: - return int(ctx["group_id"]) if ctx.get( - "mode") == "qq" and "group_id" in ctx else None - - def _config_exit_hint( - self, ctx: dict[str, Any], action: str = "退出") -> str: - return self.menu_exit_hint(self._config_group_id(ctx), action) - - def _config_back_hint( - self, ctx: dict[str, Any], action: str = "返回上级菜单") -> str: - return self.menu_back_hint(self._config_group_id(ctx), action) - - def _config_normalize_control_hints( - self, - ctx: dict[str, Any], - hints: list[str], - ) -> list[str]: - normalized: list[str] = [] - for hint in hints: - if hint == "输入 . 退出": - normalized.append(self._config_exit_hint(ctx)) - elif hint == "输入 . 取消": - normalized.append(self._config_back_hint(ctx, "取消")) - elif hint == "输入 0 返回上级": - normalized.append(self._config_back_hint(ctx, "返回上级")) - elif hint == "输入 0 返回上级菜单": - normalized.append(self._config_back_hint(ctx)) - else: - normalized.append(hint) - return normalized - - def _config_center_menu(self, ctx: dict[str, Any]): - while True: - options = [ - "模式1:整文件修改配置", - "模式2:暂未开放", - ] - choice = self._config_prompt( - ctx, - "配置中心", - options, - [f"输入 [1-{len(options)}] 选择配置模式", "输入 . 退出"], - ) - if choice is self.CONFIG_EXIT: - return - selected = self.parse_displayed_menu_choice(choice, len(options)) - if selected is None: - self._config_error(ctx, "输入有误") - continue - if selected == 1: - result = self._config_file_mode_menu(ctx) - if result is self.CONFIG_EXIT: - return - continue - if selected == 2: - self._config_error(ctx, "模式2暂未开放") - continue - - def _config_file_mode_menu(self, ctx: dict[str, Any]): - while True: - choice = self._config_prompt( - ctx, - "模式1:整文件修改", - ["修改配置文件", "还原配置文件备份"], - ["输入 [1-2] 选择操作", "输入 0 返回上级", "输入 . 退出"], - allow_back=True, - ) - if choice is self.CONFIG_EXIT: - return self.CONFIG_EXIT - if choice is self.CONFIG_BACK: - return - if choice == "1": - result = self._config_file_select_menu(ctx) - if result is self.CONFIG_EXIT: - return self.CONFIG_EXIT - continue - if choice == "2": - result = self._config_restore_backup_menu(ctx) - if result is self.CONFIG_EXIT: - return self.CONFIG_EXIT - continue - self._config_error(ctx, "输入有误") - - def _config_file_select_menu(self, ctx: dict[str, Any]): - page = 1 - while True: - files = self._discover_config_files() - if not files: - self._config_error( - ctx, f"未找到 { - self.CONFIG_FILE_DIR}/*.json 配置文件") - return - per_page = self.get_group_config_file_items_per_page( - self._config_group_id(ctx)) - total_pages, start_index, end_index = self.simple_paginate( - len(files), - per_page, - page, - ) - page = min(page, total_pages) - page_files = files[start_index - 1: end_index] - options = [item["display"] for item in page_files] - choice = self._config_prompt( - ctx, - "选择配置文件", - options, - [ - f"当前第 {page}/{total_pages} 页", - f"输入 [1-{len(options)}] 选择要整文件修改的配置", - "输入 - 转到上一页", - "输入 + 转到下一页", - "输入 正整数+页 转到对应页", - "输入 0 返回上级", - "输入 . 退出", - ], - allow_back=True, - ) - if choice is self.CONFIG_EXIT: - return self.CONFIG_EXIT - if choice is self.CONFIG_BACK: - return - if choice == "+": - if page < total_pages: - page += 1 - else: - self._config_error(ctx, "已经是最后一页啦~") - continue - if choice == "-": - if page > 1: - page -= 1 - else: - self._config_error(ctx, "已经是第一页啦~") - continue - if page_num := self.parse_page_jump(choice): - if 1 <= page_num <= total_pages: - page = page_num - else: - self._config_error(ctx, f"不存在第 {page_num} 页") - continue - selected = self.parse_displayed_menu_choice( - choice, len(page_files)) - if selected is None: - self._config_error(ctx, "输入有误") - continue - result = self._edit_config_file_whole( - ctx, files[start_index + selected - 2]) - if result is self.CONFIG_EXIT: - return self.CONFIG_EXIT - - def _edit_config_file_whole( - self, ctx: dict[str, Any], item: dict[str, str]): - try: - with open(item["path"], "r", encoding="utf-8-sig") as file: - content = file.read() - except Exception as err: - self._config_error(ctx, f"读取配置文件失败: {err}") - return - try: - original_config = json.loads(content) - except json.JSONDecodeError: - original_config = None - - if ctx["mode"] == "qq": - prompt_text = self._config_whole_file_prompt_text( - ctx, - item["name"], - content, - ) - raw = self._config_input_result( - ctx, - self.qq_prompt( - ctx["group_id"], - ctx["qqid"], - prompt_text, - timeout=600), - ) - else: - self.print_console_card( - "群服互通 配置中心", - f"整文件修改 / {item['name']}", - [ - "当前配置文件内容如下,复制并修改后粘贴回控制台", - "机器人会在替换前自动备份原配置文件", - "多行 JSON 可直接粘贴,读取到完整 JSON 后会自动提交", - "输入 END 单独一行可强制提交并检查格式", - self._config_back_hint(ctx, "返回上级"), - self._config_exit_hint(ctx, "退出本次修改"), - content, - ], - level="info", - ) - raw = self._read_console_config_json_text(ctx) - - if raw is self.CONFIG_EXIT: - return self.CONFIG_EXIT - if raw is self.CONFIG_BACK: - return - - try: - parsed = json.loads(self._normalize_config_json_text(str(raw))) - except json.JSONDecodeError as err: - self._config_error(ctx, f"JSON 格式错误,未替换配置文件: {err}") - return - - if not isinstance(parsed, dict): - self._config_error(ctx, "配置文件根节点必须是 JSON 对象,未替换配置文件") - return - if not self._config_file_shape_matches(original_config, parsed): - self._config_error(ctx, "请发送完整配置文件,不能只发送配置项内容") - return - - try: - if not self._is_safe_config_path(item["path"]): - self._config_error(ctx, "配置文件路径不在允许的插件配置目录内") - return - backup = self._backup_config_file(item) - with open(item["path"], "w", encoding="utf-8") as file: - json.dump(parsed, file, ensure_ascii=False, indent=4) - file.write("\n") - except Exception as err: - self._config_error(ctx, f"替换配置文件失败: {err}") - return - - apply_msg = self._apply_runtime_config_file(item, parsed) - self._config_success( - ctx, - f"配置文件已替换,备份编号 {backup['id']}。{apply_msg}", - ) - - def _config_whole_file_prompt_text( - self, - ctx: dict[str, Any], - config_name: str, - content: str, - ) -> str: - parts = [ - self.orion_ui_border(), - f"❐ 『群服互通云链版Ultra版』 整文件修改 / {config_name}", - "❀ 请复制下面的完整 JSON,修改后作为下一条消息发送", - "❀ 机器人会在替换前自动备份原配置文件", - f"❀ {self._config_back_hint(ctx, '返回上级')}", - f"❀ {self._config_exit_hint(ctx, '退出本次修改')}", - self.orion_ui_border(), - content, - ] - return "\n".join(parts) - - def _config_restore_backup_menu(self, ctx: dict[str, Any]): - while True: - backups = self._load_config_backup_index() - backups = [item for item in backups if os.path.isfile( - item.get("backup_path", ""))] - if not backups: - self._config_error(ctx, "暂无可还原的配置文件备份") - return - backups = list(reversed(backups[-30:])) - options = [ - f"{item['id']} / {item['config_name']} / {item.get('created_at', '')}" - for item in backups - ] - choice = self._config_prompt( - ctx, - "还原配置文件备份", - options, - [ - f"输入 [1-{len(options)}] 选择要还原的备份", - "还原前也会备份当前配置文件", - "输入 0 返回上级", - "输入 . 退出", - ], - allow_back=True, - ) - if choice is self.CONFIG_EXIT: - return self.CONFIG_EXIT - if choice is self.CONFIG_BACK: - return - selected = self.parse_displayed_menu_choice(choice, len(backups)) - if selected is None: - self._config_error(ctx, "输入有误") - continue - self._restore_config_backup(ctx, backups[selected - 1]) - return - - def _restore_config_backup( - self, ctx: dict[str, Any], backup: dict[str, str]): - current_item = { - "name": backup["config_name"], - "path": backup["original_path"], - "display": backup["config_name"], - } - try: - if not self._is_safe_config_path(current_item["path"]): - self._config_error(ctx, "备份记录指向的配置文件路径不在允许目录内") - return - if not self._is_safe_backup_path(backup["backup_path"]): - self._config_error(ctx, "备份文件路径不在允许的备份目录内") - return - if os.path.isfile(current_item["path"]): - self._backup_config_file(current_item, reason="restore-before") - os.makedirs(os.path.dirname(current_item["path"]), exist_ok=True) - shutil.copy2(backup["backup_path"], current_item["path"]) - with open(current_item["path"], "r", encoding="utf-8-sig") as file: - restored = json.load(file) - except Exception as err: - self._config_error(ctx, f"还原配置文件失败: {err}") - return - apply_msg = self._apply_runtime_config_file(current_item, restored) - self._config_success(ctx, f"已还原备份 {backup['id']}。{apply_msg}") - - def _discover_config_files(self) -> list[dict[str, str]]: - cfg_dir = os.path.abspath(self.CONFIG_FILE_DIR) - if not os.path.isdir(cfg_dir): - return [] - files: list[dict[str, str]] = [] - for name in sorted(os.listdir(cfg_dir), key=str.lower): - path = os.path.join(cfg_dir, name) - if not os.path.isfile(path) or not name.lower().endswith(".json"): - continue - config_name = os.path.splitext(name)[0] - files.append( - { - "name": config_name, - "path": path, - "display": f"{config_name} ({os.path.relpath(path)})", - } - ) - return files - - def _config_backup_root(self) -> str: - path = self.format_data_path(self.CONFIG_BACKUP_DIR) - os.makedirs(path, exist_ok=True) - return path - - def _config_backup_index_path(self) -> str: - return os.path.join( - self._config_backup_root(), - self.CONFIG_BACKUP_INDEX) - - def _load_config_backup_index(self) -> list[dict[str, str]]: - path = self._config_backup_index_path() - if not os.path.isfile(path): - return [] - try: - with open(path, "r", encoding="utf-8") as file: - data = json.load(file) - except Exception: - return [] - if not isinstance(data, list): - return [] - return [item for item in data if isinstance(item, dict)] - - def _save_config_backup_index(self, backups: list[dict[str, str]]): - path = self._config_backup_index_path() - with open(path, "w", encoding="utf-8") as file: - json.dump(backups[-100:], file, ensure_ascii=False, indent=2) - file.write("\n") - - def _backup_config_file( - self, - item: dict[str, str], - reason: str = "replace-before", - ) -> dict[str, str]: - if not self._is_safe_config_path(item["path"]): - raise ValueError("配置文件路径不在允许的插件配置目录内") - created_at = time.strftime("%Y-%m-%d %H:%M:%S") - stamp = time.strftime("%Y%m%d-%H%M%S") + \ - f"-{int(time.time() * 1000) % 1000:03d}" - safe_name = self._safe_backup_name(item["name"]) - backup_id = f"{stamp}-{safe_name}" - backup_dir = os.path.join(self._config_backup_root(), safe_name) - os.makedirs(backup_dir, exist_ok=True) - backup_path = os.path.join(backup_dir, f"{backup_id}.json") - shutil.copy2(item["path"], backup_path) - backup = { - "id": backup_id, - "config_name": item["name"], - "original_path": os.path.abspath(item["path"]), - "backup_path": os.path.abspath(backup_path), - "created_at": created_at, - "reason": reason, - } - backups = self._load_config_backup_index() - backups.append(backup) - self._save_config_backup_index(backups) - return backup - - @staticmethod - def _safe_backup_name(name: str) -> str: - return "".join(ch if ch.isalnum() or ch in ("-", "_") - else "_" for ch in name) or "config" - - def _read_console_config_json_text(self, ctx: dict[str, Any]): - lines: list[str] = [] - while True: - prompt = "请输入完整配置 JSON: " if not lines else "继续输入 JSON: " - line = input(fmts.fmt_info(f"§a❀ §b{prompt}")) - text_line = line.strip() - group_id = self._config_group_id(ctx) - if self.is_menu_exit_input(text_line, group_id): - self._config_success(ctx, "已退出配置文件修改") - return self.CONFIG_EXIT - if self.is_menu_back_input(text_line, group_id): - return self.CONFIG_BACK - lowered = text_line.lower() - if lines and lowered in ("end", "提交"): - return "\n".join(lines) - lines.append(line) - text = "\n".join(lines) - try: - json.loads(self._normalize_config_json_text(text)) - return text - except json.JSONDecodeError: - continue - - @staticmethod - def _normalize_config_json_text(raw: str) -> str: - text = raw.strip() - if not text.startswith("```") or not text.endswith("```"): - return text - lines = text.splitlines() - if len(lines) < 2: - return text - if lines[0].strip().startswith("```") and lines[-1].strip() == "```": - return "\n".join(lines[1:-1]).strip() - return text - - @staticmethod - def _config_file_shape_matches( - original: Any, new_config: dict[str, Any]) -> bool: - if not isinstance(original, dict): - return True - if isinstance(original.get("配置项"), dict): - return isinstance(new_config.get("配置项"), dict) - return True - - def _is_safe_config_path(self, path: str) -> bool: - cfg_dir = os.path.abspath(self.CONFIG_FILE_DIR) - target = os.path.abspath(path) - try: - return os.path.commonpath( - [cfg_dir, target]) == cfg_dir and target.lower().endswith( - ".json") - except ValueError: - return False - - def _is_safe_backup_path(self, path: str) -> bool: - backup_root = os.path.abspath(self._config_backup_root()) - target = os.path.abspath(path) - try: - return os.path.commonpath( - [backup_root, target]) == backup_root and target.lower().endswith(".json") - except ValueError: - return False - - @staticmethod - def _extract_config_items(full_config: dict[str, Any]) -> dict[str, Any]: - config_items = full_config.get("配置项") - if isinstance(config_items, dict): - return config_items - return full_config - - def _apply_runtime_config_file( - self, item: dict[str, str], full_config: dict[str, Any]) -> str: - config_name = item["name"] - config_items = self._extract_config_items(full_config) - try: - if config_name == self.name: - message = self.apply_ultra_runtime_config(config_items) - self._runtime_config_path = item["path"] - self._runtime_config_file_state = self.runtime_config_file_state( - item["path"]) - return message - if ( - config_name == "白名单&管理员检测云链联动版" - and self.whitelist_checker is not None - and hasattr(self.whitelist_checker, "apply_runtime_config") - ): - self.whitelist_checker.apply_runtime_config(config_items) - return "白名单&管理员检测配置已动态载入" - if ( - config_name == "任务系统云链联动版" - and self.task_system is not None - and hasattr(self.task_system, "cfg") - ): - self.task_system.cfg = config_items - return "任务系统配置已动态载入" - if ( - config_name == "领地系统云链联动版" - and self.land_system is not None - and hasattr(self.land_system, "apply_runtime_config") - ): - self.land_system.apply_runtime_config(config_items) - return "领地系统配置已动态载入" - if ( - config_name in ("『Orion System』违规与作弊行为综合反制系统", "Orion System 猎户座") - and self.orion is not None - and hasattr(self.orion, "config_mgr") - ): - mgr = self.orion.config_mgr - mgr.config = config_items - cfg.check_auto(mgr.CONFIG_STD, mgr.config) - mgr.get_parsed_config() - mgr.transfer_config() - mgr.check_permission_mgr() - mgr.concise_mode() - return "Orion 配置已动态载入" - except Exception as err: - return f"配置文件已落盘,但动态载入失败: {err}。可使用备份还原或重启后查看报错" - return "该配置所属插件未接入动态载入,通常需要重启 ToolDelta 或等待插件自行重新读取" - - def _config_prompt( - self, - ctx: dict[str, Any], - subtitle: str, - options: list[str], - hints: list[str], - allow_back: bool = False, - ): - hints = self._config_normalize_control_hints(ctx, hints) - if ctx["mode"] == "qq": - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - subtitle, - options, - hints, - self._config_group_id(ctx), - ) - result = self.qq_prompt( - ctx["group_id"], ctx["qqid"], text, timeout=120) - else: - lines = [f"[ {i + 1} ] {option}" for i, - option in enumerate(options)] - lines.extend(hints) - result = self.prompt_console_input( - "群服互通 配置中心", subtitle, lines, "请输入") - if result is None: - self._config_error(ctx, "回复超时,已退出菜单") - return self.CONFIG_EXIT - result = str(result).strip() - group_id = self._config_group_id(ctx) - if self.is_menu_exit_input(result, group_id): - self._config_success(ctx, "已退出菜单") - return self.CONFIG_EXIT - if allow_back and self.is_menu_back_input(result, group_id): - return self.CONFIG_BACK - return result - - def _config_input_result(self, ctx: dict[str, Any], result: Any): - if result is None: - self._config_error(ctx, "回复超时,已退出菜单") - return self.CONFIG_EXIT - text = str(result).strip() - group_id = self._config_group_id(ctx) - if self.is_menu_exit_input(text, group_id): - self._config_success(ctx, "已退出菜单") - return self.CONFIG_EXIT - if self.is_menu_back_input(text, group_id): - return self.CONFIG_BACK - return text - - def _config_success(self, ctx: dict[str, Any], message: str): - if ctx["mode"] == "qq": - self._reply_to_qq(ctx["group_id"], ctx["qqid"], f"❀ {message}") - else: - self.print_console_success(message) - - def _config_error(self, ctx: dict[str, Any], message: str): - if ctx["mode"] == "qq": - self._reply_to_qq(ctx["group_id"], ctx["qqid"], f"❀ {message}") - else: - self.print_console_error(message) +"""Q 群与控制台配置编辑中心。""" + +import json +import os +import shutil +import time +from typing import Any + +from tooldelta import cfg, fmts + + +class QQLinkerConfigEditorMixin: + """提供 Ultra 版和联动插件配置的菜单化编辑能力。""" + + CONFIG_MENU_TRIGGERS = ["配置中心", "配置菜单", "群服配置"] + CONFIG_FILE_DIR = "插件配置文件" + CONFIG_BACKUP_DIR = "配置文件备份" + CONFIG_BACKUP_INDEX = "backups.json" + CONFIG_EXIT = object() + CONFIG_BACK = object() + + def get_group_config_menu_triggers(self, group_id: int): + """Return group config menu triggers data.""" + group_cfg = self.group_cfgs.get(group_id) + if not group_cfg: + return list(self.CONFIG_MENU_TRIGGERS) + raw = group_cfg["指令设置"].get("配置中心唤醒词", self.CONFIG_MENU_TRIGGERS) + return self.normalize_string_triggers(raw, self.CONFIG_MENU_TRIGGERS) + + def qq_config_center_menu(self, group_id: int, qqid: int): + """Q 群侧配置中心入口。""" + if not self._can_use_group_permission(group_id, qqid, "配置配置文件权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + self._config_center_menu( + {"mode": "qq", "group_id": group_id, "qqid": qqid}) + + def on_console_config_center(self, _args: list[str]): + """控制台侧配置中心入口。""" + self._config_center_menu({"mode": "console"}) + + def _config_group_id(self, ctx: dict[str, Any]) -> int | None: + """Implement the config group id operation.""" + return int(ctx["group_id"]) if ctx.get( + "mode") == "qq" and "group_id" in ctx else None + + def _config_exit_hint( + self, ctx: dict[str, Any], action: str = "退出") -> str: + """Implement the config exit hint operation.""" + return self.menu_exit_hint(self._config_group_id(ctx), action) + + def _config_back_hint( + self, ctx: dict[str, Any], action: str = "返回上级菜单") -> str: + """Implement the config back hint operation.""" + return self.menu_back_hint(self._config_group_id(ctx), action) + + def _config_normalize_control_hints( + self, + ctx: dict[str, Any], + hints: list[str], + ) -> list[str]: + """Implement the config normalize control hints operation.""" + normalized: list[str] = [] + for hint in hints: + if hint == "输入 . 退出": + normalized.append(self._config_exit_hint(ctx)) + elif hint == "输入 . 取消": + normalized.append(self._config_back_hint(ctx, "取消")) + elif hint == "输入 0 返回上级": + normalized.append(self._config_back_hint(ctx, "返回上级")) + elif hint == "输入 0 返回上级菜单": + normalized.append(self._config_back_hint(ctx)) + else: + normalized.append(hint) + return normalized + + def _config_center_menu(self, ctx: dict[str, Any]): + """Implement the config center menu operation.""" + while True: + options = [ + "模式1:整文件修改配置", + "模式2:暂未开放", + ] + choice = self._config_prompt( + ctx, + "配置中心", + options, + [f"输入 [1-{len(options)}] 选择配置模式", "输入 . 退出"], + ) + if choice is self.CONFIG_EXIT: + return + selected = self.parse_displayed_menu_choice(choice, len(options)) + if selected is None: + self._config_error(ctx, "输入有误") + continue + if selected == 1: + result = self._config_file_mode_menu(ctx) + if result is self.CONFIG_EXIT: + return + continue + if selected == 2: + self._config_error(ctx, "模式2暂未开放") + continue + + def _config_file_mode_menu(self, ctx: dict[str, Any]): + """Implement the config file mode menu operation.""" + while True: + choice = self._config_prompt( + ctx, + "模式1:整文件修改", + ["修改配置文件", "还原配置文件备份"], + ["输入 [1-2] 选择操作", "输入 0 返回上级", "输入 . 退出"], + allow_back=True, + ) + if choice is self.CONFIG_EXIT: + return self.CONFIG_EXIT + if choice is self.CONFIG_BACK: + return + if choice == "1": + result = self._config_file_select_menu(ctx) + if result is self.CONFIG_EXIT: + return self.CONFIG_EXIT + continue + if choice == "2": + result = self._config_restore_backup_menu(ctx) + if result is self.CONFIG_EXIT: + return self.CONFIG_EXIT + continue + self._config_error(ctx, "输入有误") + + def _config_file_select_menu(self, ctx: dict[str, Any]): + """Implement the config file select menu operation.""" + page = 1 + while True: + files = self._discover_config_files() + if not files: + self._config_error( + ctx, f"未找到 { + self.CONFIG_FILE_DIR}/*.json 配置文件") + return + per_page = self.get_group_config_file_items_per_page( + self._config_group_id(ctx)) + total_pages, start_index, end_index = self.simple_paginate( + len(files), + per_page, + page, + ) + page = min(page, total_pages) + page_files = files[start_index - 1: end_index] + options = [item["display"] for item in page_files] + choice = self._config_prompt( + ctx, + "选择配置文件", + options, + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(options)}] 选择要整文件修改的配置", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 0 返回上级", + "输入 . 退出", + ], + allow_back=True, + ) + if choice is self.CONFIG_EXIT: + return self.CONFIG_EXIT + if choice is self.CONFIG_BACK: + return + if choice == "+": + if page < total_pages: + page += 1 + else: + self._config_error(ctx, "已经是最后一页啦~") + continue + if choice == "-": + if page > 1: + page -= 1 + else: + self._config_error(ctx, "已经是第一页啦~") + continue + if page_num := self.parse_page_jump(choice): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._config_error(ctx, f"不存在第 {page_num} 页") + continue + selected = self.parse_displayed_menu_choice( + choice, len(page_files)) + if selected is None: + self._config_error(ctx, "输入有误") + continue + result = self._edit_config_file_whole( + ctx, files[start_index + selected - 2]) + if result is self.CONFIG_EXIT: + return self.CONFIG_EXIT + + def _edit_config_file_whole( + self, ctx: dict[str, Any], item: dict[str, str]): + """Implement the edit config file whole operation.""" + try: + with open(item["path"], "r", encoding="utf-8-sig") as file: + content = file.read() + except Exception as err: + self._config_error(ctx, f"读取配置文件失败: {err}") + return + try: + original_config = json.loads(content) + except json.JSONDecodeError: + original_config = None + + if ctx["mode"] == "qq": + prompt_text = self._config_whole_file_prompt_text( + ctx, + item["name"], + content, + ) + raw = self._config_input_result( + ctx, + self.qq_prompt( + ctx["group_id"], + ctx["qqid"], + prompt_text, + timeout=600), + ) + else: + self.print_console_card( + "群服互通 配置中心", + f"整文件修改 / {item['name']}", + [ + "当前配置文件内容如下,复制并修改后粘贴回控制台", + "机器人会在替换前自动备份原配置文件", + "多行 JSON 可直接粘贴,读取到完整 JSON 后会自动提交", + "输入 END 单独一行可强制提交并检查格式", + self._config_back_hint(ctx, "返回上级"), + self._config_exit_hint(ctx, "退出本次修改"), + content, + ], + level="info", + ) + raw = self._read_console_config_json_text(ctx) + + if raw is self.CONFIG_EXIT: + return self.CONFIG_EXIT + if raw is self.CONFIG_BACK: + return + + try: + parsed = json.loads(self._normalize_config_json_text(str(raw))) + except json.JSONDecodeError as err: + self._config_error(ctx, f"JSON 格式错误,未替换配置文件: {err}") + return + + if not isinstance(parsed, dict): + self._config_error(ctx, "配置文件根节点必须是 JSON 对象,未替换配置文件") + return + if not self._config_file_shape_matches(original_config, parsed): + self._config_error(ctx, "请发送完整配置文件,不能只发送配置项内容") + return + + try: + if not self._is_safe_config_path(item["path"]): + self._config_error(ctx, "配置文件路径不在允许的插件配置目录内") + return + backup = self._backup_config_file(item) + with open(item["path"], "w", encoding="utf-8") as file: + json.dump(parsed, file, ensure_ascii=False, indent=4) + file.write("\n") + except Exception as err: + self._config_error(ctx, f"替换配置文件失败: {err}") + return + + apply_msg = self._apply_runtime_config_file(item, parsed) + self._config_success( + ctx, + f"配置文件已替换,备份编号 {backup['id']}。{apply_msg}", + ) + + def _config_whole_file_prompt_text( + self, + ctx: dict[str, Any], + config_name: str, + content: str, + ) -> str: + """Implement the config whole file prompt text operation.""" + parts = [ + self.orion_ui_border(), + f"❐ 『群服互通云链版Ultra版』 整文件修改 / {config_name}", + "❀ 请复制下面的完整 JSON,修改后作为下一条消息发送", + "❀ 机器人会在替换前自动备份原配置文件", + f"❀ {self._config_back_hint(ctx, '返回上级')}", + f"❀ {self._config_exit_hint(ctx, '退出本次修改')}", + self.orion_ui_border(), + content, + ] + return "\n".join(parts) + + def _config_restore_backup_menu(self, ctx: dict[str, Any]): + """Implement the config restore backup menu operation.""" + while True: + backups = self._load_config_backup_index() + backups = [item for item in backups if os.path.isfile( + item.get("backup_path", ""))] + if not backups: + self._config_error(ctx, "暂无可还原的配置文件备份") + return + backups = list(reversed(backups[-30:])) + options = [ + f"{item['id']} / {item['config_name']} / {item.get('created_at', '')}" + for item in backups + ] + choice = self._config_prompt( + ctx, + "还原配置文件备份", + options, + [ + f"输入 [1-{len(options)}] 选择要还原的备份", + "还原前也会备份当前配置文件", + "输入 0 返回上级", + "输入 . 退出", + ], + allow_back=True, + ) + if choice is self.CONFIG_EXIT: + return self.CONFIG_EXIT + if choice is self.CONFIG_BACK: + return + selected = self.parse_displayed_menu_choice(choice, len(backups)) + if selected is None: + self._config_error(ctx, "输入有误") + continue + self._restore_config_backup(ctx, backups[selected - 1]) + return + + def _restore_config_backup( + self, ctx: dict[str, Any], backup: dict[str, str]): + """Implement the restore config backup operation.""" + current_item = { + "name": backup["config_name"], + "path": backup["original_path"], + "display": backup["config_name"], + } + try: + if not self._is_safe_config_path(current_item["path"]): + self._config_error(ctx, "备份记录指向的配置文件路径不在允许目录内") + return + if not self._is_safe_backup_path(backup["backup_path"]): + self._config_error(ctx, "备份文件路径不在允许的备份目录内") + return + if os.path.isfile(current_item["path"]): + self._backup_config_file(current_item, reason="restore-before") + os.makedirs(os.path.dirname(current_item["path"]), exist_ok=True) + shutil.copy2(backup["backup_path"], current_item["path"]) + with open(current_item["path"], "r", encoding="utf-8-sig") as file: + restored = json.load(file) + except Exception as err: + self._config_error(ctx, f"还原配置文件失败: {err}") + return + apply_msg = self._apply_runtime_config_file(current_item, restored) + self._config_success(ctx, f"已还原备份 {backup['id']}。{apply_msg}") + + def _discover_config_files(self) -> list[dict[str, str]]: + """Implement the discover config files operation.""" + cfg_dir = os.path.abspath(self.CONFIG_FILE_DIR) + if not os.path.isdir(cfg_dir): + return [] + files: list[dict[str, str]] = [] + for name in sorted(os.listdir(cfg_dir), key=str.lower): + path = os.path.join(cfg_dir, name) + if not os.path.isfile(path) or not name.lower().endswith(".json"): + continue + config_name = os.path.splitext(name)[0] + files.append( + { + "name": config_name, + "path": path, + "display": f"{config_name} ({os.path.relpath(path)})", + } + ) + return files + + def _config_backup_root(self) -> str: + """Implement the config backup root operation.""" + path = self.format_data_path(self.CONFIG_BACKUP_DIR) + os.makedirs(path, exist_ok=True) + return path + + def _config_backup_index_path(self) -> str: + """Implement the config backup index path operation.""" + return os.path.join( + self._config_backup_root(), + self.CONFIG_BACKUP_INDEX) + + def _load_config_backup_index(self) -> list[dict[str, str]]: + """Load config backup index data.""" + path = self._config_backup_index_path() + if not os.path.isfile(path): + return [] + try: + with open(path, "r", encoding="utf-8") as file: + data = json.load(file) + except Exception: + return [] + if not isinstance(data, list): + return [] + return [item for item in data if isinstance(item, dict)] + + def _save_config_backup_index(self, backups: list[dict[str, str]]): + """Save config backup index data.""" + path = self._config_backup_index_path() + with open(path, "w", encoding="utf-8") as file: + json.dump(backups[-100:], file, ensure_ascii=False, indent=2) + file.write("\n") + + def _backup_config_file( + self, + item: dict[str, str], + reason: str = "replace-before", + ) -> dict[str, str]: + """Implement the backup config file operation.""" + if not self._is_safe_config_path(item["path"]): + raise ValueError("配置文件路径不在允许的插件配置目录内") + created_at = time.strftime("%Y-%m-%d %H:%M:%S") + stamp = time.strftime("%Y%m%d-%H%M%S") + \ + f"-{int(time.time() * 1000) % 1000:03d}" + safe_name = self._safe_backup_name(item["name"]) + backup_id = f"{stamp}-{safe_name}" + backup_dir = os.path.join(self._config_backup_root(), safe_name) + os.makedirs(backup_dir, exist_ok=True) + backup_path = os.path.join(backup_dir, f"{backup_id}.json") + shutil.copy2(item["path"], backup_path) + backup = { + "id": backup_id, + "config_name": item["name"], + "original_path": os.path.abspath(item["path"]), + "backup_path": os.path.abspath(backup_path), + "created_at": created_at, + "reason": reason, + } + backups = self._load_config_backup_index() + backups.append(backup) + self._save_config_backup_index(backups) + return backup + + @staticmethod + def _safe_backup_name(name: str) -> str: + """Implement the safe backup name operation.""" + return "".join(ch if ch.isalnum() or ch in ("-", "_") + else "_" for ch in name) or "config" + + def _read_console_config_json_text(self, ctx: dict[str, Any]): + """Implement the read console config json text operation.""" + lines: list[str] = [] + while True: + prompt = "请输入完整配置 JSON: " if not lines else "继续输入 JSON: " + line = input(fmts.fmt_info(f"§a❀ §b{prompt}")) + text_line = line.strip() + group_id = self._config_group_id(ctx) + if self.is_menu_exit_input(text_line, group_id): + self._config_success(ctx, "已退出配置文件修改") + return self.CONFIG_EXIT + if self.is_menu_back_input(text_line, group_id): + return self.CONFIG_BACK + lowered = text_line.lower() + if lines and lowered in ("end", "提交"): + return "\n".join(lines) + lines.append(line) + text = "\n".join(lines) + try: + json.loads(self._normalize_config_json_text(text)) + return text + except json.JSONDecodeError: + continue + + @staticmethod + def _normalize_config_json_text(raw: str) -> str: + """Normalize config json text values.""" + text = raw.strip() + if not text.startswith("```") or not text.endswith("```"): + return text + lines = text.splitlines() + if len(lines) < 2: + return text + if lines[0].strip().startswith("```") and lines[-1].strip() == "```": + return "\n".join(lines[1:-1]).strip() + return text + + @staticmethod + def _config_file_shape_matches( + original: Any, new_config: dict[str, Any]) -> bool: + """Implement the config file shape matches operation.""" + if not isinstance(original, dict): + return True + if isinstance(original.get("配置项"), dict): + return isinstance(new_config.get("配置项"), dict) + return True + + def _is_safe_config_path(self, path: str) -> bool: + """Implement the is safe config path operation.""" + cfg_dir = os.path.abspath(self.CONFIG_FILE_DIR) + target = os.path.abspath(path) + try: + return os.path.commonpath( + [cfg_dir, target]) == cfg_dir and target.lower().endswith( + ".json") + except ValueError: + return False + + def _is_safe_backup_path(self, path: str) -> bool: + """Implement the is safe backup path operation.""" + backup_root = os.path.abspath(self._config_backup_root()) + target = os.path.abspath(path) + try: + return os.path.commonpath( + [backup_root, target]) == backup_root and target.lower().endswith(".json") + except ValueError: + return False + + @staticmethod + def _extract_config_items(full_config: dict[str, Any]) -> dict[str, Any]: + """Implement the extract config items operation.""" + config_items = full_config.get("配置项") + if isinstance(config_items, dict): + return config_items + return full_config + + def _apply_runtime_config_file( + self, item: dict[str, str], full_config: dict[str, Any]) -> str: + """Implement the apply runtime config file operation.""" + config_name = item["name"] + config_items = self._extract_config_items(full_config) + try: + if config_name == self.name: + message = self.apply_ultra_runtime_config(config_items) + self._runtime_config_path = item["path"] + self._runtime_config_file_state = self.runtime_config_file_state( + item["path"]) + return message + if ( + config_name == "白名单&管理员检测云链联动版" + and self.whitelist_checker is not None + and hasattr(self.whitelist_checker, "apply_runtime_config") + ): + self.whitelist_checker.apply_runtime_config(config_items) + return "白名单&管理员检测配置已动态载入" + if ( + config_name == "任务系统云链联动版" + and self.task_system is not None + and hasattr(self.task_system, "cfg") + ): + self.task_system.cfg = config_items + return "任务系统配置已动态载入" + if ( + config_name == "领地系统云链联动版" + and self.land_system is not None + and hasattr(self.land_system, "apply_runtime_config") + ): + self.land_system.apply_runtime_config(config_items) + return "领地系统配置已动态载入" + if ( + config_name in ("『Orion System』违规与作弊行为综合反制系统", "Orion System 猎户座") + and self.orion is not None + and hasattr(self.orion, "config_mgr") + ): + mgr = self.orion.config_mgr + mgr.config = config_items + cfg.check_auto(mgr.CONFIG_STD, mgr.config) + mgr.get_parsed_config() + mgr.transfer_config() + mgr.check_permission_mgr() + mgr.concise_mode() + return "Orion 配置已动态载入" + except Exception as err: + return f"配置文件已落盘,但动态载入失败: {err}。可使用备份还原或重启后查看报错" + return "该配置所属插件未接入动态载入,通常需要重启 ToolDelta 或等待插件自行重新读取" + + def _config_prompt( + self, + ctx: dict[str, Any], + subtitle: str, + options: list[str], + hints: list[str], + allow_back: bool = False, + ): + """Implement the config prompt operation.""" + hints = self._config_normalize_control_hints(ctx, hints) + if ctx["mode"] == "qq": + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + subtitle, + options, + hints, + self._config_group_id(ctx), + ) + result = self.qq_prompt( + ctx["group_id"], ctx["qqid"], text, timeout=120) + else: + lines = [f"[ {i + 1} ] {option}" for i, + option in enumerate(options)] + lines.extend(hints) + result = self.prompt_console_input( + "群服互通 配置中心", subtitle, lines, "请输入") + if result is None: + self._config_error(ctx, "回复超时,已退出菜单") + return self.CONFIG_EXIT + result = str(result).strip() + group_id = self._config_group_id(ctx) + if self.is_menu_exit_input(result, group_id): + self._config_success(ctx, "已退出菜单") + return self.CONFIG_EXIT + if allow_back and self.is_menu_back_input(result, group_id): + return self.CONFIG_BACK + return result + + def _config_input_result(self, ctx: dict[str, Any], result: Any): + """Implement the config input result operation.""" + if result is None: + self._config_error(ctx, "回复超时,已退出菜单") + return self.CONFIG_EXIT + text = str(result).strip() + group_id = self._config_group_id(ctx) + if self.is_menu_exit_input(text, group_id): + self._config_success(ctx, "已退出菜单") + return self.CONFIG_EXIT + if self.is_menu_back_input(text, group_id): + return self.CONFIG_BACK + return text + + def _config_success(self, ctx: dict[str, Any], message: str): + """Implement the config success operation.""" + if ctx["mode"] == "qq": + self._reply_to_qq(ctx["group_id"], ctx["qqid"], f"❀ {message}") + else: + self.print_console_success(message) + + def _config_error(self, ctx: dict[str, Any], message: str): + """Implement the config error operation.""" + if ctx["mode"] == "qq": + self._reply_to_qq(ctx["group_id"], ctx["qqid"], f"❀ {message}") + else: + self.print_console_error(message) diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" index 8122dc76..0a6bdc5a 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" @@ -1,812 +1,818 @@ -"""配置与群权限相关的公共逻辑。 - -这一层解决两个问题: -1. 把历史配置逐步迁到现在的“多群结构”。 -2. 提供群管理员、超级管理员、触发词等基础数据访问能力。 -""" - -import inspect -import json -import os -from copy import deepcopy -from typing import Any -from collections.abc import Callable - -from tooldelta import cfg - -from .message_utils import QQMsgTrigger - - -# 配置迁移、群权限状态和触发词读取都收在这一层,避免散到业务逻辑里。 -class QQLinkerConfigMixin: - """配置、群状态和触发词的基础能力集合。""" - - MENU_EXIT_TRIGGERS_DEFAULT = [".", "。", "q"] - MENU_BACK_TRIGGERS_DEFAULT = ["!", "!"] - CONFIG_FILE_DIR = "插件配置文件" - RUNTIME_CONFIG_RELOAD_INTERVAL = 5 - DYNAMIC_LOAD_SETTINGS_KEY = "动态载入设置" - DYNAMIC_LOAD_ENABLED_KEY = "是否启用动态载入配置文件(仅用于本插件)" - DYNAMIC_LOAD_INTERVAL_KEY = "动态载入检测时间间隔(单位:秒)" - OWNER_QQ_DEFAULT = 1234567890 - OWNER_QQ_UNSET = 0 - PERMISSION_SETTINGS_KEY = "权限设置" - LEGACY_GROUP_STATE_DIR_NAME = "群聊权限数据" - - @staticmethod - def binding_default(): - """返回全局 QQ 与游戏 ID 绑定配置。""" - return { - "是否开启QQ号与游戏ID绑定功能": False, - "是否允许单QQ号可绑定多游戏ID": False, - "是否允许单游戏ID可绑定多QQ号": False, - "绑定触发词": ["绑定"], - "绑定超时时间(单位:分钟)": 10, - "绑定验证码群聊提示文本": "已将验证码发送至您的私信,请在{time}分钟内在游戏中发送验证码以完成绑定。", - "绑定验证码私信提示文本": "您的绑定验证码是:{auth_code}。请在{time}分钟内在游戏中发送该验证码已完成绑定。", - "拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)": "您已有绑定账号,请解绑后再绑定", - "绑定超时提示文本": "绑定超时,请重新获取验证码绑定", - "绑定成功提示文本": "恭喜你绑定成功,您的游戏ID为:{player_name}。", - } - - @classmethod - def permission_default(cls): - """返回单个群聊的权限配置。""" - return { - "所有者QQ号": cls.OWNER_QQ_DEFAULT, - "超级管理员QQ号": [cls.OWNER_QQ_DEFAULT], - "普通管理员QQ号": [cls.OWNER_QQ_DEFAULT], - "各功能权限设置": { - "查看玩家人数权限": { - "是否允许普通成员使用": True, - "是否允许普通管理员使用": True, - "是否允许超级管理员使用": True, - }, - "发送指令权限": { - "是否允许普通成员使用": False, - "是否允许普通管理员使用": True, - "是否允许超级管理员使用": True, - }, - "查询背包权限": { - "是否允许普通成员使用": False, - "是否允许普通管理员使用": True, - "是否允许超级管理员使用": True, - }, - "封禁/解封玩家权限": { - "是否允许普通成员使用": False, - "是否允许普通管理员使用": True, - "是否允许超级管理员使用": True, - }, - "白名单&管理员检测权限": { - "是否允许普通成员使用": False, - "是否允许普通管理员使用": False, - "是否允许超级管理员使用": True, - }, - "领地系统权限": { - "是否允许普通成员使用": False, - "是否允许普通管理员使用": True, - "是否允许超级管理员使用": True, - }, - "公会系统权限": { - "是否允许普通成员使用": False, - "是否允许普通管理员使用": True, - "是否允许超级管理员使用": True, - }, - "任务系统权限": { - "是否允许普通成员使用": False, - "是否允许普通管理员使用": True, - "是否允许超级管理员使用": True, - }, - "配置配置文件权限": { - "是否允许普通成员使用": False, - "是否允许普通管理员使用": False, - "是否允许超级管理员使用": True, - }, - "QQ普通管理员菜单权限": { - "是否允许普通成员使用": False, - "是否允许普通管理员使用": False, - "是否允许超级管理员使用": True, - }, - "QQ超级管理员菜单权限": { - "是否允许普通成员使用": False, - "是否允许普通管理员使用": False, - "是否允许超级管理员使用": False, - }, - }, - } - - @staticmethod - def group_default(group_id: int = 194838530): - """返回单个群聊的默认配置骨架。""" - # 单个群聊的完整默认结构,老配置迁移时也以它做兜底模板。 - return { - "群号": group_id, - "游戏到群": { - "是否启用": False, - "转发格式": "<[玩家名]> [消息]", - "仅转发以下符号开头的消息(列表为空则全部转发)": ["#"], - "屏蔽以下字符串开头的消息": [".", "。"], - "转发玩家进退提示": True, - }, - "群到游戏": { - "是否启用": True, - "转发格式": "群 <[昵称]> [消息]", - "仅转发以下符号开头的消息(列表为空则全部转发)": [], - "屏蔽的QQ号": [], - "替换花里胡哨的昵称": True, - "替换花里胡哨的消息": True, - }, - QQLinkerConfigMixin.PERMISSION_SETTINGS_KEY: QQLinkerConfigMixin.permission_default(), - "指令设置": { - "发送指令前缀": "/", - "帮助菜单唤醒词": ["help", "帮助"], - "帮助菜单非管理功能每页显示数量": 10, - "帮助菜单管理功能每页显示数量": 10, - "命令触发词帮助菜单每页显示数量": 10, - "配置文件整文件修改模式每页显示数量": 10, - "管理员菜单唤醒词": ["管理员菜单"], - "配置中心唤醒词": ["配置中心", "配置菜单", "群服配置"], - "退出整个菜单触发词": [".", "。", "q"], - "返回上一级菜单触发词": ["!", "!"], - "是否允许查看玩家列表": True, - "查看玩家人数的唤醒词": ["list", "玩家列表"], - "查询背包菜单唤醒词": ["查询背包"], - "查询背包菜单每页显示的玩家数量": 10, - "QQ群封禁唤醒词": ["orban", "orion ban", "猎户封禁"], - "QQ群解封唤醒词": ["orunban", "orion unban", "猎户解封"], - "QQ群白名单&管理员检测唤醒词": ["白名单&管理员检测", "检测管理"], - "任务系统菜单唤醒词": ["任务系统"], - "任务系统每页显示玩家数量": 10, - "任务系统每页显示任务数量": 10, - "领地系统菜单唤醒词": ["领地系统云链联动版", "领地系统", "领地管理"], - "领地系统每页显示领地数量": 10, - "公会系统管理菜单唤醒词": ["公会系统"], - "QQ群封禁/解封菜单每页显示个数": 10, - }, - } - - @classmethod - def cfg_default(cls): - """返回插件级默认配置。""" - return { - cls.DYNAMIC_LOAD_SETTINGS_KEY: { - cls.DYNAMIC_LOAD_ENABLED_KEY: True, - cls.DYNAMIC_LOAD_INTERVAL_KEY: cls.RUNTIME_CONFIG_RELOAD_INTERVAL, - }, - "云链设置": { - "地址": "ws://127.0.0.1:3001", - "校验码": ""}, - "绑定设置": cls.binding_default(), - "群聊设置": [ - cls.group_default()], - } - - @classmethod - def cfg_std(cls): - """返回 ToolDelta 用来校验配置的数据结构定义。""" - binding_std = { - "是否开启QQ号与游戏ID绑定功能": bool, - "是否允许单QQ号可绑定多游戏ID": bool, - "是否允许单游戏ID可绑定多QQ号": bool, - "绑定触发词": cfg.JsonList(str, -1), - "绑定超时时间(单位:分钟)": cfg.PInt, - "绑定验证码群聊提示文本": str, - "绑定验证码私信提示文本": str, - "拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)": str, - "绑定超时提示文本": str, - "绑定成功提示文本": str, - } - permission_item_std = { - "是否允许普通成员使用": bool, - "是否允许普通管理员使用": bool, - "是否允许超级管理员使用": bool, - } - permission_std = { - "所有者QQ号": int, - "超级管理员QQ号": cfg.JsonList(cfg.PInt, -1), - "普通管理员QQ号": cfg.JsonList(cfg.PInt, -1), - "各功能权限设置": { - "查看玩家人数权限": permission_item_std, - "发送指令权限": permission_item_std, - "查询背包权限": permission_item_std, - "封禁/解封玩家权限": permission_item_std, - "白名单&管理员检测权限": permission_item_std, - "领地系统权限": permission_item_std, - "公会系统权限": permission_item_std, - "任务系统权限": permission_item_std, - "配置配置文件权限": permission_item_std, - "QQ普通管理员菜单权限": permission_item_std, - "QQ超级管理员菜单权限": permission_item_std, - }, - } - group_std = { - "群号": cfg.PInt, - "游戏到群": { - "是否启用": bool, - "转发格式": str, - "仅转发以下符号开头的消息(列表为空则全部转发)": cfg.JsonList(str, -1), - "屏蔽以下字符串开头的消息": cfg.JsonList(str, -1), - "转发玩家进退提示": bool, - }, - "群到游戏": { - "是否启用": bool, - "转发格式": str, - "仅转发以下符号开头的消息(列表为空则全部转发)": cfg.JsonList(str, -1), - "屏蔽的QQ号": cfg.JsonList(cfg.PInt, -1), - "替换花里胡哨的昵称": bool, - "替换花里胡哨的消息": bool, - }, - cls.PERMISSION_SETTINGS_KEY: permission_std, - "指令设置": { - "发送指令前缀": str, - "帮助菜单唤醒词": cfg.JsonList(str, -1), - "帮助菜单非管理功能每页显示数量": cfg.PInt, - "帮助菜单管理功能每页显示数量": cfg.PInt, - "命令触发词帮助菜单每页显示数量": cfg.PInt, - "配置文件整文件修改模式每页显示数量": cfg.PInt, - "管理员菜单唤醒词": cfg.JsonList(str, -1), - "配置中心唤醒词": cfg.JsonList(str, -1), - "退出整个菜单触发词": cfg.JsonList(str, -1), - "返回上一级菜单触发词": cfg.JsonList(str, -1), - "是否允许查看玩家列表": bool, - "查看玩家人数的唤醒词": cfg.JsonList(str, -1), - "查询背包菜单唤醒词": cfg.JsonList(str, -1), - "查询背包菜单每页显示的玩家数量": cfg.PInt, - "QQ群封禁唤醒词": cfg.JsonList(str, -1), - "QQ群解封唤醒词": cfg.JsonList(str, -1), - "QQ群白名单&管理员检测唤醒词": cfg.JsonList(str, -1), - "任务系统菜单唤醒词": cfg.JsonList(str, -1), - "任务系统每页显示玩家数量": cfg.PInt, - "任务系统每页显示任务数量": cfg.PInt, - "领地系统菜单唤醒词": cfg.JsonList(str, -1), - "领地系统每页显示领地数量": cfg.PInt, - "公会系统管理菜单唤醒词": cfg.JsonList(str, -1), - "QQ群封禁/解封菜单每页显示个数": cfg.PInt, - }, - } - return { - cls.DYNAMIC_LOAD_SETTINGS_KEY: { - cls.DYNAMIC_LOAD_ENABLED_KEY: bool, - cls.DYNAMIC_LOAD_INTERVAL_KEY: cfg.PInt, - }, - "云链设置": {"地址": str, "校验码": str}, - "绑定设置": binding_std, - "群聊设置": cfg.JsonList(group_std, -1), - } - - @staticmethod - def normalize_int_list(values: Any) -> list[int]: - """把来源不可信的列表规整成去重后的正整数列表。""" - if not isinstance(values, list): - return [] - result: list[int] = [] - for value in values: - try: - ivalue = int(value) - except (TypeError, ValueError): - continue - if ivalue > 0 and ivalue not in result: - result.append(ivalue) - return result - - @classmethod - def normalize_owner_qq(cls, value: Any) -> int: - """把配置中的所有者 QQ 规整成单个整数 QQ 号。""" - text = str(value).strip() if value is not None else "" - if text in ("", "00000000"): - return cls.OWNER_QQ_DEFAULT - try: - qqid = int(text) - except (TypeError, ValueError): - return cls.OWNER_QQ_DEFAULT - if qqid == cls.OWNER_QQ_UNSET: - return cls.OWNER_QQ_DEFAULT - return qqid if qqid > 0 else cls.OWNER_QQ_DEFAULT - - @staticmethod - def _normalize_bool(value: Any, fallback: bool) -> bool: - if isinstance(value, bool): - return value - return fallback - - @classmethod - def merge_with_default(cls, raw: Any, default: Any): - """递归合并旧配置和默认值。 - - 这里不会粗暴覆盖未知字段,目的是在升级时尽量保留用户已经写进配置里的内容。 - """ - if isinstance(default, dict): - result = { - key: cls.merge_with_default(None, value) - for key, value in default.items() - } - if isinstance(raw, dict): - for key, value in raw.items(): - if key in result: - result[key] = cls.merge_with_default( - value, result[key]) - else: - result[key] = value - return result - if isinstance(default, list): - return list(raw) if isinstance(raw, list) else list(default) - return raw if raw is not None else default - - def migrate_group_config(self, raw_group: Any): - """把单个群聊配置迁到当前结构。 - - 这个方法主要处理字段补全、类型纠正和历史字段兼容, - 这样业务层就可以假设拿到的 group_cfg 是完整且结构稳定的。 - """ - if not isinstance(raw_group, dict): - return None - try: - group_id = int(raw_group.get("群号", 0)) - except (TypeError, ValueError): - return None - if group_id <= 0: - return None - group_cfg = self.group_default(group_id) - # 老版本配置是按几个子区块散开的,这里逐段合并到统一结构里。 - old_g2q = raw_group.get("游戏到群", {}) - old_q2g = raw_group.get("群到游戏", {}) - old_permissions = raw_group.get(self.PERMISSION_SETTINGS_KEY, {}) - old_cmd = raw_group.get("指令设置", {}) - self._merge_game_to_group_cfg(group_cfg, old_g2q) - self._merge_group_to_game_cfg(group_cfg, old_q2g) - self._merge_permission_cfg(group_cfg, old_permissions) - self._merge_command_cfg(group_cfg, old_cmd) - return group_cfg - - def _merge_binding_cfg( - self, binding_cfg: dict[str, Any], old_binding: Any): - """把 QQ 与游戏 ID 绑定设置合并进全局配置。""" - if not isinstance(old_binding, dict): - return - binding_cfg["是否开启QQ号与游戏ID绑定功能"] = bool( - old_binding.get( - "是否开启QQ号与游戏ID绑定功能", - binding_cfg["是否开启QQ号与游戏ID绑定功能"], - ) - ) - binding_cfg["是否允许单QQ号可绑定多游戏ID"] = bool( - old_binding.get( - "是否允许单QQ号可绑定多游戏ID", - binding_cfg["是否允许单QQ号可绑定多游戏ID"], - ) - ) - binding_cfg["是否允许单游戏ID可绑定多QQ号"] = bool( - old_binding.get( - "是否允许单游戏ID可绑定多QQ号", - binding_cfg["是否允许单游戏ID可绑定多QQ号"], - ) - ) - binding_cfg["绑定触发词"] = self._clean_string_list( - old_binding.get("绑定触发词", binding_cfg["绑定触发词"]), - binding_cfg["绑定触发词"], - ) - binding_cfg["绑定超时时间(单位:分钟)"] = self._normalize_positive_int( - old_binding.get( - "绑定超时时间(单位:分钟)", - binding_cfg["绑定超时时间(单位:分钟)"], - ), - binding_cfg["绑定超时时间(单位:分钟)"], - ) - old_send_text = str( - old_binding.get( - "绑定验证码群聊提示文本", - binding_cfg["绑定验证码群聊提示文本"], - ) - ).strip() - if old_send_text: - binding_cfg["绑定验证码群聊提示文本"] = old_send_text - binding_cfg["绑定验证码私信提示文本"] = ( - str( - old_binding.get( - "绑定验证码私信提示文本", - binding_cfg["绑定验证码私信提示文本"], - ) - ).strip() - or binding_cfg["绑定验证码私信提示文本"] - ) - binding_cfg["拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)"] = ( - str( - old_binding.get( - "拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)", - binding_cfg["拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)"], - ) - ).strip() - or binding_cfg["拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)"] - ) - binding_cfg["绑定超时提示文本"] = ( - str( - old_binding.get( - "绑定超时提示文本", - binding_cfg["绑定超时提示文本"], - ) - ).strip() - or binding_cfg["绑定超时提示文本"] - ) - binding_cfg["绑定成功提示文本"] = ( - str(old_binding.get("绑定成功提示文本", binding_cfg["绑定成功提示文本"])).strip() - or binding_cfg["绑定成功提示文本"] - ) - - def _merge_game_to_group_cfg( - self, group_cfg: dict[str, Any], old_g2q: Any): - """把旧版“游戏到群”配置段合并进当前群配置。""" - if not isinstance(old_g2q, dict): - return - game_to_group = group_cfg["游戏到群"] - game_to_group["是否启用"] = bool( - old_g2q.get("是否启用", game_to_group["是否启用"])) - game_to_group["转发格式"] = str(old_g2q.get("转发格式", game_to_group["转发格式"])) - game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"] = self._clean_string_list( - old_g2q.get( - "仅转发以下符号开头的消息(列表为空则全部转发)", - game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"], - ), - game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"], - allow_empty=True, - ) - game_to_group["屏蔽以下字符串开头的消息"] = self._clean_string_list( - old_g2q.get( - "屏蔽以下字符串开头的消息", - game_to_group["屏蔽以下字符串开头的消息"], - ), - game_to_group["屏蔽以下字符串开头的消息"], - ) - game_to_group["转发玩家进退提示"] = bool( - old_g2q.get("转发玩家进退提示", game_to_group["转发玩家进退提示"]) - ) - - def _merge_group_to_game_cfg( - self, group_cfg: dict[str, Any], old_q2g: Any): - """把旧版“群到游戏”配置段合并进当前群配置。""" - if not isinstance(old_q2g, dict): - return - group_to_game = group_cfg["群到游戏"] - group_to_game["是否启用"] = bool( - old_q2g.get("是否启用", group_to_game["是否启用"])) - group_to_game["转发格式"] = str(old_q2g.get("转发格式", group_to_game["转发格式"])) - group_to_game["仅转发以下符号开头的消息(列表为空则全部转发)"] = self._clean_string_list( - old_q2g.get( - "仅转发以下符号开头的消息(列表为空则全部转发)", - group_to_game["仅转发以下符号开头的消息(列表为空则全部转发)"], - ), - group_to_game["仅转发以下符号开头的消息(列表为空则全部转发)"], - allow_empty=True, - ) - group_to_game["屏蔽的QQ号"] = self.normalize_int_list( - old_q2g.get("屏蔽的QQ号", [])) - group_to_game["替换花里胡哨的昵称"] = bool( - old_q2g.get("替换花里胡哨的昵称", group_to_game["替换花里胡哨的昵称"]) - ) - group_to_game["替换花里胡哨的消息"] = bool( - old_q2g.get("替换花里胡哨的消息", group_to_game["替换花里胡哨的消息"]) - ) - - def _legacy_group_state_dir(self) -> str: - return self.format_data_path(self.LEGACY_GROUP_STATE_DIR_NAME) - - def _read_legacy_group_state_file(self, path: str) -> dict[str, list[int]]: - if not os.path.isfile(path): - return {"admins": [], "super_admins": []} - try: - with open(path, "r", encoding="utf-8") as file: - data = json.load(file) - except Exception: - data = {} - if not isinstance(data, dict): - data = {} - return { - "admins": self.normalize_int_list( - data.get( - "admins", [])), "super_admins": self.normalize_int_list( - data.get( - "super_admins", [])), } - - def _merge_permission_item( - self, next_item: dict[str, bool], raw_item: Any): - if not isinstance(raw_item, dict): - return - for key, fallback in list(next_item.items()): - next_item[key] = self._normalize_bool(raw_item.get(key), fallback) - - def _merge_permission_cfg( - self, - group_cfg: dict[str, Any], - old_permissions: Any, - ): - """把权限配置合并进当前群配置。管理员状态只来自配置本身。""" - permission_cfg = group_cfg[self.PERMISSION_SETTINGS_KEY] - - if isinstance(old_permissions, dict): - permission_cfg["所有者QQ号"] = self.normalize_owner_qq( - old_permissions.get("所有者QQ号", permission_cfg["所有者QQ号"]) - ) - permission_cfg["超级管理员QQ号"] = self.normalize_int_list( - old_permissions.get("超级管理员QQ号", permission_cfg["超级管理员QQ号"]) - ) - permission_cfg["普通管理员QQ号"] = self.normalize_int_list( - old_permissions.get("普通管理员QQ号", permission_cfg["普通管理员QQ号"]) - ) - feature_permissions = old_permissions.get("各功能权限设置", {}) - if isinstance(feature_permissions, dict): - for key, next_item in permission_cfg["各功能权限设置"].items(): - self._merge_permission_item( - next_item, feature_permissions.get(key)) - - owner_qq = self.normalize_owner_qq(permission_cfg.get("所有者QQ号")) - permission_cfg["所有者QQ号"] = owner_qq - permission_cfg["超级管理员QQ号"] = self.normalize_int_list( - permission_cfg.get("超级管理员QQ号", []) - ) - permission_cfg["普通管理员QQ号"] = self.normalize_int_list( - permission_cfg.get("普通管理员QQ号", []) - ) - - def _merge_command_cfg(self, group_cfg: dict[str, Any], old_cmd: Any): - """把旧版指令相关配置合并进当前群配置。""" - if not isinstance(old_cmd, dict): - return - command_cfg = group_cfg["指令设置"] - command_cfg["发送指令前缀"] = ( - str(old_cmd.get("发送指令前缀", command_cfg["发送指令前缀"])).strip() - or command_cfg["发送指令前缀"] - ) - command_cfg["管理员菜单唤醒词"] = self._clean_string_list( - old_cmd.get("管理员菜单唤醒词", command_cfg["管理员菜单唤醒词"]), - command_cfg["管理员菜单唤醒词"], - ) - command_cfg["帮助菜单唤醒词"] = self._clean_string_list( - old_cmd.get("帮助菜单唤醒词", command_cfg["帮助菜单唤醒词"]), - command_cfg["帮助菜单唤醒词"], - ) - command_cfg["帮助菜单非管理功能每页显示数量"] = self._normalize_positive_int( - old_cmd.get( - "帮助菜单非管理功能每页显示数量", - command_cfg["帮助菜单非管理功能每页显示数量"], - ), - command_cfg["帮助菜单非管理功能每页显示数量"], - ) - command_cfg["帮助菜单管理功能每页显示数量"] = self._normalize_positive_int( - old_cmd.get( - "帮助菜单管理功能每页显示数量", - command_cfg["帮助菜单管理功能每页显示数量"], - ), - command_cfg["帮助菜单管理功能每页显示数量"], - ) - command_cfg["命令触发词帮助菜单每页显示数量"] = self._normalize_positive_int( - old_cmd.get( - "命令触发词帮助菜单每页显示数量", - command_cfg["命令触发词帮助菜单每页显示数量"], - ), - command_cfg["命令触发词帮助菜单每页显示数量"], - ) - command_cfg["配置文件整文件修改模式每页显示数量"] = self._normalize_positive_int( - old_cmd.get( - "配置文件整文件修改模式每页显示数量", - command_cfg["配置文件整文件修改模式每页显示数量"], - ), - command_cfg["配置文件整文件修改模式每页显示数量"], - ) - command_cfg["配置中心唤醒词"] = self._clean_string_list( - old_cmd.get("配置中心唤醒词", command_cfg["配置中心唤醒词"]), - command_cfg["配置中心唤醒词"], - ) - command_cfg["退出整个菜单触发词"] = self._clean_string_list( - old_cmd.get("退出整个菜单触发词", command_cfg["退出整个菜单触发词"]), - command_cfg["退出整个菜单触发词"], - ) - command_cfg["返回上一级菜单触发词"] = self._clean_string_list( - old_cmd.get("返回上一级菜单触发词", command_cfg["返回上一级菜单触发词"]), - command_cfg["返回上一级菜单触发词"], - ) - command_cfg["是否允许查看玩家列表"] = bool( - old_cmd.get("是否允许查看玩家列表", command_cfg["是否允许查看玩家列表"]) - ) - command_cfg["查看玩家人数的唤醒词"] = self._clean_string_list( - old_cmd.get("查看玩家人数的唤醒词", command_cfg["查看玩家人数的唤醒词"]), - command_cfg["查看玩家人数的唤醒词"], - ) - command_cfg["查询背包菜单唤醒词"] = self._clean_string_list( - old_cmd.get("查询背包菜单唤醒词", command_cfg["查询背包菜单唤醒词"]), - command_cfg["查询背包菜单唤醒词"], - ) - command_cfg["查询背包菜单每页显示的玩家数量"] = self._normalize_positive_int( - old_cmd.get( - "查询背包菜单每页显示的玩家数量", - command_cfg["查询背包菜单每页显示的玩家数量"], - ), - command_cfg["查询背包菜单每页显示的玩家数量"], - ) - command_cfg["QQ群封禁唤醒词"] = self._clean_string_list( - old_cmd.get("QQ群封禁唤醒词", command_cfg["QQ群封禁唤醒词"]), - command_cfg["QQ群封禁唤醒词"], - ) - command_cfg["QQ群解封唤醒词"] = self._clean_string_list( - old_cmd.get("QQ群解封唤醒词", command_cfg["QQ群解封唤醒词"]), - command_cfg["QQ群解封唤醒词"], - ) - command_cfg["QQ群白名单&管理员检测唤醒词"] = self._clean_string_list( - old_cmd.get( - "QQ群白名单&管理员检测唤醒词", - command_cfg["QQ群白名单&管理员检测唤醒词"], - ), - command_cfg["QQ群白名单&管理员检测唤醒词"], - ) - command_cfg["任务系统菜单唤醒词"] = self._clean_string_list( - old_cmd.get("任务系统菜单唤醒词", command_cfg["任务系统菜单唤醒词"]), - command_cfg["任务系统菜单唤醒词"], - ) - command_cfg["任务系统每页显示玩家数量"] = self._normalize_positive_int( - old_cmd.get( - "任务系统每页显示玩家数量", - command_cfg["任务系统每页显示玩家数量"], - ), - command_cfg["任务系统每页显示玩家数量"], - ) - command_cfg["任务系统每页显示任务数量"] = self._normalize_positive_int( - old_cmd.get( - "任务系统每页显示任务数量", - command_cfg["任务系统每页显示任务数量"], - ), - command_cfg["任务系统每页显示任务数量"], - ) - command_cfg["领地系统菜单唤醒词"] = self._clean_string_list( - old_cmd.get("领地系统菜单唤醒词", command_cfg["领地系统菜单唤醒词"]), - command_cfg["领地系统菜单唤醒词"], - ) - command_cfg["领地系统每页显示领地数量"] = self._normalize_positive_int( - old_cmd.get( - "领地系统每页显示领地数量", - command_cfg["领地系统每页显示领地数量"], - ), - command_cfg["领地系统每页显示领地数量"], - ) - command_cfg["公会系统管理菜单唤醒词"] = self._clean_string_list( - old_cmd.get( - "公会系统管理菜单唤醒词", - command_cfg["公会系统管理菜单唤醒词"], - ), - command_cfg["公会系统管理菜单唤醒词"], - ) - command_cfg["QQ群封禁/解封菜单每页显示个数"] = self._normalize_positive_int( - old_cmd.get( - "QQ群封禁/解封菜单每页显示个数", - command_cfg["QQ群封禁/解封菜单每页显示个数"], - ), - command_cfg["QQ群封禁/解封菜单每页显示个数"], - ) - - @staticmethod - def _clean_string_list( - raw: Any, - fallback: list[str], - allow_empty: bool = False): - """清理字符串列表,失败时回退到默认值。""" - if isinstance(raw, list): - cleaned = [str(item) for item in raw if isinstance(item, str)] - if cleaned or allow_empty: - return cleaned - return fallback - - @staticmethod - def _normalize_positive_int(value: Any, fallback: int): - """把不可信的数字输入规范成正整数。""" - try: - return max(1, int(value)) - except (TypeError, ValueError): - return fallback - - def _legacy_group_state_files(self) -> list[tuple[int, str]]: - legacy_dir = self._legacy_group_state_dir() - try: - names = os.listdir(legacy_dir) - except OSError: - return [] - files: list[tuple[int, str]] = [] - for name in names: - stem, ext = os.path.splitext(name) - if ext.lower() != ".json": - continue - try: - group_id = int(stem) - except ValueError: - continue - if group_id > 0: - files.append((group_id, os.path.join(legacy_dir, name))) - return files - - @staticmethod - def _append_unique_ints(target: list[int], values: list[int]) -> bool: - changed = False - for value in values: - if value not in target: - target.append(value) - changed = True - return changed - - def migrate_legacy_group_admin_data(self, next_cfg: dict[str, Any]) -> int: - """把旧插件数据目录里的群管理员状态合并进主配置。""" - group_list = next_cfg.get("群聊设置") - if not isinstance(group_list, list): - group_list = [] - next_cfg["群聊设置"] = group_list - - groups_by_id: dict[int, dict[str, Any]] = {} - for group_cfg in group_list: - if not isinstance(group_cfg, dict): - continue - try: - group_id = int(group_cfg.get("群号", 0)) - except (TypeError, ValueError): - continue - if group_id > 0: - groups_by_id[group_id] = group_cfg - - migrated_files = 0 - for group_id, path in self._legacy_group_state_files(): - legacy_state = self._read_legacy_group_state_file(path) - if not legacy_state["admins"] and not legacy_state["super_admins"]: - continue - group_cfg = groups_by_id.get(group_id) - if group_cfg is None: - group_cfg = self.group_default(group_id) - group_list.append(group_cfg) - groups_by_id[group_id] = group_cfg - permission_cfg = group_cfg.setdefault( - self.PERMISSION_SETTINGS_KEY, - self.permission_default(), - ) - if not isinstance(permission_cfg, dict): - permission_cfg = self.permission_default() - group_cfg[self.PERMISSION_SETTINGS_KEY] = permission_cfg - - owner_qq = self.normalize_owner_qq(permission_cfg.get("所有者QQ号")) - super_admins = self.normalize_int_list( - permission_cfg.get("超级管理员QQ号", []) - ) - admins = self.normalize_int_list( - permission_cfg.get("普通管理员QQ号", [])) - self._append_unique_ints( - super_admins, legacy_state["super_admins"]) - self._append_unique_ints(admins, legacy_state["admins"]) - permission_cfg["所有者QQ号"] = owner_qq - permission_cfg["超级管理员QQ号"] = super_admins - permission_cfg["普通管理员QQ号"] = admins - self.delete_migrated_legacy_group_admin_file(path) - migrated_files += 1 - - return migrated_files - - def delete_migrated_legacy_group_admin_file(self, path: str) -> bool: - """迁移完单个旧群管理员数据文件后立即删除。""" - try: - os.remove(path) - except FileNotFoundError: - return True - except OSError as err: - if hasattr(self, "print_console_warn"): - self.print_console_warn(f"旧版群管理员数据文件删除失败: {path}: {err}") - return False - return True - - def migrate_config(self, raw_cfg: Any): - """把整个插件配置迁到最新版本。 - - 历史上这个插件经历过“单群结构”和“多群结构”两个阶段, - 所以这里除了常规默认值补齐,还要兼容最早那一版只有 `消息转发设置` 的写法。 - """ - new_cfg = self.cfg_default() - if not isinstance(raw_cfg, dict): - self.migrate_legacy_group_admin_data(new_cfg) - return new_cfg - original_cfg = raw_cfg - has_top_level_binding = isinstance(original_cfg.get("绑定设置"), dict) - raw_cfg = self.merge_with_default(raw_cfg, new_cfg) - +"""配置与群权限相关的公共逻辑。 + +这一层解决两个问题: +1. 把历史配置逐步迁到现在的“多群结构”。 +2. 提供群管理员、超级管理员、触发词等基础数据访问能力。 +""" + +import inspect +import json +import os +from copy import deepcopy +from typing import Any +from collections.abc import Callable + +from tooldelta import cfg + +from .message_utils import QQMsgTrigger + + +# 配置迁移、群权限状态和触发词读取都收在这一层,避免散到业务逻辑里。 +class QQLinkerConfigMixin: + """配置、群状态和触发词的基础能力集合。""" + + MENU_EXIT_TRIGGERS_DEFAULT = [".", "。", "q"] + MENU_BACK_TRIGGERS_DEFAULT = ["!", "!"] + CONFIG_FILE_DIR = "插件配置文件" + RUNTIME_CONFIG_RELOAD_INTERVAL = 5 + DYNAMIC_LOAD_SETTINGS_KEY = "动态载入设置" + DYNAMIC_LOAD_ENABLED_KEY = "是否启用动态载入配置文件(仅用于本插件)" + DYNAMIC_LOAD_INTERVAL_KEY = "动态载入检测时间间隔(单位:秒)" + OWNER_QQ_DEFAULT = 1234567890 + OWNER_QQ_UNSET = 0 + PERMISSION_SETTINGS_KEY = "权限设置" + LEGACY_GROUP_STATE_DIR_NAME = "群聊权限数据" + + @staticmethod + def binding_default(): + """返回全局 QQ 与游戏 ID 绑定配置。""" + return { + "是否开启QQ号与游戏ID绑定功能": False, + "是否允许单QQ号可绑定多游戏ID": False, + "是否允许单游戏ID可绑定多QQ号": False, + "绑定触发词": ["绑定"], + "绑定超时时间(单位:分钟)": 10, + "绑定验证码群聊提示文本": "已将验证码发送至您的私信,请在{time}分钟内在游戏中发送验证码以完成绑定。", + "绑定验证码私信提示文本": "您的绑定验证码是:{auth_code}。请在{time}分钟内在游戏中发送该验证码已完成绑定。", + "拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)": "您已有绑定账号,请解绑后再绑定", + "绑定超时提示文本": "绑定超时,请重新获取验证码绑定", + "绑定成功提示文本": "恭喜你绑定成功,您的游戏ID为:{player_name}。", + } + + @classmethod + def permission_default(cls): + """返回单个群聊的权限配置。""" + return { + "所有者QQ号": cls.OWNER_QQ_DEFAULT, + "超级管理员QQ号": [cls.OWNER_QQ_DEFAULT], + "普通管理员QQ号": [cls.OWNER_QQ_DEFAULT], + "各功能权限设置": { + "查看玩家人数权限": { + "是否允许普通成员使用": True, + "是否允许普通管理员使用": True, + "是否允许超级管理员使用": True, + }, + "发送指令权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": True, + "是否允许超级管理员使用": True, + }, + "查询背包权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": True, + "是否允许超级管理员使用": True, + }, + "封禁/解封玩家权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": True, + "是否允许超级管理员使用": True, + }, + "白名单&管理员检测权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": False, + "是否允许超级管理员使用": True, + }, + "领地系统权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": True, + "是否允许超级管理员使用": True, + }, + "公会系统权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": True, + "是否允许超级管理员使用": True, + }, + "任务系统权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": True, + "是否允许超级管理员使用": True, + }, + "配置配置文件权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": False, + "是否允许超级管理员使用": True, + }, + "QQ普通管理员菜单权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": False, + "是否允许超级管理员使用": True, + }, + "QQ超级管理员菜单权限": { + "是否允许普通成员使用": False, + "是否允许普通管理员使用": False, + "是否允许超级管理员使用": False, + }, + }, + } + + @staticmethod + def group_default(group_id: int = 194838530): + """返回单个群聊的默认配置骨架。""" + # 单个群聊的完整默认结构,老配置迁移时也以它做兜底模板。 + return { + "群号": group_id, + "游戏到群": { + "是否启用": False, + "转发格式": "<[玩家名]> [消息]", + "仅转发以下符号开头的消息(列表为空则全部转发)": ["#"], + "屏蔽以下字符串开头的消息": [".", "。"], + "转发玩家进退提示": True, + }, + "群到游戏": { + "是否启用": True, + "转发格式": "群 <[昵称]> [消息]", + "仅转发以下符号开头的消息(列表为空则全部转发)": [], + "屏蔽的QQ号": [], + "替换花里胡哨的昵称": True, + "替换花里胡哨的消息": True, + }, + QQLinkerConfigMixin.PERMISSION_SETTINGS_KEY: QQLinkerConfigMixin.permission_default(), + "指令设置": { + "发送指令前缀": "/", + "帮助菜单唤醒词": ["help", "帮助"], + "帮助菜单非管理功能每页显示数量": 10, + "帮助菜单管理功能每页显示数量": 10, + "命令触发词帮助菜单每页显示数量": 10, + "配置文件整文件修改模式每页显示数量": 10, + "管理员菜单唤醒词": ["管理员菜单"], + "配置中心唤醒词": ["配置中心", "配置菜单", "群服配置"], + "退出整个菜单触发词": [".", "。", "q"], + "返回上一级菜单触发词": ["!", "!"], + "是否允许查看玩家列表": True, + "查看玩家人数的唤醒词": ["list", "玩家列表"], + "查询背包菜单唤醒词": ["查询背包"], + "查询背包菜单每页显示的玩家数量": 10, + "QQ群封禁唤醒词": ["orban", "orion ban", "猎户封禁"], + "QQ群解封唤醒词": ["orunban", "orion unban", "猎户解封"], + "QQ群白名单&管理员检测唤醒词": ["白名单&管理员检测", "检测管理"], + "任务系统菜单唤醒词": ["任务系统"], + "任务系统每页显示玩家数量": 10, + "任务系统每页显示任务数量": 10, + "领地系统菜单唤醒词": ["领地系统云链联动版", "领地系统", "领地管理"], + "领地系统每页显示领地数量": 10, + "公会系统管理菜单唤醒词": ["公会系统"], + "QQ群封禁/解封菜单每页显示个数": 10, + }, + } + + @classmethod + def cfg_default(cls): + """返回插件级默认配置。""" + return { + cls.DYNAMIC_LOAD_SETTINGS_KEY: { + cls.DYNAMIC_LOAD_ENABLED_KEY: True, + cls.DYNAMIC_LOAD_INTERVAL_KEY: cls.RUNTIME_CONFIG_RELOAD_INTERVAL, + }, + "云链设置": { + "地址": "ws://127.0.0.1:3001", + "校验码": ""}, + "绑定设置": cls.binding_default(), + "群聊设置": [ + cls.group_default()], + } + + @classmethod + def cfg_std(cls): + """返回 ToolDelta 用来校验配置的数据结构定义。""" + binding_std = { + "是否开启QQ号与游戏ID绑定功能": bool, + "是否允许单QQ号可绑定多游戏ID": bool, + "是否允许单游戏ID可绑定多QQ号": bool, + "绑定触发词": cfg.JsonList(str, -1), + "绑定超时时间(单位:分钟)": cfg.PInt, + "绑定验证码群聊提示文本": str, + "绑定验证码私信提示文本": str, + "拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)": str, + "绑定超时提示文本": str, + "绑定成功提示文本": str, + } + permission_item_std = { + "是否允许普通成员使用": bool, + "是否允许普通管理员使用": bool, + "是否允许超级管理员使用": bool, + } + permission_std = { + "所有者QQ号": int, + "超级管理员QQ号": cfg.JsonList(cfg.PInt, -1), + "普通管理员QQ号": cfg.JsonList(cfg.PInt, -1), + "各功能权限设置": { + "查看玩家人数权限": permission_item_std, + "发送指令权限": permission_item_std, + "查询背包权限": permission_item_std, + "封禁/解封玩家权限": permission_item_std, + "白名单&管理员检测权限": permission_item_std, + "领地系统权限": permission_item_std, + "公会系统权限": permission_item_std, + "任务系统权限": permission_item_std, + "配置配置文件权限": permission_item_std, + "QQ普通管理员菜单权限": permission_item_std, + "QQ超级管理员菜单权限": permission_item_std, + }, + } + group_std = { + "群号": cfg.PInt, + "游戏到群": { + "是否启用": bool, + "转发格式": str, + "仅转发以下符号开头的消息(列表为空则全部转发)": cfg.JsonList(str, -1), + "屏蔽以下字符串开头的消息": cfg.JsonList(str, -1), + "转发玩家进退提示": bool, + }, + "群到游戏": { + "是否启用": bool, + "转发格式": str, + "仅转发以下符号开头的消息(列表为空则全部转发)": cfg.JsonList(str, -1), + "屏蔽的QQ号": cfg.JsonList(cfg.PInt, -1), + "替换花里胡哨的昵称": bool, + "替换花里胡哨的消息": bool, + }, + cls.PERMISSION_SETTINGS_KEY: permission_std, + "指令设置": { + "发送指令前缀": str, + "帮助菜单唤醒词": cfg.JsonList(str, -1), + "帮助菜单非管理功能每页显示数量": cfg.PInt, + "帮助菜单管理功能每页显示数量": cfg.PInt, + "命令触发词帮助菜单每页显示数量": cfg.PInt, + "配置文件整文件修改模式每页显示数量": cfg.PInt, + "管理员菜单唤醒词": cfg.JsonList(str, -1), + "配置中心唤醒词": cfg.JsonList(str, -1), + "退出整个菜单触发词": cfg.JsonList(str, -1), + "返回上一级菜单触发词": cfg.JsonList(str, -1), + "是否允许查看玩家列表": bool, + "查看玩家人数的唤醒词": cfg.JsonList(str, -1), + "查询背包菜单唤醒词": cfg.JsonList(str, -1), + "查询背包菜单每页显示的玩家数量": cfg.PInt, + "QQ群封禁唤醒词": cfg.JsonList(str, -1), + "QQ群解封唤醒词": cfg.JsonList(str, -1), + "QQ群白名单&管理员检测唤醒词": cfg.JsonList(str, -1), + "任务系统菜单唤醒词": cfg.JsonList(str, -1), + "任务系统每页显示玩家数量": cfg.PInt, + "任务系统每页显示任务数量": cfg.PInt, + "领地系统菜单唤醒词": cfg.JsonList(str, -1), + "领地系统每页显示领地数量": cfg.PInt, + "公会系统管理菜单唤醒词": cfg.JsonList(str, -1), + "QQ群封禁/解封菜单每页显示个数": cfg.PInt, + }, + } + return { + cls.DYNAMIC_LOAD_SETTINGS_KEY: { + cls.DYNAMIC_LOAD_ENABLED_KEY: bool, + cls.DYNAMIC_LOAD_INTERVAL_KEY: cfg.PInt, + }, + "云链设置": {"地址": str, "校验码": str}, + "绑定设置": binding_std, + "群聊设置": cfg.JsonList(group_std, -1), + } + + @staticmethod + def normalize_int_list(values: Any) -> list[int]: + """把来源不可信的列表规整成去重后的正整数列表。""" + if not isinstance(values, list): + return [] + result: list[int] = [] + for value in values: + try: + ivalue = int(value) + except (TypeError, ValueError): + continue + if ivalue > 0 and ivalue not in result: + result.append(ivalue) + return result + + @classmethod + def normalize_owner_qq(cls, value: Any) -> int: + """把配置中的所有者 QQ 规整成单个整数 QQ 号。""" + text = str(value).strip() if value is not None else "" + if text in ("", "00000000"): + return cls.OWNER_QQ_DEFAULT + try: + qqid = int(text) + except (TypeError, ValueError): + return cls.OWNER_QQ_DEFAULT + if qqid == cls.OWNER_QQ_UNSET: + return cls.OWNER_QQ_DEFAULT + return qqid if qqid > 0 else cls.OWNER_QQ_DEFAULT + + @staticmethod + def _normalize_bool(value: Any, fallback: bool) -> bool: + """Normalize bool values.""" + if isinstance(value, bool): + return value + return fallback + + @classmethod + def merge_with_default(cls, raw: Any, default: Any): + """递归合并旧配置和默认值。 + + 这里不会粗暴覆盖未知字段,目的是在升级时尽量保留用户已经写进配置里的内容。 + """ + if isinstance(default, dict): + result = { + key: cls.merge_with_default(None, value) + for key, value in default.items() + } + if isinstance(raw, dict): + for key, value in raw.items(): + if key in result: + result[key] = cls.merge_with_default( + value, result[key]) + else: + result[key] = value + return result + if isinstance(default, list): + return list(raw) if isinstance(raw, list) else list(default) + return raw if raw is not None else default + + def migrate_group_config(self, raw_group: Any): + """把单个群聊配置迁到当前结构。 + + 这个方法主要处理字段补全、类型纠正和历史字段兼容, + 这样业务层就可以假设拿到的 group_cfg 是完整且结构稳定的。 + """ + if not isinstance(raw_group, dict): + return None + try: + group_id = int(raw_group.get("群号", 0)) + except (TypeError, ValueError): + return None + if group_id <= 0: + return None + group_cfg = self.group_default(group_id) + # 老版本配置是按几个子区块散开的,这里逐段合并到统一结构里。 + old_g2q = raw_group.get("游戏到群", {}) + old_q2g = raw_group.get("群到游戏", {}) + old_permissions = raw_group.get(self.PERMISSION_SETTINGS_KEY, {}) + old_cmd = raw_group.get("指令设置", {}) + self._merge_game_to_group_cfg(group_cfg, old_g2q) + self._merge_group_to_game_cfg(group_cfg, old_q2g) + self._merge_permission_cfg(group_cfg, old_permissions) + self._merge_command_cfg(group_cfg, old_cmd) + return group_cfg + + def _merge_binding_cfg( + self, binding_cfg: dict[str, Any], old_binding: Any): + """把 QQ 与游戏 ID 绑定设置合并进全局配置。""" + if not isinstance(old_binding, dict): + return + binding_cfg["是否开启QQ号与游戏ID绑定功能"] = bool( + old_binding.get( + "是否开启QQ号与游戏ID绑定功能", + binding_cfg["是否开启QQ号与游戏ID绑定功能"], + ) + ) + binding_cfg["是否允许单QQ号可绑定多游戏ID"] = bool( + old_binding.get( + "是否允许单QQ号可绑定多游戏ID", + binding_cfg["是否允许单QQ号可绑定多游戏ID"], + ) + ) + binding_cfg["是否允许单游戏ID可绑定多QQ号"] = bool( + old_binding.get( + "是否允许单游戏ID可绑定多QQ号", + binding_cfg["是否允许单游戏ID可绑定多QQ号"], + ) + ) + binding_cfg["绑定触发词"] = self._clean_string_list( + old_binding.get("绑定触发词", binding_cfg["绑定触发词"]), + binding_cfg["绑定触发词"], + ) + binding_cfg["绑定超时时间(单位:分钟)"] = self._normalize_positive_int( + old_binding.get( + "绑定超时时间(单位:分钟)", + binding_cfg["绑定超时时间(单位:分钟)"], + ), + binding_cfg["绑定超时时间(单位:分钟)"], + ) + old_send_text = str( + old_binding.get( + "绑定验证码群聊提示文本", + binding_cfg["绑定验证码群聊提示文本"], + ) + ).strip() + if old_send_text: + binding_cfg["绑定验证码群聊提示文本"] = old_send_text + binding_cfg["绑定验证码私信提示文本"] = ( + str( + old_binding.get( + "绑定验证码私信提示文本", + binding_cfg["绑定验证码私信提示文本"], + ) + ).strip() + or binding_cfg["绑定验证码私信提示文本"] + ) + binding_cfg["拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)"] = ( + str( + old_binding.get( + "拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)", + binding_cfg["拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)"], + ) + ).strip() + or binding_cfg["拒绝绑定提示文本(仅在“是否允许单QQ号可绑定多游戏ID”为否时生效)"] + ) + binding_cfg["绑定超时提示文本"] = ( + str( + old_binding.get( + "绑定超时提示文本", + binding_cfg["绑定超时提示文本"], + ) + ).strip() + or binding_cfg["绑定超时提示文本"] + ) + binding_cfg["绑定成功提示文本"] = ( + str(old_binding.get("绑定成功提示文本", binding_cfg["绑定成功提示文本"])).strip() + or binding_cfg["绑定成功提示文本"] + ) + + def _merge_game_to_group_cfg( + self, group_cfg: dict[str, Any], old_g2q: Any): + """把旧版“游戏到群”配置段合并进当前群配置。""" + if not isinstance(old_g2q, dict): + return + game_to_group = group_cfg["游戏到群"] + game_to_group["是否启用"] = bool( + old_g2q.get("是否启用", game_to_group["是否启用"])) + game_to_group["转发格式"] = str(old_g2q.get("转发格式", game_to_group["转发格式"])) + game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"] = self._clean_string_list( + old_g2q.get( + "仅转发以下符号开头的消息(列表为空则全部转发)", + game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"], + ), + game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"], + allow_empty=True, + ) + game_to_group["屏蔽以下字符串开头的消息"] = self._clean_string_list( + old_g2q.get( + "屏蔽以下字符串开头的消息", + game_to_group["屏蔽以下字符串开头的消息"], + ), + game_to_group["屏蔽以下字符串开头的消息"], + ) + game_to_group["转发玩家进退提示"] = bool( + old_g2q.get("转发玩家进退提示", game_to_group["转发玩家进退提示"]) + ) + + def _merge_group_to_game_cfg( + self, group_cfg: dict[str, Any], old_q2g: Any): + """把旧版“群到游戏”配置段合并进当前群配置。""" + if not isinstance(old_q2g, dict): + return + group_to_game = group_cfg["群到游戏"] + group_to_game["是否启用"] = bool( + old_q2g.get("是否启用", group_to_game["是否启用"])) + group_to_game["转发格式"] = str(old_q2g.get("转发格式", group_to_game["转发格式"])) + group_to_game["仅转发以下符号开头的消息(列表为空则全部转发)"] = self._clean_string_list( + old_q2g.get( + "仅转发以下符号开头的消息(列表为空则全部转发)", + group_to_game["仅转发以下符号开头的消息(列表为空则全部转发)"], + ), + group_to_game["仅转发以下符号开头的消息(列表为空则全部转发)"], + allow_empty=True, + ) + group_to_game["屏蔽的QQ号"] = self.normalize_int_list( + old_q2g.get("屏蔽的QQ号", [])) + group_to_game["替换花里胡哨的昵称"] = bool( + old_q2g.get("替换花里胡哨的昵称", group_to_game["替换花里胡哨的昵称"]) + ) + group_to_game["替换花里胡哨的消息"] = bool( + old_q2g.get("替换花里胡哨的消息", group_to_game["替换花里胡哨的消息"]) + ) + + def _legacy_group_state_dir(self) -> str: + """Implement the legacy group state dir operation.""" + return self.format_data_path(self.LEGACY_GROUP_STATE_DIR_NAME) + + def _read_legacy_group_state_file(self, path: str) -> dict[str, list[int]]: + """Implement the read legacy group state file operation.""" + if not os.path.isfile(path): + return {"admins": [], "super_admins": []} + try: + with open(path, "r", encoding="utf-8") as file: + data = json.load(file) + except Exception: + data = {} + if not isinstance(data, dict): + data = {} + return { + "admins": self.normalize_int_list( + data.get( + "admins", [])), "super_admins": self.normalize_int_list( + data.get( + "super_admins", [])), } + + def _merge_permission_item( + self, next_item: dict[str, bool], raw_item: Any): + """Implement the merge permission item operation.""" + if not isinstance(raw_item, dict): + return + for key, fallback in list(next_item.items()): + next_item[key] = self._normalize_bool(raw_item.get(key), fallback) + + def _merge_permission_cfg( + self, + group_cfg: dict[str, Any], + old_permissions: Any, + ): + """把权限配置合并进当前群配置。管理员状态只来自配置本身。""" + permission_cfg = group_cfg[self.PERMISSION_SETTINGS_KEY] + + if isinstance(old_permissions, dict): + permission_cfg["所有者QQ号"] = self.normalize_owner_qq( + old_permissions.get("所有者QQ号", permission_cfg["所有者QQ号"]) + ) + permission_cfg["超级管理员QQ号"] = self.normalize_int_list( + old_permissions.get("超级管理员QQ号", permission_cfg["超级管理员QQ号"]) + ) + permission_cfg["普通管理员QQ号"] = self.normalize_int_list( + old_permissions.get("普通管理员QQ号", permission_cfg["普通管理员QQ号"]) + ) + feature_permissions = old_permissions.get("各功能权限设置", {}) + if isinstance(feature_permissions, dict): + for key, next_item in permission_cfg["各功能权限设置"].items(): + self._merge_permission_item( + next_item, feature_permissions.get(key)) + + owner_qq = self.normalize_owner_qq(permission_cfg.get("所有者QQ号")) + permission_cfg["所有者QQ号"] = owner_qq + permission_cfg["超级管理员QQ号"] = self.normalize_int_list( + permission_cfg.get("超级管理员QQ号", []) + ) + permission_cfg["普通管理员QQ号"] = self.normalize_int_list( + permission_cfg.get("普通管理员QQ号", []) + ) + + def _merge_command_cfg(self, group_cfg: dict[str, Any], old_cmd: Any): + """把旧版指令相关配置合并进当前群配置。""" + if not isinstance(old_cmd, dict): + return + command_cfg = group_cfg["指令设置"] + command_cfg["发送指令前缀"] = ( + str(old_cmd.get("发送指令前缀", command_cfg["发送指令前缀"])).strip() + or command_cfg["发送指令前缀"] + ) + command_cfg["管理员菜单唤醒词"] = self._clean_string_list( + old_cmd.get("管理员菜单唤醒词", command_cfg["管理员菜单唤醒词"]), + command_cfg["管理员菜单唤醒词"], + ) + command_cfg["帮助菜单唤醒词"] = self._clean_string_list( + old_cmd.get("帮助菜单唤醒词", command_cfg["帮助菜单唤醒词"]), + command_cfg["帮助菜单唤醒词"], + ) + command_cfg["帮助菜单非管理功能每页显示数量"] = self._normalize_positive_int( + old_cmd.get( + "帮助菜单非管理功能每页显示数量", + command_cfg["帮助菜单非管理功能每页显示数量"], + ), + command_cfg["帮助菜单非管理功能每页显示数量"], + ) + command_cfg["帮助菜单管理功能每页显示数量"] = self._normalize_positive_int( + old_cmd.get( + "帮助菜单管理功能每页显示数量", + command_cfg["帮助菜单管理功能每页显示数量"], + ), + command_cfg["帮助菜单管理功能每页显示数量"], + ) + command_cfg["命令触发词帮助菜单每页显示数量"] = self._normalize_positive_int( + old_cmd.get( + "命令触发词帮助菜单每页显示数量", + command_cfg["命令触发词帮助菜单每页显示数量"], + ), + command_cfg["命令触发词帮助菜单每页显示数量"], + ) + command_cfg["配置文件整文件修改模式每页显示数量"] = self._normalize_positive_int( + old_cmd.get( + "配置文件整文件修改模式每页显示数量", + command_cfg["配置文件整文件修改模式每页显示数量"], + ), + command_cfg["配置文件整文件修改模式每页显示数量"], + ) + command_cfg["配置中心唤醒词"] = self._clean_string_list( + old_cmd.get("配置中心唤醒词", command_cfg["配置中心唤醒词"]), + command_cfg["配置中心唤醒词"], + ) + command_cfg["退出整个菜单触发词"] = self._clean_string_list( + old_cmd.get("退出整个菜单触发词", command_cfg["退出整个菜单触发词"]), + command_cfg["退出整个菜单触发词"], + ) + command_cfg["返回上一级菜单触发词"] = self._clean_string_list( + old_cmd.get("返回上一级菜单触发词", command_cfg["返回上一级菜单触发词"]), + command_cfg["返回上一级菜单触发词"], + ) + command_cfg["是否允许查看玩家列表"] = bool( + old_cmd.get("是否允许查看玩家列表", command_cfg["是否允许查看玩家列表"]) + ) + command_cfg["查看玩家人数的唤醒词"] = self._clean_string_list( + old_cmd.get("查看玩家人数的唤醒词", command_cfg["查看玩家人数的唤醒词"]), + command_cfg["查看玩家人数的唤醒词"], + ) + command_cfg["查询背包菜单唤醒词"] = self._clean_string_list( + old_cmd.get("查询背包菜单唤醒词", command_cfg["查询背包菜单唤醒词"]), + command_cfg["查询背包菜单唤醒词"], + ) + command_cfg["查询背包菜单每页显示的玩家数量"] = self._normalize_positive_int( + old_cmd.get( + "查询背包菜单每页显示的玩家数量", + command_cfg["查询背包菜单每页显示的玩家数量"], + ), + command_cfg["查询背包菜单每页显示的玩家数量"], + ) + command_cfg["QQ群封禁唤醒词"] = self._clean_string_list( + old_cmd.get("QQ群封禁唤醒词", command_cfg["QQ群封禁唤醒词"]), + command_cfg["QQ群封禁唤醒词"], + ) + command_cfg["QQ群解封唤醒词"] = self._clean_string_list( + old_cmd.get("QQ群解封唤醒词", command_cfg["QQ群解封唤醒词"]), + command_cfg["QQ群解封唤醒词"], + ) + command_cfg["QQ群白名单&管理员检测唤醒词"] = self._clean_string_list( + old_cmd.get( + "QQ群白名单&管理员检测唤醒词", + command_cfg["QQ群白名单&管理员检测唤醒词"], + ), + command_cfg["QQ群白名单&管理员检测唤醒词"], + ) + command_cfg["任务系统菜单唤醒词"] = self._clean_string_list( + old_cmd.get("任务系统菜单唤醒词", command_cfg["任务系统菜单唤醒词"]), + command_cfg["任务系统菜单唤醒词"], + ) + command_cfg["任务系统每页显示玩家数量"] = self._normalize_positive_int( + old_cmd.get( + "任务系统每页显示玩家数量", + command_cfg["任务系统每页显示玩家数量"], + ), + command_cfg["任务系统每页显示玩家数量"], + ) + command_cfg["任务系统每页显示任务数量"] = self._normalize_positive_int( + old_cmd.get( + "任务系统每页显示任务数量", + command_cfg["任务系统每页显示任务数量"], + ), + command_cfg["任务系统每页显示任务数量"], + ) + command_cfg["领地系统菜单唤醒词"] = self._clean_string_list( + old_cmd.get("领地系统菜单唤醒词", command_cfg["领地系统菜单唤醒词"]), + command_cfg["领地系统菜单唤醒词"], + ) + command_cfg["领地系统每页显示领地数量"] = self._normalize_positive_int( + old_cmd.get( + "领地系统每页显示领地数量", + command_cfg["领地系统每页显示领地数量"], + ), + command_cfg["领地系统每页显示领地数量"], + ) + command_cfg["公会系统管理菜单唤醒词"] = self._clean_string_list( + old_cmd.get( + "公会系统管理菜单唤醒词", + command_cfg["公会系统管理菜单唤醒词"], + ), + command_cfg["公会系统管理菜单唤醒词"], + ) + command_cfg["QQ群封禁/解封菜单每页显示个数"] = self._normalize_positive_int( + old_cmd.get( + "QQ群封禁/解封菜单每页显示个数", + command_cfg["QQ群封禁/解封菜单每页显示个数"], + ), + command_cfg["QQ群封禁/解封菜单每页显示个数"], + ) + + @staticmethod + def _clean_string_list( + raw: Any, + fallback: list[str], + allow_empty: bool = False): + """清理字符串列表,失败时回退到默认值。""" + if isinstance(raw, list): + cleaned = [str(item) for item in raw if isinstance(item, str)] + if cleaned or allow_empty: + return cleaned + return fallback + + @staticmethod + def _normalize_positive_int(value: Any, fallback: int): + """把不可信的数字输入规范成正整数。""" + try: + return max(1, int(value)) + except (TypeError, ValueError): + return fallback + + def _legacy_group_state_files(self) -> list[tuple[int, str]]: + """Implement the legacy group state files operation.""" + legacy_dir = self._legacy_group_state_dir() + try: + names = os.listdir(legacy_dir) + except OSError: + return [] + files: list[tuple[int, str]] = [] + for name in names: + stem, ext = os.path.splitext(name) + if ext.lower() != ".json": + continue + try: + group_id = int(stem) + except ValueError: + continue + if group_id > 0: + files.append((group_id, os.path.join(legacy_dir, name))) + return files + + @staticmethod + def _append_unique_ints(target: list[int], values: list[int]) -> bool: + """Implement the append unique ints operation.""" + changed = False + for value in values: + if value not in target: + target.append(value) + changed = True + return changed + + def migrate_legacy_group_admin_data(self, next_cfg: dict[str, Any]) -> int: + """把旧插件数据目录里的群管理员状态合并进主配置。""" + group_list = next_cfg.get("群聊设置") + if not isinstance(group_list, list): + group_list = [] + next_cfg["群聊设置"] = group_list + + groups_by_id: dict[int, dict[str, Any]] = {} + for group_cfg in group_list: + if not isinstance(group_cfg, dict): + continue + try: + group_id = int(group_cfg.get("群号", 0)) + except (TypeError, ValueError): + continue + if group_id > 0: + groups_by_id[group_id] = group_cfg + + migrated_files = 0 + for group_id, path in self._legacy_group_state_files(): + legacy_state = self._read_legacy_group_state_file(path) + if not legacy_state["admins"] and not legacy_state["super_admins"]: + continue + group_cfg = groups_by_id.get(group_id) + if group_cfg is None: + group_cfg = self.group_default(group_id) + group_list.append(group_cfg) + groups_by_id[group_id] = group_cfg + permission_cfg = group_cfg.setdefault( + self.PERMISSION_SETTINGS_KEY, + self.permission_default(), + ) + if not isinstance(permission_cfg, dict): + permission_cfg = self.permission_default() + group_cfg[self.PERMISSION_SETTINGS_KEY] = permission_cfg + + owner_qq = self.normalize_owner_qq(permission_cfg.get("所有者QQ号")) + super_admins = self.normalize_int_list( + permission_cfg.get("超级管理员QQ号", []) + ) + admins = self.normalize_int_list( + permission_cfg.get("普通管理员QQ号", [])) + self._append_unique_ints( + super_admins, legacy_state["super_admins"]) + self._append_unique_ints(admins, legacy_state["admins"]) + permission_cfg["所有者QQ号"] = owner_qq + permission_cfg["超级管理员QQ号"] = super_admins + permission_cfg["普通管理员QQ号"] = admins + self.delete_migrated_legacy_group_admin_file(path) + migrated_files += 1 + + return migrated_files + + def delete_migrated_legacy_group_admin_file(self, path: str) -> bool: + """迁移完单个旧群管理员数据文件后立即删除。""" + try: + os.remove(path) + except FileNotFoundError: + return True + except OSError as err: + if hasattr(self, "print_console_warn"): + self.print_console_warn(f"旧版群管理员数据文件删除失败: {path}: {err}") + return False + return True + + def migrate_config(self, raw_cfg: Any): + """把整个插件配置迁到最新版本。 + + 历史上这个插件经历过“单群结构”和“多群结构”两个阶段, + 所以这里除了常规默认值补齐,还要兼容最早那一版只有 `消息转发设置` 的写法。 + """ + new_cfg = self.cfg_default() + if not isinstance(raw_cfg, dict): + self.migrate_legacy_group_admin_data(new_cfg) + return new_cfg + original_cfg = raw_cfg + has_top_level_binding = isinstance(original_cfg.get("绑定设置"), dict) + raw_cfg = self.merge_with_default(raw_cfg, new_cfg) + dynamic_load_cfg = raw_cfg.get(self.DYNAMIC_LOAD_SETTINGS_KEY, {}) if isinstance(dynamic_load_cfg, dict): dynamic_settings = new_cfg[self.DYNAMIC_LOAD_SETTINGS_KEY] @@ -824,806 +830,811 @@ def migrate_config(self, raw_cfg: Any): ), default_interval, ) - - cloud_cfg = raw_cfg.get("云链设置", {}) - if isinstance(cloud_cfg, dict): - new_cfg["云链设置"]["地址"] = str( - cloud_cfg.get("地址", new_cfg["云链设置"]["地址"]) - ) - validate_code = cloud_cfg.get("校验码", "") - new_cfg["云链设置"]["校验码"] = ( - "" if validate_code is None else str(validate_code) - ) - - if has_top_level_binding: - self._merge_binding_cfg(new_cfg["绑定设置"], original_cfg["绑定设置"]) - - group_cfgs: list[dict[str, Any]] = [] - if isinstance(raw_cfg.get("群聊设置"), list): - for raw_group in raw_cfg["群聊设置"]: - if ( - not has_top_level_binding - and isinstance(raw_group, dict) - and isinstance(raw_group.get("绑定设置"), dict) - ): - self._merge_binding_cfg(new_cfg["绑定设置"], raw_group["绑定设置"]) - migrated = self.migrate_group_config(raw_group) - if migrated is not None: - group_cfgs.append(migrated) - elif isinstance(raw_cfg.get("消息转发设置"), dict): - # 兼容最早的单群结构,迁完之后统一走“群聊设置”列表。 - old_msg_cfg = raw_cfg["消息转发设置"] - try: - old_group_id = int(old_msg_cfg.get("链接的群聊", 194838530)) - except (TypeError, ValueError): - old_group_id = 194838530 - migrated_group = self.group_default(old_group_id) - if isinstance(old_msg_cfg.get("游戏到群"), dict): - migrated_group["游戏到群"] = self.migrate_group_config( - { - "群号": old_group_id, - "游戏到群": old_msg_cfg["游戏到群"], - } - )["游戏到群"] - if isinstance(old_msg_cfg.get("群到游戏"), dict): - migrated_group["群到游戏"] = self.migrate_group_config( - { - "群号": old_group_id, - "群到游戏": old_msg_cfg["群到游戏"], - } - )["群到游戏"] - if isinstance(raw_cfg.get("指令设置"), dict): - migrated_group["指令设置"]["是否允许查看玩家列表"] = bool( - raw_cfg["指令设置"].get( - "是否允许查看玩家列表", - migrated_group["指令设置"]["是否允许查看玩家列表"], - ) - ) - self._merge_permission_cfg(migrated_group, {}) - group_cfgs.append(migrated_group) - - if group_cfgs: - dedup: dict[int, dict[str, Any]] = {} - for group_cfg in group_cfgs: - dedup[group_cfg["群号"]] = group_cfg - new_cfg["群聊设置"] = list(dedup.values()) - - self.migrate_legacy_group_admin_data(new_cfg) - return new_cfg - - def reload_group_configs(self): - """把配置中的群信息展开成运行时缓存。 - - 后续消息分发会频繁按群号查配置,所以这里同时保留: - - `group_cfgs`: 适合按群号直接读取 - - `group_order`: 适合顺序遍历和显示菜单 - """ - self.group_cfgs.clear() - self.group_order.clear() - # 运行时同时保留 dict 和顺序列表,后面做群路由会更直接。 - for group_cfg in self.cfg["群聊设置"]: - group_id = int(group_cfg["群号"]) - self.group_cfgs[group_id] = group_cfg - self.group_order.append(group_id) - for group_id in self.group_order: - self.ensure_group_state(group_id) - - def persist_runtime_config(self): - """把当前 Ultra 配置写回 ToolDelta 配置文件。""" - cfg.check_auto(self.cfg_std(), self.cfg) - cfg.upgrade_plugin_config(self.name, self.cfg, self.version) - self.refresh_runtime_config_file_state() - - def runtime_config_path(self) -> str: - """返回 ToolDelta 生成的本插件配置文件路径。""" - return os.path.join(self.CONFIG_FILE_DIR, f"{self.name}.json") - - @staticmethod - def runtime_config_file_state(path: str) -> tuple[int, int] | None: - """返回配置文件状态,用于判断外部修改。""" - try: - stat = os.stat(path) - except OSError: - return None - return stat.st_mtime_ns, stat.st_size - - def refresh_runtime_config_file_state(self) -> None: - """记录当前配置文件状态,避免刚启动就重复热载一次。""" - path = getattr( - self, - "_runtime_config_path", - None) or self.runtime_config_path() - self._runtime_config_path = path - self._runtime_config_file_state = self.runtime_config_file_state(path) - - def is_runtime_config_reload_enabled(self) -> bool: - """返回是否启用本插件配置文件动态载入。""" - settings = getattr( - self, "cfg", {}).get( - self.DYNAMIC_LOAD_SETTINGS_KEY, {}) - if not isinstance(settings, dict): - return True - return bool(settings.get(self.DYNAMIC_LOAD_ENABLED_KEY, True)) - - def runtime_config_reload_interval(self) -> int: - """返回动态载入检测间隔秒数。""" - settings = getattr( - self, "cfg", {}).get( - self.DYNAMIC_LOAD_SETTINGS_KEY, {}) - if not isinstance(settings, dict): - return self.RUNTIME_CONFIG_RELOAD_INTERVAL - return self._normalize_positive_int( - settings.get(self.DYNAMIC_LOAD_INTERVAL_KEY, - self.RUNTIME_CONFIG_RELOAD_INTERVAL), - self.RUNTIME_CONFIG_RELOAD_INTERVAL, - ) - - def apply_ultra_runtime_config(self, raw_config: Any) -> str: - """把 Ultra 配置应用到当前运行时,无需重载插件。""" - old_cloud_cfg = deepcopy(getattr(self, "cfg", {}).get("云链设置", {})) - migrated = self.migrate_config(raw_config) - cfg.check_auto(self.cfg_std(), migrated) - self.cfg = migrated - self.reload_group_configs() - self.persist_runtime_config() - if old_cloud_cfg != self.cfg.get("云链设置", {}): - self.reload_websocket_connection() - return "Ultra 配置已动态载入,云链连接正在按新配置重连" - return "Ultra 配置已动态载入" - - def check_runtime_config_file_update(self) -> bool: - """检查本插件配置文件是否被修改,变化时立即热应用。""" - if not self.is_runtime_config_reload_enabled(): - return False - - path = getattr( - self, - "_runtime_config_path", - None) or self.runtime_config_path() - self._runtime_config_path = path - current_state = self.runtime_config_file_state(path) - if current_state is None or current_state == getattr( - self, - "_runtime_config_file_state", - None, - ): - return False - - try: - with open(path, "r", encoding="utf-8-sig") as file: - full_config = json.load(file) - raw_config = self._extract_config_items(full_config) - message = self.apply_ultra_runtime_config(raw_config) - self.refresh_runtime_config_file_state() - if hasattr(self, "print_console_success"): - self.print_console_success(message) - return True - except Exception as err: - self._runtime_config_file_state = current_state - if hasattr(self, "print_console_error"): - self.print_console_error(f"Ultra 配置热更新失败: {err}") - return False - - @staticmethod - def _extract_config_items(full_config: Any) -> Any: - if isinstance( - full_config, - dict) and isinstance( - full_config.get("配置项"), - dict): - return full_config["配置项"] - return full_config - - @property - def linked_group(self) -> int | None: - """返回兼容旧插件调用时使用的默认群号。""" - # 给还按“单群互通”接口调用的旧插件留一个兼容入口。 - return self.group_order[0] if self.group_order else None - - def _permission_cfg_for_group( - self, group_id: int) -> dict[str, Any] | None: - group_cfg = getattr(self, "group_cfgs", {}).get(group_id) - if not isinstance(group_cfg, dict): - return None - permission_cfg = group_cfg.get(self.PERMISSION_SETTINGS_KEY) - return permission_cfg if isinstance(permission_cfg, dict) else None - - def read_group_state(self, group_id: int): - """从主配置读取单个群的管理员状态。""" - permission_cfg = self._permission_cfg_for_group(group_id) - if permission_cfg is None: - return {"admins": [], "super_admins": []} - return { - "admins": self.normalize_int_list( - permission_cfg.get( - "普通管理员QQ号", [])), "super_admins": self.normalize_int_list( - permission_cfg.get( - "超级管理员QQ号", [])), } - - def get_group_owner_qq(self, group_id: int) -> int | None: - """读取群配置中的所有者 QQ。""" - permission_cfg = self._permission_cfg_for_group(group_id) - if permission_cfg is None: - return None - owner_qq = self.normalize_owner_qq(permission_cfg.get("所有者QQ号")) - if owner_qq == self.OWNER_QQ_UNSET: - return None - return owner_qq - - def save_group_state(self, group_id: int, state: dict[str, list[int]]): - """把群权限状态保存到主配置文件。""" - permission_cfg = self._permission_cfg_for_group(group_id) - if permission_cfg is None: - return - owner_qq = self.get_group_owner_qq(group_id) - normalized = { - "admins": self.normalize_int_list( - state.get( - "admins", [])), "super_admins": self.normalize_int_list( - state.get( - "super_admins", [])), } - permission_cfg["普通管理员QQ号"] = normalized["admins"] - permission_cfg["超级管理员QQ号"] = normalized["super_admins"] - self.persist_runtime_config() - - def ensure_group_state(self, group_id: int): - """确保群权限配置结构可读,并移除重复/非法管理员项。""" - permission_cfg = self._permission_cfg_for_group(group_id) - if permission_cfg is None: - return - state = self.read_group_state(group_id) - permission_cfg["超级管理员QQ号"] = state["super_admins"] - permission_cfg["普通管理员QQ号"] = state["admins"] - - def is_group_owner(self, group_id: int, qqid: int): - """判断某个 QQ 是否是指定群的所有者。""" - return self.get_group_owner_qq(group_id) == qqid - - def is_group_super_admin(self, group_id: int, qqid: int): - """判断某个 QQ 是否拥有超级管理员级权限。""" - if self.is_group_owner(group_id, qqid): - return True - return qqid in self.read_group_state(group_id)["super_admins"] - - def is_group_admin(self, group_id: int, qqid: int): - """判断某个 QQ 是否拥有群内管理权限。 - - 所有者、超级管理员和普通管理员都可以执行普通管理功能。 - """ - if self.is_group_owner(group_id, qqid): - return True - state = self.read_group_state(group_id) - return qqid in state["super_admins"] or qqid in state["admins"] - - def has_group_permission( - self, - group_id: int, - qqid: int, - permission_name: str) -> bool: - """按群配置中的“各功能权限设置”判断某个 QQ 是否可用指定功能。""" - if self.is_group_owner(group_id, qqid): - return True - permission_cfg = self._permission_cfg_for_group(group_id) - if permission_cfg is None: - return False - feature_permissions = permission_cfg.get("各功能权限设置", {}) - if not isinstance(feature_permissions, dict): - return False - item = feature_permissions.get(permission_name) - if not isinstance(item, dict): - return False - state = self.read_group_state(group_id) - if qqid in state["super_admins"]: - return bool(item.get("是否允许超级管理员使用", False)) - if qqid in state["admins"]: - return bool(item.get("是否允许普通管理员使用", False)) - return bool(item.get("是否允许普通成员使用", False)) - - def is_qq_op(self, qqid: int, group_id: int | None = None): - """兼容旧命名,判断某个 QQ 是否拥有管理员权限。""" - if group_id is not None: - return self.is_group_admin(group_id, qqid) - return any(self.is_group_admin(gid, qqid) for gid in self.group_order) - - def add_group_role(self, group_id: int, qqid: int, is_super: bool): - """给群成员授予管理员或超级管理员身份。""" - if self.is_group_owner(group_id, qqid): - return False, "该 QQ 是本群所有者,已拥有最高权限" - state = self.read_group_state(group_id) - if is_super: - if qqid in state["super_admins"]: - return False, "该 QQ 已经是本群超级管理员" - if qqid in state["admins"]: - state["admins"].remove(qqid) - state["super_admins"].append(qqid) - self.save_group_state(group_id, state) - return True, "已添加为本群超级管理员" - if qqid in state["super_admins"]: - return False, "该 QQ 已经是本群超级管理员,无需再添加为管理员" - if qqid in state["admins"]: - return False, "该 QQ 已经是本群管理员" - state["admins"].append(qqid) - self.save_group_state(group_id, state) - return True, "已添加为本群管理员" - - def remove_group_role(self, group_id: int, qqid: int, is_super: bool): - """移除群成员的管理员或超级管理员身份。""" - if self.is_group_owner(group_id, qqid): - return False, "不能在管理员菜单中移除本群所有者" - state = self.read_group_state(group_id) - if is_super: - if qqid not in state["super_admins"]: - return False, "该 QQ 不是本群超级管理员" - state["super_admins"].remove(qqid) - self.save_group_state(group_id, state) - return True, "已移除本群超级管理员" - if qqid not in state["admins"]: - return False, "该 QQ 不是本群普通管理员" - state["admins"].remove(qqid) - self.save_group_state(group_id, state) - return True, "已移除本群普通管理员" - - @staticmethod - def _api_int_value(value: Any): - try: - return int(str(value).strip()) - except (TypeError, ValueError): - return None - - def api_get_linked_groups(self) -> list[int]: - """Return configured linked QQ group IDs in runtime order.""" - return list(self.group_order) - - def api_get_default_group(self) -> int | None: - """Return the first configured group ID for old single-group callers.""" - return self.linked_group - - def api_is_group_configured(self, group_id: int | str) -> bool: - """Return whether a QQ group is configured in Ultra.""" - gid = self._api_int_value(group_id) - return gid in self.group_cfgs if gid is not None else False - - def api_get_group_config(self, group_id: int | - str) -> dict[str, Any] | None: - """Return a copy of one group's runtime config.""" - gid = self._api_int_value(group_id) - if gid is None or gid not in self.group_cfgs: - return None - return deepcopy(self.group_cfgs[gid]) - - def api_get_group_state(self, group_id: int | - str) -> dict[str, list[int]] | None: - """Return a copy of one group's admin state.""" - gid = self._api_int_value(group_id) - if gid is None or gid not in self.group_cfgs: - return None - state = self.read_group_state(gid) - owner_qq = self.get_group_owner_qq(gid) - return { - "admins": list(state["admins"]), - "super_admins": list(state["super_admins"]), - "owner": [] if owner_qq is None else [owner_qq], - } - - def api_get_group_admins( - self, - group_id: int | str, - include_super: bool = True, - ) -> list[int]: - """Return normal group admins, optionally including super admins.""" - state = self.api_get_group_state(group_id) - if state is None: - return [] - admins = list(state["admins"]) - if include_super: - for qqid in state["super_admins"]: - if qqid not in admins: - admins.append(qqid) - for qqid in state.get("owner", []): - if qqid not in admins: - admins.append(qqid) - return admins - - def api_get_group_super_admins(self, group_id: int | str) -> list[int]: - """Return super admins for one configured group.""" - state = self.api_get_group_state(group_id) - return [] if state is None else list(state["super_admins"]) - - def api_is_group_admin(self, group_id: int | str, qqid: int | str) -> bool: - """Return whether a QQ number has admin permission in a group.""" - gid = self._api_int_value(group_id) - qid = self._api_int_value(qqid) - if gid is None or qid is None or gid not in self.group_cfgs: - return False - return self.is_group_admin(gid, qid) - - def api_is_group_super_admin( - self, - group_id: int | str, - qqid: int | str) -> bool: - """Return whether a QQ number is a super admin in a group.""" - gid = self._api_int_value(group_id) - qid = self._api_int_value(qqid) - if gid is None or qid is None or gid not in self.group_cfgs: - return False - return self.is_group_super_admin(gid, qid) - - def api_get_group_owner(self, group_id: int | str) -> int | None: - """Return the configured owner QQ for one group.""" - gid = self._api_int_value(group_id) - if gid is None or gid not in self.group_cfgs: - return None - return self.get_group_owner_qq(gid) - - def api_is_group_owner(self, group_id: int | str, qqid: int | str) -> bool: - """Return whether a QQ number is the configured owner in a group.""" - gid = self._api_int_value(group_id) - qid = self._api_int_value(qqid) - if gid is None or qid is None or gid not in self.group_cfgs: - return False - return self.is_group_owner(gid, qid) - - def api_add_group_admin( - self, - group_id: int | str, - qqid: int | str, - is_super: bool = False, - ) -> tuple[bool, str]: - """Grant normal-admin or super-admin permission to a QQ number.""" - gid = self._api_int_value(group_id) - qid = self._api_int_value(qqid) - if gid is None or gid not in self.group_cfgs: - return False, "群号无效或未配置" - if qid is None or qid <= 0: - return False, "QQ号无效" - return self.add_group_role(gid, qid, bool(is_super)) - - def api_remove_group_admin( - self, - group_id: int | str, - qqid: int | str, - is_super: bool = False, - ) -> tuple[bool, str]: - """Remove normal-admin or super-admin permission from a QQ number.""" - gid = self._api_int_value(group_id) - qid = self._api_int_value(qqid) - if gid is None or gid not in self.group_cfgs: - return False, "群号无效或未配置" - if qid is None or qid <= 0: - return False, "QQ号无效" - return self.remove_group_role(gid, qid, bool(is_super)) - - def api_get_group_triggers( - self, group_id: int | str) -> dict[str, Any] | None: - """Return normalized trigger words and menu controls for one group.""" - gid = self._api_int_value(group_id) - if gid is None or gid not in self.group_cfgs: - return None - triggers = { - "help": self.get_group_help_triggers(gid), - "admin_menu": self.get_group_admin_menu_triggers(gid), - "player_list": self.get_group_player_list_triggers(gid), - "inventory_menu": self.get_group_inventory_menu_triggers(gid), - "menu_exit": self.get_group_menu_exit_triggers(gid), - "menu_back": self.get_group_menu_back_triggers(gid), - "command_prefix": self.get_group_cmd_prefix(gid), - "orion_ban": self.get_group_orion_ban_triggers(gid), - "orion_unban": self.get_group_orion_unban_triggers(gid), - "checker_menu": self.get_group_checker_menu_triggers(gid), - "task_menu": self.get_group_task_menu_triggers(gid), - "land_menu": self.get_group_land_menu_triggers(gid), - "guild_menu": self.get_group_guild_menu_triggers(gid), - } - if hasattr(self, "get_group_binding_triggers"): - triggers["binding"] = self.get_group_binding_triggers(gid) - return triggers - - def api_get_registered_triggers(self) -> list[dict[str, Any]]: - """Return external QQ triggers registered through add_trigger(...).""" - return [ - { - "triggers": list(trigger.triggers), - "argument_hint": trigger.argument_hint, - "usage": trigger.usage, - "op_only": bool(trigger.op_only), - "accept_group": bool(trigger.accept_group), - } - for trigger in self.triggers - ] - - def get_group_player_list_triggers(self, group_id: int): - """读取某个群的玩家列表触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get("查看玩家人数的唤醒词", ["list", "玩家列表"]) - return self.normalize_string_triggers(raw, ["list", "玩家列表"]) - - def get_group_inventory_menu_triggers(self, group_id: int): - """读取某个群的背包查询触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get("查询背包菜单唤醒词", ["查询背包"]) - return self.normalize_string_triggers(raw, ["查询背包"]) - - def get_group_inventory_items_per_page(self, group_id: int): - """读取背包查询菜单每页条数。""" - group_cfg = self.group_cfgs.get(group_id) - if group_cfg is not None: - try: - return max( - 1, - int(group_cfg["指令设置"]["查询背包菜单每页显示的玩家数量"]), - ) - except (KeyError, TypeError, ValueError): - return 10 - return 10 - - def get_group_help_non_admin_items_per_page(self, group_id: int): - """读取帮助菜单非管理功能每页条数。""" - group_cfg = self.group_cfgs.get(group_id) - if group_cfg is not None: - try: - return max( - 1, - int(group_cfg["指令设置"]["帮助菜单非管理功能每页显示数量"]), - ) - except (KeyError, TypeError, ValueError): - return 10 - return 10 - - def get_group_help_admin_items_per_page(self, group_id: int): - """读取帮助菜单管理功能每页条数。""" - group_cfg = self.group_cfgs.get(group_id) - if group_cfg is not None: - try: - return max( - 1, - int(group_cfg["指令设置"]["帮助菜单管理功能每页显示数量"]), - ) - except (KeyError, TypeError, ValueError): - return 10 - return 10 - - def get_group_command_help_items_per_page(self, group_id: int): - """读取命令触发词帮助菜单每页条数。""" - group_cfg = self.group_cfgs.get(group_id) - if group_cfg is not None: - try: - return max( - 1, - int(group_cfg["指令设置"]["命令触发词帮助菜单每页显示数量"]), - ) - except (KeyError, TypeError, ValueError): - return 10 - return 10 - - def get_group_config_file_items_per_page( - self, group_id: int | None = None): - """读取配置文件整文件修改模式选择菜单每页条数。""" - group_cfg = self.group_cfgs.get( - group_id) if group_id is not None else None - if group_cfg is not None: - try: - return max( - 1, - int(group_cfg["指令设置"]["配置文件整文件修改模式每页显示数量"]), - ) - except (KeyError, TypeError, ValueError): - return 10 - return 10 - - def get_group_task_player_items_per_page(self, group_id: int): - """读取任务系统选择玩家菜单每页条数。""" - group_cfg = self.group_cfgs.get(group_id) - if group_cfg is not None: - try: - return max( - 1, - int(group_cfg["指令设置"]["任务系统每页显示玩家数量"]), - ) - except (KeyError, TypeError, ValueError): - return self.get_group_inventory_items_per_page(group_id) - return 10 - - def get_group_task_items_per_page(self, group_id: int): - """读取任务系统选择任务菜单每页条数。""" - group_cfg = self.group_cfgs.get(group_id) - if group_cfg is not None: - try: - return max( - 1, - int(group_cfg["指令设置"]["任务系统每页显示任务数量"]), - ) - except (KeyError, TypeError, ValueError): - return self.get_group_inventory_items_per_page(group_id) - return 10 - - def get_group_land_items_per_page(self, group_id: int): - """读取领地系统云链联动版菜单每页条数。""" - group_cfg = self.group_cfgs.get(group_id) - if group_cfg is not None: - try: - return max( - 1, - int(group_cfg["指令设置"]["领地系统每页显示领地数量"]), - ) - except (KeyError, TypeError, ValueError): - return self.get_group_inventory_items_per_page(group_id) - return 10 - - def get_group_help_triggers(self, group_id: int): - """读取某个群的帮助菜单触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get("帮助菜单唤醒词", ["help", "帮助"]) - return self.normalize_string_triggers(raw, ["help", "帮助"]) - - def get_group_admin_menu_triggers(self, group_id: int): - """读取某个群的管理员菜单触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get("管理员菜单唤醒词", ["管理员菜单"]) - return self.normalize_string_triggers(raw, ["管理员菜单"]) - - def get_group_menu_exit_triggers(self, group_id: int | None = None): - """读取菜单内“退出整个菜单”的触发词。""" - if group_id is not None and group_id in self.group_cfgs: - raw = self.group_cfgs[group_id]["指令设置"].get( - "退出整个菜单触发词", - self.MENU_EXIT_TRIGGERS_DEFAULT, - ) - return self.normalize_string_triggers( - raw, self.MENU_EXIT_TRIGGERS_DEFAULT) - return list(self.MENU_EXIT_TRIGGERS_DEFAULT) - - def get_group_menu_back_triggers(self, group_id: int | None = None): - """读取菜单内“返回上一级”的触发词。""" - if group_id is not None and group_id in self.group_cfgs: - raw = self.group_cfgs[group_id]["指令设置"].get( - "返回上一级菜单触发词", - self.MENU_BACK_TRIGGERS_DEFAULT, - ) - return self.normalize_string_triggers( - raw, self.MENU_BACK_TRIGGERS_DEFAULT) - return list(self.MENU_BACK_TRIGGERS_DEFAULT) - - def is_menu_exit_input(self, user_input: str, group_id: int | None = None): - """判断一条输入是否要求退出整个交互菜单。""" - text = str(user_input).strip() - return any( - text.lower() == trigger.lower() - for trigger in self.get_group_menu_exit_triggers(group_id) - ) - - def is_menu_back_input(self, user_input: str, group_id: int | None = None): - """判断一条输入是否要求返回上一级菜单。""" - text = str(user_input).strip() - return any( - text.lower() == trigger.lower() - for trigger in self.get_group_menu_back_triggers(group_id) - ) - - def menu_exit_hint(self, group_id: int | None = None, action: str = "退出"): - return f"输入 {' / '.join(self.get_group_menu_exit_triggers(group_id))} {action}" - - def menu_back_hint( - self, - group_id: int | None = None, - action: str = "返回上级菜单"): - return f"输入 {' / '.join(self.get_group_menu_back_triggers(group_id))} {action}" - - def normalize_menu_control_hints( - self, - hints: list[str], - group_id: int | None = None, - ) -> list[str]: - """把旧菜单里的硬编码退出/返回提示替换成当前配置中的触发词。""" - normalized: list[str] = [] - for hint in hints: - if hint == "输入 . 退出": - normalized.append(self.menu_exit_hint(group_id)) - elif hint == "输入 . 取消": - normalized.append(self.menu_back_hint(group_id, "取消")) - elif hint == "输入 0 返回上级": - normalized.append(self.menu_back_hint(group_id, "返回上级")) - elif hint == "输入 0 返回上级菜单": - normalized.append(self.menu_back_hint(group_id)) - elif hint == "输入 q 退出菜单": - normalized.append(self.menu_exit_hint(group_id, "退出菜单")) - else: - normalized.append(hint) - return normalized - - def get_group_cmd_prefix(self, group_id: int): - """读取群内执行 MC 指令时使用的命令前缀。""" - group_cfg = self.group_cfgs[group_id] - prefix = str(group_cfg["指令设置"].get("发送指令前缀", "/")).strip() - return prefix or "/" - - def get_group_orion_ban_triggers(self, group_id: int): - """读取某个群的 Orion 封禁触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get( - "QQ群封禁唤醒词", - ["orban", "orion ban", "猎户封禁"], - ) - return self.normalize_string_triggers( - raw, ["orban", "orion ban", "猎户封禁"]) - - def get_group_orion_unban_triggers(self, group_id: int): - """读取某个群的 Orion 解封触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get( - "QQ群解封唤醒词", - ["orunban", "orion unban", "猎户解封"], - ) - return self.normalize_string_triggers( - raw, ["orunban", "orion unban", "猎户解封"]) - - def get_group_checker_menu_triggers(self, group_id: int): - """读取某个群的白名单联动菜单触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get( - "QQ群白名单&管理员检测唤醒词", - ["白名单&管理员检测", "检测管理"], - ) - return self.normalize_string_triggers(raw, ["白名单&管理员检测", "检测管理"]) - - def get_group_task_menu_triggers(self, group_id: int): - """读取某个群的任务系统菜单触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get("任务系统菜单唤醒词", ["任务系统"]) - return self.normalize_string_triggers(raw, ["任务系统"]) - - def get_group_land_menu_triggers(self, group_id: int): - """读取某个群的领地系统云链联动版菜单触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get("领地系统菜单唤醒词", ["领地系统云链联动版", "领地系统", "领地管理"]) - return self.normalize_string_triggers( - raw, ["领地系统云链联动版", "领地系统", "领地管理"]) - - def get_group_guild_menu_triggers(self, group_id: int): - """读取某个群的公会系统管理菜单触发词。""" - group_cfg = self.group_cfgs[group_id] - raw = group_cfg["指令设置"].get("公会系统管理菜单唤醒词", ["公会系统"]) - return self.normalize_string_triggers(raw, ["公会系统"]) - - @staticmethod - def normalize_string_triggers(raw: Any, fallback: list[str]): - """把触发词列表清洗成无空值、无重复的稳定序列。""" - triggers: list[str] = [] - if isinstance(raw, list): - for item in raw: - if not isinstance(item, str): - continue - text = item.strip() - if text and text not in triggers: - triggers.append(text) - return triggers or fallback - - def add_trigger( - self, - triggers: list[str], - argument_hint: str | None, - usage: str, - func: Callable[..., Any], - args_pd: Callable[[int], bool] = lambda _: True, - op_only: bool = False, - ): - """把外部插件注册的 QQ 触发规则挂入统一分发入口。""" - # 允许外部插件把自己的 QQ 指令挂进互通插件的统一分发入口。 - if not inspect.isroutine(func) and not callable(func): - raise TypeError("func 必须是可调用对象") - self.triggers.append( - QQMsgTrigger( - triggers, - argument_hint, - usage, - func, - args_pd, - op_only)) - - def set_manual_launch(self, port: int): - """切换到“本地启动器负责拉起云链”的模式。""" - self._manual_launch = True - self._manual_launch_port = port - - def manual_launch(self): - """给本地启动器调用的显式连接入口。""" - self.connect_to_websocket() + + cloud_cfg = raw_cfg.get("云链设置", {}) + if isinstance(cloud_cfg, dict): + new_cfg["云链设置"]["地址"] = str( + cloud_cfg.get("地址", new_cfg["云链设置"]["地址"]) + ) + validate_code = cloud_cfg.get("校验码", "") + new_cfg["云链设置"]["校验码"] = ( + "" if validate_code is None else str(validate_code) + ) + + if has_top_level_binding: + self._merge_binding_cfg(new_cfg["绑定设置"], original_cfg["绑定设置"]) + + group_cfgs: list[dict[str, Any]] = [] + if isinstance(raw_cfg.get("群聊设置"), list): + for raw_group in raw_cfg["群聊设置"]: + if ( + not has_top_level_binding + and isinstance(raw_group, dict) + and isinstance(raw_group.get("绑定设置"), dict) + ): + self._merge_binding_cfg(new_cfg["绑定设置"], raw_group["绑定设置"]) + migrated = self.migrate_group_config(raw_group) + if migrated is not None: + group_cfgs.append(migrated) + elif isinstance(raw_cfg.get("消息转发设置"), dict): + # 兼容最早的单群结构,迁完之后统一走“群聊设置”列表。 + old_msg_cfg = raw_cfg["消息转发设置"] + try: + old_group_id = int(old_msg_cfg.get("链接的群聊", 194838530)) + except (TypeError, ValueError): + old_group_id = 194838530 + migrated_group = self.group_default(old_group_id) + if isinstance(old_msg_cfg.get("游戏到群"), dict): + migrated_group["游戏到群"] = self.migrate_group_config( + { + "群号": old_group_id, + "游戏到群": old_msg_cfg["游戏到群"], + } + )["游戏到群"] + if isinstance(old_msg_cfg.get("群到游戏"), dict): + migrated_group["群到游戏"] = self.migrate_group_config( + { + "群号": old_group_id, + "群到游戏": old_msg_cfg["群到游戏"], + } + )["群到游戏"] + if isinstance(raw_cfg.get("指令设置"), dict): + migrated_group["指令设置"]["是否允许查看玩家列表"] = bool( + raw_cfg["指令设置"].get( + "是否允许查看玩家列表", + migrated_group["指令设置"]["是否允许查看玩家列表"], + ) + ) + self._merge_permission_cfg(migrated_group, {}) + group_cfgs.append(migrated_group) + + if group_cfgs: + dedup: dict[int, dict[str, Any]] = {} + for group_cfg in group_cfgs: + dedup[group_cfg["群号"]] = group_cfg + new_cfg["群聊设置"] = list(dedup.values()) + + self.migrate_legacy_group_admin_data(new_cfg) + return new_cfg + + def reload_group_configs(self): + """把配置中的群信息展开成运行时缓存。 + + 后续消息分发会频繁按群号查配置,所以这里同时保留: + - `group_cfgs`: 适合按群号直接读取 + - `group_order`: 适合顺序遍历和显示菜单 + """ + self.group_cfgs.clear() + self.group_order.clear() + # 运行时同时保留 dict 和顺序列表,后面做群路由会更直接。 + for group_cfg in self.cfg["群聊设置"]: + group_id = int(group_cfg["群号"]) + self.group_cfgs[group_id] = group_cfg + self.group_order.append(group_id) + for group_id in self.group_order: + self.ensure_group_state(group_id) + + def persist_runtime_config(self): + """把当前 Ultra 配置写回 ToolDelta 配置文件。""" + cfg.check_auto(self.cfg_std(), self.cfg) + cfg.upgrade_plugin_config(self.name, self.cfg, self.version) + self.refresh_runtime_config_file_state() + + def runtime_config_path(self) -> str: + """返回 ToolDelta 生成的本插件配置文件路径。""" + return os.path.join(self.CONFIG_FILE_DIR, f"{self.name}.json") + + @staticmethod + def runtime_config_file_state(path: str) -> tuple[int, int] | None: + """返回配置文件状态,用于判断外部修改。""" + try: + stat = os.stat(path) + except OSError: + return None + return stat.st_mtime_ns, stat.st_size + + def refresh_runtime_config_file_state(self) -> None: + """记录当前配置文件状态,避免刚启动就重复热载一次。""" + path = getattr( + self, + "_runtime_config_path", + None) or self.runtime_config_path() + self._runtime_config_path = path + self._runtime_config_file_state = self.runtime_config_file_state(path) + + def is_runtime_config_reload_enabled(self) -> bool: + """返回是否启用本插件配置文件动态载入。""" + settings = getattr( + self, "cfg", {}).get( + self.DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return True + return bool(settings.get(self.DYNAMIC_LOAD_ENABLED_KEY, True)) + + def runtime_config_reload_interval(self) -> int: + """返回动态载入检测间隔秒数。""" + settings = getattr( + self, "cfg", {}).get( + self.DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return self.RUNTIME_CONFIG_RELOAD_INTERVAL + return self._normalize_positive_int( + settings.get(self.DYNAMIC_LOAD_INTERVAL_KEY, + self.RUNTIME_CONFIG_RELOAD_INTERVAL), + self.RUNTIME_CONFIG_RELOAD_INTERVAL, + ) + + def apply_ultra_runtime_config(self, raw_config: Any) -> str: + """把 Ultra 配置应用到当前运行时,无需重载插件。""" + old_cloud_cfg = deepcopy(getattr(self, "cfg", {}).get("云链设置", {})) + migrated = self.migrate_config(raw_config) + cfg.check_auto(self.cfg_std(), migrated) + self.cfg = migrated + self.reload_group_configs() + self.persist_runtime_config() + if old_cloud_cfg != self.cfg.get("云链设置", {}): + self.reload_websocket_connection() + return "Ultra 配置已动态载入,云链连接正在按新配置重连" + return "Ultra 配置已动态载入" + + def check_runtime_config_file_update(self) -> bool: + """检查本插件配置文件是否被修改,变化时立即热应用。""" + if not self.is_runtime_config_reload_enabled(): + return False + + path = getattr( + self, + "_runtime_config_path", + None) or self.runtime_config_path() + self._runtime_config_path = path + current_state = self.runtime_config_file_state(path) + if current_state is None or current_state == getattr( + self, + "_runtime_config_file_state", + None, + ): + return False + + try: + with open(path, "r", encoding="utf-8-sig") as file: + full_config = json.load(file) + raw_config = self._extract_config_items(full_config) + message = self.apply_ultra_runtime_config(raw_config) + self.refresh_runtime_config_file_state() + if hasattr(self, "print_console_success"): + self.print_console_success(message) + return True + except Exception as err: + self._runtime_config_file_state = current_state + if hasattr(self, "print_console_error"): + self.print_console_error(f"Ultra 配置热更新失败: {err}") + return False + + @staticmethod + def _extract_config_items(full_config: Any) -> Any: + """Implement the extract config items operation.""" + if isinstance( + full_config, + dict) and isinstance( + full_config.get("配置项"), + dict): + return full_config["配置项"] + return full_config + + @property + def linked_group(self) -> int | None: + """返回兼容旧插件调用时使用的默认群号。""" + # 给还按“单群互通”接口调用的旧插件留一个兼容入口。 + return self.group_order[0] if self.group_order else None + + def _permission_cfg_for_group( + self, group_id: int) -> dict[str, Any] | None: + """Implement the permission cfg for group operation.""" + group_cfg = getattr(self, "group_cfgs", {}).get(group_id) + if not isinstance(group_cfg, dict): + return None + permission_cfg = group_cfg.get(self.PERMISSION_SETTINGS_KEY) + return permission_cfg if isinstance(permission_cfg, dict) else None + + def read_group_state(self, group_id: int): + """从主配置读取单个群的管理员状态。""" + permission_cfg = self._permission_cfg_for_group(group_id) + if permission_cfg is None: + return {"admins": [], "super_admins": []} + return { + "admins": self.normalize_int_list( + permission_cfg.get( + "普通管理员QQ号", [])), "super_admins": self.normalize_int_list( + permission_cfg.get( + "超级管理员QQ号", [])), } + + def get_group_owner_qq(self, group_id: int) -> int | None: + """读取群配置中的所有者 QQ。""" + permission_cfg = self._permission_cfg_for_group(group_id) + if permission_cfg is None: + return None + owner_qq = self.normalize_owner_qq(permission_cfg.get("所有者QQ号")) + if owner_qq == self.OWNER_QQ_UNSET: + return None + return owner_qq + + def save_group_state(self, group_id: int, state: dict[str, list[int]]): + """把群权限状态保存到主配置文件。""" + permission_cfg = self._permission_cfg_for_group(group_id) + if permission_cfg is None: + return + owner_qq = self.get_group_owner_qq(group_id) + normalized = { + "admins": self.normalize_int_list( + state.get( + "admins", [])), "super_admins": self.normalize_int_list( + state.get( + "super_admins", [])), } + permission_cfg["普通管理员QQ号"] = normalized["admins"] + permission_cfg["超级管理员QQ号"] = normalized["super_admins"] + self.persist_runtime_config() + + def ensure_group_state(self, group_id: int): + """确保群权限配置结构可读,并移除重复/非法管理员项。""" + permission_cfg = self._permission_cfg_for_group(group_id) + if permission_cfg is None: + return + state = self.read_group_state(group_id) + permission_cfg["超级管理员QQ号"] = state["super_admins"] + permission_cfg["普通管理员QQ号"] = state["admins"] + + def is_group_owner(self, group_id: int, qqid: int): + """判断某个 QQ 是否是指定群的所有者。""" + return self.get_group_owner_qq(group_id) == qqid + + def is_group_super_admin(self, group_id: int, qqid: int): + """判断某个 QQ 是否拥有超级管理员级权限。""" + if self.is_group_owner(group_id, qqid): + return True + return qqid in self.read_group_state(group_id)["super_admins"] + + def is_group_admin(self, group_id: int, qqid: int): + """判断某个 QQ 是否拥有群内管理权限。 + + 所有者、超级管理员和普通管理员都可以执行普通管理功能。 + """ + if self.is_group_owner(group_id, qqid): + return True + state = self.read_group_state(group_id) + return qqid in state["super_admins"] or qqid in state["admins"] + + def has_group_permission( + self, + group_id: int, + qqid: int, + permission_name: str) -> bool: + """按群配置中的“各功能权限设置”判断某个 QQ 是否可用指定功能。""" + if self.is_group_owner(group_id, qqid): + return True + permission_cfg = self._permission_cfg_for_group(group_id) + if permission_cfg is None: + return False + feature_permissions = permission_cfg.get("各功能权限设置", {}) + if not isinstance(feature_permissions, dict): + return False + item = feature_permissions.get(permission_name) + if not isinstance(item, dict): + return False + state = self.read_group_state(group_id) + if qqid in state["super_admins"]: + return bool(item.get("是否允许超级管理员使用", False)) + if qqid in state["admins"]: + return bool(item.get("是否允许普通管理员使用", False)) + return bool(item.get("是否允许普通成员使用", False)) + + def is_qq_op(self, qqid: int, group_id: int | None = None): + """兼容旧命名,判断某个 QQ 是否拥有管理员权限。""" + if group_id is not None: + return self.is_group_admin(group_id, qqid) + return any(self.is_group_admin(gid, qqid) for gid in self.group_order) + + def add_group_role(self, group_id: int, qqid: int, is_super: bool): + """给群成员授予管理员或超级管理员身份。""" + if self.is_group_owner(group_id, qqid): + return False, "该 QQ 是本群所有者,已拥有最高权限" + state = self.read_group_state(group_id) + if is_super: + if qqid in state["super_admins"]: + return False, "该 QQ 已经是本群超级管理员" + if qqid in state["admins"]: + state["admins"].remove(qqid) + state["super_admins"].append(qqid) + self.save_group_state(group_id, state) + return True, "已添加为本群超级管理员" + if qqid in state["super_admins"]: + return False, "该 QQ 已经是本群超级管理员,无需再添加为管理员" + if qqid in state["admins"]: + return False, "该 QQ 已经是本群管理员" + state["admins"].append(qqid) + self.save_group_state(group_id, state) + return True, "已添加为本群管理员" + + def remove_group_role(self, group_id: int, qqid: int, is_super: bool): + """移除群成员的管理员或超级管理员身份。""" + if self.is_group_owner(group_id, qqid): + return False, "不能在管理员菜单中移除本群所有者" + state = self.read_group_state(group_id) + if is_super: + if qqid not in state["super_admins"]: + return False, "该 QQ 不是本群超级管理员" + state["super_admins"].remove(qqid) + self.save_group_state(group_id, state) + return True, "已移除本群超级管理员" + if qqid not in state["admins"]: + return False, "该 QQ 不是本群普通管理员" + state["admins"].remove(qqid) + self.save_group_state(group_id, state) + return True, "已移除本群普通管理员" + + @staticmethod + def _api_int_value(value: Any): + """Implement the api int value operation.""" + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return None + + def api_get_linked_groups(self) -> list[int]: + """Return configured linked QQ group IDs in runtime order.""" + return list(self.group_order) + + def api_get_default_group(self) -> int | None: + """Return the first configured group ID for old single-group callers.""" + return self.linked_group + + def api_is_group_configured(self, group_id: int | str) -> bool: + """Return whether a QQ group is configured in Ultra.""" + gid = self._api_int_value(group_id) + return gid in self.group_cfgs if gid is not None else False + + def api_get_group_config(self, group_id: int | + str) -> dict[str, Any] | None: + """Return a copy of one group's runtime config.""" + gid = self._api_int_value(group_id) + if gid is None or gid not in self.group_cfgs: + return None + return deepcopy(self.group_cfgs[gid]) + + def api_get_group_state(self, group_id: int | + str) -> dict[str, list[int]] | None: + """Return a copy of one group's admin state.""" + gid = self._api_int_value(group_id) + if gid is None or gid not in self.group_cfgs: + return None + state = self.read_group_state(gid) + owner_qq = self.get_group_owner_qq(gid) + return { + "admins": list(state["admins"]), + "super_admins": list(state["super_admins"]), + "owner": [] if owner_qq is None else [owner_qq], + } + + def api_get_group_admins( + self, + group_id: int | str, + include_super: bool = True, + ) -> list[int]: + """Return normal group admins, optionally including super admins.""" + state = self.api_get_group_state(group_id) + if state is None: + return [] + admins = list(state["admins"]) + if include_super: + for qqid in state["super_admins"]: + if qqid not in admins: + admins.append(qqid) + for qqid in state.get("owner", []): + if qqid not in admins: + admins.append(qqid) + return admins + + def api_get_group_super_admins(self, group_id: int | str) -> list[int]: + """Return super admins for one configured group.""" + state = self.api_get_group_state(group_id) + return [] if state is None else list(state["super_admins"]) + + def api_is_group_admin(self, group_id: int | str, qqid: int | str) -> bool: + """Return whether a QQ number has admin permission in a group.""" + gid = self._api_int_value(group_id) + qid = self._api_int_value(qqid) + if gid is None or qid is None or gid not in self.group_cfgs: + return False + return self.is_group_admin(gid, qid) + + def api_is_group_super_admin( + self, + group_id: int | str, + qqid: int | str) -> bool: + """Return whether a QQ number is a super admin in a group.""" + gid = self._api_int_value(group_id) + qid = self._api_int_value(qqid) + if gid is None or qid is None or gid not in self.group_cfgs: + return False + return self.is_group_super_admin(gid, qid) + + def api_get_group_owner(self, group_id: int | str) -> int | None: + """Return the configured owner QQ for one group.""" + gid = self._api_int_value(group_id) + if gid is None or gid not in self.group_cfgs: + return None + return self.get_group_owner_qq(gid) + + def api_is_group_owner(self, group_id: int | str, qqid: int | str) -> bool: + """Return whether a QQ number is the configured owner in a group.""" + gid = self._api_int_value(group_id) + qid = self._api_int_value(qqid) + if gid is None or qid is None or gid not in self.group_cfgs: + return False + return self.is_group_owner(gid, qid) + + def api_add_group_admin( + self, + group_id: int | str, + qqid: int | str, + is_super: bool = False, + ) -> tuple[bool, str]: + """Grant normal-admin or super-admin permission to a QQ number.""" + gid = self._api_int_value(group_id) + qid = self._api_int_value(qqid) + if gid is None or gid not in self.group_cfgs: + return False, "群号无效或未配置" + if qid is None or qid <= 0: + return False, "QQ号无效" + return self.add_group_role(gid, qid, bool(is_super)) + + def api_remove_group_admin( + self, + group_id: int | str, + qqid: int | str, + is_super: bool = False, + ) -> tuple[bool, str]: + """Remove normal-admin or super-admin permission from a QQ number.""" + gid = self._api_int_value(group_id) + qid = self._api_int_value(qqid) + if gid is None or gid not in self.group_cfgs: + return False, "群号无效或未配置" + if qid is None or qid <= 0: + return False, "QQ号无效" + return self.remove_group_role(gid, qid, bool(is_super)) + + def api_get_group_triggers( + self, group_id: int | str) -> dict[str, Any] | None: + """Return normalized trigger words and menu controls for one group.""" + gid = self._api_int_value(group_id) + if gid is None or gid not in self.group_cfgs: + return None + triggers = { + "help": self.get_group_help_triggers(gid), + "admin_menu": self.get_group_admin_menu_triggers(gid), + "player_list": self.get_group_player_list_triggers(gid), + "inventory_menu": self.get_group_inventory_menu_triggers(gid), + "menu_exit": self.get_group_menu_exit_triggers(gid), + "menu_back": self.get_group_menu_back_triggers(gid), + "command_prefix": self.get_group_cmd_prefix(gid), + "orion_ban": self.get_group_orion_ban_triggers(gid), + "orion_unban": self.get_group_orion_unban_triggers(gid), + "checker_menu": self.get_group_checker_menu_triggers(gid), + "task_menu": self.get_group_task_menu_triggers(gid), + "land_menu": self.get_group_land_menu_triggers(gid), + "guild_menu": self.get_group_guild_menu_triggers(gid), + } + if hasattr(self, "get_group_binding_triggers"): + triggers["binding"] = self.get_group_binding_triggers(gid) + return triggers + + def api_get_registered_triggers(self) -> list[dict[str, Any]]: + """Return external QQ triggers registered through add_trigger(...).""" + return [ + { + "triggers": list(trigger.triggers), + "argument_hint": trigger.argument_hint, + "usage": trigger.usage, + "op_only": bool(trigger.op_only), + "accept_group": bool(trigger.accept_group), + } + for trigger in self.triggers + ] + + def get_group_player_list_triggers(self, group_id: int): + """读取某个群的玩家列表触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get("查看玩家人数的唤醒词", ["list", "玩家列表"]) + return self.normalize_string_triggers(raw, ["list", "玩家列表"]) + + def get_group_inventory_menu_triggers(self, group_id: int): + """读取某个群的背包查询触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get("查询背包菜单唤醒词", ["查询背包"]) + return self.normalize_string_triggers(raw, ["查询背包"]) + + def get_group_inventory_items_per_page(self, group_id: int): + """读取背包查询菜单每页条数。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["查询背包菜单每页显示的玩家数量"]), + ) + except (KeyError, TypeError, ValueError): + return 10 + return 10 + + def get_group_help_non_admin_items_per_page(self, group_id: int): + """读取帮助菜单非管理功能每页条数。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["帮助菜单非管理功能每页显示数量"]), + ) + except (KeyError, TypeError, ValueError): + return 10 + return 10 + + def get_group_help_admin_items_per_page(self, group_id: int): + """读取帮助菜单管理功能每页条数。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["帮助菜单管理功能每页显示数量"]), + ) + except (KeyError, TypeError, ValueError): + return 10 + return 10 + + def get_group_command_help_items_per_page(self, group_id: int): + """读取命令触发词帮助菜单每页条数。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["命令触发词帮助菜单每页显示数量"]), + ) + except (KeyError, TypeError, ValueError): + return 10 + return 10 + + def get_group_config_file_items_per_page( + self, group_id: int | None = None): + """读取配置文件整文件修改模式选择菜单每页条数。""" + group_cfg = self.group_cfgs.get( + group_id) if group_id is not None else None + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["配置文件整文件修改模式每页显示数量"]), + ) + except (KeyError, TypeError, ValueError): + return 10 + return 10 + + def get_group_task_player_items_per_page(self, group_id: int): + """读取任务系统选择玩家菜单每页条数。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["任务系统每页显示玩家数量"]), + ) + except (KeyError, TypeError, ValueError): + return self.get_group_inventory_items_per_page(group_id) + return 10 + + def get_group_task_items_per_page(self, group_id: int): + """读取任务系统选择任务菜单每页条数。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["任务系统每页显示任务数量"]), + ) + except (KeyError, TypeError, ValueError): + return self.get_group_inventory_items_per_page(group_id) + return 10 + + def get_group_land_items_per_page(self, group_id: int): + """读取领地系统云链联动版菜单每页条数。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["领地系统每页显示领地数量"]), + ) + except (KeyError, TypeError, ValueError): + return self.get_group_inventory_items_per_page(group_id) + return 10 + + def get_group_help_triggers(self, group_id: int): + """读取某个群的帮助菜单触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get("帮助菜单唤醒词", ["help", "帮助"]) + return self.normalize_string_triggers(raw, ["help", "帮助"]) + + def get_group_admin_menu_triggers(self, group_id: int): + """读取某个群的管理员菜单触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get("管理员菜单唤醒词", ["管理员菜单"]) + return self.normalize_string_triggers(raw, ["管理员菜单"]) + + def get_group_menu_exit_triggers(self, group_id: int | None = None): + """读取菜单内“退出整个菜单”的触发词。""" + if group_id is not None and group_id in self.group_cfgs: + raw = self.group_cfgs[group_id]["指令设置"].get( + "退出整个菜单触发词", + self.MENU_EXIT_TRIGGERS_DEFAULT, + ) + return self.normalize_string_triggers( + raw, self.MENU_EXIT_TRIGGERS_DEFAULT) + return list(self.MENU_EXIT_TRIGGERS_DEFAULT) + + def get_group_menu_back_triggers(self, group_id: int | None = None): + """读取菜单内“返回上一级”的触发词。""" + if group_id is not None and group_id in self.group_cfgs: + raw = self.group_cfgs[group_id]["指令设置"].get( + "返回上一级菜单触发词", + self.MENU_BACK_TRIGGERS_DEFAULT, + ) + return self.normalize_string_triggers( + raw, self.MENU_BACK_TRIGGERS_DEFAULT) + return list(self.MENU_BACK_TRIGGERS_DEFAULT) + + def is_menu_exit_input(self, user_input: str, group_id: int | None = None): + """判断一条输入是否要求退出整个交互菜单。""" + text = str(user_input).strip() + return any( + text.lower() == trigger.lower() + for trigger in self.get_group_menu_exit_triggers(group_id) + ) + + def is_menu_back_input(self, user_input: str, group_id: int | None = None): + """判断一条输入是否要求返回上一级菜单。""" + text = str(user_input).strip() + return any( + text.lower() == trigger.lower() + for trigger in self.get_group_menu_back_triggers(group_id) + ) + + def menu_exit_hint(self, group_id: int | None = None, action: str = "退出"): + """Implement the menu exit hint operation.""" + return f"输入 {' / '.join(self.get_group_menu_exit_triggers(group_id))} {action}" + + def menu_back_hint( + self, + group_id: int | None = None, + action: str = "返回上级菜单"): + """Implement the menu back hint operation.""" + return f"输入 {' / '.join(self.get_group_menu_back_triggers(group_id))} {action}" + + def normalize_menu_control_hints( + self, + hints: list[str], + group_id: int | None = None, + ) -> list[str]: + """把旧菜单里的硬编码退出/返回提示替换成当前配置中的触发词。""" + normalized: list[str] = [] + for hint in hints: + if hint == "输入 . 退出": + normalized.append(self.menu_exit_hint(group_id)) + elif hint == "输入 . 取消": + normalized.append(self.menu_back_hint(group_id, "取消")) + elif hint == "输入 0 返回上级": + normalized.append(self.menu_back_hint(group_id, "返回上级")) + elif hint == "输入 0 返回上级菜单": + normalized.append(self.menu_back_hint(group_id)) + elif hint == "输入 q 退出菜单": + normalized.append(self.menu_exit_hint(group_id, "退出菜单")) + else: + normalized.append(hint) + return normalized + + def get_group_cmd_prefix(self, group_id: int): + """读取群内执行 MC 指令时使用的命令前缀。""" + group_cfg = self.group_cfgs[group_id] + prefix = str(group_cfg["指令设置"].get("发送指令前缀", "/")).strip() + return prefix or "/" + + def get_group_orion_ban_triggers(self, group_id: int): + """读取某个群的 Orion 封禁触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get( + "QQ群封禁唤醒词", + ["orban", "orion ban", "猎户封禁"], + ) + return self.normalize_string_triggers( + raw, ["orban", "orion ban", "猎户封禁"]) + + def get_group_orion_unban_triggers(self, group_id: int): + """读取某个群的 Orion 解封触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get( + "QQ群解封唤醒词", + ["orunban", "orion unban", "猎户解封"], + ) + return self.normalize_string_triggers( + raw, ["orunban", "orion unban", "猎户解封"]) + + def get_group_checker_menu_triggers(self, group_id: int): + """读取某个群的白名单联动菜单触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get( + "QQ群白名单&管理员检测唤醒词", + ["白名单&管理员检测", "检测管理"], + ) + return self.normalize_string_triggers(raw, ["白名单&管理员检测", "检测管理"]) + + def get_group_task_menu_triggers(self, group_id: int): + """读取某个群的任务系统菜单触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get("任务系统菜单唤醒词", ["任务系统"]) + return self.normalize_string_triggers(raw, ["任务系统"]) + + def get_group_land_menu_triggers(self, group_id: int): + """读取某个群的领地系统云链联动版菜单触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get("领地系统菜单唤醒词", ["领地系统云链联动版", "领地系统", "领地管理"]) + return self.normalize_string_triggers( + raw, ["领地系统云链联动版", "领地系统", "领地管理"]) + + def get_group_guild_menu_triggers(self, group_id: int): + """读取某个群的公会系统管理菜单触发词。""" + group_cfg = self.group_cfgs[group_id] + raw = group_cfg["指令设置"].get("公会系统管理菜单唤醒词", ["公会系统"]) + return self.normalize_string_triggers(raw, ["公会系统"]) + + @staticmethod + def normalize_string_triggers(raw: Any, fallback: list[str]): + """把触发词列表清洗成无空值、无重复的稳定序列。""" + triggers: list[str] = [] + if isinstance(raw, list): + for item in raw: + if not isinstance(item, str): + continue + text = item.strip() + if text and text not in triggers: + triggers.append(text) + return triggers or fallback + + def add_trigger( + self, + triggers: list[str], + argument_hint: str | None, + usage: str, + func: Callable[..., Any], + args_pd: Callable[[int], bool] = lambda _: True, + op_only: bool = False, + ): + """把外部插件注册的 QQ 触发规则挂入统一分发入口。""" + # 允许外部插件把自己的 QQ 指令挂进互通插件的统一分发入口。 + if not inspect.isroutine(func) and not callable(func): + raise TypeError("func 必须是可调用对象") + self.triggers.append( + QQMsgTrigger( + triggers, + argument_hint, + usage, + func, + args_pd, + op_only)) + + def set_manual_launch(self, port: int): + """切换到“本地启动器负责拉起云链”的模式。""" + self._manual_launch = True + self._manual_launch_port = port + + def manual_launch(self): + """给本地启动器调用的显式连接入口。""" + self.connect_to_websocket() diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" index 939a4676..7c400f24 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" @@ -1,1839 +1,1880 @@ -"""QQ group menu and command handlers for Ultra.""" - -import re -import time -from typing import Any - -from tooldelta import utils - -try: - from tooldelta.utils.mc_translator import translate -except ImportError: - translate = None - - -# 日常群聊交互放在这一层:帮助、背包查询、管理员菜单、联动检查菜单。 -class QQLinkerQQMixin: - """负责群聊菜单、查询类功能以及白名单联动命令。""" - - @utils.thread_func("群服执行指令并获取返回") - def on_qq_execute_cmd(self, group_id: int, qqid: int, cmd: list[str]): - """在群里执行 Minecraft 指令,并把执行结果回发到群里。""" - if not self._can_use_group_permission(group_id, qqid, "发送指令权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - res = self.execute_cmd_and_get_zhcn_cb(" ".join(cmd)) - self.sendmsg(group_id, res) - - def _reply_to_qq(self, group_id: int, qqid: int, text: str): - """向指定群成员回复一条文本消息。""" - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {text}", - do_remove_cq_code=False, - ) - - def _reply_result(self, group_id: int, qqid: int, ok: bool, msg: str): - """统一回发成功/失败结果。""" - self._reply_to_qq(group_id, qqid, f"{'😄' if ok else '😭'} {msg}") - - def _can_use_group_permission( - self, - group_id: int, - qqid: int, - permission_name: str, - ) -> bool: - if hasattr(self, "_has_group_permission"): - return self._has_group_permission(group_id, qqid, permission_name) - return self.has_group_permission(group_id, qqid, permission_name) - - def _can_use_any_group_permission( - self, - group_id: int, - qqid: int, - permission_names: tuple[str, ...], - ) -> bool: - return any( - self._can_use_group_permission(group_id, qqid, permission_name) - for permission_name in permission_names - ) - - def _reply_menu_permission_denied(self, group_id: int, qqid: int): - if hasattr(self, "_reply_permission_denied"): - self._reply_permission_denied(group_id, qqid) - return - self._reply_to_qq(group_id, qqid, "你没有权限执行此指令") - - def _ensure_group_permission( - self, - group_id: int, - qqid: int, - permission_name: str, - ) -> bool: - if self._can_use_group_permission(group_id, qqid, permission_name): - return True - self._reply_menu_permission_denied(group_id, qqid) - return False - - def _append_permission_action( - self, - group_id: int, - qqid: int, - options: list[str], - actions: list[Any], - permission_name: str, - label: str, - action, - ): - if self._can_use_group_permission(group_id, qqid, permission_name): - options.append(label) - actions.append(action) - - def _has_help_admin_actions(self, group_id: int, qqid: int) -> bool: - permission_names = [ - "QQ普通管理员菜单权限", - "QQ超级管理员菜单权限", - "发送指令权限", - "配置配置文件权限", - "查询背包权限", - "封禁/解封玩家权限", - "白名单&管理员检测权限", - "领地系统权限", - "公会系统权限", - ] - if self.task_system is not None: - permission_names.append("任务系统权限") - return self._can_use_any_group_permission( - group_id, - qqid, - tuple(permission_names), - ) - - @staticmethod - def _extract_plugin_enabled_flag(plugin: Any): - """读取联动插件明确暴露的整体启用状态;缺少该项时返回 None。""" - for method_name in ("_plugin_enabled", "is_plugin_enabled"): - method = getattr(plugin, method_name, None) - if callable(method): - try: - return bool(method()) - except Exception: - return None - - enabled = getattr(plugin, "enabled", None) - if isinstance(enabled, bool): - return enabled - - for method_name in ("api_get_runtime_status", "get_runtime_status"): - method = getattr(plugin, method_name, None) - if not callable(method): - continue - try: - status = method() - except Exception: - continue - if isinstance(status, tuple) and len(status) >= 3: - status = status[2] - if isinstance(status, dict): - for key in ("enabled", "是否启用", "是否启用插件"): - if isinstance(status.get(key), bool): - return status[key] - - for attr_name in ("config", "cfg", "_cfg"): - config = getattr(plugin, attr_name, None) - if not isinstance(config, dict): - continue - for key in ("是否启用", "是否启用插件"): - if isinstance(config.get(key), bool): - return config[key] - base_config = config.get("基础配置") - if isinstance(base_config, dict): - for key in ("是否启用", "是否启用插件"): - if isinstance(base_config.get(key), bool): - return base_config[key] - - return None - - def ensure_linked_plugin_enabled( - self, - plugin: Any, - group_id: int, - sender: int) -> bool: - """联动插件明确配置为禁用时,不进入群内管理菜单。""" - enabled = self._extract_plugin_enabled_flag(plugin) - if not enabled and enabled is not None: - self._reply_to_qq(group_id, sender, "相关插件未启用") - return False - return True - - def on_qq_help(self, group_id: int, sender: int, _): - """打开可交互的群帮助主菜单。""" - self.qq_help_main_menu(group_id, sender) - - def _is_menu_exit(self, user_input: str, group_id: int | None = None): - return self.is_menu_exit_input(user_input, group_id) - - def _is_menu_back(self, user_input: str, group_id: int | None = None): - return self.is_menu_back_input(user_input, group_id) - - def _prompt_help_menu( - self, - group_id: int, - qqid: int, - subtitle: str, - options: list[str], - hints: list[str], - allow_back: bool = False, - ): - choice = self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - subtitle, - options, - hints, - group_id), - timeout=120, - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - choice = choice.strip() - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if allow_back and self._is_menu_back(choice, group_id): - return "back" - return choice - - def _parse_help_choice( - self, - group_id: int, - qqid: int, - choice: str, - count: int): - selected = self.parse_displayed_menu_choice(choice, count) - if selected is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return selected - - def _prompt_paginated_help_actions( - self, - group_id: int, - qqid: int, - subtitle: str, - options: list[str], - actions: list[Any], - per_page: int, - ): - if len(options) != len(actions): - self._reply_to_qq(group_id, qqid, "❀ 菜单配置错误") - return None - if not options: - self._reply_to_qq(group_id, qqid, "当前没有可用功能") - return None - page = 1 - while True: - total_pages, start_index, end_index = ( - utils.paginate(len(options), per_page, page) - if hasattr(utils, "paginate") - else self.simple_paginate(len(options), per_page, page) - ) - page_options = options[start_index - 1: end_index] - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - subtitle, - page_options, - [ - f"当前第 {page}/{total_pages} 页", - f"输入 [1-{len(page_options)}] 之间的数字以选择 对应功能", - "输入 - 转到上一页", - "输入 + 转到下一页", - "输入 正整数+页 转到对应页", - "输入 0 返回上级菜单", - "输入 . 退出", - ], - group_id, - ) - user_input = self.qq_prompt(group_id, qqid, text, timeout=120) - if user_input is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - user_input = user_input.strip() - if self._is_menu_exit(user_input, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if self._is_menu_back(user_input, group_id): - return "back" - if user_input == "+": - if page < total_pages: - page += 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") - continue - if user_input == "-": - if page > 1: - page -= 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") - continue - if page_num := self.parse_page_jump(user_input): - if 1 <= page_num <= total_pages: - page = page_num - else: - self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") - continue - selected = self._parse_help_choice( - group_id, - qqid, - user_input, - len(page_options), - ) - if selected is None: - continue - result = actions[start_index + selected - 2]() - return result if result == "back" else None - - def qq_help_main_menu(self, group_id: int, qqid: int): - """群帮助主菜单,负责路由到各个二级菜单。""" - options = ["非管理功能"] - handlers = [self.qq_help_basic_menu] - if self._has_help_admin_actions(group_id, qqid): - options.append("管理功能") - handlers.append(self.qq_help_admin_menu) - options.append("命令说明") - handlers.append(self.qq_help_show_all_reference) - while True: - choice = self._prompt_help_menu( - group_id, - qqid, - "帮助主菜单", - options, - [f"输入 [1-{len(options)}] 之间的数字以选择 对应菜单", "输入 . 退出"], - ) - if choice is None: - return - selected = self._parse_help_choice( - group_id, qqid, choice, len(options)) - if selected is None: - continue - if handlers[selected - 1](group_id, qqid) == "back": - continue - return - - def qq_help_basic_menu(self, group_id: int, qqid: int): - """非管理功能二级菜单。""" - options = [] - actions = [] - if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"] and ( - self._can_use_group_permission(group_id, qqid, "查看玩家人数权限") - ): - options.append("查看在线玩家列表") - actions.append(lambda: self.on_qq_player_list(group_id, qqid, [])) - options.append("公会系统菜单") - actions.append(lambda: self.qq_guild_player_menu(group_id, qqid)) - options.append("查看非管理功能触发词") - actions.append( - lambda: self.qq_help_show_basic_reference( - group_id, qqid)) - return self._prompt_paginated_help_actions( - group_id, - qqid, - "非管理功能", - options, - actions, - self.get_group_help_non_admin_items_per_page(group_id), - ) - - def qq_help_admin_menu(self, group_id: int, qqid: int): - """管理功能二级菜单。""" - options = [] - actions = [] - if self._can_use_any_group_permission( - group_id, - qqid, - ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), - ): - options.append("QQ群管理员管理菜单") - actions.append(lambda: self.qq_admin_menu(group_id, qqid)) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "发送指令权限", - "发送 Minecraft 指令", - lambda: self.qq_help_execute_command_prompt(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "配置配置文件权限", - "配置中心", - lambda: self.qq_config_center_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "查询背包权限", - "查询在线玩家背包", - lambda: self.qq_inventory_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "封禁/解封玩家权限", - "Orion QQ 封禁菜单", - lambda: self.qq_orion_ban_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "封禁/解封玩家权限", - "Orion QQ 解封菜单", - lambda: self.qq_orion_unban_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "白名单&管理员检测权限", - "白名单&管理员检测管理菜单", - lambda: self.qq_checker_menu(group_id, qqid), - ) - if self.task_system is not None: - self._append_permission_action( - group_id, - qqid, - options, - actions, - "任务系统权限", - "任务系统管理菜单", - lambda: self.qq_task_system_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "领地系统权限", - "领地系统云链联动版管理菜单", - lambda: self.qq_land_system_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "公会系统权限", - "公会系统管理菜单", - lambda: self.qq_guild_system_menu(group_id, qqid), - ) - options.append("查看管理功能触发词") - actions.append( - lambda: self.qq_help_show_admin_reference( - group_id, qqid)) - return self._prompt_paginated_help_actions( - group_id, - qqid, - "管理功能", - options, - actions, - self.get_group_help_admin_items_per_page(group_id), - ) - - def qq_help_integration_menu(self, group_id: int, qqid: int): - """联动系统二级菜单。""" - while True: - options = [] - actions = [] - self._append_permission_action( - group_id, - qqid, - options, - actions, - "封禁/解封玩家权限", - "Orion QQ 封禁菜单", - lambda: self.qq_orion_ban_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "封禁/解封玩家权限", - "Orion QQ 解封菜单", - lambda: self.qq_orion_unban_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "白名单&管理员检测权限", - "白名单&管理员检测管理菜单", - lambda: self.qq_checker_menu(group_id, qqid), - ) - if self.task_system is not None: - self._append_permission_action( - group_id, - qqid, - options, - actions, - "任务系统权限", - "任务系统管理菜单", - lambda: self.qq_task_system_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "领地系统权限", - "领地系统云链联动版管理菜单", - lambda: self.qq_land_system_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "公会系统权限", - "公会系统管理菜单", - lambda: self.qq_guild_system_menu(group_id, qqid), - ) - if not options: - self._reply_to_qq(group_id, qqid, "当前没有可用功能") - return None - choice = self._prompt_help_menu( - group_id, - qqid, - "联动系统", - options, - [ - f"输入 [1-{len(options)}] 之间的数字以选择 对应功能", - "输入 0 返回上级菜单", - "输入 . 退出", - ], - allow_back=True, - ) - if choice is None: - return None - if choice == "back": - return "back" - selected = self._parse_help_choice( - group_id, qqid, choice, len(options)) - if selected is None: - continue - actions[selected - 1]() - return None - - def qq_help_show_all_reference(self, group_id: int, qqid: int): - """直接分页展示本群当前可用的全部命令触发词。""" - lines = self.qq_help_build_all_reference_lines(group_id, qqid) - if not lines: - self._reply_to_qq(group_id, qqid, "当前没有可展示的命令触发词") - return None - page = 1 - per_page = self.get_group_command_help_items_per_page(group_id) - while True: - total_pages, start_index, end_index = ( - utils.paginate(len(lines), per_page, page) - if hasattr(utils, "paginate") - else self.simple_paginate(len(lines), per_page, page) - ) - page_lines = lines[start_index - 1: end_index] - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - "命令说明", - page_lines, - [ - f"当前第 {page}/{total_pages} 页", - "输入 - 转到上一页", - "输入 + 转到下一页", - "输入 正整数+页 转到对应页", - "输入 0 返回上级菜单", - "输入 . 退出", - ], - group_id, - ) - user_input = self.qq_prompt(group_id, qqid, text, timeout=120) - if user_input is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - if self._is_menu_exit(user_input, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if self._is_menu_back(user_input, group_id): - return "back" - if user_input == "+": - if page < total_pages: - page += 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") - continue - if user_input == "-": - if page > 1: - page -= 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") - continue - if page_num := self.parse_page_jump(user_input): - if 1 <= page_num <= total_pages: - page = page_num - else: - self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") - continue - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - - def qq_help_build_all_reference_lines(self, group_id: int, qqid: int): - """按运行时触发分发入口整理全部命令触发词说明。""" - cmd_prefix = self.get_group_cmd_prefix(group_id) - lines = [ - f"{' / '.join(self.get_group_help_triggers(group_id))} - 打开帮助菜单", - ] - if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"] and ( - self._can_use_group_permission(group_id, qqid, "查看玩家人数权限") - ): - lines.append( - f"{' / '.join(self.get_group_player_list_triggers(group_id))} - 查看玩家列表" - ) - lines.append( - f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统菜单(普通成员需绑定游戏账号;管理员进入管理菜单)" - ) - if self._can_use_group_permission(group_id, qqid, "查询背包权限"): - lines.append( - f"{' / '.join(self.get_group_inventory_menu_triggers(group_id))} - 查询在线玩家背包" - ) - if self._can_use_group_permission(group_id, qqid, "发送指令权限"): - lines.append(f"{cmd_prefix}[指令] - 向租赁服发送指令") - if self._can_use_any_group_permission( - group_id, - qqid, - ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), - ): - lines.append( - f"{' / '.join(self.get_group_admin_menu_triggers(group_id))} - QQ群管理员管理菜单" - ) - if self._can_use_group_permission(group_id, qqid, "配置配置文件权限"): - lines.append( - f"{' / '.join(self.get_group_config_menu_triggers(group_id))} - 配置中心" - ) - if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): - lines.extend( - [ - ( - f"{' / '.join(self.get_group_orion_ban_triggers(group_id))} " - "[玩家名/xuid] [封禁时间] [原因可选] - Orion QQ 封禁" - ), - ( - f"{' / '.join(self.get_group_orion_unban_triggers(group_id))} " - "[玩家名/xuid] - Orion QQ 解封" - ), - ] - ) - if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): - lines.append( - f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - 白名单&管理员检测管理菜单" - ) - if self.task_system is not None and self._can_use_group_permission( - group_id, - qqid, - "任务系统权限", - ): - lines.append( - f"{' / '.join(self.get_group_task_menu_triggers(group_id))} - 任务系统管理菜单" - ) - if self._can_use_group_permission(group_id, qqid, "领地系统权限"): - lines.append( - f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - 领地系统云链联动版管理菜单" - ) - if self._can_use_group_permission(group_id, qqid, "公会系统权限"): - lines.append( - f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统管理菜单(管理员)" - ) - for trigger in self.triggers: - if trigger.op_only and not self.is_group_admin(group_id, qqid): - continue - trigger_text = " / ".join(trigger.triggers) - argument_hint = f" { - trigger.argument_hint}" if trigger.argument_hint else "" - permission_hint = "(管理员)" if trigger.op_only else "" - lines.append( - f"外部|{trigger_text}{argument_hint} - {trigger.usage}{permission_hint}" - ) - return lines - - def qq_help_reference_menu(self, group_id: int, qqid: int): - """命令说明二级菜单。""" - while True: - options = ["非管理功能触发词"] - actions = [ - lambda: self.qq_help_show_basic_reference( - group_id, qqid)] - if self._has_help_admin_actions(group_id, qqid): - options.append("管理功能触发词") - actions.append( - lambda: self.qq_help_show_admin_reference(group_id, qqid)) - choice = self._prompt_help_menu( - group_id, - qqid, - "命令说明", - options, - [ - f"输入 [1-{len(options)}] 之间的数字以查看 对应说明", - "输入 0 返回上级菜单", - "输入 . 退出", - ], - allow_back=True, - ) - if choice is None: - return None - if choice == "back": - return "back" - selected = self._parse_help_choice( - group_id, qqid, choice, len(options)) - if selected is None: - continue - actions[selected - 1]() - return None - - def qq_help_execute_command_prompt(self, group_id: int, qqid: int): - """通过帮助菜单交互式发送一条 Minecraft 指令。""" - if not self._can_use_group_permission(group_id, qqid, "发送指令权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - cmd_prefix = self.get_group_cmd_prefix(group_id) - command = self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - "发送 Minecraft 指令", - ["在下一条消息中输入要发送到租赁服的指令"], - [f"可以省略或保留前缀 {cmd_prefix}", "输入 . 退出"], - group_id, - ), - timeout=120, - ) - if command is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - command = command.strip() - if self._is_menu_exit(command, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if command.startswith(cmd_prefix): - command = command.removeprefix(cmd_prefix).strip() - if not command: - self._reply_to_qq(group_id, qqid, f"参数错误,格式:{cmd_prefix}[指令]") - return - self.on_qq_execute_cmd(group_id, qqid, command.split()) - - def qq_help_show_basic_reference(self, group_id: int, qqid: int): - options = [ - f"{' / '.join(self.get_group_help_triggers(group_id))} - 打开帮助菜单", - ] - if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"] and ( - self._can_use_group_permission(group_id, qqid, "查看玩家人数权限") - ): - options.append( - f"{' / '.join(self.get_group_player_list_triggers(group_id))} - 查看玩家列表" - ) - options.append( - f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统菜单(需绑定游戏账号)" - ) - self._reply_to_qq( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - "非管理功能触发词", - options, - ["输入帮助菜单唤醒词可重新打开菜单"], - group_id, - ), - ) - - def qq_help_show_admin_reference(self, group_id: int, qqid: int): - cmd_prefix = self.get_group_cmd_prefix(group_id) - options = [] - if self._can_use_group_permission(group_id, qqid, "发送指令权限"): - options.append(f"{cmd_prefix}[指令] - 向租赁服发送指令") - if self._can_use_any_group_permission( - group_id, - qqid, - ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), - ): - options.append( - f"{' / '.join(self.get_group_admin_menu_triggers(group_id))} - QQ群管理员管理菜单" - ) - if self._can_use_group_permission(group_id, qqid, "配置配置文件权限"): - options.append( - f"{' / '.join(self.get_group_config_menu_triggers(group_id))} - 配置中心" - ) - if self._can_use_group_permission(group_id, qqid, "查询背包权限"): - options.append( - f"{' / '.join(self.get_group_inventory_menu_triggers(group_id))} - 查询在线玩家背包" - ) - if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): - options.extend( - [ - f"{' / '.join(self.get_group_orion_ban_triggers(group_id))} - Orion QQ 封禁菜单", - f"{' / '.join(self.get_group_orion_unban_triggers(group_id))} - Orion QQ 解封菜单", - ] - ) - if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): - options.append( - f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - 白名单&管理员检测管理菜单" - ) - if self.task_system is not None and self._can_use_group_permission( - group_id, - qqid, - "任务系统权限", - ): - options.append( - f"{' / '.join(self.get_group_task_menu_triggers(group_id))} - 任务系统管理菜单" - ) - if self._can_use_group_permission(group_id, qqid, "领地系统权限"): - options.append( - f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - 领地系统云链联动版管理菜单" - ) - if self._can_use_group_permission(group_id, qqid, "公会系统权限"): - options.append( - f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统管理菜单(管理员)" - ) - if not options: - self._reply_to_qq(group_id, qqid, "当前没有可展示的管理功能触发词") - return - self._reply_to_qq( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - "管理功能触发词", - options, - ["这些功能需要本群所有者、超级管理员或普通管理员权限"], - group_id, - ), - ) - - def qq_help_show_integration_reference(self, group_id: int, qqid: int): - options = [] - if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): - options.extend( - [ - f"{' / '.join(self.get_group_orion_ban_triggers(group_id))} - Orion QQ 封禁菜单", - f"{' / '.join(self.get_group_orion_unban_triggers(group_id))} - Orion QQ 解封菜单", - ] - ) - if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): - options.append( - f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - 白名单&管理员检测管理菜单" - ) - if self.task_system is not None and self._can_use_group_permission( - group_id, - qqid, - "任务系统权限", - ): - options.append( - f"{' / '.join(self.get_group_task_menu_triggers(group_id))} - 任务系统管理菜单" - ) - if self._can_use_group_permission(group_id, qqid, "领地系统权限"): - options.append( - f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - 领地系统云链联动版管理菜单" - ) - if self._can_use_group_permission(group_id, qqid, "公会系统权限"): - options.append( - f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统菜单(普通成员需绑定;管理员进入管理菜单)" - ) - if not options: - self._reply_to_qq(group_id, qqid, "当前没有可展示的联动系统触发词") - return - self._reply_to_qq( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - "联动系统触发词", - options, - ["公会系统普通菜单需要 QQ 绑定游戏账号,管理菜单需要本群管理员权限"], - group_id, - ), - ) - - def on_qq_player_list(self, group_id: int, _sender: int, _): - """把在线玩家列表和可用 TPS 信息发到群里。""" - if not self._can_use_group_permission(group_id, _sender, "查看玩家人数权限"): - self._reply_menu_permission_denied(group_id, _sender) - return - group_cfg = self.group_cfgs[group_id] - if not group_cfg["指令设置"]["是否允许查看玩家列表"]: - self.sendmsg(group_id, "当前群未启用玩家列表查询") - return - players = [f"{i + 1}.{j}" for i, - j in enumerate(self.game_ctrl.allplayers)] - fmt_msg = ( - f"在线玩家有 {len(players)} 人:\n " - + "\n ".join(players) - + ( - f"\n当前 TPS: {round(self.tps_calc.get_tps(), 1)}/20" - if self.tps_calc - else "" - ) - ) - self.sendmsg(group_id, fmt_msg) - - def qq_inventory_menu(self, group_id: int, qqid: int): - """分页展示在线玩家,并允许在群里进一步查询某个人的背包。""" - if not self._can_use_group_permission(group_id, qqid, "查询背包权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - online_names = list(self.game_ctrl.allplayers) - if not online_names: - self._reply_to_qq(group_id, qqid, "当前没有在线玩家") - return - page = 1 - per_page = self.get_group_inventory_items_per_page(group_id) - while True: - # 这里保留一个循环式菜单,避免每次翻页都重新走一遍入口指令。 - total_pages, start_index, end_index = ( - utils.paginate(len(online_names), per_page, page) - if hasattr(utils, "paginate") - else self.simple_paginate(len(online_names), per_page, page) - ) - page_names = online_names[start_index - 1: end_index] - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - "查询背包", - page_names, - [ - f"当前第 {page}/{total_pages} 页", - f"输入 [1-{len(page_names)}] 之间的数字以选择 对应玩家", - "输入 - 转到上一页", - "输入 + 转到下一页", - "输入 正整数+页 转到对应页", - "输入 . 退出", - ], - group_id, - ) - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {text}", - do_remove_cq_code=False) - user_input = self.waitMsg(qqid, timeout=120, group_id=group_id) - if user_input is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - user_input = user_input.strip() - if self._is_menu_exit(user_input, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if user_input == "+": - # 菜单保持在同一条交互链里,翻页只改当前页码。 - if page < total_pages: - page += 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") - continue - if user_input == "-": - if page > 1: - page -= 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") - continue - if page_num := self.parse_page_jump(user_input): - if 1 <= page_num <= total_pages: - page = page_num - else: - self._reply_to_qq( - group_id, - qqid, - f"❀ 不存在第 {page_num} 页!请重新输入!", - ) - continue - choice = self.parse_displayed_menu_choice( - user_input, len(page_names)) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - continue - self.show_player_inventory(group_id, qqid, page_names[choice - 1]) - return - - @staticmethod - def simple_paginate(total_len: int, per_page: int, page: int): - """在缺少 utils.paginate 时使用的本地分页兜底实现。""" - total_pages = max(1, (total_len + per_page - 1) // per_page) - page = max(1, min(page, total_pages)) - start_index = (page - 1) * per_page + 1 - end_index = min(page * per_page, total_len) - return total_pages, start_index, end_index - - @staticmethod - def parse_displayed_menu_choice(user_input: str, displayed_count: int): - """Parse a choice using the numbers shown on the current menu page.""" - choice = utils.try_int(user_input.strip().strip("[]")) - if choice is None or choice not in range(1, displayed_count + 1): - return None - return choice - - @staticmethod - def parse_page_jump(user_input: str): - """Parse page jumps only when the input explicitly ends with a page suffix.""" - text = user_input.strip() - if len(text) < 2 or text[-1] not in ("页", "頁", "椤"): - return None - return utils.try_int(text[:-1]) - - def show_player_inventory( - self, - group_id: int, - qqid: int, - player_name: str): - """把背包槽位整理成适合群聊阅读的文本。""" - player_obj = self.game_ctrl.players.getPlayerByName(player_name) - if player_obj is None: - self._reply_to_qq(group_id, qqid, f"玩家 {player_name} 当前已不在线") - return - try: - inventory = player_obj.queryInventory() - except Exception as err: - self._reply_to_qq(group_id, qqid, f"查询背包失败: {err}") - return - items: list[str] = [] - for idx, slot in enumerate(inventory.slots): - if slot is None: - continue - # 这里把原始槽位信息尽量整理成适合群内阅读的文本。 - item_id = getattr(slot, "id", "未知ID") - display_name = self.translate_item_name(item_id) - stack_size = getattr(slot, "stackSize", 0) - aux = getattr(slot, "aux", 0) - line = f"槽位 {idx}: {display_name} x{stack_size}" - if display_name != item_id: - line += f" ({item_id})" - if aux not in (None, -1, 0): - line += f" (数据值:{aux})" - custom_name = self.get_item_custom_name(slot) - if custom_name: - line += f" (命名:{custom_name})" - ench_text = self.get_item_enchantments_text(slot) - if ench_text: - line += f" (附魔:{ench_text})" - items.append(line) - if not items: - items = ["该玩家背包为空"] - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - f"背包查询 - {player_name}", - items, - ["输入 . 退出"], - group_id, - ) - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {text}", - do_remove_cq_code=False) - - def ensure_task_system(self, group_id: int, sender: int): - if self.task_system is None: - self._reply_to_qq(group_id, sender, "相关插件未安装:任务系统云链联动版") - return False - return self.ensure_linked_plugin_enabled( - self.task_system, group_id, sender) - - @staticmethod - def _format_task_time(timestamp: int): - try: - return time.strftime( - "%Y-%m-%d %H:%M:%S", - time.localtime(timestamp)) - except Exception: - return str(timestamp) - - @staticmethod - def _format_task_label(task_info: dict[str, Any]): - show_name = str(task_info.get("show_name", "")).strip() - tag_name = str(task_info.get("tag_name", "")).strip() - if not show_name or show_name == tag_name: - return tag_name or show_name or "<未知任务>" - return f"{show_name} ({tag_name})" - - def _format_task_menu_item(self, task_info: dict[str, Any], status: str): - label = self._format_task_label(task_info) - lines = [f"{status} {label}"] - description = str(task_info.get("description", "")).strip() - if description: - lines.append(f" {description}") - if "finished_time" in task_info: - try: - finished_time = self._format_task_time( - int(task_info["finished_time"])) - except Exception: - finished_time = str(task_info.get("finished_time", "未知时间")) - lines.append(f" 完成时间:{finished_time}") - return "\n".join(lines) - - def _format_task_progress_text( - self, progress: dict[str, Any], group_id: int): - in_progress = progress.get("in_progress", []) - completed = progress.get("completed", []) - options = [] - if in_progress: - for task_info in in_progress: - options.append(self._format_task_menu_item(task_info, "未完成")) - else: - options.append("未完成任务:无") - if completed: - for task_info in completed: - options.append(self._format_task_menu_item(task_info, "已完成")) - else: - options.append("已完成任务:无") - player_name = progress.get("player_name", "<未知玩家>") - return self.plugin_ui_menu( - "群服互通云链版Ultra版", - f"任务进度 - {player_name}", - options, - [ - f"未完成任务:{len(in_progress)} 个", - f"已完成任务:{len(completed)} 个", - "输入 . 退出", - ], - group_id, - ) - - def qq_task_system_menu(self, group_id: int, qqid: int): - if not self._can_use_group_permission(group_id, qqid, "任务系统权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - if not self.ensure_task_system(group_id, qqid): - return - choice = self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - "任务系统菜单", - ["查看在线玩家任务进度", "给在线玩家下发任务", "直接完成在线玩家任务"], - ["输入 [1-3] 之间的数字以选择 对应功能", "输入 . 退出"], - group_id, - ), - timeout=120, - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") - return - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if choice == "1": - player_name = self.qq_select_online_player( - group_id, qqid, "查看任务进度") - if player_name is None: - return - self.on_qq_task_progress(group_id, qqid, [player_name]) - return - if choice == "2": - player_name = self.qq_select_online_player(group_id, qqid, "下发任务") - if player_name is None: - return - quest_tag = self.qq_select_task_from_catalog(group_id, qqid) - if quest_tag is None: - return - self.on_qq_task_add(group_id, qqid, [player_name, quest_tag]) - return - if choice == "3": - player_name = self.qq_select_online_player(group_id, qqid, "完成任务") - if player_name is None: - return - quest_tag = self.qq_select_in_progress_task( - group_id, qqid, player_name) - if quest_tag is None: - return - self.on_qq_task_finish(group_id, qqid, [player_name, quest_tag]) - return - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - - def qq_select_online_player(self, group_id: int, qqid: int, subtitle: str): - online_names = list(self.game_ctrl.allplayers) - if not online_names: - self._reply_to_qq(group_id, qqid, "当前没有在线玩家") - return None - page = 1 - per_page = self.get_group_task_player_items_per_page(group_id) - while True: - total_pages, start_index, end_index = ( - utils.paginate(len(online_names), per_page, page) - if hasattr(utils, "paginate") - else self.simple_paginate(len(online_names), per_page, page) - ) - page_names = online_names[start_index - 1: end_index] - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - f"任务系统 - {subtitle}", - page_names, - [ - f"当前第 {page}/{total_pages} 页", - f"输入 [1-{len(page_names)}] 之间的数字以选择 对应玩家", - "输入 - 转到上一页", - "输入 + 转到下一页", - "输入 正整数+页 转到对应页", - "输入 . 退出", - ], - group_id, - ) - user_input = self.qq_prompt(group_id, qqid, text, timeout=120) - if user_input is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") - return None - if self._is_menu_exit(user_input, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if user_input == "+": - if page < total_pages: - page += 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") - continue - if user_input == "-": - if page > 1: - page -= 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") - continue - if page_num := self.parse_page_jump(user_input): - if 1 <= page_num <= total_pages: - page = page_num - else: - self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") - continue - choice = self.parse_displayed_menu_choice( - user_input, len(page_names)) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - continue - return page_names[choice - 1] - - def qq_select_task_from_catalog(self, group_id: int, qqid: int): - quests = self.task_system.list_available_quests() - if not quests: - self._reply_to_qq(group_id, qqid, "任务系统中暂无可用任务") - return None - page = 1 - per_page = self.get_group_task_items_per_page(group_id) - while True: - total_pages, start_index, end_index = ( - utils.paginate(len(quests), per_page, page) - if hasattr(utils, "paginate") - else self.simple_paginate(len(quests), per_page, page) - ) - page_quests = quests[start_index - 1: end_index] - options = [] - for quest_info in page_quests: - label = self._format_task_label(quest_info) - description = str(quest_info.get("description", "")).strip() - options.append( - label if not description else f"{label}\n {description}") - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - "任务系统 - 选择任务", - options, - [ - f"当前第 {page}/{total_pages} 页", - f"输入 [1-{len(page_quests)}] 之间的数字以选择 对应任务", - "输入 - 转到上一页", - "输入 + 转到下一页", - "输入 正整数+页 转到对应页", - "输入 . 退出", - ], - group_id, - ) - user_input = self.qq_prompt(group_id, qqid, text, timeout=120) - if user_input is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") - return None - if self._is_menu_exit(user_input, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if user_input == "+": - if page < total_pages: - page += 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") - continue - if user_input == "-": - if page > 1: - page -= 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") - continue - if page_num := self.parse_page_jump(user_input): - if 1 <= page_num <= total_pages: - page = page_num - else: - self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") - continue - choice = self.parse_displayed_menu_choice( - user_input, len(page_quests)) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - continue - return page_quests[choice - 1]["tag_name"] - - def qq_select_in_progress_task( - self, - group_id: int, - qqid: int, - player_name: str): - ok, result = self.task_system.get_online_player_task_progress( - player_name) - if not ok: - self._reply_to_qq(group_id, qqid, str(result)) - return None - in_progress = result.get("in_progress", []) - if not in_progress: - self._reply_to_qq(group_id, qqid, f"玩家 {player_name} 当前没有进行中的任务") - return None - options = [] - for task_info in in_progress: - label = self._format_task_label(task_info) - description = str(task_info.get("description", "")).strip() - options.append( - label if not description else f"{label}\n {description}") - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - f"任务系统 - 完成任务 - {player_name}", - options, - [f"输入 [1-{len(options)}] 之间的数字以选择 对应任务", "输入 . 退出"], - group_id, - ) - user_input = self.qq_prompt(group_id, qqid, text, timeout=120) - if user_input is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") - return None - if self._is_menu_exit(user_input, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - choice = self.parse_displayed_menu_choice(user_input, len(options)) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return None - return in_progress[choice - 1]["tag_name"] - - def on_qq_task_progress(self, group_id: int, sender: int, args: list[str]): - if not self._can_use_group_permission(group_id, sender, "任务系统权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_task_system(group_id, sender): - return - ok, result = self.task_system.get_online_player_task_progress(args[0]) - if not ok: - self._reply_to_qq(group_id, sender, str(result)) - return - self._reply_to_qq( - group_id, - sender, - self._format_task_progress_text(result, group_id), - ) - - def on_qq_task_add(self, group_id: int, sender: int, args: list[str]): - if not self._can_use_group_permission(group_id, sender, "任务系统权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_task_system(group_id, sender): - return - player_name = args[0] - quest_query = " ".join(args[1:]).strip() - ok, msg = self.task_system.add_quest_to_online_player( - player_name, quest_query) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_task_finish(self, group_id: int, sender: int, args: list[str]): - if not self._can_use_group_permission(group_id, sender, "任务系统权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_task_system(group_id, sender): - return - player_name = args[0] - quest_query = " ".join(args[1:]).strip() - ok, msg = self.task_system.finish_quest_for_online_player( - player_name, quest_query - ) - self._reply_result(group_id, sender, ok, msg) - - def ensure_guild_system(self, group_id: int, sender: int): - """检查公会系统云链联动版 API 是否可用。""" - if self.guild_system is None: - self._reply_to_qq(group_id, sender, "相关插件未安装:公会系统云链联动版") - return False - return self.ensure_linked_plugin_enabled( - self.guild_system, group_id, sender) - - @staticmethod - def _guild_actor(group_id: int, qqid: int): - return f"QQ群{group_id}:{qqid}" - - def _qq_guild_prompt( - self, - group_id: int, - qqid: int, - subtitle: str, - options: list[str], - hints: list[str], - ): - return self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "公会系统云链联动版", - subtitle, - options, - hints, - group_id, - ), - timeout=120, - ) - - def _qq_guild_prompt_text( - self, - group_id: int, - qqid: int, - subtitle: str, - prompt: str, - allow_empty: bool = False, - ): - value = self._qq_guild_prompt( - group_id, qqid, subtitle, [], [ - prompt, "输入 . 退出"]) - if value is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - value = value.strip() - if self._is_menu_exit(value, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if not allow_empty and not value: - self._reply_to_qq(group_id, qqid, "❀ 输入不能为空") - return None - return value - - def _qq_guild_prompt_optional_query( - self, - group_id: int, - qqid: int, - subtitle: str, - prompt: str, - ): - value = self._qq_guild_prompt_text( - group_id, qqid, subtitle, prompt, allow_empty=True) - if value is None: - return None - if value in ("", "全部", "全服", "all", "*", "-"): - return "" - return value - - def _qq_guild_prompt_int( - self, - group_id: int, - qqid: int, - subtitle: str, - prompt: str, - minimum: int | None = None, - default: int | None = None, - ): - hints = [prompt, "输入 . 退出"] - if default is not None: - hints.insert(1, f"输入 默认 使用 {default}") - value = self._qq_guild_prompt(group_id, qqid, subtitle, [], hints) - if value is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - value = value.strip() - if self._is_menu_exit(value, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if default is not None and value in ("", "默认", "default"): - return default - parsed = utils.try_int(value) - if parsed is None: - self._reply_to_qq(group_id, qqid, "❀ 请输入整数") - return None - if minimum is not None and parsed < minimum: - self._reply_to_qq(group_id, qqid, f"❀ 数值不能小于 {minimum}") - return None - return parsed - - def _qq_guild_confirm( - self, - group_id: int, - qqid: int, - subtitle: str, - detail: str): - choice = self._qq_guild_prompt( - group_id, - qqid, - subtitle, - [detail], - ["请输入 确认 继续执行", "输入 . 取消"], - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已取消") - return False - choice = choice.strip() - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已取消") - return False - if choice != "确认": - self._reply_to_qq(group_id, qqid, "❀ 已取消") - return False - return True - - def _reply_guild_api_result(self, group_id: int, qqid: int, result): - if not isinstance(result, tuple) or not result: - self._reply_to_qq(group_id, qqid, "公会系统接口返回异常") - return - ok = bool(result[0]) - msg = str( - result[1]) if len(result) >= 2 else ( - "操作成功" if ok else "操作失败") - if len(result) >= 3 and isinstance(result[2], str) and result[2]: - msg = f"{msg}\n{result[2]}" - self._reply_result(group_id, qqid, ok, msg) - - @staticmethod - def _format_guild_base(base: dict[str, Any] | None): - if not base: - return "未设置" - return f"{ - base.get( - 'dimension', - 0)} ({ - base.get( - 'x', - 0):.1f}, { - base.get( - 'y', - 0):.1f}, { - base.get( - 'z', - 0):.1f})" - - @staticmethod - def _format_guild_line(item: dict[str, Any], index: int): - frozen = " 冻结" if item.get("frozen") else "" - return ( - f"{index}. {item.get('name', '<未知>')} Lv.{item.get('level', 0)} " - f"成员 {item.get('member_count', 0)}/{item.get('max_members', 0)} " - f"会长 {item.get('owner', '<未知>')}{frozen}" - ) - - def _format_guild_summary(self, guild: dict[str, Any], group_id: int): - effects = guild.get("purchased_effects", {}) - effect_text = "无" - if isinstance(effects, dict) and effects: - effect_text = "、".join( - f"{key}:{level}" for key, - level in effects.items()) - return self.plugin_ui_menu( - "公会系统云链联动版", - f"公会信息 - {guild.get('name', '<未知>')}", - [ - f"ID:{guild.get('guild_id', '<未知>')}", - f"会长:{guild.get('owner', '<未知>')}", - f"等级/经验:{guild.get('level', 0)} / {guild.get('exp', 0)}", - f"成员:{guild.get('member_count', 0)}/{guild.get('max_members', 0)}", - f"仓库:{guild.get('vault_count', 0)}/{guild.get('vault_capacity', 0)}", - f"资金:{guild.get('funds', 0)}", - f"据点:{self._format_guild_base(guild.get('base'))}", - f"据点锁定:{'是' if guild.get('base_locked') else '否'}", - f"冻结:{'是' if guild.get('frozen') else '否'}", - f"冻结原因:{guild.get('frozen_reason') or '无'}", - f"活跃/完成任务:{guild.get('active_tasks', 0)} / {guild.get('completed_tasks', 0)}", - f"总贡献:{guild.get('total_contribution', 0)}", - f"效果:{effect_text}", - f"公告:{guild.get('announcement') or '无'}", - ], - ["输入 . 退出"], - group_id, - ) - - def _reply_guild_lines( - self, - group_id: int, - qqid: int, - title: str, - lines: list[str], - hints: list[str] | None = None, - ): - self._reply_to_qq( - group_id, - qqid, - self.plugin_ui_menu( - "公会系统云链联动版", - title, - lines or ["暂无数据"], - hints or ["输入 . 退出"], - group_id, - ), - ) - - def _qq_guild_run_menu( - self, - group_id: int, - qqid: int, - subtitle: str, - options: list[str], - actions: list[Any], - allow_back: bool = False, - ): - if len(options) != len(actions): - self._reply_to_qq(group_id, qqid, "❀ 菜单配置错误") - return None - hints = [f"输入 [1-{len(options)}] 之间的数字以选择 对应功能"] - if allow_back: - hints.append("输入 0 返回上级菜单") - hints.append("输入 . 退出") - choice = self._qq_guild_prompt( - group_id, - qqid, - subtitle, - options, - hints, - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - choice = choice.strip() - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if allow_back and self._is_menu_back(choice, group_id): - return "back" - selected = self.parse_displayed_menu_choice(choice, len(options)) - if selected is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return None - return actions[selected - 1]() - - def qq_guild_entry_menu(self, group_id: int, qqid: int): - """公会系统统一入口:群管理员进管理菜单,普通成员进绑定账号菜单。""" - if self._can_use_group_permission(group_id, qqid, "公会系统权限"): - self.qq_guild_system_menu(group_id, qqid) - return - self.qq_guild_player_menu(group_id, qqid) - - def _qq_guild_select_bound_player(self, group_id: int, qqid: int): - """选择一个 QQ 已绑定的游戏账号,返回玩家名。""" - bound_players = self.api_get_bound_players_by_qq(qqid) - usable_players = [ - item - for item in bound_players - if str(item.get("player_name", "")).strip() - ] - if not usable_players: - bind_hint = " / ".join(self.get_group_binding_triggers(group_id)) - if not self.api_is_binding_enabled(group_id): - self._reply_to_qq( - group_id, - qqid, - "请先绑定游戏账号后再使用公会菜单;当前群的 QQ 绑定功能未开启,请联系管理员。", - ) - return None - self._reply_to_qq( - group_id, - qqid, - f"请先绑定游戏账号后再使用公会菜单。绑定触发词:{bind_hint}", - ) - return None - if len(usable_players) == 1: - return str(usable_players[0].get("player_name", "")).strip() - - options = [ - f"{item.get('player_name', '<未知玩家>')} ({item.get('xuid', '<未知XUID>')})" - for item in usable_players - ] - choice = self._qq_guild_prompt( - group_id, - qqid, - "选择绑定账号", - options, - [f"输入 [1-{len(options)}] 之间的数字以选择 游戏账号", "输入 . 退出"], - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - selected = self.parse_displayed_menu_choice(choice, len(options)) - if selected is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return None - return str(usable_players[selected - 1].get("player_name", "")).strip() - - def _qq_guild_player_state( - self, - group_id: int, - qqid: int, - player_name: str): - result = self.guild_system.api_get_player_guild_menu_state(player_name) - if not isinstance(result, tuple) or len(result) < 3: - self._reply_to_qq(group_id, qqid, "公会系统接口返回异常") - return None - ok, msg, state = result - if not ok or not isinstance(state, dict): - self._reply_result(group_id, qqid, bool(ok), str(msg)) - return None - return state - - def qq_guild_player_menu(self, group_id: int, qqid: int): - """普通群成员可使用的公会菜单,要求 QQ 已绑定游戏账号。""" - if not self.ensure_guild_system(group_id, qqid): - return - player_name = self._qq_guild_select_bound_player(group_id, qqid) - if not player_name: - return - while True: - state = self._qq_guild_player_state(group_id, qqid, player_name) - if state is None: - return - if state.get("in_guild"): - guild = state.get("guild") if isinstance( - state.get("guild"), dict) else {} - member = state.get("member") if isinstance( - state.get("member"), dict) else {} - permissions = set(state.get("permissions") or []) - is_owner = bool(state.get("is_owner")) - is_frozen = bool(state.get("is_frozen")) - subtitle = ( - f"玩家菜单 - {player_name} | {guild.get('name', '<未知公会>')} " - f"({member.get('rank_name', member.get('rank', '成员'))})" - ) - options = [ - "我的公会信息", - "查看成员列表", - "查看公会日志", - "查看公会公告", - "查看公会排行", - "查看贡献排行", - ] - actions = [ - lambda: self.qq_guild_player_show_self( - group_id, qqid, player_name), - lambda: self.qq_guild_player_show_members( - group_id, qqid, player_name), - lambda: self.qq_guild_player_show_logs( - group_id, qqid, player_name), - lambda: self.qq_guild_player_show_announcement( - group_id, qqid, player_name), - lambda: self.qq_guild_show_rankings(group_id, qqid), - lambda: self.qq_guild_show_donation_rankings( - group_id, qqid),] - if not is_frozen: - if "announce" in permissions: - options.append("设置公会公告") - actions.append( - lambda: self.qq_guild_player_set_announcement( - group_id, qqid, player_name)) - if "vault" in permissions: - options.append("查看公会仓库") - actions.append(lambda: self.qq_guild_player_show_vault( - group_id, qqid, player_name)) - options.append("查看公会任务") - actions.append(lambda: self.qq_guild_player_show_tasks( - group_id, qqid, player_name)) - options.append("参与公会任务") - actions.append(lambda: self.qq_guild_player_join_task( - group_id, qqid, player_name)) - if "return_base" in permissions: - options.append("返回公会据点") - actions.append( - lambda: self.qq_guild_player_return_base( - group_id, qqid, player_name)) - if not is_owner: - options.append("退出公会") - actions.append(lambda: self.qq_guild_player_leave( - group_id, qqid, player_name)) - if is_owner and not is_frozen: - options.append("解散我的公会") - actions.append(lambda: self.qq_guild_player_disband( - group_id, qqid, player_name)) - else: - subtitle = f"玩家菜单 - {player_name} | 未加入公会" - options = ["查看公会列表", "申请加入公会", "查看公会排行", "查看贡献排行"] - actions = [ - lambda: self.qq_guild_list( - group_id, qqid), lambda: self.qq_guild_player_request_join( - group_id, qqid, player_name), lambda: self.qq_guild_show_rankings( - group_id, qqid), lambda: self.qq_guild_show_donation_rankings( - group_id, qqid), ] - result = self._qq_guild_run_menu( - group_id, qqid, subtitle, options, actions) - if result == "back": - continue - return - - def qq_guild_player_show_self( - self, - group_id: int, - qqid: int, - player_name: str): - state = self._qq_guild_player_state(group_id, qqid, player_name) - if state is None: - return - guild = state.get("guild") if isinstance( - state.get("guild"), dict) else {} - member = state.get("member") if isinstance( - state.get("member"), dict) else {} - if not guild: - self._reply_to_qq(group_id, qqid, f"{player_name} 暂未加入公会") - return - self._reply_guild_lines( - group_id, - qqid, - f"我的公会 - {player_name}", - [ - f"公会:{guild.get('name', '<未知>')} ({guild.get('guild_id', '<未知>')})", - f"职位:{member.get('rank_name', member.get('rank', '<未知>'))}", - f"贡献:{member.get('contribution', 0)}", - f"会长:{guild.get('owner', '<未知>')}", - f"等级/经验:{guild.get('level', 0)} / {guild.get('exp', 0)}", - f"成员:{guild.get('member_count', 0)}/{guild.get('max_members', 0)}", - f"仓库:{guild.get('vault_count', 0)}/{guild.get('vault_capacity', 0)}", - f"据点:{self._format_guild_base(guild.get('base'))}", - f"冻结:{'是' if guild.get('frozen') else '否'}", - f"公告:{guild.get('announcement') or '无'}", - ], - ) - - def qq_guild_player_show_members( - self, - group_id: int, - qqid: int, - player_name: str): - state = self._qq_guild_player_state(group_id, qqid, player_name) - if state is None: - return - guild = state.get("guild") if isinstance( - state.get("guild"), dict) else {} - guild_id = str(guild.get("guild_id", "")).strip() - if not guild_id: - self._reply_to_qq(group_id, qqid, "你尚未加入任何公会") - return - ok, msg, data = self.guild_system.api_get_guild(guild_id) - if not ok or not data: - self._reply_result(group_id, qqid, False, msg) - return +"""QQ group menu and command handlers for Ultra.""" + +import re +import time +from typing import Any + +from tooldelta import utils + +try: + from tooldelta.utils.mc_translator import translate +except ImportError: + translate = None + + +# 日常群聊交互放在这一层:帮助、背包查询、管理员菜单、联动检查菜单。 +class QQLinkerQQMixin: + """负责群聊菜单、查询类功能以及白名单联动命令。""" + + @utils.thread_func("群服执行指令并获取返回") + def on_qq_execute_cmd(self, group_id: int, qqid: int, cmd: list[str]): + """在群里执行 Minecraft 指令,并把执行结果回发到群里。""" + if not self._can_use_group_permission(group_id, qqid, "发送指令权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + res = self.execute_cmd_and_get_zhcn_cb(" ".join(cmd)) + self.sendmsg(group_id, res) + + def _reply_to_qq(self, group_id: int, qqid: int, text: str): + """向指定群成员回复一条文本消息。""" + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False, + ) + + def _reply_result(self, group_id: int, qqid: int, ok: bool, msg: str): + """统一回发成功/失败结果。""" + self._reply_to_qq(group_id, qqid, f"{'😄' if ok else '😭'} {msg}") + + def _can_use_group_permission( + self, + group_id: int, + qqid: int, + permission_name: str, + ) -> bool: + """Implement the can use group permission operation.""" + if hasattr(self, "_has_group_permission"): + return self._has_group_permission(group_id, qqid, permission_name) + return self.has_group_permission(group_id, qqid, permission_name) + + def _can_use_any_group_permission( + self, + group_id: int, + qqid: int, + permission_names: tuple[str, ...], + ) -> bool: + """Implement the can use any group permission operation.""" + return any( + self._can_use_group_permission(group_id, qqid, permission_name) + for permission_name in permission_names + ) + + def _reply_menu_permission_denied(self, group_id: int, qqid: int): + """Implement the reply menu permission denied operation.""" + if hasattr(self, "_reply_permission_denied"): + self._reply_permission_denied(group_id, qqid) + return + self._reply_to_qq(group_id, qqid, "你没有权限执行此指令") + + def _ensure_group_permission( + self, + group_id: int, + qqid: int, + permission_name: str, + ) -> bool: + """Implement the ensure group permission operation.""" + if self._can_use_group_permission(group_id, qqid, permission_name): + return True + self._reply_menu_permission_denied(group_id, qqid) + return False + + def _append_permission_action( + self, + group_id: int, + qqid: int, + options: list[str], + actions: list[Any], + permission_name: str, + label: str, + action, + ): + """Implement the append permission action operation.""" + if self._can_use_group_permission(group_id, qqid, permission_name): + options.append(label) + actions.append(action) + + def _has_help_admin_actions(self, group_id: int, qqid: int) -> bool: + """Implement the has help admin actions operation.""" + permission_names = [ + "QQ普通管理员菜单权限", + "QQ超级管理员菜单权限", + "发送指令权限", + "配置配置文件权限", + "查询背包权限", + "封禁/解封玩家权限", + "白名单&管理员检测权限", + "领地系统权限", + "公会系统权限", + ] + if self.task_system is not None: + permission_names.append("任务系统权限") + return self._can_use_any_group_permission( + group_id, + qqid, + tuple(permission_names), + ) + + @staticmethod + def _extract_plugin_enabled_flag(plugin: Any): + """读取联动插件明确暴露的整体启用状态;缺少该项时返回 None。""" + for method_name in ("_plugin_enabled", "is_plugin_enabled"): + method = getattr(plugin, method_name, None) + if callable(method): + try: + return bool(method()) + except Exception: + return None + + enabled = getattr(plugin, "enabled", None) + if isinstance(enabled, bool): + return enabled + + for method_name in ("api_get_runtime_status", "get_runtime_status"): + method = getattr(plugin, method_name, None) + if not callable(method): + continue + try: + status = method() + except Exception: + continue + if isinstance(status, tuple) and len(status) >= 3: + status = status[2] + if isinstance(status, dict): + for key in ("enabled", "是否启用", "是否启用插件"): + if isinstance(status.get(key), bool): + return status[key] + + for attr_name in ("config", "cfg", "_cfg"): + config = getattr(plugin, attr_name, None) + if not isinstance(config, dict): + continue + for key in ("是否启用", "是否启用插件"): + if isinstance(config.get(key), bool): + return config[key] + base_config = config.get("基础配置") + if isinstance(base_config, dict): + for key in ("是否启用", "是否启用插件"): + if isinstance(base_config.get(key), bool): + return base_config[key] + + return None + + def ensure_linked_plugin_enabled( + self, + plugin: Any, + group_id: int, + sender: int) -> bool: + """联动插件明确配置为禁用时,不进入群内管理菜单。""" + enabled = self._extract_plugin_enabled_flag(plugin) + if not enabled and enabled is not None: + self._reply_to_qq(group_id, sender, "相关插件未启用") + return False + return True + + def on_qq_help(self, group_id: int, sender: int, _): + """打开可交互的群帮助主菜单。""" + self.qq_help_main_menu(group_id, sender) + + def _is_menu_exit(self, user_input: str, group_id: int | None = None): + """Implement the is menu exit operation.""" + return self.is_menu_exit_input(user_input, group_id) + + def _is_menu_back(self, user_input: str, group_id: int | None = None): + """Implement the is menu back operation.""" + return self.is_menu_back_input(user_input, group_id) + + def _prompt_help_menu( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + hints: list[str], + allow_back: bool = False, + ): + """Implement the prompt help menu operation.""" + choice = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + subtitle, + options, + hints, + group_id), + timeout=120, + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + choice = choice.strip() + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if allow_back and self._is_menu_back(choice, group_id): + return "back" + return choice + + def _parse_help_choice( + self, + group_id: int, + qqid: int, + choice: str, + count: int): + """Implement the parse help choice operation.""" + selected = self.parse_displayed_menu_choice(choice, count) + if selected is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return selected + + def _prompt_paginated_help_actions( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + actions: list[Any], + per_page: int, + ): + """Implement the prompt paginated help actions operation.""" + if len(options) != len(actions): + self._reply_to_qq(group_id, qqid, "❀ 菜单配置错误") + return None + if not options: + self._reply_to_qq(group_id, qqid, "当前没有可用功能") + return None + page = 1 + while True: + total_pages, start_index, end_index = ( + utils.paginate(len(options), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(options), per_page, page) + ) + page_options = options[start_index - 1: end_index] + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + subtitle, + page_options, + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(page_options)}] 之间的数字以选择 对应功能", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 0 返回上级菜单", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + user_input = user_input.strip() + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if self._is_menu_back(user_input, group_id): + return "back" + if user_input == "+": + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") + continue + selected = self._parse_help_choice( + group_id, + qqid, + user_input, + len(page_options), + ) + if selected is None: + continue + result = actions[start_index + selected - 2]() + return result if result == "back" else None + + def qq_help_main_menu(self, group_id: int, qqid: int): + """群帮助主菜单,负责路由到各个二级菜单。""" + options = ["非管理功能"] + handlers = [self.qq_help_basic_menu] + if self._has_help_admin_actions(group_id, qqid): + options.append("管理功能") + handlers.append(self.qq_help_admin_menu) + options.append("命令说明") + handlers.append(self.qq_help_show_all_reference) + while True: + choice = self._prompt_help_menu( + group_id, + qqid, + "帮助主菜单", + options, + [f"输入 [1-{len(options)}] 之间的数字以选择 对应菜单", "输入 . 退出"], + ) + if choice is None: + return + selected = self._parse_help_choice( + group_id, qqid, choice, len(options)) + if selected is None: + continue + if handlers[selected - 1](group_id, qqid) == "back": + continue + return + + def qq_help_basic_menu(self, group_id: int, qqid: int): + """非管理功能二级菜单。""" + options = [] + actions = [] + if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"] and ( + self._can_use_group_permission(group_id, qqid, "查看玩家人数权限") + ): + options.append("查看在线玩家列表") + actions.append(lambda: self.on_qq_player_list(group_id, qqid, [])) + options.append("公会系统菜单") + actions.append(lambda: self.qq_guild_player_menu(group_id, qqid)) + options.append("查看非管理功能触发词") + actions.append( + lambda: self.qq_help_show_basic_reference( + group_id, qqid)) + return self._prompt_paginated_help_actions( + group_id, + qqid, + "非管理功能", + options, + actions, + self.get_group_help_non_admin_items_per_page(group_id), + ) + + def qq_help_admin_menu(self, group_id: int, qqid: int): + """管理功能二级菜单。""" + options = [] + actions = [] + if self._can_use_any_group_permission( + group_id, + qqid, + ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), + ): + options.append("QQ群管理员管理菜单") + actions.append(lambda: self.qq_admin_menu(group_id, qqid)) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "发送指令权限", + "发送 Minecraft 指令", + lambda: self.qq_help_execute_command_prompt(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "配置配置文件权限", + "配置中心", + lambda: self.qq_config_center_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "查询背包权限", + "查询在线玩家背包", + lambda: self.qq_inventory_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "封禁/解封玩家权限", + "Orion QQ 封禁菜单", + lambda: self.qq_orion_ban_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "封禁/解封玩家权限", + "Orion QQ 解封菜单", + lambda: self.qq_orion_unban_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "白名单&管理员检测权限", + "白名单&管理员检测管理菜单", + lambda: self.qq_checker_menu(group_id, qqid), + ) + if self.task_system is not None: + self._append_permission_action( + group_id, + qqid, + options, + actions, + "任务系统权限", + "任务系统管理菜单", + lambda: self.qq_task_system_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "领地系统权限", + "领地系统云链联动版管理菜单", + lambda: self.qq_land_system_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "公会系统权限", + "公会系统管理菜单", + lambda: self.qq_guild_system_menu(group_id, qqid), + ) + options.append("查看管理功能触发词") + actions.append( + lambda: self.qq_help_show_admin_reference( + group_id, qqid)) + return self._prompt_paginated_help_actions( + group_id, + qqid, + "管理功能", + options, + actions, + self.get_group_help_admin_items_per_page(group_id), + ) + + def qq_help_integration_menu(self, group_id: int, qqid: int): + """联动系统二级菜单。""" + while True: + options = [] + actions = [] + self._append_permission_action( + group_id, + qqid, + options, + actions, + "封禁/解封玩家权限", + "Orion QQ 封禁菜单", + lambda: self.qq_orion_ban_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "封禁/解封玩家权限", + "Orion QQ 解封菜单", + lambda: self.qq_orion_unban_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "白名单&管理员检测权限", + "白名单&管理员检测管理菜单", + lambda: self.qq_checker_menu(group_id, qqid), + ) + if self.task_system is not None: + self._append_permission_action( + group_id, + qqid, + options, + actions, + "任务系统权限", + "任务系统管理菜单", + lambda: self.qq_task_system_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "领地系统权限", + "领地系统云链联动版管理菜单", + lambda: self.qq_land_system_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "公会系统权限", + "公会系统管理菜单", + lambda: self.qq_guild_system_menu(group_id, qqid), + ) + if not options: + self._reply_to_qq(group_id, qqid, "当前没有可用功能") + return None + choice = self._prompt_help_menu( + group_id, + qqid, + "联动系统", + options, + [ + f"输入 [1-{len(options)}] 之间的数字以选择 对应功能", + "输入 0 返回上级菜单", + "输入 . 退出", + ], + allow_back=True, + ) + if choice is None: + return None + if choice == "back": + return "back" + selected = self._parse_help_choice( + group_id, qqid, choice, len(options)) + if selected is None: + continue + actions[selected - 1]() + return None + + def qq_help_show_all_reference(self, group_id: int, qqid: int): + """直接分页展示本群当前可用的全部命令触发词。""" + lines = self.qq_help_build_all_reference_lines(group_id, qqid) + if not lines: + self._reply_to_qq(group_id, qqid, "当前没有可展示的命令触发词") + return None + page = 1 + per_page = self.get_group_command_help_items_per_page(group_id) + while True: + total_pages, start_index, end_index = ( + utils.paginate(len(lines), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(lines), per_page, page) + ) + page_lines = lines[start_index - 1: end_index] + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + "命令说明", + page_lines, + [ + f"当前第 {page}/{total_pages} 页", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 0 返回上级菜单", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if self._is_menu_back(user_input, group_id): + return "back" + if user_input == "+": + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") + continue + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + + def qq_help_build_all_reference_lines(self, group_id: int, qqid: int): + """按运行时触发分发入口整理全部命令触发词说明。""" + cmd_prefix = self.get_group_cmd_prefix(group_id) + lines = [ + f"{' / '.join(self.get_group_help_triggers(group_id))} - 打开帮助菜单", + ] + if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"] and ( + self._can_use_group_permission(group_id, qqid, "查看玩家人数权限") + ): + lines.append( + f"{' / '.join(self.get_group_player_list_triggers(group_id))} - 查看玩家列表" + ) + lines.append( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统菜单(普通成员需绑定游戏账号;管理员进入管理菜单)" + ) + if self._can_use_group_permission(group_id, qqid, "查询背包权限"): + lines.append( + f"{' / '.join(self.get_group_inventory_menu_triggers(group_id))} - 查询在线玩家背包" + ) + if self._can_use_group_permission(group_id, qqid, "发送指令权限"): + lines.append(f"{cmd_prefix}[指令] - 向租赁服发送指令") + if self._can_use_any_group_permission( + group_id, + qqid, + ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), + ): + lines.append( + f"{' / '.join(self.get_group_admin_menu_triggers(group_id))} - QQ群管理员管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "配置配置文件权限"): + lines.append( + f"{' / '.join(self.get_group_config_menu_triggers(group_id))} - 配置中心" + ) + if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + lines.extend( + [ + ( + f"{' / '.join(self.get_group_orion_ban_triggers(group_id))} " + "[玩家名/xuid] [封禁时间] [原因可选] - Orion QQ 封禁" + ), + ( + f"{' / '.join(self.get_group_orion_unban_triggers(group_id))} " + "[玩家名/xuid] - Orion QQ 解封" + ), + ] + ) + if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): + lines.append( + f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - 白名单&管理员检测管理菜单" + ) + if self.task_system is not None and self._can_use_group_permission( + group_id, + qqid, + "任务系统权限", + ): + lines.append( + f"{' / '.join(self.get_group_task_menu_triggers(group_id))} - 任务系统管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "领地系统权限"): + lines.append( + f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - 领地系统云链联动版管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "公会系统权限"): + lines.append( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统管理菜单(管理员)" + ) + for trigger in self.triggers: + if trigger.op_only and not self.is_group_admin(group_id, qqid): + continue + trigger_text = " / ".join(trigger.triggers) + argument_hint = f" { + trigger.argument_hint}" if trigger.argument_hint else "" + permission_hint = "(管理员)" if trigger.op_only else "" + lines.append( + f"外部|{trigger_text}{argument_hint} - {trigger.usage}{permission_hint}" + ) + return lines + + def qq_help_reference_menu(self, group_id: int, qqid: int): + """命令说明二级菜单。""" + while True: + options = ["非管理功能触发词"] + actions = [ + lambda: self.qq_help_show_basic_reference( + group_id, qqid)] + if self._has_help_admin_actions(group_id, qqid): + options.append("管理功能触发词") + actions.append( + lambda: self.qq_help_show_admin_reference(group_id, qqid)) + choice = self._prompt_help_menu( + group_id, + qqid, + "命令说明", + options, + [ + f"输入 [1-{len(options)}] 之间的数字以查看 对应说明", + "输入 0 返回上级菜单", + "输入 . 退出", + ], + allow_back=True, + ) + if choice is None: + return None + if choice == "back": + return "back" + selected = self._parse_help_choice( + group_id, qqid, choice, len(options)) + if selected is None: + continue + actions[selected - 1]() + return None + + def qq_help_execute_command_prompt(self, group_id: int, qqid: int): + """通过帮助菜单交互式发送一条 Minecraft 指令。""" + if not self._can_use_group_permission(group_id, qqid, "发送指令权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + cmd_prefix = self.get_group_cmd_prefix(group_id) + command = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "发送 Minecraft 指令", + ["在下一条消息中输入要发送到租赁服的指令"], + [f"可以省略或保留前缀 {cmd_prefix}", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if command is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + command = command.strip() + if self._is_menu_exit(command, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if command.startswith(cmd_prefix): + command = command.removeprefix(cmd_prefix).strip() + if not command: + self._reply_to_qq(group_id, qqid, f"参数错误,格式:{cmd_prefix}[指令]") + return + self.on_qq_execute_cmd(group_id, qqid, command.split()) + + def qq_help_show_basic_reference(self, group_id: int, qqid: int): + """Handle the qq help show basic reference QQ menu operation.""" + options = [ + f"{' / '.join(self.get_group_help_triggers(group_id))} - 打开帮助菜单", + ] + if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"] and ( + self._can_use_group_permission(group_id, qqid, "查看玩家人数权限") + ): + options.append( + f"{' / '.join(self.get_group_player_list_triggers(group_id))} - 查看玩家列表" + ) + options.append( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统菜单(需绑定游戏账号)" + ) + self._reply_to_qq( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "非管理功能触发词", + options, + ["输入帮助菜单唤醒词可重新打开菜单"], + group_id, + ), + ) + + def qq_help_show_admin_reference(self, group_id: int, qqid: int): + """Handle the qq help show admin reference QQ menu operation.""" + cmd_prefix = self.get_group_cmd_prefix(group_id) + options = [] + if self._can_use_group_permission(group_id, qqid, "发送指令权限"): + options.append(f"{cmd_prefix}[指令] - 向租赁服发送指令") + if self._can_use_any_group_permission( + group_id, + qqid, + ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), + ): + options.append( + f"{' / '.join(self.get_group_admin_menu_triggers(group_id))} - QQ群管理员管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "配置配置文件权限"): + options.append( + f"{' / '.join(self.get_group_config_menu_triggers(group_id))} - 配置中心" + ) + if self._can_use_group_permission(group_id, qqid, "查询背包权限"): + options.append( + f"{' / '.join(self.get_group_inventory_menu_triggers(group_id))} - 查询在线玩家背包" + ) + if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + options.extend( + [ + f"{' / '.join(self.get_group_orion_ban_triggers(group_id))} - Orion QQ 封禁菜单", + f"{' / '.join(self.get_group_orion_unban_triggers(group_id))} - Orion QQ 解封菜单", + ] + ) + if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): + options.append( + f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - 白名单&管理员检测管理菜单" + ) + if self.task_system is not None and self._can_use_group_permission( + group_id, + qqid, + "任务系统权限", + ): + options.append( + f"{' / '.join(self.get_group_task_menu_triggers(group_id))} - 任务系统管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "领地系统权限"): + options.append( + f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - 领地系统云链联动版管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "公会系统权限"): + options.append( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统管理菜单(管理员)" + ) + if not options: + self._reply_to_qq(group_id, qqid, "当前没有可展示的管理功能触发词") + return + self._reply_to_qq( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "管理功能触发词", + options, + ["这些功能需要本群所有者、超级管理员或普通管理员权限"], + group_id, + ), + ) + + def qq_help_show_integration_reference(self, group_id: int, qqid: int): + """Handle the qq help show integration reference QQ menu operation.""" + options = [] + if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + options.extend( + [ + f"{' / '.join(self.get_group_orion_ban_triggers(group_id))} - Orion QQ 封禁菜单", + f"{' / '.join(self.get_group_orion_unban_triggers(group_id))} - Orion QQ 解封菜单", + ] + ) + if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): + options.append( + f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - 白名单&管理员检测管理菜单" + ) + if self.task_system is not None and self._can_use_group_permission( + group_id, + qqid, + "任务系统权限", + ): + options.append( + f"{' / '.join(self.get_group_task_menu_triggers(group_id))} - 任务系统管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "领地系统权限"): + options.append( + f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - 领地系统云链联动版管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "公会系统权限"): + options.append( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统菜单(普通成员需绑定;管理员进入管理菜单)" + ) + if not options: + self._reply_to_qq(group_id, qqid, "当前没有可展示的联动系统触发词") + return + self._reply_to_qq( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "联动系统触发词", + options, + ["公会系统普通菜单需要 QQ 绑定游戏账号,管理菜单需要本群管理员权限"], + group_id, + ), + ) + + def on_qq_player_list(self, group_id: int, _sender: int, _): + """把在线玩家列表和可用 TPS 信息发到群里。""" + if not self._can_use_group_permission(group_id, _sender, "查看玩家人数权限"): + self._reply_menu_permission_denied(group_id, _sender) + return + group_cfg = self.group_cfgs[group_id] + if not group_cfg["指令设置"]["是否允许查看玩家列表"]: + self.sendmsg(group_id, "当前群未启用玩家列表查询") + return + players = [f"{i + 1}.{j}" for i, + j in enumerate(self.game_ctrl.allplayers)] + fmt_msg = ( + f"在线玩家有 {len(players)} 人:\n " + + "\n ".join(players) + + ( + f"\n当前 TPS: {round(self.tps_calc.get_tps(), 1)}/20" + if self.tps_calc + else "" + ) + ) + self.sendmsg(group_id, fmt_msg) + + def qq_inventory_menu(self, group_id: int, qqid: int): + """分页展示在线玩家,并允许在群里进一步查询某个人的背包。""" + if not self._can_use_group_permission(group_id, qqid, "查询背包权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + online_names = list(self.game_ctrl.allplayers) + if not online_names: + self._reply_to_qq(group_id, qqid, "当前没有在线玩家") + return + page = 1 + per_page = self.get_group_inventory_items_per_page(group_id) + while True: + # 这里保留一个循环式菜单,避免每次翻页都重新走一遍入口指令。 + total_pages, start_index, end_index = ( + utils.paginate(len(online_names), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(online_names), per_page, page) + ) + page_names = online_names[start_index - 1: end_index] + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + "查询背包", + page_names, + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(page_names)}] 之间的数字以选择 对应玩家", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 . 退出", + ], + group_id, + ) + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False) + user_input = self.waitMsg(qqid, timeout=120, group_id=group_id) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + user_input = user_input.strip() + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if user_input == "+": + # 菜单保持在同一条交互链里,翻页只改当前页码。 + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq( + group_id, + qqid, + f"❀ 不存在第 {page_num} 页!请重新输入!", + ) + continue + choice = self.parse_displayed_menu_choice( + user_input, len(page_names)) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + continue + self.show_player_inventory(group_id, qqid, page_names[choice - 1]) + return + + @staticmethod + def simple_paginate(total_len: int, per_page: int, page: int): + """在缺少 utils.paginate 时使用的本地分页兜底实现。""" + total_pages = max(1, (total_len + per_page - 1) // per_page) + page = max(1, min(page, total_pages)) + start_index = (page - 1) * per_page + 1 + end_index = min(page * per_page, total_len) + return total_pages, start_index, end_index + + @staticmethod + def parse_displayed_menu_choice(user_input: str, displayed_count: int): + """Parse a choice using the numbers shown on the current menu page.""" + choice = utils.try_int(user_input.strip().strip("[]")) + if choice is None or choice not in range(1, displayed_count + 1): + return None + return choice + + @staticmethod + def parse_page_jump(user_input: str): + """Parse page jumps only when the input explicitly ends with a page suffix.""" + text = user_input.strip() + if len(text) < 2 or text[-1] not in ("页", "頁", "椤"): + return None + return utils.try_int(text[:-1]) + + def show_player_inventory( + self, + group_id: int, + qqid: int, + player_name: str): + """把背包槽位整理成适合群聊阅读的文本。""" + player_obj = self.game_ctrl.players.getPlayerByName(player_name) + if player_obj is None: + self._reply_to_qq(group_id, qqid, f"玩家 {player_name} 当前已不在线") + return + try: + inventory = player_obj.queryInventory() + except Exception as err: + self._reply_to_qq(group_id, qqid, f"查询背包失败: {err}") + return + items: list[str] = [] + for idx, slot in enumerate(inventory.slots): + if slot is None: + continue + # 这里把原始槽位信息尽量整理成适合群内阅读的文本。 + item_id = getattr(slot, "id", "未知ID") + display_name = self.translate_item_name(item_id) + stack_size = getattr(slot, "stackSize", 0) + aux = getattr(slot, "aux", 0) + line = f"槽位 {idx}: {display_name} x{stack_size}" + if display_name != item_id: + line += f" ({item_id})" + if aux not in (None, -1, 0): + line += f" (数据值:{aux})" + custom_name = self.get_item_custom_name(slot) + if custom_name: + line += f" (命名:{custom_name})" + ench_text = self.get_item_enchantments_text(slot) + if ench_text: + line += f" (附魔:{ench_text})" + items.append(line) + if not items: + items = ["该玩家背包为空"] + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"背包查询 - {player_name}", + items, + ["输入 . 退出"], + group_id, + ) + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False) + + def ensure_task_system(self, group_id: int, sender: int): + """Implement the ensure task system operation.""" + if self.task_system is None: + self._reply_to_qq(group_id, sender, "相关插件未安装:任务系统云链联动版") + return False + return self.ensure_linked_plugin_enabled( + self.task_system, group_id, sender) + + @staticmethod + def _format_task_time(timestamp: int): + """Implement the format task time operation.""" + try: + return time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(timestamp)) + except Exception: + return str(timestamp) + + @staticmethod + def _format_task_label(task_info: dict[str, Any]): + """Implement the format task label operation.""" + show_name = str(task_info.get("show_name", "")).strip() + tag_name = str(task_info.get("tag_name", "")).strip() + if not show_name or show_name == tag_name: + return tag_name or show_name or "<未知任务>" + return f"{show_name} ({tag_name})" + + def _format_task_menu_item(self, task_info: dict[str, Any], status: str): + """Implement the format task menu item operation.""" + label = self._format_task_label(task_info) + lines = [f"{status} {label}"] + description = str(task_info.get("description", "")).strip() + if description: + lines.append(f" {description}") + if "finished_time" in task_info: + try: + finished_time = self._format_task_time( + int(task_info["finished_time"])) + except Exception: + finished_time = str(task_info.get("finished_time", "未知时间")) + lines.append(f" 完成时间:{finished_time}") + return "\n".join(lines) + + def _format_task_progress_text( + self, progress: dict[str, Any], group_id: int): + """Implement the format task progress text operation.""" + in_progress = progress.get("in_progress", []) + completed = progress.get("completed", []) + options = [] + if in_progress: + for task_info in in_progress: + options.append(self._format_task_menu_item(task_info, "未完成")) + else: + options.append("未完成任务:无") + if completed: + for task_info in completed: + options.append(self._format_task_menu_item(task_info, "已完成")) + else: + options.append("已完成任务:无") + player_name = progress.get("player_name", "<未知玩家>") + return self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"任务进度 - {player_name}", + options, + [ + f"未完成任务:{len(in_progress)} 个", + f"已完成任务:{len(completed)} 个", + "输入 . 退出", + ], + group_id, + ) + + def qq_task_system_menu(self, group_id: int, qqid: int): + """Handle the qq task system menu QQ menu operation.""" + if not self._can_use_group_permission(group_id, qqid, "任务系统权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if not self.ensure_task_system(group_id, qqid): + return + choice = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "任务系统菜单", + ["查看在线玩家任务进度", "给在线玩家下发任务", "直接完成在线玩家任务"], + ["输入 [1-3] 之间的数字以选择 对应功能", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if choice == "1": + player_name = self.qq_select_online_player( + group_id, qqid, "查看任务进度") + if player_name is None: + return + self.on_qq_task_progress(group_id, qqid, [player_name]) + return + if choice == "2": + player_name = self.qq_select_online_player(group_id, qqid, "下发任务") + if player_name is None: + return + quest_tag = self.qq_select_task_from_catalog(group_id, qqid) + if quest_tag is None: + return + self.on_qq_task_add(group_id, qqid, [player_name, quest_tag]) + return + if choice == "3": + player_name = self.qq_select_online_player(group_id, qqid, "完成任务") + if player_name is None: + return + quest_tag = self.qq_select_in_progress_task( + group_id, qqid, player_name) + if quest_tag is None: + return + self.on_qq_task_finish(group_id, qqid, [player_name, quest_tag]) + return + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + + def qq_select_online_player(self, group_id: int, qqid: int, subtitle: str): + """Handle the qq select online player QQ menu operation.""" + online_names = list(self.game_ctrl.allplayers) + if not online_names: + self._reply_to_qq(group_id, qqid, "当前没有在线玩家") + return None + page = 1 + per_page = self.get_group_task_player_items_per_page(group_id) + while True: + total_pages, start_index, end_index = ( + utils.paginate(len(online_names), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(online_names), per_page, page) + ) + page_names = online_names[start_index - 1: end_index] + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"任务系统 - {subtitle}", + page_names, + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(page_names)}] 之间的数字以选择 对应玩家", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") + return None + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if user_input == "+": + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") + continue + choice = self.parse_displayed_menu_choice( + user_input, len(page_names)) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + continue + return page_names[choice - 1] + + def qq_select_task_from_catalog(self, group_id: int, qqid: int): + """Handle the qq select task from catalog QQ menu operation.""" + quests = self.task_system.list_available_quests() + if not quests: + self._reply_to_qq(group_id, qqid, "任务系统中暂无可用任务") + return None + page = 1 + per_page = self.get_group_task_items_per_page(group_id) + while True: + total_pages, start_index, end_index = ( + utils.paginate(len(quests), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(quests), per_page, page) + ) + page_quests = quests[start_index - 1: end_index] + options = [] + for quest_info in page_quests: + label = self._format_task_label(quest_info) + description = str(quest_info.get("description", "")).strip() + options.append( + label if not description else f"{label}\n {description}") + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + "任务系统 - 选择任务", + options, + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(page_quests)}] 之间的数字以选择 对应任务", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") + return None + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if user_input == "+": + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") + continue + choice = self.parse_displayed_menu_choice( + user_input, len(page_quests)) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + continue + return page_quests[choice - 1]["tag_name"] + + def qq_select_in_progress_task( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq select in progress task QQ menu operation.""" + ok, result = self.task_system.get_online_player_task_progress( + player_name) + if not ok: + self._reply_to_qq(group_id, qqid, str(result)) + return None + in_progress = result.get("in_progress", []) + if not in_progress: + self._reply_to_qq(group_id, qqid, f"玩家 {player_name} 当前没有进行中的任务") + return None + options = [] + for task_info in in_progress: + label = self._format_task_label(task_info) + description = str(task_info.get("description", "")).strip() + options.append( + label if not description else f"{label}\n {description}") + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"任务系统 - 完成任务 - {player_name}", + options, + [f"输入 [1-{len(options)}] 之间的数字以选择 对应任务", "输入 . 退出"], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") + return None + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + choice = self.parse_displayed_menu_choice(user_input, len(options)) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return None + return in_progress[choice - 1]["tag_name"] + + def on_qq_task_progress(self, group_id: int, sender: int, args: list[str]): + """Implement the on qq task progress operation.""" + if not self._can_use_group_permission(group_id, sender, "任务系统权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_task_system(group_id, sender): + return + ok, result = self.task_system.get_online_player_task_progress(args[0]) + if not ok: + self._reply_to_qq(group_id, sender, str(result)) + return + self._reply_to_qq( + group_id, + sender, + self._format_task_progress_text(result, group_id), + ) + + def on_qq_task_add(self, group_id: int, sender: int, args: list[str]): + """Implement the on qq task add operation.""" + if not self._can_use_group_permission(group_id, sender, "任务系统权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_task_system(group_id, sender): + return + player_name = args[0] + quest_query = " ".join(args[1:]).strip() + ok, msg = self.task_system.add_quest_to_online_player( + player_name, quest_query) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_task_finish(self, group_id: int, sender: int, args: list[str]): + """Implement the on qq task finish operation.""" + if not self._can_use_group_permission(group_id, sender, "任务系统权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_task_system(group_id, sender): + return + player_name = args[0] + quest_query = " ".join(args[1:]).strip() + ok, msg = self.task_system.finish_quest_for_online_player( + player_name, quest_query + ) + self._reply_result(group_id, sender, ok, msg) + + def ensure_guild_system(self, group_id: int, sender: int): + """检查公会系统云链联动版 API 是否可用。""" + if self.guild_system is None: + self._reply_to_qq(group_id, sender, "相关插件未安装:公会系统云链联动版") + return False + return self.ensure_linked_plugin_enabled( + self.guild_system, group_id, sender) + + @staticmethod + def _guild_actor(group_id: int, qqid: int): + """Implement the guild actor operation.""" + return f"QQ群{group_id}:{qqid}" + + def _qq_guild_prompt( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + hints: list[str], + ): + """Implement the qq guild prompt operation.""" + return self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "公会系统云链联动版", + subtitle, + options, + hints, + group_id, + ), + timeout=120, + ) + + def _qq_guild_prompt_text( + self, + group_id: int, + qqid: int, + subtitle: str, + prompt: str, + allow_empty: bool = False, + ): + """Implement the qq guild prompt text operation.""" + value = self._qq_guild_prompt( + group_id, qqid, subtitle, [], [ + prompt, "输入 . 退出"]) + if value is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + value = value.strip() + if self._is_menu_exit(value, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if not allow_empty and not value: + self._reply_to_qq(group_id, qqid, "❀ 输入不能为空") + return None + return value + + def _qq_guild_prompt_optional_query( + self, + group_id: int, + qqid: int, + subtitle: str, + prompt: str, + ): + """Implement the qq guild prompt optional query operation.""" + value = self._qq_guild_prompt_text( + group_id, qqid, subtitle, prompt, allow_empty=True) + if value is None: + return None + if value in ("", "全部", "全服", "all", "*", "-"): + return "" + return value + + def _qq_guild_prompt_int( + self, + group_id: int, + qqid: int, + subtitle: str, + prompt: str, + minimum: int | None = None, + default: int | None = None, + ): + """Implement the qq guild prompt int operation.""" + hints = [prompt, "输入 . 退出"] + if default is not None: + hints.insert(1, f"输入 默认 使用 {default}") + value = self._qq_guild_prompt(group_id, qqid, subtitle, [], hints) + if value is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + value = value.strip() + if self._is_menu_exit(value, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if default is not None and value in ("", "默认", "default"): + return default + parsed = utils.try_int(value) + if parsed is None: + self._reply_to_qq(group_id, qqid, "❀ 请输入整数") + return None + if minimum is not None and parsed < minimum: + self._reply_to_qq(group_id, qqid, f"❀ 数值不能小于 {minimum}") + return None + return parsed + + def _qq_guild_confirm( + self, + group_id: int, + qqid: int, + subtitle: str, + detail: str): + """Implement the qq guild confirm operation.""" + choice = self._qq_guild_prompt( + group_id, + qqid, + subtitle, + [detail], + ["请输入 确认 继续执行", "输入 . 取消"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已取消") + return False + choice = choice.strip() + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已取消") + return False + if choice != "确认": + self._reply_to_qq(group_id, qqid, "❀ 已取消") + return False + return True + + def _reply_guild_api_result(self, group_id: int, qqid: int, result): + """Implement the reply guild api result operation.""" + if not isinstance(result, tuple) or not result: + self._reply_to_qq(group_id, qqid, "公会系统接口返回异常") + return + ok = bool(result[0]) + msg = str( + result[1]) if len(result) >= 2 else ( + "操作成功" if ok else "操作失败") + if len(result) >= 3 and isinstance(result[2], str) and result[2]: + msg = f"{msg}\n{result[2]}" + self._reply_result(group_id, qqid, ok, msg) + + @staticmethod + def _format_guild_base(base: dict[str, Any] | None): + """Implement the format guild base operation.""" + if not base: + return "未设置" + return f"{ + base.get( + 'dimension', + 0)} ({ + base.get( + 'x', + 0):.1f}, { + base.get( + 'y', + 0):.1f}, { + base.get( + 'z', + 0):.1f})" + + @staticmethod + def _format_guild_line(item: dict[str, Any], index: int): + """Implement the format guild line operation.""" + frozen = " 冻结" if item.get("frozen") else "" + return ( + f"{index}. {item.get('name', '<未知>')} Lv.{item.get('level', 0)} " + f"成员 {item.get('member_count', 0)}/{item.get('max_members', 0)} " + f"会长 {item.get('owner', '<未知>')}{frozen}" + ) + + def _format_guild_summary(self, guild: dict[str, Any], group_id: int): + """Implement the format guild summary operation.""" + effects = guild.get("purchased_effects", {}) + effect_text = "无" + if isinstance(effects, dict) and effects: + effect_text = "、".join( + f"{key}:{level}" for key, + level in effects.items()) + return self.plugin_ui_menu( + "公会系统云链联动版", + f"公会信息 - {guild.get('name', '<未知>')}", + [ + f"ID:{guild.get('guild_id', '<未知>')}", + f"会长:{guild.get('owner', '<未知>')}", + f"等级/经验:{guild.get('level', 0)} / {guild.get('exp', 0)}", + f"成员:{guild.get('member_count', 0)}/{guild.get('max_members', 0)}", + f"仓库:{guild.get('vault_count', 0)}/{guild.get('vault_capacity', 0)}", + f"资金:{guild.get('funds', 0)}", + f"据点:{self._format_guild_base(guild.get('base'))}", + f"据点锁定:{'是' if guild.get('base_locked') else '否'}", + f"冻结:{'是' if guild.get('frozen') else '否'}", + f"冻结原因:{guild.get('frozen_reason') or '无'}", + f"活跃/完成任务:{guild.get('active_tasks', 0)} / {guild.get('completed_tasks', 0)}", + f"总贡献:{guild.get('total_contribution', 0)}", + f"效果:{effect_text}", + f"公告:{guild.get('announcement') or '无'}", + ], + ["输入 . 退出"], + group_id, + ) + + def _reply_guild_lines( + self, + group_id: int, + qqid: int, + title: str, + lines: list[str], + hints: list[str] | None = None, + ): + """Implement the reply guild lines operation.""" + self._reply_to_qq( + group_id, + qqid, + self.plugin_ui_menu( + "公会系统云链联动版", + title, + lines or ["暂无数据"], + hints or ["输入 . 退出"], + group_id, + ), + ) + + def _qq_guild_run_menu( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + actions: list[Any], + allow_back: bool = False, + ): + """Implement the qq guild run menu operation.""" + if len(options) != len(actions): + self._reply_to_qq(group_id, qqid, "❀ 菜单配置错误") + return None + hints = [f"输入 [1-{len(options)}] 之间的数字以选择 对应功能"] + if allow_back: + hints.append("输入 0 返回上级菜单") + hints.append("输入 . 退出") + choice = self._qq_guild_prompt( + group_id, + qqid, + subtitle, + options, + hints, + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + choice = choice.strip() + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if allow_back and self._is_menu_back(choice, group_id): + return "back" + selected = self.parse_displayed_menu_choice(choice, len(options)) + if selected is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return None + return actions[selected - 1]() + + def qq_guild_entry_menu(self, group_id: int, qqid: int): + """公会系统统一入口:群管理员进管理菜单,普通成员进绑定账号菜单。""" + if self._can_use_group_permission(group_id, qqid, "公会系统权限"): + self.qq_guild_system_menu(group_id, qqid) + return + self.qq_guild_player_menu(group_id, qqid) + + def _qq_guild_select_bound_player(self, group_id: int, qqid: int): + """选择一个 QQ 已绑定的游戏账号,返回玩家名。""" + bound_players = self.api_get_bound_players_by_qq(qqid) + usable_players = [ + item + for item in bound_players + if str(item.get("player_name", "")).strip() + ] + if not usable_players: + bind_hint = " / ".join(self.get_group_binding_triggers(group_id)) + if not self.api_is_binding_enabled(group_id): + self._reply_to_qq( + group_id, + qqid, + "请先绑定游戏账号后再使用公会菜单;当前群的 QQ 绑定功能未开启,请联系管理员。", + ) + return None + self._reply_to_qq( + group_id, + qqid, + f"请先绑定游戏账号后再使用公会菜单。绑定触发词:{bind_hint}", + ) + return None + if len(usable_players) == 1: + return str(usable_players[0].get("player_name", "")).strip() + + options = [ + f"{item.get('player_name', '<未知玩家>')} ({item.get('xuid', '<未知XUID>')})" + for item in usable_players + ] + choice = self._qq_guild_prompt( + group_id, + qqid, + "选择绑定账号", + options, + [f"输入 [1-{len(options)}] 之间的数字以选择 游戏账号", "输入 . 退出"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + selected = self.parse_displayed_menu_choice(choice, len(options)) + if selected is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return None + return str(usable_players[selected - 1].get("player_name", "")).strip() + + def _qq_guild_player_state( + self, + group_id: int, + qqid: int, + player_name: str): + """Implement the qq guild player state operation.""" + result = self.guild_system.api_get_player_guild_menu_state(player_name) + if not isinstance(result, tuple) or len(result) < 3: + self._reply_to_qq(group_id, qqid, "公会系统接口返回异常") + return None + ok, msg, state = result + if not ok or not isinstance(state, dict): + self._reply_result(group_id, qqid, bool(ok), str(msg)) + return None + return state + + def qq_guild_player_menu(self, group_id: int, qqid: int): + """普通群成员可使用的公会菜单,要求 QQ 已绑定游戏账号。""" + if not self.ensure_guild_system(group_id, qqid): + return + player_name = self._qq_guild_select_bound_player(group_id, qqid) + if not player_name: + return + while True: + state = self._qq_guild_player_state(group_id, qqid, player_name) + if state is None: + return + if state.get("in_guild"): + guild = state.get("guild") if isinstance( + state.get("guild"), dict) else {} + member = state.get("member") if isinstance( + state.get("member"), dict) else {} + permissions = set(state.get("permissions") or []) + is_owner = bool(state.get("is_owner")) + is_frozen = bool(state.get("is_frozen")) + subtitle = ( + f"玩家菜单 - {player_name} | {guild.get('name', '<未知公会>')} " + f"({member.get('rank_name', member.get('rank', '成员'))})" + ) + options = [ + "我的公会信息", + "查看成员列表", + "查看公会日志", + "查看公会公告", + "查看公会排行", + "查看贡献排行", + ] + actions = [ + lambda: self.qq_guild_player_show_self( + group_id, qqid, player_name), + lambda: self.qq_guild_player_show_members( + group_id, qqid, player_name), + lambda: self.qq_guild_player_show_logs( + group_id, qqid, player_name), + lambda: self.qq_guild_player_show_announcement( + group_id, qqid, player_name), + lambda: self.qq_guild_show_rankings(group_id, qqid), + lambda: self.qq_guild_show_donation_rankings( + group_id, qqid),] + if not is_frozen: + if "announce" in permissions: + options.append("设置公会公告") + actions.append( + lambda: self.qq_guild_player_set_announcement( + group_id, qqid, player_name)) + if "vault" in permissions: + options.append("查看公会仓库") + actions.append(lambda: self.qq_guild_player_show_vault( + group_id, qqid, player_name)) + options.append("查看公会任务") + actions.append(lambda: self.qq_guild_player_show_tasks( + group_id, qqid, player_name)) + options.append("参与公会任务") + actions.append(lambda: self.qq_guild_player_join_task( + group_id, qqid, player_name)) + if "return_base" in permissions: + options.append("返回公会据点") + actions.append( + lambda: self.qq_guild_player_return_base( + group_id, qqid, player_name)) + if not is_owner: + options.append("退出公会") + actions.append(lambda: self.qq_guild_player_leave( + group_id, qqid, player_name)) + if is_owner and not is_frozen: + options.append("解散我的公会") + actions.append(lambda: self.qq_guild_player_disband( + group_id, qqid, player_name)) + else: + subtitle = f"玩家菜单 - {player_name} | 未加入公会" + options = ["查看公会列表", "申请加入公会", "查看公会排行", "查看贡献排行"] + actions = [ + lambda: self.qq_guild_list( + group_id, qqid), lambda: self.qq_guild_player_request_join( + group_id, qqid, player_name), lambda: self.qq_guild_show_rankings( + group_id, qqid), lambda: self.qq_guild_show_donation_rankings( + group_id, qqid), ] + result = self._qq_guild_run_menu( + group_id, qqid, subtitle, options, actions) + if result == "back": + continue + return + + def qq_guild_player_show_self( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player show self QQ menu operation.""" + state = self._qq_guild_player_state(group_id, qqid, player_name) + if state is None: + return + guild = state.get("guild") if isinstance( + state.get("guild"), dict) else {} + member = state.get("member") if isinstance( + state.get("member"), dict) else {} + if not guild: + self._reply_to_qq(group_id, qqid, f"{player_name} 暂未加入公会") + return + self._reply_guild_lines( + group_id, + qqid, + f"我的公会 - {player_name}", + [ + f"公会:{guild.get('name', '<未知>')} ({guild.get('guild_id', '<未知>')})", + f"职位:{member.get('rank_name', member.get('rank', '<未知>'))}", + f"贡献:{member.get('contribution', 0)}", + f"会长:{guild.get('owner', '<未知>')}", + f"等级/经验:{guild.get('level', 0)} / {guild.get('exp', 0)}", + f"成员:{guild.get('member_count', 0)}/{guild.get('max_members', 0)}", + f"仓库:{guild.get('vault_count', 0)}/{guild.get('vault_capacity', 0)}", + f"据点:{self._format_guild_base(guild.get('base'))}", + f"冻结:{'是' if guild.get('frozen') else '否'}", + f"公告:{guild.get('announcement') or '无'}", + ], + ) + + def qq_guild_player_show_members( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player show members QQ menu operation.""" + state = self._qq_guild_player_state(group_id, qqid, player_name) + if state is None: + return + guild = state.get("guild") if isinstance( + state.get("guild"), dict) else {} + guild_id = str(guild.get("guild_id", "")).strip() + if not guild_id: + self._reply_to_qq(group_id, qqid, "你尚未加入任何公会") + return + ok, msg, data = self.guild_system.api_get_guild(guild_id) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return members = data.get("members", []) lines = [] for index, member in enumerate(members[:30], start=1): @@ -1841,72 +1882,76 @@ def qq_guild_player_show_members( name = member.get("name", "<未知>") contribution = member.get("contribution", 0) lines.append(f"{index}. {rank} {name} 贡献 {contribution}") - self._reply_guild_lines( - group_id, qqid, f"{ - data.get( - 'name', guild_id)} 成员", lines or ["暂无成员"], [ - f"共 { - len(members)} 名成员"]) - - def qq_guild_player_show_logs( - self, - group_id: int, - qqid: int, - player_name: str): - ok, msg, data = self.guild_system.api_get_own_guild_logs( - player_name, 20) - if not ok or not data: - self._reply_result(group_id, qqid, False, msg) - return - lines = [str(item) for item in data.get("logs", [])[-20:]] - audit_logs = data.get("audit_logs", []) - if audit_logs: - lines.append("--- 审计日志 ---") + self._reply_guild_lines( + group_id, qqid, f"{ + data.get( + 'name', guild_id)} 成员", lines or ["暂无成员"], [ + f"共 { + len(members)} 名成员"]) + + def qq_guild_player_show_logs( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player show logs QQ menu operation.""" + ok, msg, data = self.guild_system.api_get_own_guild_logs( + player_name, 20) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return + lines = [str(item) for item in data.get("logs", [])[-20:]] + audit_logs = data.get("audit_logs", []) + if audit_logs: + lines.append("--- 审计日志 ---") for item in audit_logs[-10:]: action = item.get("action", "<未知>") actor = item.get("actor", "") target = item.get("target", "") detail = item.get("detail", "") lines.append(f"{action} {actor} -> {target} {detail}".strip()) - self._reply_guild_lines( - group_id, qqid, f"{player_name} 的公会日志", lines or ["暂无日志"]) - - def qq_guild_player_show_announcement( - self, group_id: int, qqid: int, player_name: str): - state = self._qq_guild_player_state(group_id, qqid, player_name) - if state is None: - return - guild = state.get("guild") if isinstance( - state.get("guild"), dict) else {} - if not guild: - self._reply_to_qq(group_id, qqid, "你尚未加入任何公会") - return - self._reply_guild_lines( - group_id, - qqid, - f"{guild.get('name', '<未知公会>')} 公告", - [guild.get("announcement") or "当前没有公告"], - ) - - def qq_guild_player_set_announcement( - self, group_id: int, qqid: int, player_name: str): - text = self._qq_guild_prompt_text( - group_id, qqid, "设置公会公告", "请输入新的公告内容(不超过200字符)") - if text is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_announcement_as_player( - player_name, text), ) - - def qq_guild_player_show_vault( - self, - group_id: int, - qqid: int, - player_name: str): - ok, msg, data = self.guild_system.api_get_own_guild_vault(player_name) - if not ok or data is None: - self._reply_result(group_id, qqid, False, msg) - return + self._reply_guild_lines( + group_id, qqid, f"{player_name} 的公会日志", lines or ["暂无日志"]) + + def qq_guild_player_show_announcement( + self, group_id: int, qqid: int, player_name: str): + """Handle the qq guild player show announcement QQ menu operation.""" + state = self._qq_guild_player_state(group_id, qqid, player_name) + if state is None: + return + guild = state.get("guild") if isinstance( + state.get("guild"), dict) else {} + if not guild: + self._reply_to_qq(group_id, qqid, "你尚未加入任何公会") + return + self._reply_guild_lines( + group_id, + qqid, + f"{guild.get('name', '<未知公会>')} 公告", + [guild.get("announcement") or "当前没有公告"], + ) + + def qq_guild_player_set_announcement( + self, group_id: int, qqid: int, player_name: str): + """Handle the qq guild player set announcement QQ menu operation.""" + text = self._qq_guild_prompt_text( + group_id, qqid, "设置公会公告", "请输入新的公告内容(不超过200字符)") + if text is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_announcement_as_player( + player_name, text), ) + + def qq_guild_player_show_vault( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player show vault QQ menu operation.""" + ok, msg, data = self.guild_system.api_get_own_guild_vault(player_name) + if not ok or data is None: + self._reply_result(group_id, qqid, False, msg) + return lines = [] for index, item in enumerate(data[:30], start=1): item_index = item.get("index", index) @@ -1915,380 +1960,394 @@ def qq_guild_player_show_vault( price = item.get("price", 0) seller = item.get("seller", "<未知>") lines.append(f"{item_index}. {item_id} x{count} 价格 {price} 卖家 {seller}") - self._reply_guild_lines(group_id, qqid, msg, lines or ["仓库为空"]) - - def qq_guild_player_show_tasks( - self, - group_id: int, - qqid: int, - player_name: str): - ok, msg, data = self.guild_system.api_get_own_guild_tasks(player_name) - if not ok or data is None: - self._reply_result(group_id, qqid, False, msg) - return - lines = [] - for index, task in enumerate(data[:30], start=1): - status = "已完成" if task.get("completed") else f"进行中 {task.get( - 'current_count', 0)} /{task.get('target_count', 0)} " - joined = " 已参与" if player_name in task.get( - "participants", []) else "" - lines.append( - f"{index}. { - task.get( - 'name', - '<未知任务>')} [{status}{joined}] 奖励 { - task.get( - 'reward_contribution', - 0)}贡献/{ - task.get( - 'reward_exp', - 0)}经验") - self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无任务"]) - - def qq_guild_player_join_task( - self, - group_id: int, - qqid: int, - player_name: str): - ok, msg, data = self.guild_system.api_get_own_guild_tasks(player_name) - if not ok or data is None: - self._reply_result(group_id, qqid, False, msg) - return - active_tasks = [task for task in data if not task.get("completed")] - if not active_tasks: - self._reply_to_qq(group_id, qqid, "暂无可参与的任务") - return - options = [ - f"{task.get('name', '<未知任务>')} ({task.get('current_count', 0)}/{task.get('target_count', 0)})" - for task in active_tasks[:20] - ] - choice = self._qq_guild_prompt( - group_id, - qqid, - "参与公会任务", - options, - [f"输入 [1-{len(options)}] 之间的数字以选择 任务", "输入 . 退出"], - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - selected = self.parse_displayed_menu_choice(choice, len(options)) - if selected is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - task = active_tasks[selected - 1] - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_join_guild_task_as_player( - player_name, - task.get("task_id") or task.get("name", ""), - ), - ) - - def qq_guild_player_return_base( - self, - group_id: int, - qqid: int, - player_name: str): - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_return_to_guild_base_as_player(player_name), - ) - - def qq_guild_player_request_join( - self, - group_id: int, - qqid: int, - player_name: str): - guild = self._qq_guild_prompt_text( - group_id, qqid, "申请加入公会", "请输入公会名称或ID") - if guild is None: - return - reason = self._qq_guild_prompt_text( - group_id, - qqid, - "申请加入公会", - "请输入申请理由,可输入 无 跳过", - allow_empty=True, - ) - if reason is None: - return - if reason == "无": - reason = "" - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_request_join_guild_as_player( - player_name, guild, reason), - ) - - def qq_guild_player_leave( - self, - group_id: int, - qqid: int, - player_name: str): - if not self._qq_guild_confirm( - group_id, qqid, "退出公会", f"{player_name} 将退出当前公会"): - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_leave_guild_as_player(player_name), - ) - - def qq_guild_player_disband( - self, - group_id: int, - qqid: int, - player_name: str): - if not self._qq_guild_confirm( - group_id, - qqid, - "解散公会", - f"{player_name} 将解散自己担任会长的公会"): - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_disband_owned_guild_as_player(player_name), - ) - - def qq_guild_system_menu(self, group_id: int, qqid: int): - """在群里打开公会系统云链联动版管理菜单。""" - if not self._can_use_group_permission(group_id, qqid, "公会系统权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - if not self.ensure_guild_system(group_id, qqid): - return - while True: - result = self._qq_guild_run_menu( - group_id, - qqid, - "管理菜单", - ["查询与排行", "公会与成员管理", "仓库与效果管理", "任务与据点管理", "数据维护与活动"], - [ - lambda: self.qq_guild_query_menu(group_id, qqid), - lambda: self.qq_guild_member_manage_menu(group_id, qqid), - lambda: self.qq_guild_vault_effect_menu(group_id, qqid), - lambda: self.qq_guild_task_base_menu(group_id, qqid), - lambda: self.qq_guild_data_activity_menu(group_id, qqid), - ], - ) - if result == "back": - continue - return - - def qq_guild_query_menu(self, group_id: int, qqid: int): - if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): - return - if not self.ensure_guild_system(group_id, qqid): - return - self._qq_guild_run_menu( - group_id, - qqid, - "查询与排行", - [ - "查看公会列表", - "查看公会信息", - "查看公会成员", - "查看公会仓库", - "查看公会日志", - "查询玩家公会记录", - "查看系统统计", - "查看公会排行", - "查看贡献排行", - "查看异常交易", - ], - [ - lambda: self.qq_guild_list(group_id, qqid), - lambda: self.qq_guild_show_info(group_id, qqid), - lambda: self.qq_guild_show_members(group_id, qqid), - lambda: self.qq_guild_show_vault(group_id, qqid), - lambda: self.qq_guild_show_logs(group_id, qqid), - lambda: self.qq_guild_show_player_record(group_id, qqid), - lambda: self.qq_guild_show_statistics(group_id, qqid), - lambda: self.qq_guild_show_rankings(group_id, qqid), - lambda: self.qq_guild_show_donation_rankings(group_id, qqid), - lambda: self.qq_guild_show_abnormal_trades(group_id, qqid), - ], - allow_back=True, - ) - - def qq_guild_member_manage_menu(self, group_id: int, qqid: int): - if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): - return - if not self.ensure_guild_system(group_id, qqid): - return - self._qq_guild_run_menu( - group_id, - qqid, - "公会与成员管理", - [ - "强制解散公会", - "修改公会名称", - "设置公会等级", - "设置公会经验", - "转让公会会长", - "强制玩家加入公会", - "强制玩家退出公会", - "冻结公会", - "解冻公会", - "调整公会资金", - "设置公会资金", - "调整成员贡献", - "设置成员贡献", - "清空公会贡献", - "发送全服公会公告", - ], - [ - lambda: self.qq_guild_force_disband(group_id, qqid), - lambda: self.qq_guild_rename(group_id, qqid), - lambda: self.qq_guild_set_level(group_id, qqid), - lambda: self.qq_guild_set_exp(group_id, qqid), - lambda: self.qq_guild_transfer_owner(group_id, qqid), - lambda: self.qq_guild_force_join(group_id, qqid), - lambda: self.qq_guild_force_leave(group_id, qqid), - lambda: self.qq_guild_set_frozen(group_id, qqid, True), - lambda: self.qq_guild_set_frozen(group_id, qqid, False), - lambda: self.qq_guild_add_funds(group_id, qqid), - lambda: self.qq_guild_set_funds(group_id, qqid), - lambda: self.qq_guild_add_member_contribution(group_id, qqid), - lambda: self.qq_guild_set_member_contribution(group_id, qqid), - lambda: self.qq_guild_reset_contributions(group_id, qqid), - lambda: self.qq_guild_broadcast_announcement(group_id, qqid), - ], - allow_back=True, - ) - - def qq_guild_vault_effect_menu(self, group_id: int, qqid: int): - if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): - return - if not self.ensure_guild_system(group_id, qqid): - return - self._qq_guild_run_menu( - group_id, - qqid, - "仓库与效果管理", - [ - "备份公会仓库", - "清空公会仓库", - "删除仓库物品", - "回滚仓库备份", - "导出仓库数据", - "重置市场价格", - "清空公会效果", - "设置公会效果", - ], - [ - lambda: self.qq_guild_backup_vault(group_id, qqid), - lambda: self.qq_guild_clear_vault(group_id, qqid), - lambda: self.qq_guild_delete_vault_item(group_id, qqid), - lambda: self.qq_guild_rollback_vault(group_id, qqid), - lambda: self.qq_guild_export_vault(group_id, qqid), - lambda: self.qq_guild_reset_market_prices(group_id, qqid), - lambda: self.qq_guild_clear_effects(group_id, qqid), - lambda: self.qq_guild_set_effect(group_id, qqid), - ], - allow_back=True, - ) - - def qq_guild_task_base_menu(self, group_id: int, qqid: int): - if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): - return - if not self.ensure_guild_system(group_id, qqid): - return - self._qq_guild_run_menu( - group_id, - qqid, - "任务与据点管理", - [ - "刷新公会任务", - "创建全服任务", - "删除公会任务", - "重置任务进度", - "强制完成任务", - "传送玩家到公会据点", - "删除公会据点", - "设置公会据点", - "锁定公会据点", - "解锁公会据点", - ], - [ - lambda: self.qq_guild_refresh_tasks(group_id, qqid), - lambda: self.qq_guild_create_global_task(group_id, qqid), - lambda: self.qq_guild_delete_task(group_id, qqid), - lambda: self.qq_guild_reset_task(group_id, qqid), - lambda: self.qq_guild_complete_task(group_id, qqid), - lambda: self.qq_guild_teleport_base(group_id, qqid), - lambda: self.qq_guild_delete_base(group_id, qqid), - lambda: self.qq_guild_set_base(group_id, qqid), - lambda: self.qq_guild_set_base_locked(group_id, qqid, True), - lambda: self.qq_guild_set_base_locked(group_id, qqid, False), - ], - allow_back=True, - ) - - def qq_guild_data_activity_menu(self, group_id: int, qqid: int): - if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): - return - if not self.ensure_guild_system(group_id, qqid): - return - self._qq_guild_run_menu( - group_id, qqid, "数据维护与活动", - ["重载公会配置", "保存公会数据", "备份公会数据", "修复公会数据", "查看活动状态", "开启双倍经验", - "开启双倍贡献", "开启公会争霸", "停止活动", "结算排行奖励",], - [lambda: self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_reload_guild_config()), - lambda: self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_save_guild_data()), - lambda: self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_backup_guild_data()), - lambda: self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_repair_guild_data( - self._guild_actor(group_id, qqid))), - lambda: self.qq_guild_show_activity_status(group_id, qqid), - lambda: self.qq_guild_start_activity( - group_id, qqid, "exp", 2.0), - lambda: self.qq_guild_start_activity( - group_id, qqid, "contribution", 2.0), - lambda: self.qq_guild_start_activity( - group_id, qqid, "contest", 1.0), - lambda: self.qq_guild_stop_activity(group_id, qqid), - lambda: self.qq_guild_settle_rewards(group_id, qqid),], - allow_back=True,) - - def qq_guild_list(self, group_id: int, qqid: int): - ok, msg, guilds = self.guild_system.api_list_guilds() - if not ok: - self._reply_result(group_id, qqid, False, msg) - return - lines = [self._format_guild_line(item, index) - for index, item in enumerate(guilds[:20], start=1)] - self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无公会"]) - - def qq_guild_show_info(self, group_id: int, qqid: int): - query = self._qq_guild_prompt_text( - group_id, qqid, "查看公会信息", "请输入公会名称或ID") - if query is None: - return - ok, msg, data = self.guild_system.api_get_guild(query) - self._reply_to_qq(group_id, qqid, self._format_guild_summary( - data, group_id) if ok and data else msg) - - def qq_guild_show_members(self, group_id: int, qqid: int): - query = self._qq_guild_prompt_text( - group_id, qqid, "查看公会成员", "请输入公会名称或ID") - if query is None: - return - ok, msg, data = self.guild_system.api_get_guild(query) - if not ok or not data: - self._reply_result(group_id, qqid, False, msg) - return + self._reply_guild_lines(group_id, qqid, msg, lines or ["仓库为空"]) + + def qq_guild_player_show_tasks( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player show tasks QQ menu operation.""" + ok, msg, data = self.guild_system.api_get_own_guild_tasks(player_name) + if not ok or data is None: + self._reply_result(group_id, qqid, False, msg) + return + lines = [] + for index, task in enumerate(data[:30], start=1): + status = "已完成" if task.get("completed") else f"进行中 {task.get( + 'current_count', 0)} /{task.get('target_count', 0)} " + joined = " 已参与" if player_name in task.get( + "participants", []) else "" + lines.append( + f"{index}. { + task.get( + 'name', + '<未知任务>')} [{status}{joined}] 奖励 { + task.get( + 'reward_contribution', + 0)}贡献/{ + task.get( + 'reward_exp', + 0)}经验") + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无任务"]) + + def qq_guild_player_join_task( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player join task QQ menu operation.""" + ok, msg, data = self.guild_system.api_get_own_guild_tasks(player_name) + if not ok or data is None: + self._reply_result(group_id, qqid, False, msg) + return + active_tasks = [task for task in data if not task.get("completed")] + if not active_tasks: + self._reply_to_qq(group_id, qqid, "暂无可参与的任务") + return + options = [ + f"{task.get('name', '<未知任务>')} ({task.get('current_count', 0)}/{task.get('target_count', 0)})" + for task in active_tasks[:20] + ] + choice = self._qq_guild_prompt( + group_id, + qqid, + "参与公会任务", + options, + [f"输入 [1-{len(options)}] 之间的数字以选择 任务", "输入 . 退出"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + selected = self.parse_displayed_menu_choice(choice, len(options)) + if selected is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + task = active_tasks[selected - 1] + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_join_guild_task_as_player( + player_name, + task.get("task_id") or task.get("name", ""), + ), + ) + + def qq_guild_player_return_base( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player return base QQ menu operation.""" + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_return_to_guild_base_as_player(player_name), + ) + + def qq_guild_player_request_join( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player request join QQ menu operation.""" + guild = self._qq_guild_prompt_text( + group_id, qqid, "申请加入公会", "请输入公会名称或ID") + if guild is None: + return + reason = self._qq_guild_prompt_text( + group_id, + qqid, + "申请加入公会", + "请输入申请理由,可输入 无 跳过", + allow_empty=True, + ) + if reason is None: + return + if reason == "无": + reason = "" + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_request_join_guild_as_player( + player_name, guild, reason), + ) + + def qq_guild_player_leave( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player leave QQ menu operation.""" + if not self._qq_guild_confirm( + group_id, qqid, "退出公会", f"{player_name} 将退出当前公会"): + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_leave_guild_as_player(player_name), + ) + + def qq_guild_player_disband( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player disband QQ menu operation.""" + if not self._qq_guild_confirm( + group_id, + qqid, + "解散公会", + f"{player_name} 将解散自己担任会长的公会"): + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_disband_owned_guild_as_player(player_name), + ) + + def qq_guild_system_menu(self, group_id: int, qqid: int): + """在群里打开公会系统云链联动版管理菜单。""" + if not self._can_use_group_permission(group_id, qqid, "公会系统权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if not self.ensure_guild_system(group_id, qqid): + return + while True: + result = self._qq_guild_run_menu( + group_id, + qqid, + "管理菜单", + ["查询与排行", "公会与成员管理", "仓库与效果管理", "任务与据点管理", "数据维护与活动"], + [ + lambda: self.qq_guild_query_menu(group_id, qqid), + lambda: self.qq_guild_member_manage_menu(group_id, qqid), + lambda: self.qq_guild_vault_effect_menu(group_id, qqid), + lambda: self.qq_guild_task_base_menu(group_id, qqid), + lambda: self.qq_guild_data_activity_menu(group_id, qqid), + ], + ) + if result == "back": + continue + return + + def qq_guild_query_menu(self, group_id: int, qqid: int): + """Handle the qq guild query menu QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): + return + if not self.ensure_guild_system(group_id, qqid): + return + self._qq_guild_run_menu( + group_id, + qqid, + "查询与排行", + [ + "查看公会列表", + "查看公会信息", + "查看公会成员", + "查看公会仓库", + "查看公会日志", + "查询玩家公会记录", + "查看系统统计", + "查看公会排行", + "查看贡献排行", + "查看异常交易", + ], + [ + lambda: self.qq_guild_list(group_id, qqid), + lambda: self.qq_guild_show_info(group_id, qqid), + lambda: self.qq_guild_show_members(group_id, qqid), + lambda: self.qq_guild_show_vault(group_id, qqid), + lambda: self.qq_guild_show_logs(group_id, qqid), + lambda: self.qq_guild_show_player_record(group_id, qqid), + lambda: self.qq_guild_show_statistics(group_id, qqid), + lambda: self.qq_guild_show_rankings(group_id, qqid), + lambda: self.qq_guild_show_donation_rankings(group_id, qqid), + lambda: self.qq_guild_show_abnormal_trades(group_id, qqid), + ], + allow_back=True, + ) + + def qq_guild_member_manage_menu(self, group_id: int, qqid: int): + """Handle the qq guild member manage menu QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): + return + if not self.ensure_guild_system(group_id, qqid): + return + self._qq_guild_run_menu( + group_id, + qqid, + "公会与成员管理", + [ + "强制解散公会", + "修改公会名称", + "设置公会等级", + "设置公会经验", + "转让公会会长", + "强制玩家加入公会", + "强制玩家退出公会", + "冻结公会", + "解冻公会", + "调整公会资金", + "设置公会资金", + "调整成员贡献", + "设置成员贡献", + "清空公会贡献", + "发送全服公会公告", + ], + [ + lambda: self.qq_guild_force_disband(group_id, qqid), + lambda: self.qq_guild_rename(group_id, qqid), + lambda: self.qq_guild_set_level(group_id, qqid), + lambda: self.qq_guild_set_exp(group_id, qqid), + lambda: self.qq_guild_transfer_owner(group_id, qqid), + lambda: self.qq_guild_force_join(group_id, qqid), + lambda: self.qq_guild_force_leave(group_id, qqid), + lambda: self.qq_guild_set_frozen(group_id, qqid, True), + lambda: self.qq_guild_set_frozen(group_id, qqid, False), + lambda: self.qq_guild_add_funds(group_id, qqid), + lambda: self.qq_guild_set_funds(group_id, qqid), + lambda: self.qq_guild_add_member_contribution(group_id, qqid), + lambda: self.qq_guild_set_member_contribution(group_id, qqid), + lambda: self.qq_guild_reset_contributions(group_id, qqid), + lambda: self.qq_guild_broadcast_announcement(group_id, qqid), + ], + allow_back=True, + ) + + def qq_guild_vault_effect_menu(self, group_id: int, qqid: int): + """Handle the qq guild vault effect menu QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): + return + if not self.ensure_guild_system(group_id, qqid): + return + self._qq_guild_run_menu( + group_id, + qqid, + "仓库与效果管理", + [ + "备份公会仓库", + "清空公会仓库", + "删除仓库物品", + "回滚仓库备份", + "导出仓库数据", + "重置市场价格", + "清空公会效果", + "设置公会效果", + ], + [ + lambda: self.qq_guild_backup_vault(group_id, qqid), + lambda: self.qq_guild_clear_vault(group_id, qqid), + lambda: self.qq_guild_delete_vault_item(group_id, qqid), + lambda: self.qq_guild_rollback_vault(group_id, qqid), + lambda: self.qq_guild_export_vault(group_id, qqid), + lambda: self.qq_guild_reset_market_prices(group_id, qqid), + lambda: self.qq_guild_clear_effects(group_id, qqid), + lambda: self.qq_guild_set_effect(group_id, qqid), + ], + allow_back=True, + ) + + def qq_guild_task_base_menu(self, group_id: int, qqid: int): + """Handle the qq guild task base menu QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): + return + if not self.ensure_guild_system(group_id, qqid): + return + self._qq_guild_run_menu( + group_id, + qqid, + "任务与据点管理", + [ + "刷新公会任务", + "创建全服任务", + "删除公会任务", + "重置任务进度", + "强制完成任务", + "传送玩家到公会据点", + "删除公会据点", + "设置公会据点", + "锁定公会据点", + "解锁公会据点", + ], + [ + lambda: self.qq_guild_refresh_tasks(group_id, qqid), + lambda: self.qq_guild_create_global_task(group_id, qqid), + lambda: self.qq_guild_delete_task(group_id, qqid), + lambda: self.qq_guild_reset_task(group_id, qqid), + lambda: self.qq_guild_complete_task(group_id, qqid), + lambda: self.qq_guild_teleport_base(group_id, qqid), + lambda: self.qq_guild_delete_base(group_id, qqid), + lambda: self.qq_guild_set_base(group_id, qqid), + lambda: self.qq_guild_set_base_locked(group_id, qqid, True), + lambda: self.qq_guild_set_base_locked(group_id, qqid, False), + ], + allow_back=True, + ) + + def qq_guild_data_activity_menu(self, group_id: int, qqid: int): + """Handle the qq guild data activity menu QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): + return + if not self.ensure_guild_system(group_id, qqid): + return + self._qq_guild_run_menu( + group_id, qqid, "数据维护与活动", + ["重载公会配置", "保存公会数据", "备份公会数据", "修复公会数据", "查看活动状态", "开启双倍经验", + "开启双倍贡献", "开启公会争霸", "停止活动", "结算排行奖励",], + [lambda: self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_reload_guild_config()), + lambda: self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_save_guild_data()), + lambda: self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_backup_guild_data()), + lambda: self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_repair_guild_data( + self._guild_actor(group_id, qqid))), + lambda: self.qq_guild_show_activity_status(group_id, qqid), + lambda: self.qq_guild_start_activity( + group_id, qqid, "exp", 2.0), + lambda: self.qq_guild_start_activity( + group_id, qqid, "contribution", 2.0), + lambda: self.qq_guild_start_activity( + group_id, qqid, "contest", 1.0), + lambda: self.qq_guild_stop_activity(group_id, qqid), + lambda: self.qq_guild_settle_rewards(group_id, qqid),], + allow_back=True,) + + def qq_guild_list(self, group_id: int, qqid: int): + """Handle the qq guild list QQ menu operation.""" + ok, msg, guilds = self.guild_system.api_list_guilds() + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + lines = [self._format_guild_line(item, index) + for index, item in enumerate(guilds[:20], start=1)] + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无公会"]) + + def qq_guild_show_info(self, group_id: int, qqid: int): + """Handle the qq guild show info QQ menu operation.""" + query = self._qq_guild_prompt_text( + group_id, qqid, "查看公会信息", "请输入公会名称或ID") + if query is None: + return + ok, msg, data = self.guild_system.api_get_guild(query) + self._reply_to_qq(group_id, qqid, self._format_guild_summary( + data, group_id) if ok and data else msg) + + def qq_guild_show_members(self, group_id: int, qqid: int): + """Handle the qq guild show members QQ menu operation.""" + query = self._qq_guild_prompt_text( + group_id, qqid, "查看公会成员", "请输入公会名称或ID") + if query is None: + return + ok, msg, data = self.guild_system.api_get_guild(query) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return members = data.get("members", []) lines = [] for index, member in enumerate(members[:30], start=1): @@ -2296,22 +2355,23 @@ def qq_guild_show_members(self, group_id: int, qqid: int): name = member.get("name", "<未知>") contribution = member.get("contribution", 0) lines.append(f"{index}. {rank} {name} 贡献 {contribution}") - self._reply_guild_lines( - group_id, qqid, f"{ - data.get( - 'name', query)} 成员", lines or ["暂无成员"], [ - f"共 { - len(members)} 名成员"]) - - def qq_guild_show_vault(self, group_id: int, qqid: int): - query = self._qq_guild_prompt_text( - group_id, qqid, "查看公会仓库", "请输入公会名称或ID") - if query is None: - return - ok, msg, data = self.guild_system.api_get_guild_vault(query) - if not ok or data is None: - self._reply_result(group_id, qqid, False, msg) - return + self._reply_guild_lines( + group_id, qqid, f"{ + data.get( + 'name', query)} 成员", lines or ["暂无成员"], [ + f"共 { + len(members)} 名成员"]) + + def qq_guild_show_vault(self, group_id: int, qqid: int): + """Handle the qq guild show vault QQ menu operation.""" + query = self._qq_guild_prompt_text( + group_id, qqid, "查看公会仓库", "请输入公会名称或ID") + if query is None: + return + ok, msg, data = self.guild_system.api_get_guild_vault(query) + if not ok or data is None: + self._reply_result(group_id, qqid, False, msg) + return lines = [] for index, item in enumerate(data[:30], start=1): item_index = item.get("index", index) @@ -2320,147 +2380,153 @@ def qq_guild_show_vault(self, group_id: int, qqid: int): price = item.get("price", 0) seller = item.get("seller", "<未知>") lines.append(f"{item_index}. {item_id} x{count} 价格 {price} 卖家 {seller}") - self._reply_guild_lines(group_id, qqid, msg, lines or ["仓库为空"]) - - def qq_guild_show_logs(self, group_id: int, qqid: int): - query = self._qq_guild_prompt_text( - group_id, qqid, "查看公会日志", "请输入公会名称或ID") - if query is None: - return - limit = self._qq_guild_prompt_int( - group_id, qqid, "查看公会日志", "请输入日志数量", minimum=1, default=10) - if limit is None: - return - ok, msg, data = self.guild_system.api_get_guild_logs(query, limit) - if not ok or not data: - self._reply_result(group_id, qqid, False, msg) - return - logs = [str(item) for item in data.get("logs", [])[-limit:]] - self._reply_guild_lines( - group_id, - qqid, - f"{query} 日志", - logs or ["暂无日志"]) - - def qq_guild_show_player_record(self, group_id: int, qqid: int): - player_name = self._qq_guild_prompt_text( - group_id, qqid, "查询玩家公会记录", "请输入玩家名") - if player_name is None: - return - ok, msg, data = self.guild_system.api_get_player_record(player_name) - if not ok or not data: - self._reply_result(group_id, qqid, False, msg) - return - member = data.get("member", {}) - guild = data.get("guild", {}) - self._reply_guild_lines( - group_id, - qqid, - f"玩家记录 - {member.get('name', player_name)}", - [ - f"所属公会:{guild.get('name', '<未知>')}", - f"职位:{member.get('rank_name', member.get('rank', '<未知>'))}", - f"贡献:{member.get('contribution', 0)}", - f"相关审计:{len(data.get('audit_logs', []))} 条", - f"仓库交易:{len(data.get('vault_trade_logs', []))} 条", - ], - ) - - def qq_guild_show_statistics(self, group_id: int, qqid: int): - ok, msg, data = self.guild_system.api_get_guild_statistics() - if not ok: - self._reply_result(group_id, qqid, False, msg) - return - self._reply_guild_lines( - group_id, - qqid, - "公会系统统计", - [ - f"公会:{data.get('guild_count', 0)}", - f"成员:{data.get('member_count', 0)}", - f"仓库物品:{data.get('vault_item_count', 0)}", - f"任务:{data.get('task_count', 0)}", - f"活跃任务:{data.get('active_task_count', 0)}", - f"冻结公会:{data.get('frozen_guild_count', 0)}", - ], - ) - - def qq_guild_show_rankings(self, group_id: int, qqid: int): - sort_choice = self._qq_guild_prompt( - group_id, - qqid, - "查看公会排行", - ["等级", "成员", "贡献", "活跃"], - ["输入 [1-4] 之间的数字以选择 排行类型", "输入 . 退出"], - ) - if sort_choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(sort_choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - sort_map = { - "1": "level", - "2": "members", - "3": "contribution", - "4": "activity"} - sort_by = sort_map.get(sort_choice.strip()) - if sort_by is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - ok, msg, data = self.guild_system.api_get_guild_rankings(sort_by, 10) - if not ok: - self._reply_result(group_id, qqid, False, msg) - return - lines = [ - f"{ - item.get( - 'rank', index)}. { - item.get( - 'name', '<未知>')} 分值 { - item.get( - 'score', 0)} 会长 { - item.get( - 'owner', '<未知>')}" for index, item in enumerate( - data, start=1)] - self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无排行"]) - - def qq_guild_show_donation_rankings(self, group_id: int, qqid: int): - query = self._qq_guild_prompt_optional_query( - group_id, qqid, "查看贡献排行", "请输入公会名/ID,输入 全部 查看全服") - if query is None: - return - ok, msg, data = self.guild_system.api_get_donation_rankings( - query or None, 10) - if not ok: - self._reply_result(group_id, qqid, False, msg) - return - lines = [ - f"{index}. { - item.get( - 'player_name', - '<未知>')} { - item.get( - 'guild_name', - '<未知>')} 贡献 { - item.get( - 'contribution', - 0)}" for index, - item in enumerate( - data, - start=1)] - self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无排行"]) - - def qq_guild_show_abnormal_trades(self, group_id: int, qqid: int): - query = self._qq_guild_prompt_optional_query( - group_id, qqid, "查看异常交易", "请输入公会名/ID,输入 全部 查看全服") - if query is None: - return - ok, msg, data = self.guild_system.api_get_abnormal_trades( - query or None) - if not ok: - self._reply_result(group_id, qqid, False, msg) - return + self._reply_guild_lines(group_id, qqid, msg, lines or ["仓库为空"]) + + def qq_guild_show_logs(self, group_id: int, qqid: int): + """Handle the qq guild show logs QQ menu operation.""" + query = self._qq_guild_prompt_text( + group_id, qqid, "查看公会日志", "请输入公会名称或ID") + if query is None: + return + limit = self._qq_guild_prompt_int( + group_id, qqid, "查看公会日志", "请输入日志数量", minimum=1, default=10) + if limit is None: + return + ok, msg, data = self.guild_system.api_get_guild_logs(query, limit) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return + logs = [str(item) for item in data.get("logs", [])[-limit:]] + self._reply_guild_lines( + group_id, + qqid, + f"{query} 日志", + logs or ["暂无日志"]) + + def qq_guild_show_player_record(self, group_id: int, qqid: int): + """Handle the qq guild show player record QQ menu operation.""" + player_name = self._qq_guild_prompt_text( + group_id, qqid, "查询玩家公会记录", "请输入玩家名") + if player_name is None: + return + ok, msg, data = self.guild_system.api_get_player_record(player_name) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return + member = data.get("member", {}) + guild = data.get("guild", {}) + self._reply_guild_lines( + group_id, + qqid, + f"玩家记录 - {member.get('name', player_name)}", + [ + f"所属公会:{guild.get('name', '<未知>')}", + f"职位:{member.get('rank_name', member.get('rank', '<未知>'))}", + f"贡献:{member.get('contribution', 0)}", + f"相关审计:{len(data.get('audit_logs', []))} 条", + f"仓库交易:{len(data.get('vault_trade_logs', []))} 条", + ], + ) + + def qq_guild_show_statistics(self, group_id: int, qqid: int): + """Handle the qq guild show statistics QQ menu operation.""" + ok, msg, data = self.guild_system.api_get_guild_statistics() + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + self._reply_guild_lines( + group_id, + qqid, + "公会系统统计", + [ + f"公会:{data.get('guild_count', 0)}", + f"成员:{data.get('member_count', 0)}", + f"仓库物品:{data.get('vault_item_count', 0)}", + f"任务:{data.get('task_count', 0)}", + f"活跃任务:{data.get('active_task_count', 0)}", + f"冻结公会:{data.get('frozen_guild_count', 0)}", + ], + ) + + def qq_guild_show_rankings(self, group_id: int, qqid: int): + """Handle the qq guild show rankings QQ menu operation.""" + sort_choice = self._qq_guild_prompt( + group_id, + qqid, + "查看公会排行", + ["等级", "成员", "贡献", "活跃"], + ["输入 [1-4] 之间的数字以选择 排行类型", "输入 . 退出"], + ) + if sort_choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(sort_choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + sort_map = { + "1": "level", + "2": "members", + "3": "contribution", + "4": "activity"} + sort_by = sort_map.get(sort_choice.strip()) + if sort_by is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + ok, msg, data = self.guild_system.api_get_guild_rankings(sort_by, 10) + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + lines = [ + f"{ + item.get( + 'rank', index)}. { + item.get( + 'name', '<未知>')} 分值 { + item.get( + 'score', 0)} 会长 { + item.get( + 'owner', '<未知>')}" for index, item in enumerate( + data, start=1)] + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无排行"]) + + def qq_guild_show_donation_rankings(self, group_id: int, qqid: int): + """Handle the qq guild show donation rankings QQ menu operation.""" + query = self._qq_guild_prompt_optional_query( + group_id, qqid, "查看贡献排行", "请输入公会名/ID,输入 全部 查看全服") + if query is None: + return + ok, msg, data = self.guild_system.api_get_donation_rankings( + query or None, 10) + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + lines = [ + f"{index}. { + item.get( + 'player_name', + '<未知>')} { + item.get( + 'guild_name', + '<未知>')} 贡献 { + item.get( + 'contribution', + 0)}" for index, + item in enumerate( + data, + start=1)] + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无排行"]) + + def qq_guild_show_abnormal_trades(self, group_id: int, qqid: int): + """Handle the qq guild show abnormal trades QQ menu operation.""" + query = self._qq_guild_prompt_optional_query( + group_id, qqid, "查看异常交易", "请输入公会名/ID,输入 全部 查看全服") + if query is None: + return + ok, msg, data = self.guild_system.api_get_abnormal_trades( + query or None) + if not ok: + self._reply_result(group_id, qqid, False, msg) + return lines = [] for item in data[:20]: guild_name = item.get("guild_name", "<未知>") @@ -2469,266 +2535,286 @@ def qq_guild_show_abnormal_trades(self, group_id: int, qqid: int): price = item.get("price", 0) ratio = item.get("ratio", 0) lines.append(f"{guild_name} {item_id} x{count} 价格 {price} 倍率 {ratio}") - self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无异常交易"]) - - def _qq_guild_prompt_guild(self, group_id: int, qqid: int, subtitle: str): - return self._qq_guild_prompt_text( - group_id, qqid, subtitle, "请输入公会名称或ID") - - def qq_guild_force_disband(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "强制解散公会") - if guild is None: - return - if not self._qq_guild_confirm( - group_id, qqid, "强制解散公会", f"即将解散公会:{guild}"): - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_force_disband_guild( - guild, self._guild_actor( - group_id, qqid))) - - def qq_guild_rename(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "修改公会名称") - if guild is None: - return - new_name = self._qq_guild_prompt_text( - group_id, qqid, "修改公会名称", "请输入新公会名") - if new_name is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_rename_guild( - guild, new_name, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_level(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会等级") - if guild is None: - return - level = self._qq_guild_prompt_int( - group_id, qqid, "设置公会等级", "请输入等级", minimum=1) - if level is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_guild_level( - guild, level, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_exp(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会经验") - if guild is None: - return - exp = self._qq_guild_prompt_int( - group_id, qqid, "设置公会经验", "请输入经验值", minimum=0) - if exp is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_guild_exp( - guild, exp, self._guild_actor( - group_id, qqid))) - - def qq_guild_transfer_owner(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "转让公会会长") - if guild is None: - return - player = self._qq_guild_prompt_text( - group_id, qqid, "转让公会会长", "请输入新会长玩家名") - if player is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_transfer_guild_owner( - guild, player, self._guild_actor( - group_id, qqid))) - - def qq_guild_force_join(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "强制玩家加入公会") - if guild is None: - return - player = self._qq_guild_prompt_text( - group_id, qqid, "强制玩家加入公会", "请输入玩家名") - if player is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_force_join_guild( - guild, player, self._guild_actor( - group_id, qqid))) - - def qq_guild_force_leave(self, group_id: int, qqid: int): - player = self._qq_guild_prompt_text( - group_id, qqid, "强制玩家退出公会", "请输入玩家名") - if player is None: - return - if not self._qq_guild_confirm( - group_id, - qqid, - "强制玩家退出公会", - f"即将移出玩家:{player}"): - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_force_leave_guild( - player, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_frozen(self, group_id: int, qqid: int, frozen: bool): - title = "冻结公会" if frozen else "解冻公会" - guild = self._qq_guild_prompt_guild(group_id, qqid, title) - if guild is None: - return - reason = "" - if frozen: - reason = self._qq_guild_prompt_text( - group_id, qqid, title, "请输入冻结原因,可输入 无", allow_empty=True) - if reason is None: - return - if reason == "无": - reason = "" - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_guild_frozen( - guild, frozen, reason, self._guild_actor( - group_id, qqid))) - - def qq_guild_add_funds(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "调整公会资金") - if guild is None: - return - amount = self._qq_guild_prompt_int( - group_id, qqid, "调整公会资金", "请输入调整数量,可为负数") - if amount is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_add_guild_funds( - guild, amount, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_funds(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会资金") - if guild is None: - return - amount = self._qq_guild_prompt_int( - group_id, qqid, "设置公会资金", "请输入资金余额", minimum=0) - if amount is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_guild_funds( - guild, amount, self._guild_actor( - group_id, qqid))) - - def qq_guild_add_member_contribution(self, group_id: int, qqid: int): - player = self._qq_guild_prompt_text(group_id, qqid, "调整成员贡献", "请输入玩家名") - if player is None: - return - amount = self._qq_guild_prompt_int( - group_id, qqid, "调整成员贡献", "请输入调整数量,可为负数") - if amount is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_add_member_contribution( - player, amount, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_member_contribution(self, group_id: int, qqid: int): - player = self._qq_guild_prompt_text(group_id, qqid, "设置成员贡献", "请输入玩家名") - if player is None: - return - amount = self._qq_guild_prompt_int( - group_id, qqid, "设置成员贡献", "请输入贡献值", minimum=0) - if amount is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_member_contribution( - player, amount, self._guild_actor( - group_id, qqid))) - - def qq_guild_reset_contributions(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "清空公会贡献") - if guild is None: - return - if not self._qq_guild_confirm( - group_id, qqid, "清空公会贡献", f"即将清空公会贡献:{guild}"): - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_reset_guild_contributions( - guild, - self._guild_actor( - group_id, - qqid))) - - def qq_guild_broadcast_announcement(self, group_id: int, qqid: int): - message = self._qq_guild_prompt_text( - group_id, qqid, "发送全服公会公告", "请输入公告内容") - if message is None: - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_broadcast_guild_announcement( - message, - self._guild_actor( - group_id, - qqid))) - - def qq_guild_backup_vault(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "备份公会仓库") - if guild is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_backup_guild_vault( - guild, "qq", self._guild_actor( - group_id, qqid))) - - def qq_guild_clear_vault(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "清空公会仓库") - if guild is None: - return - if not self._qq_guild_confirm( - group_id, qqid, "清空公会仓库", f"即将清空仓库:{guild}"): - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_clear_guild_vault( - guild, self._guild_actor( - group_id, qqid))) - - def qq_guild_delete_vault_item(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "删除仓库物品") - if guild is None: - return - index = self._qq_guild_prompt_int( - group_id, qqid, "删除仓库物品", "请输入仓库序号", minimum=1) - if index is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_delete_guild_vault_item( - guild, index, self._guild_actor( - group_id, qqid))) - - def qq_guild_rollback_vault(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "回滚仓库备份") - if guild is None: - return - index = self._qq_guild_prompt_int( - group_id, qqid, "回滚仓库备份", "请输入备份序号", minimum=1, default=1) - if index is None: - return - if not self._qq_guild_confirm( - group_id, - qqid, - "回滚仓库备份", - f"即将回滚 {guild} 的仓库备份 {index}"): - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_rollback_guild_vault( - guild, index, self._guild_actor( - group_id, qqid))) - - def qq_guild_export_vault(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "导出仓库数据") - if guild is None: - return - ok, msg, data = self.guild_system.api_export_guild_vault(guild) - if not ok or not data: - self._reply_result(group_id, qqid, False, msg) - return + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无异常交易"]) + + def _qq_guild_prompt_guild(self, group_id: int, qqid: int, subtitle: str): + """Implement the qq guild prompt guild operation.""" + return self._qq_guild_prompt_text( + group_id, qqid, subtitle, "请输入公会名称或ID") + + def qq_guild_force_disband(self, group_id: int, qqid: int): + """Handle the qq guild force disband QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "强制解散公会") + if guild is None: + return + if not self._qq_guild_confirm( + group_id, qqid, "强制解散公会", f"即将解散公会:{guild}"): + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_force_disband_guild( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_rename(self, group_id: int, qqid: int): + """Handle the qq guild rename QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "修改公会名称") + if guild is None: + return + new_name = self._qq_guild_prompt_text( + group_id, qqid, "修改公会名称", "请输入新公会名") + if new_name is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_rename_guild( + guild, new_name, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_level(self, group_id: int, qqid: int): + """Handle the qq guild set level QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会等级") + if guild is None: + return + level = self._qq_guild_prompt_int( + group_id, qqid, "设置公会等级", "请输入等级", minimum=1) + if level is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_level( + guild, level, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_exp(self, group_id: int, qqid: int): + """Handle the qq guild set exp QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会经验") + if guild is None: + return + exp = self._qq_guild_prompt_int( + group_id, qqid, "设置公会经验", "请输入经验值", minimum=0) + if exp is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_exp( + guild, exp, self._guild_actor( + group_id, qqid))) + + def qq_guild_transfer_owner(self, group_id: int, qqid: int): + """Handle the qq guild transfer owner QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "转让公会会长") + if guild is None: + return + player = self._qq_guild_prompt_text( + group_id, qqid, "转让公会会长", "请输入新会长玩家名") + if player is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_transfer_guild_owner( + guild, player, self._guild_actor( + group_id, qqid))) + + def qq_guild_force_join(self, group_id: int, qqid: int): + """Handle the qq guild force join QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "强制玩家加入公会") + if guild is None: + return + player = self._qq_guild_prompt_text( + group_id, qqid, "强制玩家加入公会", "请输入玩家名") + if player is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_force_join_guild( + guild, player, self._guild_actor( + group_id, qqid))) + + def qq_guild_force_leave(self, group_id: int, qqid: int): + """Handle the qq guild force leave QQ menu operation.""" + player = self._qq_guild_prompt_text( + group_id, qqid, "强制玩家退出公会", "请输入玩家名") + if player is None: + return + if not self._qq_guild_confirm( + group_id, + qqid, + "强制玩家退出公会", + f"即将移出玩家:{player}"): + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_force_leave_guild( + player, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_frozen(self, group_id: int, qqid: int, frozen: bool): + """Handle the qq guild set frozen QQ menu operation.""" + title = "冻结公会" if frozen else "解冻公会" + guild = self._qq_guild_prompt_guild(group_id, qqid, title) + if guild is None: + return + reason = "" + if frozen: + reason = self._qq_guild_prompt_text( + group_id, qqid, title, "请输入冻结原因,可输入 无", allow_empty=True) + if reason is None: + return + if reason == "无": + reason = "" + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_frozen( + guild, frozen, reason, self._guild_actor( + group_id, qqid))) + + def qq_guild_add_funds(self, group_id: int, qqid: int): + """Handle the qq guild add funds QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "调整公会资金") + if guild is None: + return + amount = self._qq_guild_prompt_int( + group_id, qqid, "调整公会资金", "请输入调整数量,可为负数") + if amount is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_add_guild_funds( + guild, amount, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_funds(self, group_id: int, qqid: int): + """Handle the qq guild set funds QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会资金") + if guild is None: + return + amount = self._qq_guild_prompt_int( + group_id, qqid, "设置公会资金", "请输入资金余额", minimum=0) + if amount is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_funds( + guild, amount, self._guild_actor( + group_id, qqid))) + + def qq_guild_add_member_contribution(self, group_id: int, qqid: int): + """Handle the qq guild add member contribution QQ menu operation.""" + player = self._qq_guild_prompt_text(group_id, qqid, "调整成员贡献", "请输入玩家名") + if player is None: + return + amount = self._qq_guild_prompt_int( + group_id, qqid, "调整成员贡献", "请输入调整数量,可为负数") + if amount is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_add_member_contribution( + player, amount, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_member_contribution(self, group_id: int, qqid: int): + """Handle the qq guild set member contribution QQ menu operation.""" + player = self._qq_guild_prompt_text(group_id, qqid, "设置成员贡献", "请输入玩家名") + if player is None: + return + amount = self._qq_guild_prompt_int( + group_id, qqid, "设置成员贡献", "请输入贡献值", minimum=0) + if amount is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_member_contribution( + player, amount, self._guild_actor( + group_id, qqid))) + + def qq_guild_reset_contributions(self, group_id: int, qqid: int): + """Handle the qq guild reset contributions QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "清空公会贡献") + if guild is None: + return + if not self._qq_guild_confirm( + group_id, qqid, "清空公会贡献", f"即将清空公会贡献:{guild}"): + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_reset_guild_contributions( + guild, + self._guild_actor( + group_id, + qqid))) + + def qq_guild_broadcast_announcement(self, group_id: int, qqid: int): + """Handle the qq guild broadcast announcement QQ menu operation.""" + message = self._qq_guild_prompt_text( + group_id, qqid, "发送全服公会公告", "请输入公告内容") + if message is None: + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_broadcast_guild_announcement( + message, + self._guild_actor( + group_id, + qqid))) + + def qq_guild_backup_vault(self, group_id: int, qqid: int): + """Handle the qq guild backup vault QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "备份公会仓库") + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_backup_guild_vault( + guild, "qq", self._guild_actor( + group_id, qqid))) + + def qq_guild_clear_vault(self, group_id: int, qqid: int): + """Handle the qq guild clear vault QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "清空公会仓库") + if guild is None: + return + if not self._qq_guild_confirm( + group_id, qqid, "清空公会仓库", f"即将清空仓库:{guild}"): + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_clear_guild_vault( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_delete_vault_item(self, group_id: int, qqid: int): + """Handle the qq guild delete vault item QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "删除仓库物品") + if guild is None: + return + index = self._qq_guild_prompt_int( + group_id, qqid, "删除仓库物品", "请输入仓库序号", minimum=1) + if index is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_delete_guild_vault_item( + guild, index, self._guild_actor( + group_id, qqid))) + + def qq_guild_rollback_vault(self, group_id: int, qqid: int): + """Handle the qq guild rollback vault QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "回滚仓库备份") + if guild is None: + return + index = self._qq_guild_prompt_int( + group_id, qqid, "回滚仓库备份", "请输入备份序号", minimum=1, default=1) + if index is None: + return + if not self._qq_guild_confirm( + group_id, + qqid, + "回滚仓库备份", + f"即将回滚 {guild} 的仓库备份 {index}"): + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_rollback_guild_vault( + guild, index, self._guild_actor( + group_id, qqid))) + + def qq_guild_export_vault(self, group_id: int, qqid: int): + """Handle the qq guild export vault QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "导出仓库数据") + if guild is None: + return + ok, msg, data = self.guild_system.api_export_guild_vault(guild) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return items = data.get("vault_items", []) trade_logs = data.get("vault_trade_logs", []) self._reply_guild_lines( @@ -2737,1157 +2823,1186 @@ def qq_guild_export_vault(self, group_id: int, qqid: int): msg, [f"仓库物品:{len(items)} 件", f"交易日志:{len(trade_logs)} 条"], ) - - def qq_guild_reset_market_prices(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "重置市场价格") - if guild is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_reset_market_prices( - guild, self._guild_actor( - group_id, qqid))) - - def qq_guild_clear_effects(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "清空公会效果") - if guild is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_clear_guild_effects( - guild, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_effect(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会效果") - if guild is None: - return - effect = self._qq_guild_prompt_text( - group_id, qqid, "设置公会效果", "请输入效果ID或名称") - if effect is None: - return - level = self._qq_guild_prompt_int( - group_id, qqid, "设置公会效果", "请输入效果等级,0 表示移除", minimum=0) - if level is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_guild_effect( - guild, effect, level, self._guild_actor( - group_id, qqid))) - - def qq_guild_refresh_tasks(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "刷新公会任务") - if guild is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_refresh_guild_tasks( - guild, self._guild_actor( - group_id, qqid))) - - def qq_guild_create_global_task(self, group_id: int, qqid: int): - name = self._qq_guild_prompt_text(group_id, qqid, "创建全服任务", "请输入任务名称") - if name is None: - return - task_type = self._qq_guild_prompt_text( - group_id, qqid, "创建全服任务", "请输入任务类型,如 trade/collect/kill/build") - if task_type is None: - return - target = self._qq_guild_prompt_text( - group_id, qqid, "创建全服任务", "请输入任务目标") - if target is None: - return - target_count = self._qq_guild_prompt_int( - group_id, qqid, "创建全服任务", "请输入目标数量", minimum=1) - if target_count is None: - return - reward_exp = self._qq_guild_prompt_int( - group_id, qqid, "创建全服任务", "请输入经验奖励", minimum=0, default=0) - if reward_exp is None: - return - reward_contribution = self._qq_guild_prompt_int( - group_id, qqid, "创建全服任务", "请输入贡献奖励", minimum=0, default=0) - if reward_contribution is None: - return - description = self._qq_guild_prompt_text( - group_id, qqid, "创建全服任务", "请输入任务描述,可输入 默认", allow_empty=True) - if description is None: - return - if description == "默认": - description = name - deadline = self._qq_guild_prompt_int( - group_id, qqid, "创建全服任务", "请输入截止秒数,0 表示无限期", minimum=0, default=0) - if deadline is None: - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_create_global_task( - name, - task_type, - target, - target_count, - reward_exp, - reward_contribution, - description, - deadline, - self._guild_actor(group_id, qqid), - ), - ) - - def qq_guild_delete_task(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "删除公会任务") - if guild is None: - return - task = self._qq_guild_prompt_text( - group_id, qqid, "删除公会任务", "请输入任务ID或名称") - if task is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_delete_guild_task( - guild, task, self._guild_actor( - group_id, qqid))) - - def qq_guild_reset_task(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "重置任务进度") - if guild is None: - return - task = self._qq_guild_prompt_text( - group_id, qqid, "重置任务进度", "请输入任务ID或名称") - if task is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_reset_guild_task_progress( - guild, task, self._guild_actor( - group_id, qqid))) - - def qq_guild_complete_task(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "强制完成任务") - if guild is None: - return - task = self._qq_guild_prompt_text( - group_id, qqid, "强制完成任务", "请输入任务ID或名称") - if task is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_force_complete_guild_task( - guild, task, self._guild_actor( - group_id, qqid))) - - def qq_guild_teleport_base(self, group_id: int, qqid: int): - player = self._qq_guild_prompt_text( - group_id, qqid, "传送玩家到公会据点", "请输入玩家名") - if player is None: - return - guild = self._qq_guild_prompt_optional_query( - group_id, qqid, "传送玩家到公会据点", "请输入公会名/ID,输入 全部 使用玩家所在公会") - if guild is None: - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_teleport_player_to_guild_base( - player, - guild or None)) - - def qq_guild_delete_base(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "删除公会据点") - if guild is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_delete_guild_base( - guild, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_base(self, group_id: int, qqid: int): - guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会据点") - if guild is None: - return - dimension = self._qq_guild_prompt_int( - group_id, qqid, "设置公会据点", "请输入维度ID") - if dimension is None: - return - x = self._qq_guild_prompt_text(group_id, qqid, "设置公会据点", "请输入 x 坐标") - if x is None: - return - y = self._qq_guild_prompt_text(group_id, qqid, "设置公会据点", "请输入 y 坐标") - if y is None: - return - z = self._qq_guild_prompt_text(group_id, qqid, "设置公会据点", "请输入 z 坐标") - if z is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_guild_base( - guild, dimension, x, y, z, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_base_locked(self, group_id: int, qqid: int, locked: bool): - title = "锁定公会据点" if locked else "解锁公会据点" - guild = self._qq_guild_prompt_guild(group_id, qqid, title) - if guild is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_guild_base_locked( - guild, locked, self._guild_actor( - group_id, qqid))) - - def qq_guild_show_activity_status(self, group_id: int, qqid: int): - ok, msg, data = self.guild_system.api_get_guild_activity_status() - if not ok: - self._reply_result(group_id, qqid, False, msg) - return - lines = [] - for key, event in data.items(): - lines.append( - f"{key}: 倍率 { - event.get( - 'multiplier', - 1)} 剩余 { - event.get( - 'remaining_seconds', - 0)} 秒 发起 { - event.get( - 'actor', - '<未知>')}") - self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无活动"]) - - def qq_guild_start_activity( - self, - group_id: int, - qqid: int, - activity: str, - multiplier: float): - duration = self._qq_guild_prompt_int( - group_id, qqid, "开启公会活动", "请输入持续秒数", minimum=1, default=3600) - if duration is None: - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_start_guild_activity( - activity, - duration, - multiplier, - self._guild_actor( - group_id, - qqid))) - - def qq_guild_stop_activity(self, group_id: int, qqid: int): - choice = self._qq_guild_prompt( - group_id, - qqid, - "停止活动", - ["双倍经验", "双倍贡献", "公会争霸"], - ["输入 [1-3] 之间的数字以选择 活动", "输入 . 退出"], - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - activity = { - "1": "exp", - "2": "contribution", - "3": "contest"}.get( - choice.strip()) - if activity is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - self._reply_guild_api_result( - group_id, qqid, - self.guild_system.api_stop_guild_activity(activity)) - - def qq_guild_settle_rewards(self, group_id: int, qqid: int): - sort_by = self._qq_guild_prompt_text( - group_id, qqid, "结算排行奖励", - "请输入排行类型:level/members/contribution/activity") - if sort_by is None: - return - top = self._qq_guild_prompt_int( - group_id, qqid, "结算排行奖励", "请输入结算名次", minimum=1, default=3) - if top is None: - return - reward_exp = self._qq_guild_prompt_int( - group_id, qqid, "结算排行奖励", "请输入经验奖励", minimum=0, default=0) - if reward_exp is None: - return - reward_funds = self._qq_guild_prompt_int( - group_id, qqid, "结算排行奖励", "请输入资金奖励", minimum=0, default=0) - if reward_funds is None: - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_settle_guild_ranking_rewards( - sort_by, - top, - reward_exp, - reward_funds, - self._guild_actor( - group_id, - qqid))) - - def ensure_land_system(self, group_id: int, sender: int): - """检查领地系统云链联动版 API 是否可用。""" - if self.land_system is None: - self._reply_to_qq(group_id, sender, "相关插件未安装:领地系统云链联动版") - return False - return self.ensure_linked_plugin_enabled( - self.land_system, group_id, sender) - - def _format_land_summary(self, land: dict[str, Any], group_id: int): - center = land.get("center", (0, 0, 0)) - admins = "、".join(land.get("admins", [])) or "无" - members = "、".join(land.get("members", [])) or "无" - return self.plugin_ui_menu( - "领地系统云链联动版", - f"领地信息 - {land.get('name', '<未知>')}", - [ - f"领主:{land.get('owner', '<未知>')}", - f"中心:{center[0]}, {center[1]}, {center[2]}", - f"范围:{land.get('range_text', '<未知>')}", - f"管理员:{admins}", - f"成员:{members}", - ], - ["输入 . 退出"], - group_id, - ) - - def _qq_land_prompt( - self, - group_id: int, - qqid: int, - subtitle: str, - options: list[str], - hints: list[str], - ): - return self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "领地系统云链联动版", - subtitle, - options, - hints, - group_id, - ), - timeout=120, - ) - - def qq_land_system_menu(self, group_id: int, qqid: int): - """在群里打开领地系统云链联动版管理菜单。""" - if not self._can_use_group_permission(group_id, qqid, "领地系统权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - if not self.ensure_land_system(group_id, qqid): - return - choice = self._qq_land_prompt( - group_id, - qqid, - "管理菜单", - [ - "查看领地列表", - "查看领地信息", - "新增玩家领地", - "删除玩家领地", - "添加领地成员", - "移除领地成员", - "添加领地管理员", - "移除领地管理员", - "修改领地所有者", - "修改领地中心点", - "修改领地范围", - ], - ["输入 [1-11] 之间的数字以选择 对应功能", "输入 . 退出"], - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - handler_map = { - "1": self.qq_land_list, - "2": self.qq_land_show_info, - "3": self.qq_land_add, - "4": self.qq_land_delete, - "5": lambda gid, - qid: self.qq_land_member_action( - gid, - qid, - is_add=True, - rank="member"), - "6": lambda gid, - qid: self.qq_land_member_action( - gid, - qid, - is_add=False, - rank="member"), - "7": lambda gid, - qid: self.qq_land_member_action( - gid, - qid, - is_add=True, - rank="admin"), - "8": lambda gid, - qid: self.qq_land_member_action( - gid, - qid, - is_add=False, - rank="admin"), - "9": self.qq_land_transfer_owner, - "10": self.qq_land_update_center, - "11": self.qq_land_update_range, - } - handler = handler_map.get(choice) - if handler is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - handler(group_id, qqid) - - def qq_land_list(self, group_id: int, qqid: int): - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - if not self.ensure_land_system(group_id, qqid): - return - ok, msg, lands = self.land_system.api_list_lands() - if not ok: - self._reply_result(group_id, qqid, False, msg) - return - if not lands: - self._reply_to_qq(group_id, qqid, "暂无任何领地") - return - page = 1 - per_page = self.get_group_land_items_per_page(group_id) - while True: - total_pages, start_index, end_index = ( - utils.paginate(len(lands), per_page, page) - if hasattr(utils, "paginate") - else self.simple_paginate(len(lands), per_page, page) - ) - page_lands = lands[start_index - 1: end_index] - text = self.plugin_ui_menu( - "领地系统云链联动版", - "领地列表", - [ - f"{land['name']} - 领主: {land['owner']}, {land['range_text']}" - for land in page_lands - ], - [ - f"当前第 {page}/{total_pages} 页", - f"输入 [1-{len(page_lands)}] 之间的数字查看详情", - "输入 - 转到上一页", - "输入 + 转到下一页", - "输入 正整数+页 转到对应页", - "输入 . 退出", - ], - group_id, - ) - user_input = self.qq_prompt(group_id, qqid, text, timeout=120) - if user_input is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(user_input, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if user_input == "+": - if page < total_pages: - page += 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") - continue - if user_input == "-": - if page > 1: - page -= 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") - continue - if page_num := self.parse_page_jump(user_input): - if 1 <= page_num <= total_pages: - page = page_num - else: - self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") - continue - choice = self.parse_displayed_menu_choice( - user_input, len(page_lands)) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - continue - self._reply_to_qq( - group_id, - qqid, - self._format_land_summary(page_lands[choice - 1], group_id), - ) - return - - def _qq_land_prompt_text( - self, - group_id: int, - qqid: int, - subtitle: str, - prompt: str): - value = self._qq_land_prompt( - group_id, qqid, subtitle, [], [ - prompt, "输入 . 退出"]) - if value is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - if self._is_menu_exit(value, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - return value - - @staticmethod - def _parse_land_pos(raw: str): - parts = raw.replace(",", " ").replace(",", " ").split() - if len(parts) != 3: - return None - try: - return (float(parts[0]), float(parts[1]), float(parts[2])) - except ValueError: - return None - - @staticmethod - def _parse_land_size(raw: str): - parts = raw.replace(",", " ").replace(",", " ").split() - if len(parts) != 3: - return None - try: - size = (int(parts[0]), int(parts[1]), int(parts[2])) - except ValueError: - return None - if any(value <= 0 for value in size): - return None - return size - - def qq_land_show_info(self, group_id: int, qqid: int): - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - query = self._qq_land_prompt_text(group_id, qqid, "查看领地信息", "请输入领地名称") - if query is None: - return - ok, msg, land = self.land_system.api_get_land(query) - self._reply_to_qq( - group_id, - qqid, - self._format_land_summary(land, group_id) if ok else msg, - ) - - def qq_land_add(self, group_id: int, qqid: int): - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - owner = self._qq_land_prompt_text( - group_id, qqid, "新增玩家领地", "请输入领地主人玩家名") - if owner is None: - return - name = self._qq_land_prompt_text(group_id, qqid, "新增玩家领地", "请输入领地名称") - if name is None: - return - center_text = self._qq_land_prompt_text( - group_id, qqid, "新增玩家领地", "请输入领地中心坐标,格式:x y z") - if center_text is None: - return - center = self._parse_land_pos(center_text) - if center is None: - self._reply_to_qq(group_id, qqid, "❀ 坐标格式有误") - return - shape_choice = self._qq_land_prompt( - group_id, - qqid, - "新增玩家领地", - ["圆形领地", "方形领地"], - ["输入 [1-2] 之间的数字以选择 领地类型", "输入 . 退出"], - ) - if shape_choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(shape_choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if shape_choice == "1": - radius_text = self._qq_land_prompt_text( - group_id, qqid, "新增圆形领地", "请输入领地半径") - if radius_text is None: - return - try: - radius = int(radius_text) - except ValueError: - self._reply_to_qq(group_id, qqid, "❀ 半径必须为整数") - return - ok, msg, _land = self.land_system.api_add_land( - owner, name, center, "圆形", radius=radius) - elif shape_choice == "2": - size_text = self._qq_land_prompt_text( - group_id, qqid, "新增方形领地", "请输入 长 高 宽") - if size_text is None: - return - size = self._parse_land_size(size_text) - if size is None: - self._reply_to_qq(group_id, qqid, "❀ 方形尺寸格式有误") - return - ok, msg, _land = self.land_system.api_add_land( - owner, name, center, "方形", size=size) - else: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - self._reply_result(group_id, qqid, ok, msg) - - def qq_land_delete(self, group_id: int, qqid: int): - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - query = self._qq_land_prompt_text(group_id, qqid, "删除玩家领地", "请输入领地名称") - if query is None: - return - ok, msg, _data = self.land_system.api_delete_land(query) - self._reply_result(group_id, qqid, ok, msg) - - def qq_land_member_action( - self, - group_id: int, - qqid: int, - is_add: bool, - rank: str): - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - title = "添加" if is_add else "移除" - role = "管理员" if rank == "admin" else "成员" - query = self._qq_land_prompt_text( - group_id, qqid, f"{title}领地{role}", "请输入领地名称") - if query is None: - return - player_name = self._qq_land_prompt_text( - group_id, qqid, f"{title}领地{role}", "请输入玩家名称") - if player_name is None: - return - if is_add: - ok, msg, _land = self.land_system.api_add_member( - query, player_name, rank=rank) - elif rank == "admin": - ok, msg, _land = self.land_system.api_set_member_rank( - query, player_name, "member") - else: - ok, msg, _land = self.land_system.api_remove_member( - query, player_name) - self._reply_result(group_id, qqid, ok, msg) - - def qq_land_transfer_owner(self, group_id: int, qqid: int): - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - query = self._qq_land_prompt_text(group_id, qqid, "修改领地所有者", "请输入领地名称") - if query is None: - return - owner = self._qq_land_prompt_text( - group_id, qqid, "修改领地所有者", "请输入新所有者玩家名") - if owner is None: - return - ok, msg, _land = self.land_system.api_transfer_owner(query, owner) - self._reply_result(group_id, qqid, ok, msg) - - def qq_land_update_center(self, group_id: int, qqid: int): - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - query = self._qq_land_prompt_text(group_id, qqid, "修改领地中心点", "请输入领地名称") - if query is None: - return - center_text = self._qq_land_prompt_text( - group_id, qqid, "修改领地中心点", "请输入新中心坐标,格式:x y z") - if center_text is None: - return - center = self._parse_land_pos(center_text) - if center is None: - self._reply_to_qq(group_id, qqid, "❀ 坐标格式有误") - return - ok, msg, _land = self.land_system.api_update_land_center(query, center) - self._reply_result(group_id, qqid, ok, msg) - - def qq_land_update_range(self, group_id: int, qqid: int): - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - query = self._qq_land_prompt_text(group_id, qqid, "修改领地范围", "请输入领地名称") - if query is None: - return - ok, msg, land = self.land_system.api_get_land(query) - if not ok: - self._reply_result(group_id, qqid, False, msg) - return - if land.get("shape") == "方形": - size_text = self._qq_land_prompt_text( - group_id, qqid, "修改方形领地范围", "请输入新的 长 高 宽") - if size_text is None: - return - size = self._parse_land_size(size_text) - if size is None: - self._reply_to_qq(group_id, qqid, "❀ 方形尺寸格式有误") - return - ok, msg, _land = self.land_system.api_update_land_range( - query, size=size) - else: - radius_text = self._qq_land_prompt_text( - group_id, qqid, "修改圆形领地范围", "请输入新的半径") - if radius_text is None: - return - try: - radius = int(radius_text) - except ValueError: - self._reply_to_qq(group_id, qqid, "❀ 半径必须为整数") - return - ok, msg, _land = self.land_system.api_update_land_range( - query, radius=radius) - self._reply_result(group_id, qqid, ok, msg) - - @staticmethod - def translate_item_name(item_id: str): - """尽量把物品 ID 翻译成中文显示名,失败时退回原始 ID。""" - if not isinstance(item_id, str) or item_id == "": - return "未知物品" - item_tail = item_id.split(":")[-1] - if translate is None: - return item_id - for key in (f"item.{item_tail}.name", f"tile.{item_tail}.name"): - try: - translated = translate(key) - except Exception: - continue - if isinstance( - translated, - str) and translated and translated != key: - return translated - return item_id - - @staticmethod - def get_item_custom_name(slot: Any): - """尝试从物品槽位对象里提取自定义名称。""" - for attr in ("customName", "custom_name", "name"): - value = getattr(slot, attr, None) - if ( - isinstance(value, str) - and value.strip() - and value.strip() != getattr(slot, "id", "") - ): - return value.strip() - return "" - - @staticmethod - def get_item_enchantments_text(slot: Any): - """把槽位上的附魔信息整理成单行文字。""" - enchants = getattr(slot, "enchantments", None) - if not isinstance(enchants, list) or not enchants: - return "" - outputs: list[str] = [] - for enchant in enchants: - if enchant is None: - continue - name = getattr(enchant, "name", None) - level = getattr(enchant, "level", None) - if isinstance(name, str) and name.strip(): - if isinstance(level, int): - outputs.append(f"{name.strip()} {level}") - else: - outputs.append(name.strip()) - else: - etype = getattr(enchant, "type", None) - if etype is not None: - if isinstance(level, int): - outputs.append(f"ID{etype} {level}") - else: - outputs.append(f"ID{etype}") - return "、".join(outputs) - - def on_qq_add_admin(self, group_id: int, sender: int, args: list[str]): - """给当前群添加普通管理员。""" - if not self._can_use_group_permission(group_id, sender, "QQ普通管理员菜单权限"): - self._reply_menu_permission_denied(group_id, sender) - return - try: - qqid = int(args[0]) - except (TypeError, ValueError): - self._reply_to_qq(group_id, sender, "QQ号格式有误") - return - _ok, msg = self.add_group_role(group_id, qqid, is_super=False) - self._reply_to_qq(group_id, sender, msg) - - def qq_admin_menu(self, group_id: int, qqid: int): - """QQ群管理员菜单。 - - 可管理的层级由“权限设置”中的管理员菜单权限决定。 - """ - options = [] - actions = [] - if self._can_use_group_permission(group_id, qqid, "QQ普通管理员菜单权限"): - options.append("普通管理员管理") - actions.append(lambda: self.qq_admin_role_menu( - group_id, qqid, is_super=False)) - if self._can_use_group_permission(group_id, qqid, "QQ超级管理员菜单权限"): - options.append("超级管理员管理") - actions.append(lambda: self.qq_admin_role_menu( - group_id, qqid, is_super=True)) - if not options: - self._reply_menu_permission_denied(group_id, qqid) - return - - if len(options) == 1: - actions[0]() - return - - choice = self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - "QQ群管理员 管理菜单", - options, - [f"输入 [1-{len(options)}] 之间的数字以选择 对应功能", "输入 . 退出"], - group_id, - ), - timeout=120, - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - choice = choice.strip() - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - selected = self.parse_displayed_menu_choice(choice, len(options)) - if selected is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - actions[selected - 1]() - - def qq_admin_role_menu(self, group_id: int, qqid: int, is_super: bool): - """增删指定层级的群管理员。""" - permission_name = "QQ超级管理员菜单权限" if is_super else "QQ普通管理员菜单权限" - if not self._can_use_group_permission(group_id, qqid, permission_name): - self._reply_menu_permission_denied(group_id, qqid) - return - role_name = "超级管理员" if is_super else "普通管理员" - choice = self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - f"{role_name} 管理菜单", - [f"添加{role_name}", f"删除{role_name}"], - ["输入 [1-2] 之间的数字以选择 对应功能", "输入 . 退出"], - group_id, - ), - timeout=120, - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if choice not in ("1", "2"): - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - qq_text = self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - f"{role_name} 管理菜单", - [], - [f"请输入要{'添加' if choice == '1' else '删除'}的 QQ 号", "输入 . 退出"], - group_id, - ), - timeout=120, - ) - if qq_text is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(qq_text, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - qqid_target = utils.try_int(qq_text) - if qqid_target is None or qqid_target <= 0: - self._reply_to_qq(group_id, qqid, "❀ QQ号格式有误") - return - if choice == "1": - ok, msg = self.add_group_role( - group_id, qqid_target, is_super=is_super) - else: - ok, msg = self.remove_group_role( - group_id, qqid_target, is_super=is_super) - self._reply_result(group_id, qqid, ok, msg) - - def ensure_whitelist_checker(self, group_id: int, sender: int): - """检查白名单联动插件是否可用,避免菜单点进去后才报空引用。""" - if self.whitelist_checker is None: - self._reply_to_qq(group_id, sender, "相关插件未安装:白名单&管理员检测云链联动版") - return False - return self.ensure_linked_plugin_enabled( - self.whitelist_checker, group_id, sender) - - def on_qq_whitelist_add(self, group_id: int, sender: int, args: list[str]): - """通过群命令把玩家加入白名单。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - ok, msg = self.whitelist_checker.add_whitelist_player(args[0]) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_whitelist_remove( - self, - group_id: int, - sender: int, - args: list[str]): - """通过群命令把玩家移出白名单。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - ok, msg = self.whitelist_checker.remove_whitelist_player(args[0]) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_server_admin_add( - self, - group_id: int, - sender: int, - args: list[str]): - """通过群命令把玩家登记为服务器管理员。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - ok, msg = self.whitelist_checker.add_admin_player(args[0]) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_server_admin_remove( - self, - group_id: int, - sender: int, - args: list[str]): - """通过群命令把玩家从服务器管理员名单中移除。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - ok, msg = self.whitelist_checker.remove_admin_player(args[0]) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_whitelist_toggle( - self, - group_id: int, - sender: int, - args: list[str]): - """通过群命令切换白名单检测开关。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - action = args[0].strip() - if action not in ("开启", "关闭", "on", "off"): - self._reply_to_qq(group_id, sender, "参数错误,格式:白名单检测 [开启/关闭]") - return - ok, msg = self.whitelist_checker.set_whitelist_enabled( - action in ("开启", "on")) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_admin_check_toggle( - self, - group_id: int, - sender: int, - args: list[str]): - """通过群命令切换管理员检测开关。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - action = args[0].strip() - if action not in ("开启", "关闭", "on", "off"): - self._reply_to_qq(group_id, sender, "参数错误,格式:管理员检测 [开启/关闭]") - return - ok, msg = self.whitelist_checker.set_admin_check_enabled( - action in ("开启", "on")) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_check_interval( - self, - group_id: int, - sender: int, - args: list[str]): - """通过群命令修改联动插件的轮询周期。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - try: - seconds = float(args[0]) - except (TypeError, ValueError): - self._reply_to_qq(group_id, sender, "参数错误,格式:检测周期 [秒数]") - return - ok, msg = self.whitelist_checker.set_check_interval(seconds) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_check_status(self, group_id: int, sender: int, _args: list[str]): - """把联动插件当前状态摘要发回群里。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - status = self.whitelist_checker.get_runtime_status() - output = ( - f"[CQ:at,qq={sender}] 白名单&管理员检测云链联动版状态:\n" - f"检测周期:{status['check_interval']} 秒\n" - f"白名单检测:{'开启' if status['whitelist_enabled'] else '关闭'}\n" - f"白名单人数:{status['whitelist_count']}\n" - f"管理员检测:{'开启' if status['admin_check_enabled'] else '关闭'}\n" - f"管理员人数:{status['admin_count']}" - ) - self.sendmsg(group_id, output, do_remove_cq_code=False) - - def _qq_checker_prompt( - self, - group_id: int, - qqid: int, - subtitle: str, - options: list[str], - hints: list[str], - ): - """统一构造白名单联动菜单提示并等待回复。""" - return self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "白名单&管理员检测云链联动版", - subtitle, - options, - hints, - group_id, - ), - timeout=120, - ) - - def _qq_checker_handle_player_action( - self, group_id: int, qqid: int, choice: str): - """处理添加/移除白名单与服务器管理员这四类玩家操作。""" - title_map = { - "1": "白名单 添加玩家", - "2": "白名单 移除玩家", - "3": "管理员 添加玩家", - "4": "管理员 移除玩家", - } - handler_map = { - "1": self.on_qq_whitelist_add, - "2": self.on_qq_whitelist_remove, - "3": self.on_qq_server_admin_add, - "4": self.on_qq_server_admin_remove, - } - player_name = self._qq_checker_prompt( - group_id, - qqid, - title_map[choice], - [], - ["请输入玩家名称", "输入 . 退出"], - ) - if player_name is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(player_name, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - handler_map[choice](group_id, qqid, [player_name]) - - def _qq_checker_handle_toggle_action( - self, group_id: int, qqid: int, choice: str): - """处理白名单检测和管理员检测的开关菜单。""" - subtitle = "白名单检测 设置" if choice == "5" else "管理员检测 设置" - handler = ( - self.on_qq_whitelist_toggle - if choice == "5" - else self.on_qq_admin_check_toggle - ) - action = self._qq_checker_prompt( - group_id, - qqid, - subtitle, - ["开启", "关闭"], - ["输入 [1-2] 之间的数字以选择 对应操作", "输入 . 退出"], - ) - if action is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(action, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - action_arg = {"1": ["开启"], "2": ["关闭"]}.get(action) - if action_arg is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - handler(group_id, qqid, action_arg) - - def _qq_checker_handle_interval_action(self, group_id: int, qqid: int): - """处理检测周期设置菜单。""" - seconds = self._qq_checker_prompt( - group_id, - qqid, - "检测周期 设置", - [], - ["请输入检测周期秒数", "输入 . 退出"], - ) - if seconds is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(seconds, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - self.on_qq_check_interval(group_id, qqid, [seconds]) - - def qq_checker_menu(self, group_id: int, qqid: int): - """在群里打开白名单与管理员检测联动菜单。""" - if not self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - if not self.ensure_whitelist_checker(group_id, qqid): - return - # 这个菜单本质上是把白名单插件暴露出来的 API 做了一层群聊版操作面板。 - # 这样权限仍然统一归群服互通管理,而不是把原插件的控制台能力原样暴露出来。 - choice = self._qq_checker_prompt( - group_id, - qqid, - "管理系统", - [ - "添加玩家到白名单", - "从白名单中移除玩家", - "添加服务器管理员", - "移除服务器管理员", - "开启/关闭 白名单检测", - "开启/关闭 管理员检测", - "设置检测周期", - "查看当前状态", - ], - ["输入 [1-8] 之间的数字以选择 对应功能", "输入 . 退出"], - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - - if choice in ("1", "2", "3", "4"): - self._qq_checker_handle_player_action(group_id, qqid, choice) - return - - if choice in ("5", "6"): - self._qq_checker_handle_toggle_action(group_id, qqid, choice) - return - - if choice == "7": - self._qq_checker_handle_interval_action(group_id, qqid) - return - - if choice == "8": - self.on_qq_check_status(group_id, qqid, []) - return - - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + + def qq_guild_reset_market_prices(self, group_id: int, qqid: int): + """Handle the qq guild reset market prices QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "重置市场价格") + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_reset_market_prices( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_clear_effects(self, group_id: int, qqid: int): + """Handle the qq guild clear effects QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "清空公会效果") + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_clear_guild_effects( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_effect(self, group_id: int, qqid: int): + """Handle the qq guild set effect QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会效果") + if guild is None: + return + effect = self._qq_guild_prompt_text( + group_id, qqid, "设置公会效果", "请输入效果ID或名称") + if effect is None: + return + level = self._qq_guild_prompt_int( + group_id, qqid, "设置公会效果", "请输入效果等级,0 表示移除", minimum=0) + if level is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_effect( + guild, effect, level, self._guild_actor( + group_id, qqid))) + + def qq_guild_refresh_tasks(self, group_id: int, qqid: int): + """Handle the qq guild refresh tasks QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "刷新公会任务") + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_refresh_guild_tasks( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_create_global_task(self, group_id: int, qqid: int): + """Handle the qq guild create global task QQ menu operation.""" + name = self._qq_guild_prompt_text(group_id, qqid, "创建全服任务", "请输入任务名称") + if name is None: + return + task_type = self._qq_guild_prompt_text( + group_id, qqid, "创建全服任务", "请输入任务类型,如 trade/collect/kill/build") + if task_type is None: + return + target = self._qq_guild_prompt_text( + group_id, qqid, "创建全服任务", "请输入任务目标") + if target is None: + return + target_count = self._qq_guild_prompt_int( + group_id, qqid, "创建全服任务", "请输入目标数量", minimum=1) + if target_count is None: + return + reward_exp = self._qq_guild_prompt_int( + group_id, qqid, "创建全服任务", "请输入经验奖励", minimum=0, default=0) + if reward_exp is None: + return + reward_contribution = self._qq_guild_prompt_int( + group_id, qqid, "创建全服任务", "请输入贡献奖励", minimum=0, default=0) + if reward_contribution is None: + return + description = self._qq_guild_prompt_text( + group_id, qqid, "创建全服任务", "请输入任务描述,可输入 默认", allow_empty=True) + if description is None: + return + if description == "默认": + description = name + deadline = self._qq_guild_prompt_int( + group_id, qqid, "创建全服任务", "请输入截止秒数,0 表示无限期", minimum=0, default=0) + if deadline is None: + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_create_global_task( + name, + task_type, + target, + target_count, + reward_exp, + reward_contribution, + description, + deadline, + self._guild_actor(group_id, qqid), + ), + ) + + def qq_guild_delete_task(self, group_id: int, qqid: int): + """Handle the qq guild delete task QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "删除公会任务") + if guild is None: + return + task = self._qq_guild_prompt_text( + group_id, qqid, "删除公会任务", "请输入任务ID或名称") + if task is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_delete_guild_task( + guild, task, self._guild_actor( + group_id, qqid))) + + def qq_guild_reset_task(self, group_id: int, qqid: int): + """Handle the qq guild reset task QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "重置任务进度") + if guild is None: + return + task = self._qq_guild_prompt_text( + group_id, qqid, "重置任务进度", "请输入任务ID或名称") + if task is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_reset_guild_task_progress( + guild, task, self._guild_actor( + group_id, qqid))) + + def qq_guild_complete_task(self, group_id: int, qqid: int): + """Handle the qq guild complete task QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "强制完成任务") + if guild is None: + return + task = self._qq_guild_prompt_text( + group_id, qqid, "强制完成任务", "请输入任务ID或名称") + if task is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_force_complete_guild_task( + guild, task, self._guild_actor( + group_id, qqid))) + + def qq_guild_teleport_base(self, group_id: int, qqid: int): + """Handle the qq guild teleport base QQ menu operation.""" + player = self._qq_guild_prompt_text( + group_id, qqid, "传送玩家到公会据点", "请输入玩家名") + if player is None: + return + guild = self._qq_guild_prompt_optional_query( + group_id, qqid, "传送玩家到公会据点", "请输入公会名/ID,输入 全部 使用玩家所在公会") + if guild is None: + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_teleport_player_to_guild_base( + player, + guild or None)) + + def qq_guild_delete_base(self, group_id: int, qqid: int): + """Handle the qq guild delete base QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "删除公会据点") + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_delete_guild_base( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_base(self, group_id: int, qqid: int): + """Handle the qq guild set base QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会据点") + if guild is None: + return + dimension = self._qq_guild_prompt_int( + group_id, qqid, "设置公会据点", "请输入维度ID") + if dimension is None: + return + x = self._qq_guild_prompt_text(group_id, qqid, "设置公会据点", "请输入 x 坐标") + if x is None: + return + y = self._qq_guild_prompt_text(group_id, qqid, "设置公会据点", "请输入 y 坐标") + if y is None: + return + z = self._qq_guild_prompt_text(group_id, qqid, "设置公会据点", "请输入 z 坐标") + if z is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_base( + guild, dimension, x, y, z, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_base_locked(self, group_id: int, qqid: int, locked: bool): + """Handle the qq guild set base locked QQ menu operation.""" + title = "锁定公会据点" if locked else "解锁公会据点" + guild = self._qq_guild_prompt_guild(group_id, qqid, title) + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_base_locked( + guild, locked, self._guild_actor( + group_id, qqid))) + + def qq_guild_show_activity_status(self, group_id: int, qqid: int): + """Handle the qq guild show activity status QQ menu operation.""" + ok, msg, data = self.guild_system.api_get_guild_activity_status() + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + lines = [] + for key, event in data.items(): + lines.append( + f"{key}: 倍率 { + event.get( + 'multiplier', + 1)} 剩余 { + event.get( + 'remaining_seconds', + 0)} 秒 发起 { + event.get( + 'actor', + '<未知>')}") + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无活动"]) + + def qq_guild_start_activity( + self, + group_id: int, + qqid: int, + activity: str, + multiplier: float): + """Handle the qq guild start activity QQ menu operation.""" + duration = self._qq_guild_prompt_int( + group_id, qqid, "开启公会活动", "请输入持续秒数", minimum=1, default=3600) + if duration is None: + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_start_guild_activity( + activity, + duration, + multiplier, + self._guild_actor( + group_id, + qqid))) + + def qq_guild_stop_activity(self, group_id: int, qqid: int): + """Handle the qq guild stop activity QQ menu operation.""" + choice = self._qq_guild_prompt( + group_id, + qqid, + "停止活动", + ["双倍经验", "双倍贡献", "公会争霸"], + ["输入 [1-3] 之间的数字以选择 活动", "输入 . 退出"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + activity = { + "1": "exp", + "2": "contribution", + "3": "contest"}.get( + choice.strip()) + if activity is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + self._reply_guild_api_result( + group_id, qqid, + self.guild_system.api_stop_guild_activity(activity)) + + def qq_guild_settle_rewards(self, group_id: int, qqid: int): + """Handle the qq guild settle rewards QQ menu operation.""" + sort_by = self._qq_guild_prompt_text( + group_id, qqid, "结算排行奖励", + "请输入排行类型:level/members/contribution/activity") + if sort_by is None: + return + top = self._qq_guild_prompt_int( + group_id, qqid, "结算排行奖励", "请输入结算名次", minimum=1, default=3) + if top is None: + return + reward_exp = self._qq_guild_prompt_int( + group_id, qqid, "结算排行奖励", "请输入经验奖励", minimum=0, default=0) + if reward_exp is None: + return + reward_funds = self._qq_guild_prompt_int( + group_id, qqid, "结算排行奖励", "请输入资金奖励", minimum=0, default=0) + if reward_funds is None: + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_settle_guild_ranking_rewards( + sort_by, + top, + reward_exp, + reward_funds, + self._guild_actor( + group_id, + qqid))) + + def ensure_land_system(self, group_id: int, sender: int): + """检查领地系统云链联动版 API 是否可用。""" + if self.land_system is None: + self._reply_to_qq(group_id, sender, "相关插件未安装:领地系统云链联动版") + return False + return self.ensure_linked_plugin_enabled( + self.land_system, group_id, sender) + + def _format_land_summary(self, land: dict[str, Any], group_id: int): + """Implement the format land summary operation.""" + center = land.get("center", (0, 0, 0)) + admins = "、".join(land.get("admins", [])) or "无" + members = "、".join(land.get("members", [])) or "无" + return self.plugin_ui_menu( + "领地系统云链联动版", + f"领地信息 - {land.get('name', '<未知>')}", + [ + f"领主:{land.get('owner', '<未知>')}", + f"中心:{center[0]}, {center[1]}, {center[2]}", + f"范围:{land.get('range_text', '<未知>')}", + f"管理员:{admins}", + f"成员:{members}", + ], + ["输入 . 退出"], + group_id, + ) + + def _qq_land_prompt( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + hints: list[str], + ): + """Implement the qq land prompt operation.""" + return self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "领地系统云链联动版", + subtitle, + options, + hints, + group_id, + ), + timeout=120, + ) + + def qq_land_system_menu(self, group_id: int, qqid: int): + """在群里打开领地系统云链联动版管理菜单。""" + if not self._can_use_group_permission(group_id, qqid, "领地系统权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if not self.ensure_land_system(group_id, qqid): + return + choice = self._qq_land_prompt( + group_id, + qqid, + "管理菜单", + [ + "查看领地列表", + "查看领地信息", + "新增玩家领地", + "删除玩家领地", + "添加领地成员", + "移除领地成员", + "添加领地管理员", + "移除领地管理员", + "修改领地所有者", + "修改领地中心点", + "修改领地范围", + ], + ["输入 [1-11] 之间的数字以选择 对应功能", "输入 . 退出"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + handler_map = { + "1": self.qq_land_list, + "2": self.qq_land_show_info, + "3": self.qq_land_add, + "4": self.qq_land_delete, + "5": lambda gid, + qid: self.qq_land_member_action( + gid, + qid, + is_add=True, + rank="member"), + "6": lambda gid, + qid: self.qq_land_member_action( + gid, + qid, + is_add=False, + rank="member"), + "7": lambda gid, + qid: self.qq_land_member_action( + gid, + qid, + is_add=True, + rank="admin"), + "8": lambda gid, + qid: self.qq_land_member_action( + gid, + qid, + is_add=False, + rank="admin"), + "9": self.qq_land_transfer_owner, + "10": self.qq_land_update_center, + "11": self.qq_land_update_range, + } + handler = handler_map.get(choice) + if handler is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + handler(group_id, qqid) + + def qq_land_list(self, group_id: int, qqid: int): + """Handle the qq land list QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + if not self.ensure_land_system(group_id, qqid): + return + ok, msg, lands = self.land_system.api_list_lands() + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + if not lands: + self._reply_to_qq(group_id, qqid, "暂无任何领地") + return + page = 1 + per_page = self.get_group_land_items_per_page(group_id) + while True: + total_pages, start_index, end_index = ( + utils.paginate(len(lands), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(lands), per_page, page) + ) + page_lands = lands[start_index - 1: end_index] + text = self.plugin_ui_menu( + "领地系统云链联动版", + "领地列表", + [ + f"{land['name']} - 领主: {land['owner']}, {land['range_text']}" + for land in page_lands + ], + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(page_lands)}] 之间的数字查看详情", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if user_input == "+": + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") + continue + choice = self.parse_displayed_menu_choice( + user_input, len(page_lands)) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + continue + self._reply_to_qq( + group_id, + qqid, + self._format_land_summary(page_lands[choice - 1], group_id), + ) + return + + def _qq_land_prompt_text( + self, + group_id: int, + qqid: int, + subtitle: str, + prompt: str): + """Implement the qq land prompt text operation.""" + value = self._qq_land_prompt( + group_id, qqid, subtitle, [], [ + prompt, "输入 . 退出"]) + if value is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + if self._is_menu_exit(value, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + return value + + @staticmethod + def _parse_land_pos(raw: str): + """Implement the parse land pos operation.""" + parts = raw.replace(",", " ").replace(",", " ").split() + if len(parts) != 3: + return None + try: + return (float(parts[0]), float(parts[1]), float(parts[2])) + except ValueError: + return None + + @staticmethod + def _parse_land_size(raw: str): + """Implement the parse land size operation.""" + parts = raw.replace(",", " ").replace(",", " ").split() + if len(parts) != 3: + return None + try: + size = (int(parts[0]), int(parts[1]), int(parts[2])) + except ValueError: + return None + if any(value <= 0 for value in size): + return None + return size + + def qq_land_show_info(self, group_id: int, qqid: int): + """Handle the qq land show info QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + query = self._qq_land_prompt_text(group_id, qqid, "查看领地信息", "请输入领地名称") + if query is None: + return + ok, msg, land = self.land_system.api_get_land(query) + self._reply_to_qq( + group_id, + qqid, + self._format_land_summary(land, group_id) if ok else msg, + ) + + def qq_land_add(self, group_id: int, qqid: int): + """Handle the qq land add QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + owner = self._qq_land_prompt_text( + group_id, qqid, "新增玩家领地", "请输入领地主人玩家名") + if owner is None: + return + name = self._qq_land_prompt_text(group_id, qqid, "新增玩家领地", "请输入领地名称") + if name is None: + return + center_text = self._qq_land_prompt_text( + group_id, qqid, "新增玩家领地", "请输入领地中心坐标,格式:x y z") + if center_text is None: + return + center = self._parse_land_pos(center_text) + if center is None: + self._reply_to_qq(group_id, qqid, "❀ 坐标格式有误") + return + shape_choice = self._qq_land_prompt( + group_id, + qqid, + "新增玩家领地", + ["圆形领地", "方形领地"], + ["输入 [1-2] 之间的数字以选择 领地类型", "输入 . 退出"], + ) + if shape_choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(shape_choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if shape_choice == "1": + radius_text = self._qq_land_prompt_text( + group_id, qqid, "新增圆形领地", "请输入领地半径") + if radius_text is None: + return + try: + radius = int(radius_text) + except ValueError: + self._reply_to_qq(group_id, qqid, "❀ 半径必须为整数") + return + ok, msg, _land = self.land_system.api_add_land( + owner, name, center, "圆形", radius=radius) + elif shape_choice == "2": + size_text = self._qq_land_prompt_text( + group_id, qqid, "新增方形领地", "请输入 长 高 宽") + if size_text is None: + return + size = self._parse_land_size(size_text) + if size is None: + self._reply_to_qq(group_id, qqid, "❀ 方形尺寸格式有误") + return + ok, msg, _land = self.land_system.api_add_land( + owner, name, center, "方形", size=size) + else: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + self._reply_result(group_id, qqid, ok, msg) + + def qq_land_delete(self, group_id: int, qqid: int): + """Handle the qq land delete QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + query = self._qq_land_prompt_text(group_id, qqid, "删除玩家领地", "请输入领地名称") + if query is None: + return + ok, msg, _data = self.land_system.api_delete_land(query) + self._reply_result(group_id, qqid, ok, msg) + + def qq_land_member_action( + self, + group_id: int, + qqid: int, + is_add: bool, + rank: str): + """Handle the qq land member action QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + title = "添加" if is_add else "移除" + role = "管理员" if rank == "admin" else "成员" + query = self._qq_land_prompt_text( + group_id, qqid, f"{title}领地{role}", "请输入领地名称") + if query is None: + return + player_name = self._qq_land_prompt_text( + group_id, qqid, f"{title}领地{role}", "请输入玩家名称") + if player_name is None: + return + if is_add: + ok, msg, _land = self.land_system.api_add_member( + query, player_name, rank=rank) + elif rank == "admin": + ok, msg, _land = self.land_system.api_set_member_rank( + query, player_name, "member") + else: + ok, msg, _land = self.land_system.api_remove_member( + query, player_name) + self._reply_result(group_id, qqid, ok, msg) + + def qq_land_transfer_owner(self, group_id: int, qqid: int): + """Handle the qq land transfer owner QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + query = self._qq_land_prompt_text(group_id, qqid, "修改领地所有者", "请输入领地名称") + if query is None: + return + owner = self._qq_land_prompt_text( + group_id, qqid, "修改领地所有者", "请输入新所有者玩家名") + if owner is None: + return + ok, msg, _land = self.land_system.api_transfer_owner(query, owner) + self._reply_result(group_id, qqid, ok, msg) + + def qq_land_update_center(self, group_id: int, qqid: int): + """Handle the qq land update center QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + query = self._qq_land_prompt_text(group_id, qqid, "修改领地中心点", "请输入领地名称") + if query is None: + return + center_text = self._qq_land_prompt_text( + group_id, qqid, "修改领地中心点", "请输入新中心坐标,格式:x y z") + if center_text is None: + return + center = self._parse_land_pos(center_text) + if center is None: + self._reply_to_qq(group_id, qqid, "❀ 坐标格式有误") + return + ok, msg, _land = self.land_system.api_update_land_center(query, center) + self._reply_result(group_id, qqid, ok, msg) + + def qq_land_update_range(self, group_id: int, qqid: int): + """Handle the qq land update range QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + query = self._qq_land_prompt_text(group_id, qqid, "修改领地范围", "请输入领地名称") + if query is None: + return + ok, msg, land = self.land_system.api_get_land(query) + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + if land.get("shape") == "方形": + size_text = self._qq_land_prompt_text( + group_id, qqid, "修改方形领地范围", "请输入新的 长 高 宽") + if size_text is None: + return + size = self._parse_land_size(size_text) + if size is None: + self._reply_to_qq(group_id, qqid, "❀ 方形尺寸格式有误") + return + ok, msg, _land = self.land_system.api_update_land_range( + query, size=size) + else: + radius_text = self._qq_land_prompt_text( + group_id, qqid, "修改圆形领地范围", "请输入新的半径") + if radius_text is None: + return + try: + radius = int(radius_text) + except ValueError: + self._reply_to_qq(group_id, qqid, "❀ 半径必须为整数") + return + ok, msg, _land = self.land_system.api_update_land_range( + query, radius=radius) + self._reply_result(group_id, qqid, ok, msg) + + @staticmethod + def translate_item_name(item_id: str): + """尽量把物品 ID 翻译成中文显示名,失败时退回原始 ID。""" + if not isinstance(item_id, str) or item_id == "": + return "未知物品" + item_tail = item_id.split(":")[-1] + if translate is None: + return item_id + for key in (f"item.{item_tail}.name", f"tile.{item_tail}.name"): + try: + translated = translate(key) + except Exception: + continue + if isinstance( + translated, + str) and translated and translated != key: + return translated + return item_id + + @staticmethod + def get_item_custom_name(slot: Any): + """尝试从物品槽位对象里提取自定义名称。""" + for attr in ("customName", "custom_name", "name"): + value = getattr(slot, attr, None) + if ( + isinstance(value, str) + and value.strip() + and value.strip() != getattr(slot, "id", "") + ): + return value.strip() + return "" + + @staticmethod + def get_item_enchantments_text(slot: Any): + """把槽位上的附魔信息整理成单行文字。""" + enchants = getattr(slot, "enchantments", None) + if not isinstance(enchants, list) or not enchants: + return "" + outputs: list[str] = [] + for enchant in enchants: + if enchant is None: + continue + name = getattr(enchant, "name", None) + level = getattr(enchant, "level", None) + if isinstance(name, str) and name.strip(): + if isinstance(level, int): + outputs.append(f"{name.strip()} {level}") + else: + outputs.append(name.strip()) + else: + etype = getattr(enchant, "type", None) + if etype is not None: + if isinstance(level, int): + outputs.append(f"ID{etype} {level}") + else: + outputs.append(f"ID{etype}") + return "、".join(outputs) + + def on_qq_add_admin(self, group_id: int, sender: int, args: list[str]): + """给当前群添加普通管理员。""" + if not self._can_use_group_permission(group_id, sender, "QQ普通管理员菜单权限"): + self._reply_menu_permission_denied(group_id, sender) + return + try: + qqid = int(args[0]) + except (TypeError, ValueError): + self._reply_to_qq(group_id, sender, "QQ号格式有误") + return + _ok, msg = self.add_group_role(group_id, qqid, is_super=False) + self._reply_to_qq(group_id, sender, msg) + + def qq_admin_menu(self, group_id: int, qqid: int): + """QQ群管理员菜单。 + + 可管理的层级由“权限设置”中的管理员菜单权限决定。 + """ + options = [] + actions = [] + if self._can_use_group_permission(group_id, qqid, "QQ普通管理员菜单权限"): + options.append("普通管理员管理") + actions.append(lambda: self.qq_admin_role_menu( + group_id, qqid, is_super=False)) + if self._can_use_group_permission(group_id, qqid, "QQ超级管理员菜单权限"): + options.append("超级管理员管理") + actions.append(lambda: self.qq_admin_role_menu( + group_id, qqid, is_super=True)) + if not options: + self._reply_menu_permission_denied(group_id, qqid) + return + + if len(options) == 1: + actions[0]() + return + + choice = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "QQ群管理员 管理菜单", + options, + [f"输入 [1-{len(options)}] 之间的数字以选择 对应功能", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + choice = choice.strip() + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + selected = self.parse_displayed_menu_choice(choice, len(options)) + if selected is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + actions[selected - 1]() + + def qq_admin_role_menu(self, group_id: int, qqid: int, is_super: bool): + """增删指定层级的群管理员。""" + permission_name = "QQ超级管理员菜单权限" if is_super else "QQ普通管理员菜单权限" + if not self._can_use_group_permission(group_id, qqid, permission_name): + self._reply_menu_permission_denied(group_id, qqid) + return + role_name = "超级管理员" if is_super else "普通管理员" + choice = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"{role_name} 管理菜单", + [f"添加{role_name}", f"删除{role_name}"], + ["输入 [1-2] 之间的数字以选择 对应功能", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if choice not in ("1", "2"): + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + qq_text = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"{role_name} 管理菜单", + [], + [f"请输入要{'添加' if choice == '1' else '删除'}的 QQ 号", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if qq_text is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(qq_text, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + qqid_target = utils.try_int(qq_text) + if qqid_target is None or qqid_target <= 0: + self._reply_to_qq(group_id, qqid, "❀ QQ号格式有误") + return + if choice == "1": + ok, msg = self.add_group_role( + group_id, qqid_target, is_super=is_super) + else: + ok, msg = self.remove_group_role( + group_id, qqid_target, is_super=is_super) + self._reply_result(group_id, qqid, ok, msg) + + def ensure_whitelist_checker(self, group_id: int, sender: int): + """检查白名单联动插件是否可用,避免菜单点进去后才报空引用。""" + if self.whitelist_checker is None: + self._reply_to_qq(group_id, sender, "相关插件未安装:白名单&管理员检测云链联动版") + return False + return self.ensure_linked_plugin_enabled( + self.whitelist_checker, group_id, sender) + + def on_qq_whitelist_add(self, group_id: int, sender: int, args: list[str]): + """通过群命令把玩家加入白名单。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + ok, msg = self.whitelist_checker.add_whitelist_player(args[0]) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_whitelist_remove( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令把玩家移出白名单。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + ok, msg = self.whitelist_checker.remove_whitelist_player(args[0]) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_server_admin_add( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令把玩家登记为服务器管理员。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + ok, msg = self.whitelist_checker.add_admin_player(args[0]) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_server_admin_remove( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令把玩家从服务器管理员名单中移除。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + ok, msg = self.whitelist_checker.remove_admin_player(args[0]) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_whitelist_toggle( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令切换白名单检测开关。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + action = args[0].strip() + if action not in ("开启", "关闭", "on", "off"): + self._reply_to_qq(group_id, sender, "参数错误,格式:白名单检测 [开启/关闭]") + return + ok, msg = self.whitelist_checker.set_whitelist_enabled( + action in ("开启", "on")) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_admin_check_toggle( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令切换管理员检测开关。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + action = args[0].strip() + if action not in ("开启", "关闭", "on", "off"): + self._reply_to_qq(group_id, sender, "参数错误,格式:管理员检测 [开启/关闭]") + return + ok, msg = self.whitelist_checker.set_admin_check_enabled( + action in ("开启", "on")) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_check_interval( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令修改联动插件的轮询周期。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + try: + seconds = float(args[0]) + except (TypeError, ValueError): + self._reply_to_qq(group_id, sender, "参数错误,格式:检测周期 [秒数]") + return + ok, msg = self.whitelist_checker.set_check_interval(seconds) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_check_status(self, group_id: int, sender: int, _args: list[str]): + """把联动插件当前状态摘要发回群里。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + status = self.whitelist_checker.get_runtime_status() + output = ( + f"[CQ:at,qq={sender}] 白名单&管理员检测云链联动版状态:\n" + f"检测周期:{status['check_interval']} 秒\n" + f"白名单检测:{'开启' if status['whitelist_enabled'] else '关闭'}\n" + f"白名单人数:{status['whitelist_count']}\n" + f"管理员检测:{'开启' if status['admin_check_enabled'] else '关闭'}\n" + f"管理员人数:{status['admin_count']}" + ) + self.sendmsg(group_id, output, do_remove_cq_code=False) + + def _qq_checker_prompt( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + hints: list[str], + ): + """统一构造白名单联动菜单提示并等待回复。""" + return self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "白名单&管理员检测云链联动版", + subtitle, + options, + hints, + group_id, + ), + timeout=120, + ) + + def _qq_checker_handle_player_action( + self, group_id: int, qqid: int, choice: str): + """处理添加/移除白名单与服务器管理员这四类玩家操作。""" + title_map = { + "1": "白名单 添加玩家", + "2": "白名单 移除玩家", + "3": "管理员 添加玩家", + "4": "管理员 移除玩家", + } + handler_map = { + "1": self.on_qq_whitelist_add, + "2": self.on_qq_whitelist_remove, + "3": self.on_qq_server_admin_add, + "4": self.on_qq_server_admin_remove, + } + player_name = self._qq_checker_prompt( + group_id, + qqid, + title_map[choice], + [], + ["请输入玩家名称", "输入 . 退出"], + ) + if player_name is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(player_name, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + handler_map[choice](group_id, qqid, [player_name]) + + def _qq_checker_handle_toggle_action( + self, group_id: int, qqid: int, choice: str): + """处理白名单检测和管理员检测的开关菜单。""" + subtitle = "白名单检测 设置" if choice == "5" else "管理员检测 设置" + handler = ( + self.on_qq_whitelist_toggle + if choice == "5" + else self.on_qq_admin_check_toggle + ) + action = self._qq_checker_prompt( + group_id, + qqid, + subtitle, + ["开启", "关闭"], + ["输入 [1-2] 之间的数字以选择 对应操作", "输入 . 退出"], + ) + if action is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(action, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + action_arg = {"1": ["开启"], "2": ["关闭"]}.get(action) + if action_arg is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + handler(group_id, qqid, action_arg) + + def _qq_checker_handle_interval_action(self, group_id: int, qqid: int): + """处理检测周期设置菜单。""" + seconds = self._qq_checker_prompt( + group_id, + qqid, + "检测周期 设置", + [], + ["请输入检测周期秒数", "输入 . 退出"], + ) + if seconds is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(seconds, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + self.on_qq_check_interval(group_id, qqid, [seconds]) + + def qq_checker_menu(self, group_id: int, qqid: int): + """在群里打开白名单与管理员检测联动菜单。""" + if not self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if not self.ensure_whitelist_checker(group_id, qqid): + return + # 这个菜单本质上是把白名单插件暴露出来的 API 做了一层群聊版操作面板。 + # 这样权限仍然统一归群服互通管理,而不是把原插件的控制台能力原样暴露出来。 + choice = self._qq_checker_prompt( + group_id, + qqid, + "管理系统", + [ + "添加玩家到白名单", + "从白名单中移除玩家", + "添加服务器管理员", + "移除服务器管理员", + "开启/关闭 白名单检测", + "开启/关闭 管理员检测", + "设置检测周期", + "查看当前状态", + ], + ["输入 [1-8] 之间的数字以选择 对应功能", "输入 . 退出"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + + if choice in ("1", "2", "3", "4"): + self._qq_checker_handle_player_action(group_id, qqid, choice) + return + + if choice in ("5", "6"): + self._qq_checker_handle_toggle_action(group_id, qqid, choice) + return + + if choice == "7": + self._qq_checker_handle_interval_action(group_id, qqid) + return + + if choice == "8": + self.on_qq_check_status(group_id, qqid, []) + return + + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" index 365e10de..e1e2fcdf 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" @@ -1,1008 +1,1010 @@ -"""Runtime websocket and message forwarding logic for Ultra.""" - -import json -import time -from copy import deepcopy -from typing import Any - -try: - import websocket -except ImportError: - from . import websocket - -from tooldelta import Chat, InternalBroadcast, Player, utils - -from .message_utils import ( - EASTER_EGG_QQIDS, - remove_color, - remove_cq_code, - replace_cq, -) - -try: - from tooldelta.utils.mc_translator import translate -except ImportError: - translate = None - - -# 运行时层只管消息流转:执行指令、WebSocket、广播、群服互通分发。 -class QQLinkerRuntimeMixin: - """负责云链运行时、消息分发与 WebSocket 生命周期。""" - - def _start_ws_session(self): - """注册一个新的 WebSocket 会话编号,并清空上次重连状态。""" - self._ws_session_id += 1 - self.reloaded = False - self._ws_reconnect_delay = None - return self._ws_session_id - - def _is_current_ws_session(self, ws_obj, session_id: int): - """判断回调是否来自当前仍然有效的 WebSocket 会话。""" - return session_id == self._ws_session_id and ws_obj is self.ws - - def _print_cloud_status( - self, - title: str, - page_label: str, - lines: list[str], - level: str = "info", - ): - """按统一的控制台卡片样式输出云链连接状态。""" - self.print_console_card(title, page_label, lines, level=level) - - def execute_cmd_and_get_zhcn_cb(self, cmd: str): - """执行 MC 指令,并把原始返回整理成适合群聊展示的文本。""" - try: - result = self.game_ctrl.sendwscmd_with_resp(cmd, 10) - if len(result.OutputMessages) == 0: - return ["😅 指令执行失败", "😄 指令执行成功"][bool(result.SuccessCount)] - if result.OutputMessages[0].Message in ( - "commands.generic.syntax", - "commands.generic.unknown", - ): - return f'😅 未知的 MC 指令, 可能是指令格式有误: "{cmd}"' - if translate is not None: - output_text = "\n".join( - translate( - i.Message, - i.Parameters) for i in result.OutputMessages) - else: - output_text = "\n".join( - i.Message for i in result.OutputMessages) - if result.SuccessCount: - return "😄 指令执行成功,执行结果:\n" + output_text - return "😭 指令执行失败,原因:\n" + output_text - except IndexError as exec_err: - import traceback - - traceback.print_exc() - return f"执行出现问题: {exec_err}" - except TimeoutError: - return "😭 超时:指令获取结果返回超时" - - def iter_game_to_group_targets(self): - """遍历当前启用了“游戏到群”转发的群。""" - for group_id in self.group_order: - group_cfg = self.group_cfgs[group_id] - if group_cfg["游戏到群"]["是否启用"]: - yield group_id, group_cfg - - @staticmethod - def should_forward_game_message(msg: str, group_cfg: dict[str, Any]): - """根据群配置判断一条游戏消息是否要转发,以及转发时应裁掉哪些前缀。""" - trans_chars = group_cfg["游戏到群"]["仅转发以下符号开头的消息(列表为空则全部转发)"] - block_prefixs = group_cfg["游戏到群"]["屏蔽以下字符串开头的消息"] - if trans_chars: - for prefix in trans_chars: - if msg.startswith(prefix): - return True, msg[len(prefix):] - return False, msg - if block_prefixs: - for prefix in block_prefixs: - if msg.startswith(prefix): - return False, msg - return True, msg - - @utils.thread_func("云链群服连接进程") - def connect_to_websocket(self): - """按当前配置或本地桥接参数建立到云链的连接。""" - with self._ws_runner_lock: - if self._ws_runner_active: - self._print_cloud_status( - "群服互通 云链连接", - "运行中", - ["云链连接线程已在运行", "本次重复连接请求已忽略"], - level="warn", - ) - return - self._ws_runner_active = True - - try: - while True: - target = self._get_websocket_target() - header = None - validate_code = self.cfg["云链设置"]["校验码"].strip() - if validate_code: - header = {"Authorization": f"Bearer {validate_code}"} - self._print_cloud_status( - "群服互通 云链连接", - "连接中", - ["正在尝试连接云链", f"目标地址: {target}"], - level="info", - ) - session_id = self._start_ws_session() - ws_app = websocket.WebSocketApp( - target, header, on_message=lambda a, b, sid=session_id: self.on_ws_message( - a, b, sid) and None, on_error=lambda a, b, sid=session_id: self.on_ws_error( - a, b, sid), on_close=lambda a, b, c, sid=session_id: self.on_ws_close( - a, b, c, sid), ) - ws_app.on_open = lambda ws_obj, sid=session_id: self.on_ws_open( - ws_obj, sid) - self.ws = ws_app - self.available = False - ws_app.run_forever() - - delay = self._ws_reconnect_delay - if delay is None: - break - time.sleep(delay) - finally: - with self._ws_runner_lock: - self._ws_runner_active = False - - def _get_websocket_target(self): - """返回当前应连接的 WebSocket 地址。""" - if self._manual_launch: - return f"ws://127.0.0.1:{self._manual_launch_port}" - return self.cfg["云链设置"]["地址"] - - def api_get_status(self) -> dict[str, Any]: - """Return a compact runtime status snapshot for external plugins.""" - try: - websocket_target = self._get_websocket_target() - except Exception: - websocket_target = "" - return { - "available": bool(self.available), - "ws_initialized": self.ws is not None, - "websocket_target": websocket_target, - "manual_launch": bool(self._manual_launch), - "manual_launch_port": int(self._manual_launch_port), - "reloaded": bool(self.reloaded), - "reconnect_delay": self._ws_reconnect_delay, - "session_id": int(self._ws_session_id), - "linked_groups": list(self.group_order), - "default_group": self.linked_group, - } - - def api_get_online_players(self) -> list[str]: - """Return a copy of current online player names.""" - game_ctrl = getattr(self, "game_ctrl", None) - if game_ctrl is None: - return [] - raw_players = getattr(game_ctrl, "allplayers", []) - try: - players = list(raw_players) - except TypeError: - return [] - result: list[str] = [] - for player in players: - name = getattr(player, "name", player) - name = str(name).strip() - if name: - result.append(name) - return result - - def api_is_player_online( - self, - player_name: str, - ignore_case: bool = False, - ) -> bool: - """Return whether a player name is currently online.""" - name = str(player_name).strip() - if not name: - return False - players = self.api_get_online_players() - if ignore_case: - name = name.lower() - return any(player.lower() == name for player in players) - return name in players - - def api_execute_game_cmd(self, command: str) -> tuple[bool, str]: - """Execute an MC command and return a stable result tuple.""" - cmd = str(command).strip() - if not cmd: - return False, "MC指令不能为空" - if getattr(self, "game_ctrl", None) is None: - return False, "游戏控制器不可用" - try: - result = self.execute_cmd_and_get_zhcn_cb(cmd) - except Exception as err: - return False, f"MC指令执行失败: {err}" - message = "\n".join(result) if isinstance( - result, list) else str(result) - fail_markers = ( - "指令执行失败", - "未知的 MC 指令", - "执行出现问题", - "超时", - ) - return not any(marker in message for marker in fail_markers), message - - def api_get_game_to_group_targets( - self, - enabled_only: bool = True, - ) -> list[dict[str, Any]]: - """Return game-to-group forwarding rules for configured groups.""" - targets: list[dict[str, Any]] = [] - for group_id in self.group_order: - group_cfg = self.group_cfgs.get(group_id) - if group_cfg is None: - continue - game_to_group = group_cfg["游戏到群"] - enabled = bool(game_to_group["是否启用"]) - if enabled_only and not enabled: - continue - targets.append( - { - "group_id": group_id, - "enabled": enabled, - "format": str(game_to_group["转发格式"]), - "required_prefixes": list( - game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"] - ), - "blocked_prefixes": list(game_to_group["屏蔽以下字符串开头的消息"]), - "forward_player_events": bool(game_to_group["转发玩家进退提示"]), - "config": deepcopy(group_cfg), - } - ) - return targets - - def api_should_forward_game_message( - self, - group_id: int | str, - message: str, - ) -> tuple[bool, str] | None: - """Preview whether a game chat message would be forwarded to a group.""" - try: - gid = int(str(group_id).strip()) - except (TypeError, ValueError): - return None - group_cfg = self.group_cfgs.get(gid) - if group_cfg is None: - return None - msg = str(message) - if not group_cfg["游戏到群"]["是否启用"]: - return False, msg - return self.should_forward_game_message(msg, group_cfg) - - def reload_websocket_connection(self): - """让云链连接按当前配置重新建立。""" - self.reloaded = True - self._ws_reconnect_delay = None - self.available = False - self._ws_session_id += 1 - ws_obj = self.ws - if ws_obj is not None: - try: - ws_obj.close() - except Exception as err: - self._print_cloud_status( - "群服互通 云链连接", - "重载", - [f"关闭旧连接失败: {err}", "将继续尝试使用新配置连接"], - level="warn", - ) - self.ws = None - if not self._manual_launch: - self.connect_to_websocket() - - def api_reload_websocket(self) -> tuple[bool, str]: - """Request the cloud WebSocket connection to reload.""" - try: - self.reload_websocket_connection() - except Exception as err: - return False, f"云链重载失败: {err}" - return True, "已请求云链重载" - - def _get_message_listener_store(self): - """Return the raw group message listener registry.""" - if not hasattr(self, "_message_listeners") or not isinstance( - self._message_listeners, dict - ): - self._message_listeners = {} - return self._message_listeners - - def api_register_message_listener( - self, name: str, listener) -> tuple[bool, str]: - """Register a raw group message listener callback.""" - listener_name = str(name).strip() - if not listener_name: - return False, "监听器名称不能为空" - if not callable(listener): - return False, "监听器必须是可调用对象" - listeners = self._get_message_listener_store() - if listener_name in listeners: - return False, "监听器已存在" - listeners[listener_name] = listener - return True, "已注册原始群消息监听器" - - def api_unregister_message_listener(self, name: str) -> tuple[bool, str]: - """Unregister a raw group message listener callback.""" - listener_name = str(name).strip() - if not listener_name: - return False, "监听器名称不能为空" - listeners = self._get_message_listener_store() - if listener_name not in listeners: - return False, "监听器不存在" - del listeners[listener_name] - return True, "已注销原始群消息监听器" - - def api_get_message_listeners(self) -> list[dict[str, Any]]: - """Return metadata for registered raw group message listeners.""" - return [ - {"name": name, "callable": callable(listener)} - for name, listener in self._get_message_listener_store().items() - ] - - def _stop_when_message_listener_handled( - self, data: dict[str, Any]) -> bool: - """Run registered raw message listeners; truthy return stops processing.""" - for name, listener in list(self._get_message_listener_store().items()): - try: - if listener(deepcopy(data)): - return True - except Exception as err: - if hasattr(self, "_print_cloud_status"): - self._print_cloud_status( - "群服互通 原始消息监听", - "监听器异常", - [f"{name}: {err}"], - level="warn", - ) - return False - - @utils.thread_func("云链群服消息广播进程") - def broadcast(self, data): - """把原始群消息广播给主动注册的其他插件。""" - for plugin_name in self.plugin: - self.GetPluginAPI(plugin_name).QQLinker_message(data) - - def on_ws_open(self, _ws, session_id: int): - """在 WebSocket 建立后标记连接可用。""" - if not self._is_current_ws_session(_ws, session_id): - return - self.available = True - self._print_cloud_status( - "群服互通 云链连接", - "已连接", - ["已成功连接到群服互通云链版Ultra版", f"当前地址: {self._get_websocket_target()}"], - level="success", - ) - - @utils.thread_func("群服互通消息接收线程") - def on_ws_message(self, _ws, message, session_id: int): - """处理来自云链的群消息,并按配置分发到不同入口。""" - if not self._is_current_ws_session(_ws, session_id): - return - data = json.loads(message) - if self._stop_when_data_broadcast_handled(data): - return - - payload = self._build_group_message_payload(data) - if payload is None: - return - - group_id, group_cfg, msg, user_id, nickname = payload - if self._consume_waiting_reply(group_id, user_id, msg): - return - if self._stop_when_group_broadcast_handled( - group_id, user_id, nickname, msg): - return - if self.execute_triggers(group_id, user_id, msg): - return - self._forward_group_message_to_game(group_cfg, user_id, nickname, msg) - - def _stop_when_data_broadcast_handled(self, data: dict[str, Any]) -> bool: - """把原始数据广播给框架,其它插件声明已处理时立即停止后续流程。""" - bc_recv = self.BroadcastEvent(InternalBroadcast("群服互通/数据json", data)) - if any(bc_recv): - return True - if data.get("post_type") != "message" or data.get( - "message_type") != "group": - return True - if self._stop_when_message_listener_handled(data): - return True - self.broadcast(data) - return False - - def _build_group_message_payload(self, data: dict[str, Any]): - """把云链原始消息整理成后续逻辑统一使用的结构。""" - group_id = data.get("group_id") - if group_id not in self.group_cfgs: - return None - group_cfg = self.group_cfgs[group_id] - msg = self._extract_text_message(data["message"]) - user_id = int(data["sender"]["user_id"]) - nickname = data["sender"]["card"] or data["sender"]["nickname"] - return group_id, group_cfg, msg, user_id, nickname - - @staticmethod - def _extract_text_message(msg: Any) -> str: - """从云链消息结构里提取可处理的纯文本。""" - if isinstance(msg, list): - msg_rawdict = msg[0] - msg_type = msg_rawdict["type"] - msg_data = msg_rawdict["data"] - if msg_type != "text": - return "" - return msg_data["text"] - if not isinstance(msg, str): - raise ValueError(f"键 'message' 值不是字符串类型, 而是 {msg}") - return msg - - def _consume_waiting_reply( - self, - group_id: int, - user_id: int, - msg: str) -> bool: - """把当前消息投递给等待输入的菜单回调。""" - wait_key = (group_id, user_id) - cb = self.waitmsg_cbs.pop(wait_key, None) - if cb is not None: - cb(msg) - return True - cb = self.waitmsg_cbs.pop(user_id, None) - if cb is not None: - cb(msg) - return True - return False - - def _stop_when_group_broadcast_handled( - self, - group_id: int, - user_id: int, - nickname: str, - msg: str, - ) -> bool: - """把群消息广播给框架层,其它插件声明已处理时立即停止。""" - bc_recv = self.BroadcastEvent( - InternalBroadcast( - "群服互通/链接群消息", - {"群号": group_id, "QQ号": user_id, "昵称": nickname, "消息": msg}, - ), - ) - return any(bc_recv) - - def _forward_group_message_to_game( - self, - group_cfg: dict[str, Any], - user_id: int, - nickname: str, - msg: str, - ): - """把普通群消息按当前群配置转发到游戏内。""" - if not group_cfg["群到游戏"]["是否启用"]: - return - if user_id in group_cfg["群到游戏"]["屏蔽的QQ号"]: - return - trans_chars = group_cfg["群到游戏"]["仅转发以下符号开头的消息(列表为空则全部转发)"] - if trans_chars: - matched_prefix = None - for prefix in trans_chars: - if msg.startswith(prefix): - matched_prefix = prefix - break - if matched_prefix is None: - return - msg = msg[len(matched_prefix):] - - if group_cfg["群到游戏"]["替换花里胡哨的昵称"]: - nickname = remove_color(nickname) - if group_cfg["群到游戏"]["替换花里胡哨的消息"]: - msg = remove_color(msg) - self.game_ctrl.say_to( - "@a", - utils.simple_fmt( - {"[昵称]": nickname, "[消息]": replace_cq(msg)}, - group_cfg["群到游戏"]["转发格式"], - ), - ) - - def on_ws_error(self, _ws, error, session_id: int): - """处理 WebSocket 错误并按配置尝试重连。""" - if not self._is_current_ws_session(_ws, session_id): - return - if not isinstance(error, Exception): - # 某些 WebSocket 实现会在连接仍然可用时回调空字符串/None。 - # 这类“空错误”没有实际诊断价值,也不代表连接真的断开。 - if error is None or (isinstance(error, str) - and error.strip() == ""): - return - self._print_cloud_status( - "群服互通 云链连接", - "停止", - [f"连接线程已结束: {error}", "收到非异常错误对象,已停止重连"], - level="info", - ) - self.reloaded = True - self._ws_reconnect_delay = None - return - self.available = False - self._ws_reconnect_delay = 15 - self._print_cloud_status( - "群服互通 云链连接", - "异常", - [f"连接失败: {error}", "15 秒后尝试重连"], - level="error", - ) - - def waitMsg( - self, - qqid: int, - timeout=60, - group_id: int | None = None) -> str | None: - """等待某个 QQ 在指定群里的下一条回复。 - 带 `group_id` 时只收同群回复,不带时保留对旧插件的兼容行为。 - """ - getter, setter = utils.create_result_cb(str) - key: int | tuple[int, int] = qqid if group_id is None else ( - group_id, qqid) - self.waitmsg_cbs[key] = setter - try: - return getter(timeout) - finally: - if self.waitmsg_cbs.get(key) is setter: - del self.waitmsg_cbs[key] - - def api_wait_group_msg( - self, - qqid: int | str, - timeout: int = 60, - group_id: int | str | None = None, - ) -> str | None: - """Wait for one QQ member's next group message.""" - try: - qid = int(str(qqid).strip()) - except (TypeError, ValueError): - return None - if qid <= 0: - return None - gid = None - if group_id is not None: - try: - gid = int(str(group_id).strip()) - except (TypeError, ValueError): - return None - try: - wait_seconds = max(0, int(timeout)) - except (TypeError, ValueError): - wait_seconds = 60 - return self.waitMsg(qid, wait_seconds, gid) - - def on_ws_close(self, _ws, _, _2, session_id: int): - """连接关闭时按当前状态决定是否自动重连。""" - if not self._is_current_ws_session(_ws, session_id): - return - self.available = False - if self.reloaded: - return - if self._ws_reconnect_delay is None: - self._ws_reconnect_delay = 10 - self._print_cloud_status( - "群服互通 云链连接", - "关闭", - ["连接已关闭", "10 秒后尝试重连"], - level="error", - ) - - def on_player_join(self, playerf: Player): - """把玩家加入事件转发到所有启用了游戏到群的群。""" - player = playerf.name - if not self.ws: - return - for group_id, group_cfg in self.iter_game_to_group_targets(): - if group_cfg["游戏到群"]["转发玩家进退提示"]: - self.sendmsg(group_id, f"{player} 加入了游戏") - - def on_player_leave(self, playerf: Player): - """把玩家离开事件转发到所有启用了游戏到群的群。""" - player = playerf.name - if not self.ws: - return - for group_id, group_cfg in self.iter_game_to_group_targets(): - if group_cfg["游戏到群"]["转发玩家进退提示"]: - self.sendmsg(group_id, f"{player} 退出了游戏") - - def on_player_message(self, chat: Chat): - """按各群配置把游戏聊天消息转发到对应群聊。""" - if self.consume_game_binding_code(chat): - return True - - player = chat.player.name - msg = chat.msg - if not self.ws: - return - for group_id, group_cfg in self.iter_game_to_group_targets(): - can_send, filtered_msg = self.should_forward_game_message( - msg, group_cfg) - if not can_send: - continue - self.sendmsg( - group_id, - utils.simple_fmt( - {"[玩家名]": player, "[消息]": remove_cq_code(filtered_msg)}, - group_cfg["游戏到群"]["转发格式"], - ), - ) - - def execute_triggers(self, group_id: int, qqid: int, msg: str): - """对一条群消息做内置命令和外挂命令的统一分发。""" - clean_msg = msg.strip() - if self._handle_exact_trigger(group_id, qqid, clean_msg): - return True - if self._handle_prefixed_command(group_id, qqid, clean_msg): - return True - if self._handle_group_orion_triggers(group_id, qqid, clean_msg): - return True - return self._handle_external_trigger(group_id, qqid, msg) - - def _reply_to_qq(self, group_id: int, qqid: int, text: str): - """向指定 QQ 回复一条消息。""" - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {text}", - do_remove_cq_code=False, - ) - - def _handle_exact_trigger( - self, - group_id: int, - qqid: int, - clean_msg: str) -> bool: - """处理帮助、管理员菜单、背包查询等完全匹配型触发词。""" - if self._handle_binding_trigger(group_id, qqid, clean_msg): - return True - if clean_msg in self.get_group_help_triggers(group_id): - self.on_qq_help(group_id, qqid, []) - return True - if clean_msg in self.get_group_admin_menu_triggers(group_id): - if not self._has_any_group_permission( - group_id, - qqid, - ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), - ): - self._reply_permission_denied(group_id, qqid) - return True - self.qq_admin_menu(group_id, qqid) - return True - if clean_msg in self.get_group_config_menu_triggers(group_id): - return self._run_permission_action( - group_id, - qqid, - "配置配置文件权限", - lambda: self.qq_config_center_menu(group_id, qqid), - ) - if clean_msg in self.get_group_player_list_triggers(group_id): - if not self._has_group_permission(group_id, qqid, "查看玩家人数权限"): - self._reply_permission_denied(group_id, qqid) - return True - self.on_qq_player_list(group_id, qqid, []) - return True - if clean_msg in self.get_group_inventory_menu_triggers(group_id): - return self._run_permission_action( - group_id, - qqid, - "查询背包权限", - lambda: self.qq_inventory_menu(group_id, qqid), - ) - if clean_msg in self.get_group_checker_menu_triggers(group_id): - return self._run_permission_action( - group_id, - qqid, - "白名单&管理员检测权限", - lambda: self.qq_checker_menu(group_id, qqid), - ) - if clean_msg in self.get_group_task_menu_triggers(group_id): - return self._run_permission_action( - group_id, - qqid, - "任务系统权限", - lambda: self.qq_task_system_menu(group_id, qqid), - ) - if clean_msg in self.get_group_land_menu_triggers(group_id): - return self._run_permission_action( - group_id, - qqid, - "领地系统权限", - lambda: self.qq_land_system_menu(group_id, qqid), - ) - if clean_msg in self.get_group_guild_menu_triggers(group_id): - self.qq_guild_entry_menu(group_id, qqid) - return True - return False - - def _handle_prefixed_command( - self, - group_id: int, - qqid: int, - clean_msg: str, - ) -> bool: - """处理带统一前缀的群内执行指令入口。""" - cmd_prefix = self.get_group_cmd_prefix(group_id) - if not clean_msg.startswith(cmd_prefix): - return False - - args = clean_msg.removeprefix(cmd_prefix).strip().split() - if not self._has_group_permission(group_id, qqid, "发送指令权限"): - self._reply_permission_denied(group_id, qqid) - return True - if len(args) == 0: - self._reply_to_qq(group_id, qqid, f"参数错误,格式:{cmd_prefix}[指令]") - return True - - self.on_qq_execute_cmd(group_id, qqid, args) - return True - - def _handle_group_orion_triggers( - self, - group_id: int, - qqid: int, - clean_msg: str, - ) -> bool: - """处理 Orion 封禁/解封相关的前缀命令。""" - if self._handle_orion_trigger( - group_id, - qqid, - clean_msg, - self.get_group_orion_ban_triggers(group_id), - self.on_qq_orion_ban, - "[玩家名/xuid] [封禁时间] [原因可选]", - lambda args: len(args) == 0 or len(args) >= 2, - ): - return True - return self._handle_orion_trigger( - group_id, - qqid, - clean_msg, - self.get_group_orion_unban_triggers(group_id), - self.on_qq_orion_unban, - "[玩家名/xuid]", - lambda args: len(args) in (0, 1), - ) - - def _handle_orion_trigger( - self, - group_id: int, - qqid: int, - clean_msg: str, - triggers: list[str], - handler, - args_hint: str, - args_validator, - ) -> bool: - """处理一组 Orion 触发词。""" - for trigger in triggers: - if not clean_msg.startswith(trigger): - continue - args = clean_msg.removeprefix(trigger).strip().split() - if not self._has_group_permission(group_id, qqid, "封禁/解封玩家权限"): - self._reply_permission_denied(group_id, qqid) - return True - if not args_validator(args): - self._reply_to_qq( - group_id, qqid, f"参数错误,格式:{trigger} {args_hint}") - return True - handler(group_id, qqid, args) - return True - return False - - def _handle_external_trigger( - self, - group_id: int, - qqid: int, - msg: str) -> bool: - """处理外部插件注册进来的自定义触发词。""" - for trigger in self.triggers: - matched = trigger.match(msg) - if not matched: - continue - - if trigger.op_only and not self.is_group_admin(group_id, qqid): - self._reply_permission_denied(group_id, qqid) - return True - - args = msg.removeprefix(matched).strip().split() - if not trigger.args_pd(len(args)): - self._reply_trigger_arg_error( - group_id, - qqid, - matched, - trigger.argument_hint, - ) - return True - - if trigger.accept_group: - trigger.func(group_id, qqid, args) - else: - trigger.func(qqid, args) - return True - return False - - def _reply_permission_denied(self, group_id: int, qqid: int): - """统一处理没有管理权限时的回复。""" - if easter_egg := EASTER_EGG_QQIDS.get(qqid): - _name, nickname = easter_egg - self._reply_to_qq(group_id, qqid, f"你没有权限执行此指令,即使你是 {nickname}..") - return - self._reply_to_qq(group_id, qqid, "你没有权限执行此指令") - - def _reply_trigger_arg_error( - self, - group_id: int, - qqid: int, - trigger: str, - argument_hint: str | None, - ): - """统一处理外部触发器参数不足时的回复。""" - suffix = f" {argument_hint}" if argument_hint else "" - self._reply_to_qq(group_id, qqid, f"参数错误,格式:{trigger}{suffix}") - - def _has_group_permission( - self, - group_id: int, - qqid: int, - permission_name: str) -> bool: - if hasattr(self, "has_group_permission"): - return self.has_group_permission(group_id, qqid, permission_name) - return self.is_group_admin(group_id, qqid) - - def _has_any_group_permission( - self, - group_id: int, - qqid: int, - permission_names: tuple[str, ...], - ) -> bool: - return any( - self._has_group_permission(group_id, qqid, permission_name) - for permission_name in permission_names - ) - - def _run_permission_action( - self, - group_id: int, - qqid: int, - permission_name: str, - action, - ) -> bool: - """执行按配置权限控制的动作。""" - if not self._has_group_permission(group_id, qqid, permission_name): - self._reply_permission_denied(group_id, qqid) - return True - action() - return True - - def _run_admin_only_action(self, group_id: int, qqid: int, action) -> bool: - """执行仅群管理员可用的动作。""" - if not self.is_group_admin(group_id, qqid): - self._reply_to_qq(group_id, qqid, "你没有权限执行此指令") - return True - action() - return True - - def on_sendmsg_test(self, args: list[str]): - """供控制台快速验证群消息发送链路是否正常。""" - if not self.ws: - self.print_console_error("还没有连接到群服互通云链版Ultra版") - return - if not args: - self.print_console_error("请输入要发送的消息") - return - target_group = None - if len(args) >= 2: - maybe_gid = utils.try_int(args[0]) - if maybe_gid in self.group_cfgs: - target_group = maybe_gid - args = args[1:] - if target_group is not None: - self.sendmsg(target_group, " ".join(args)) - return - for group_id in self.group_order: - self.sendmsg(group_id, " ".join(args)) - - def sendmsg(self, group: int, msg: str, do_remove_cq_code=True): - """向目标群发消息。 - 这里顺手处理了两件事: - - 在还没连上云链时直接忽略发送,避免抛异常 - - at 消息后面补换行,让群里显示更自然 - """ - if self.ws is None: - raise RuntimeError("WebSocket 尚未初始化") - if not self.available: - self._print_cloud_status( - "群服互通 云链连接", - "忽略发送", - ["当前未连接云链", f"已忽略发送到群 {group} 的消息"], - level="warn", - ) - return - if msg.startswith("[CQ:at,qq="): - cq_end = msg.find("]") - if cq_end != -1: - head = msg[: cq_end + 1] - tail = msg[cq_end + 1:].lstrip() - msg = head if tail == "" else head + "\n" + tail - if do_remove_cq_code: - msg = remove_cq_code(msg) - payload = { - "action": "send_group_msg", - "params": {"group_id": group, "message": msg}, - } - self.ws.send(json.dumps(payload)) - - def api_send_group_msg( - self, - group_id: int | str, - message: str, - remove_cq_code: bool = True, - ) -> tuple[bool, str]: - """Send a QQ group message and return a stable result tuple.""" - try: - gid = int(str(group_id).strip()) - except (TypeError, ValueError): - return False, "群号无效" - if gid <= 0: - return False, "群号无效" - if self.ws is None: - return False, "WebSocket 尚未初始化" - if not self.available: - return False, "云链当前未连接" - try: - self.sendmsg( - gid, - str(message), - do_remove_cq_code=bool(remove_cq_code)) - except Exception as err: - return False, f"发送群消息失败: {err}" - return True, "已发送群消息" - - def api_reply_group_member( - self, - group_id: int | str, - qqid: int | str, - message: str, - ) -> tuple[bool, str]: - """Reply to a QQ group member with an at-mention.""" - try: - qid = int(str(qqid).strip()) - except (TypeError, ValueError): - return False, "QQ号无效" - if qid <= 0: - return False, "QQ号无效" - return self.api_send_group_msg( - group_id, - f"[CQ:at,qq={qid}] {message}", - remove_cq_code=False, - ) - - def api_send_private_msg( - self, - qqid: int | str, - message: str, - ) -> tuple[bool, str]: - """Send a private QQ message and return a stable result tuple.""" - try: - qid = int(str(qqid).strip()) - except (TypeError, ValueError): - return False, "QQ号无效" - if qid <= 0: - return False, "QQ号无效" - if self.ws is None: - return False, "WebSocket 尚未初始化" - if not self.available: - return False, "云链当前未连接" - try: - self.send_private_msg(qid, str(message)) - except Exception as err: - return False, f"发送私信失败: {err}" - return True, "已发送私信" +"""Runtime websocket and message forwarding logic for Ultra.""" + +import json +import time +from copy import deepcopy +from typing import Any + +try: + import websocket +except ImportError: + from . import websocket + +from tooldelta import Chat, InternalBroadcast, Player, utils + +from .message_utils import ( + EASTER_EGG_QQIDS, + remove_color, + remove_cq_code, + replace_cq, +) + +try: + from tooldelta.utils.mc_translator import translate +except ImportError: + translate = None + + +# 运行时层只管消息流转:执行指令、WebSocket、广播、群服互通分发。 +class QQLinkerRuntimeMixin: + """负责云链运行时、消息分发与 WebSocket 生命周期。""" + + def _start_ws_session(self): + """注册一个新的 WebSocket 会话编号,并清空上次重连状态。""" + self._ws_session_id += 1 + self.reloaded = False + self._ws_reconnect_delay = None + return self._ws_session_id + + def _is_current_ws_session(self, ws_obj, session_id: int): + """判断回调是否来自当前仍然有效的 WebSocket 会话。""" + return session_id == self._ws_session_id and ws_obj is self.ws + + def _print_cloud_status( + self, + title: str, + page_label: str, + lines: list[str], + level: str = "info", + ): + """按统一的控制台卡片样式输出云链连接状态。""" + self.print_console_card(title, page_label, lines, level=level) + + def execute_cmd_and_get_zhcn_cb(self, cmd: str): + """执行 MC 指令,并把原始返回整理成适合群聊展示的文本。""" + try: + result = self.game_ctrl.sendwscmd_with_resp(cmd, 10) + if len(result.OutputMessages) == 0: + return ["😅 指令执行失败", "😄 指令执行成功"][bool(result.SuccessCount)] + if result.OutputMessages[0].Message in ( + "commands.generic.syntax", + "commands.generic.unknown", + ): + return f'😅 未知的 MC 指令, 可能是指令格式有误: "{cmd}"' + if translate is not None: + output_text = "\n".join( + translate( + i.Message, + i.Parameters) for i in result.OutputMessages) + else: + output_text = "\n".join( + i.Message for i in result.OutputMessages) + if result.SuccessCount: + return "😄 指令执行成功,执行结果:\n" + output_text + return "😭 指令执行失败,原因:\n" + output_text + except IndexError as exec_err: + import traceback + + traceback.print_exc() + return f"执行出现问题: {exec_err}" + except TimeoutError: + return "😭 超时:指令获取结果返回超时" + + def iter_game_to_group_targets(self): + """遍历当前启用了“游戏到群”转发的群。""" + for group_id in self.group_order: + group_cfg = self.group_cfgs[group_id] + if group_cfg["游戏到群"]["是否启用"]: + yield group_id, group_cfg + + @staticmethod + def should_forward_game_message(msg: str, group_cfg: dict[str, Any]): + """根据群配置判断一条游戏消息是否要转发,以及转发时应裁掉哪些前缀。""" + trans_chars = group_cfg["游戏到群"]["仅转发以下符号开头的消息(列表为空则全部转发)"] + block_prefixs = group_cfg["游戏到群"]["屏蔽以下字符串开头的消息"] + if trans_chars: + for prefix in trans_chars: + if msg.startswith(prefix): + return True, msg[len(prefix):] + return False, msg + if block_prefixs: + for prefix in block_prefixs: + if msg.startswith(prefix): + return False, msg + return True, msg + + @utils.thread_func("云链群服连接进程") + def connect_to_websocket(self): + """按当前配置或本地桥接参数建立到云链的连接。""" + with self._ws_runner_lock: + if self._ws_runner_active: + self._print_cloud_status( + "群服互通 云链连接", + "运行中", + ["云链连接线程已在运行", "本次重复连接请求已忽略"], + level="warn", + ) + return + self._ws_runner_active = True + + try: + while True: + target = self._get_websocket_target() + header = None + validate_code = self.cfg["云链设置"]["校验码"].strip() + if validate_code: + header = {"Authorization": f"Bearer {validate_code}"} + self._print_cloud_status( + "群服互通 云链连接", + "连接中", + ["正在尝试连接云链", f"目标地址: {target}"], + level="info", + ) + session_id = self._start_ws_session() + ws_app = websocket.WebSocketApp( + target, header, on_message=lambda a, b, sid=session_id: self.on_ws_message( + a, b, sid) and None, on_error=lambda a, b, sid=session_id: self.on_ws_error( + a, b, sid), on_close=lambda a, b, c, sid=session_id: self.on_ws_close( + a, b, c, sid), ) + ws_app.on_open = lambda ws_obj, sid=session_id: self.on_ws_open( + ws_obj, sid) + self.ws = ws_app + self.available = False + ws_app.run_forever() + + delay = self._ws_reconnect_delay + if delay is None: + break + time.sleep(delay) + finally: + with self._ws_runner_lock: + self._ws_runner_active = False + + def _get_websocket_target(self): + """返回当前应连接的 WebSocket 地址。""" + if self._manual_launch: + return f"ws://127.0.0.1:{self._manual_launch_port}" + return self.cfg["云链设置"]["地址"] + + def api_get_status(self) -> dict[str, Any]: + """Return a compact runtime status snapshot for external plugins.""" + try: + websocket_target = self._get_websocket_target() + except Exception: + websocket_target = "" + return { + "available": bool(self.available), + "ws_initialized": self.ws is not None, + "websocket_target": websocket_target, + "manual_launch": bool(self._manual_launch), + "manual_launch_port": int(self._manual_launch_port), + "reloaded": bool(self.reloaded), + "reconnect_delay": self._ws_reconnect_delay, + "session_id": int(self._ws_session_id), + "linked_groups": list(self.group_order), + "default_group": self.linked_group, + } + + def api_get_online_players(self) -> list[str]: + """Return a copy of current online player names.""" + game_ctrl = getattr(self, "game_ctrl", None) + if game_ctrl is None: + return [] + raw_players = getattr(game_ctrl, "allplayers", []) + try: + players = list(raw_players) + except TypeError: + return [] + result: list[str] = [] + for player in players: + name = getattr(player, "name", player) + name = str(name).strip() + if name: + result.append(name) + return result + + def api_is_player_online( + self, + player_name: str, + ignore_case: bool = False, + ) -> bool: + """Return whether a player name is currently online.""" + name = str(player_name).strip() + if not name: + return False + players = self.api_get_online_players() + if ignore_case: + name = name.lower() + return any(player.lower() == name for player in players) + return name in players + + def api_execute_game_cmd(self, command: str) -> tuple[bool, str]: + """Execute an MC command and return a stable result tuple.""" + cmd = str(command).strip() + if not cmd: + return False, "MC指令不能为空" + if getattr(self, "game_ctrl", None) is None: + return False, "游戏控制器不可用" + try: + result = self.execute_cmd_and_get_zhcn_cb(cmd) + except Exception as err: + return False, f"MC指令执行失败: {err}" + message = "\n".join(result) if isinstance( + result, list) else str(result) + fail_markers = ( + "指令执行失败", + "未知的 MC 指令", + "执行出现问题", + "超时", + ) + return not any(marker in message for marker in fail_markers), message + + def api_get_game_to_group_targets( + self, + enabled_only: bool = True, + ) -> list[dict[str, Any]]: + """Return game-to-group forwarding rules for configured groups.""" + targets: list[dict[str, Any]] = [] + for group_id in self.group_order: + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is None: + continue + game_to_group = group_cfg["游戏到群"] + enabled = bool(game_to_group["是否启用"]) + if enabled_only and not enabled: + continue + targets.append( + { + "group_id": group_id, + "enabled": enabled, + "format": str(game_to_group["转发格式"]), + "required_prefixes": list( + game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"] + ), + "blocked_prefixes": list(game_to_group["屏蔽以下字符串开头的消息"]), + "forward_player_events": bool(game_to_group["转发玩家进退提示"]), + "config": deepcopy(group_cfg), + } + ) + return targets + + def api_should_forward_game_message( + self, + group_id: int | str, + message: str, + ) -> tuple[bool, str] | None: + """Preview whether a game chat message would be forwarded to a group.""" + try: + gid = int(str(group_id).strip()) + except (TypeError, ValueError): + return None + group_cfg = self.group_cfgs.get(gid) + if group_cfg is None: + return None + msg = str(message) + if not group_cfg["游戏到群"]["是否启用"]: + return False, msg + return self.should_forward_game_message(msg, group_cfg) + + def reload_websocket_connection(self): + """让云链连接按当前配置重新建立。""" + self.reloaded = True + self._ws_reconnect_delay = None + self.available = False + self._ws_session_id += 1 + ws_obj = self.ws + if ws_obj is not None: + try: + ws_obj.close() + except Exception as err: + self._print_cloud_status( + "群服互通 云链连接", + "重载", + [f"关闭旧连接失败: {err}", "将继续尝试使用新配置连接"], + level="warn", + ) + self.ws = None + if not self._manual_launch: + self.connect_to_websocket() + + def api_reload_websocket(self) -> tuple[bool, str]: + """Request the cloud WebSocket connection to reload.""" + try: + self.reload_websocket_connection() + except Exception as err: + return False, f"云链重载失败: {err}" + return True, "已请求云链重载" + + def _get_message_listener_store(self): + """Return the raw group message listener registry.""" + if not hasattr(self, "_message_listeners") or not isinstance( + self._message_listeners, dict + ): + self._message_listeners = {} + return self._message_listeners + + def api_register_message_listener( + self, name: str, listener) -> tuple[bool, str]: + """Register a raw group message listener callback.""" + listener_name = str(name).strip() + if not listener_name: + return False, "监听器名称不能为空" + if not callable(listener): + return False, "监听器必须是可调用对象" + listeners = self._get_message_listener_store() + if listener_name in listeners: + return False, "监听器已存在" + listeners[listener_name] = listener + return True, "已注册原始群消息监听器" + + def api_unregister_message_listener(self, name: str) -> tuple[bool, str]: + """Unregister a raw group message listener callback.""" + listener_name = str(name).strip() + if not listener_name: + return False, "监听器名称不能为空" + listeners = self._get_message_listener_store() + if listener_name not in listeners: + return False, "监听器不存在" + del listeners[listener_name] + return True, "已注销原始群消息监听器" + + def api_get_message_listeners(self) -> list[dict[str, Any]]: + """Return metadata for registered raw group message listeners.""" + return [ + {"name": name, "callable": callable(listener)} + for name, listener in self._get_message_listener_store().items() + ] + + def _stop_when_message_listener_handled( + self, data: dict[str, Any]) -> bool: + """Run registered raw message listeners; truthy return stops processing.""" + for name, listener in list(self._get_message_listener_store().items()): + try: + if listener(deepcopy(data)): + return True + except Exception as err: + if hasattr(self, "_print_cloud_status"): + self._print_cloud_status( + "群服互通 原始消息监听", + "监听器异常", + [f"{name}: {err}"], + level="warn", + ) + return False + + @utils.thread_func("云链群服消息广播进程") + def broadcast(self, data): + """把原始群消息广播给主动注册的其他插件。""" + for plugin_name in self.plugin: + self.GetPluginAPI(plugin_name).QQLinker_message(data) + + def on_ws_open(self, _ws, session_id: int): + """在 WebSocket 建立后标记连接可用。""" + if not self._is_current_ws_session(_ws, session_id): + return + self.available = True + self._print_cloud_status( + "群服互通 云链连接", + "已连接", + ["已成功连接到群服互通云链版Ultra版", f"当前地址: {self._get_websocket_target()}"], + level="success", + ) + + @utils.thread_func("群服互通消息接收线程") + def on_ws_message(self, _ws, message, session_id: int): + """处理来自云链的群消息,并按配置分发到不同入口。""" + if not self._is_current_ws_session(_ws, session_id): + return + data = json.loads(message) + if self._stop_when_data_broadcast_handled(data): + return + + payload = self._build_group_message_payload(data) + if payload is None: + return + + group_id, group_cfg, msg, user_id, nickname = payload + if self._consume_waiting_reply(group_id, user_id, msg): + return + if self._stop_when_group_broadcast_handled( + group_id, user_id, nickname, msg): + return + if self.execute_triggers(group_id, user_id, msg): + return + self._forward_group_message_to_game(group_cfg, user_id, nickname, msg) + + def _stop_when_data_broadcast_handled(self, data: dict[str, Any]) -> bool: + """把原始数据广播给框架,其它插件声明已处理时立即停止后续流程。""" + bc_recv = self.BroadcastEvent(InternalBroadcast("群服互通/数据json", data)) + if any(bc_recv): + return True + if data.get("post_type") != "message" or data.get( + "message_type") != "group": + return True + if self._stop_when_message_listener_handled(data): + return True + self.broadcast(data) + return False + + def _build_group_message_payload(self, data: dict[str, Any]): + """把云链原始消息整理成后续逻辑统一使用的结构。""" + group_id = data.get("group_id") + if group_id not in self.group_cfgs: + return None + group_cfg = self.group_cfgs[group_id] + msg = self._extract_text_message(data["message"]) + user_id = int(data["sender"]["user_id"]) + nickname = data["sender"]["card"] or data["sender"]["nickname"] + return group_id, group_cfg, msg, user_id, nickname + + @staticmethod + def _extract_text_message(msg: Any) -> str: + """从云链消息结构里提取可处理的纯文本。""" + if isinstance(msg, list): + msg_rawdict = msg[0] + msg_type = msg_rawdict["type"] + msg_data = msg_rawdict["data"] + if msg_type != "text": + return "" + return msg_data["text"] + if not isinstance(msg, str): + raise ValueError(f"键 'message' 值不是字符串类型, 而是 {msg}") + return msg + + def _consume_waiting_reply( + self, + group_id: int, + user_id: int, + msg: str) -> bool: + """把当前消息投递给等待输入的菜单回调。""" + wait_key = (group_id, user_id) + cb = self.waitmsg_cbs.pop(wait_key, None) + if cb is not None: + cb(msg) + return True + cb = self.waitmsg_cbs.pop(user_id, None) + if cb is not None: + cb(msg) + return True + return False + + def _stop_when_group_broadcast_handled( + self, + group_id: int, + user_id: int, + nickname: str, + msg: str, + ) -> bool: + """把群消息广播给框架层,其它插件声明已处理时立即停止。""" + bc_recv = self.BroadcastEvent( + InternalBroadcast( + "群服互通/链接群消息", + {"群号": group_id, "QQ号": user_id, "昵称": nickname, "消息": msg}, + ), + ) + return any(bc_recv) + + def _forward_group_message_to_game( + self, + group_cfg: dict[str, Any], + user_id: int, + nickname: str, + msg: str, + ): + """把普通群消息按当前群配置转发到游戏内。""" + if not group_cfg["群到游戏"]["是否启用"]: + return + if user_id in group_cfg["群到游戏"]["屏蔽的QQ号"]: + return + trans_chars = group_cfg["群到游戏"]["仅转发以下符号开头的消息(列表为空则全部转发)"] + if trans_chars: + matched_prefix = None + for prefix in trans_chars: + if msg.startswith(prefix): + matched_prefix = prefix + break + if matched_prefix is None: + return + msg = msg[len(matched_prefix):] + + if group_cfg["群到游戏"]["替换花里胡哨的昵称"]: + nickname = remove_color(nickname) + if group_cfg["群到游戏"]["替换花里胡哨的消息"]: + msg = remove_color(msg) + self.game_ctrl.say_to( + "@a", + utils.simple_fmt( + {"[昵称]": nickname, "[消息]": replace_cq(msg)}, + group_cfg["群到游戏"]["转发格式"], + ), + ) + + def on_ws_error(self, _ws, error, session_id: int): + """处理 WebSocket 错误并按配置尝试重连。""" + if not self._is_current_ws_session(_ws, session_id): + return + if not isinstance(error, Exception): + # 某些 WebSocket 实现会在连接仍然可用时回调空字符串/None。 + # 这类“空错误”没有实际诊断价值,也不代表连接真的断开。 + if error is None or (isinstance(error, str) + and error.strip() == ""): + return + self._print_cloud_status( + "群服互通 云链连接", + "停止", + [f"连接线程已结束: {error}", "收到非异常错误对象,已停止重连"], + level="info", + ) + self.reloaded = True + self._ws_reconnect_delay = None + return + self.available = False + self._ws_reconnect_delay = 15 + self._print_cloud_status( + "群服互通 云链连接", + "异常", + [f"连接失败: {error}", "15 秒后尝试重连"], + level="error", + ) + + def waitMsg( + self, + qqid: int, + timeout=60, + group_id: int | None = None) -> str | None: + """等待某个 QQ 在指定群里的下一条回复。 + 带 `group_id` 时只收同群回复,不带时保留对旧插件的兼容行为。 + """ + getter, setter = utils.create_result_cb(str) + key: int | tuple[int, int] = qqid if group_id is None else ( + group_id, qqid) + self.waitmsg_cbs[key] = setter + try: + return getter(timeout) + finally: + if self.waitmsg_cbs.get(key) is setter: + del self.waitmsg_cbs[key] + + def api_wait_group_msg( + self, + qqid: int | str, + timeout: int = 60, + group_id: int | str | None = None, + ) -> str | None: + """Wait for one QQ member's next group message.""" + try: + qid = int(str(qqid).strip()) + except (TypeError, ValueError): + return None + if qid <= 0: + return None + gid = None + if group_id is not None: + try: + gid = int(str(group_id).strip()) + except (TypeError, ValueError): + return None + try: + wait_seconds = max(0, int(timeout)) + except (TypeError, ValueError): + wait_seconds = 60 + return self.waitMsg(qid, wait_seconds, gid) + + def on_ws_close(self, _ws, _, _2, session_id: int): + """连接关闭时按当前状态决定是否自动重连。""" + if not self._is_current_ws_session(_ws, session_id): + return + self.available = False + if self.reloaded: + return + if self._ws_reconnect_delay is None: + self._ws_reconnect_delay = 10 + self._print_cloud_status( + "群服互通 云链连接", + "关闭", + ["连接已关闭", "10 秒后尝试重连"], + level="error", + ) + + def on_player_join(self, playerf: Player): + """把玩家加入事件转发到所有启用了游戏到群的群。""" + player = playerf.name + if not self.ws: + return + for group_id, group_cfg in self.iter_game_to_group_targets(): + if group_cfg["游戏到群"]["转发玩家进退提示"]: + self.sendmsg(group_id, f"{player} 加入了游戏") + + def on_player_leave(self, playerf: Player): + """把玩家离开事件转发到所有启用了游戏到群的群。""" + player = playerf.name + if not self.ws: + return + for group_id, group_cfg in self.iter_game_to_group_targets(): + if group_cfg["游戏到群"]["转发玩家进退提示"]: + self.sendmsg(group_id, f"{player} 退出了游戏") + + def on_player_message(self, chat: Chat): + """按各群配置把游戏聊天消息转发到对应群聊。""" + if self.consume_game_binding_code(chat): + return True + + player = chat.player.name + msg = chat.msg + if not self.ws: + return + for group_id, group_cfg in self.iter_game_to_group_targets(): + can_send, filtered_msg = self.should_forward_game_message( + msg, group_cfg) + if not can_send: + continue + self.sendmsg( + group_id, + utils.simple_fmt( + {"[玩家名]": player, "[消息]": remove_cq_code(filtered_msg)}, + group_cfg["游戏到群"]["转发格式"], + ), + ) + + def execute_triggers(self, group_id: int, qqid: int, msg: str): + """对一条群消息做内置命令和外挂命令的统一分发。""" + clean_msg = msg.strip() + if self._handle_exact_trigger(group_id, qqid, clean_msg): + return True + if self._handle_prefixed_command(group_id, qqid, clean_msg): + return True + if self._handle_group_orion_triggers(group_id, qqid, clean_msg): + return True + return self._handle_external_trigger(group_id, qqid, msg) + + def _reply_to_qq(self, group_id: int, qqid: int, text: str): + """向指定 QQ 回复一条消息。""" + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False, + ) + + def _handle_exact_trigger( + self, + group_id: int, + qqid: int, + clean_msg: str) -> bool: + """处理帮助、管理员菜单、背包查询等完全匹配型触发词。""" + if self._handle_binding_trigger(group_id, qqid, clean_msg): + return True + if clean_msg in self.get_group_help_triggers(group_id): + self.on_qq_help(group_id, qqid, []) + return True + if clean_msg in self.get_group_admin_menu_triggers(group_id): + if not self._has_any_group_permission( + group_id, + qqid, + ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), + ): + self._reply_permission_denied(group_id, qqid) + return True + self.qq_admin_menu(group_id, qqid) + return True + if clean_msg in self.get_group_config_menu_triggers(group_id): + return self._run_permission_action( + group_id, + qqid, + "配置配置文件权限", + lambda: self.qq_config_center_menu(group_id, qqid), + ) + if clean_msg in self.get_group_player_list_triggers(group_id): + if not self._has_group_permission(group_id, qqid, "查看玩家人数权限"): + self._reply_permission_denied(group_id, qqid) + return True + self.on_qq_player_list(group_id, qqid, []) + return True + if clean_msg in self.get_group_inventory_menu_triggers(group_id): + return self._run_permission_action( + group_id, + qqid, + "查询背包权限", + lambda: self.qq_inventory_menu(group_id, qqid), + ) + if clean_msg in self.get_group_checker_menu_triggers(group_id): + return self._run_permission_action( + group_id, + qqid, + "白名单&管理员检测权限", + lambda: self.qq_checker_menu(group_id, qqid), + ) + if clean_msg in self.get_group_task_menu_triggers(group_id): + return self._run_permission_action( + group_id, + qqid, + "任务系统权限", + lambda: self.qq_task_system_menu(group_id, qqid), + ) + if clean_msg in self.get_group_land_menu_triggers(group_id): + return self._run_permission_action( + group_id, + qqid, + "领地系统权限", + lambda: self.qq_land_system_menu(group_id, qqid), + ) + if clean_msg in self.get_group_guild_menu_triggers(group_id): + self.qq_guild_entry_menu(group_id, qqid) + return True + return False + + def _handle_prefixed_command( + self, + group_id: int, + qqid: int, + clean_msg: str, + ) -> bool: + """处理带统一前缀的群内执行指令入口。""" + cmd_prefix = self.get_group_cmd_prefix(group_id) + if not clean_msg.startswith(cmd_prefix): + return False + + args = clean_msg.removeprefix(cmd_prefix).strip().split() + if not self._has_group_permission(group_id, qqid, "发送指令权限"): + self._reply_permission_denied(group_id, qqid) + return True + if len(args) == 0: + self._reply_to_qq(group_id, qqid, f"参数错误,格式:{cmd_prefix}[指令]") + return True + + self.on_qq_execute_cmd(group_id, qqid, args) + return True + + def _handle_group_orion_triggers( + self, + group_id: int, + qqid: int, + clean_msg: str, + ) -> bool: + """处理 Orion 封禁/解封相关的前缀命令。""" + if self._handle_orion_trigger( + group_id, + qqid, + clean_msg, + self.get_group_orion_ban_triggers(group_id), + self.on_qq_orion_ban, + "[玩家名/xuid] [封禁时间] [原因可选]", + lambda args: len(args) == 0 or len(args) >= 2, + ): + return True + return self._handle_orion_trigger( + group_id, + qqid, + clean_msg, + self.get_group_orion_unban_triggers(group_id), + self.on_qq_orion_unban, + "[玩家名/xuid]", + lambda args: len(args) in (0, 1), + ) + + def _handle_orion_trigger( + self, + group_id: int, + qqid: int, + clean_msg: str, + triggers: list[str], + handler, + args_hint: str, + args_validator, + ) -> bool: + """处理一组 Orion 触发词。""" + for trigger in triggers: + if not clean_msg.startswith(trigger): + continue + args = clean_msg.removeprefix(trigger).strip().split() + if not self._has_group_permission(group_id, qqid, "封禁/解封玩家权限"): + self._reply_permission_denied(group_id, qqid) + return True + if not args_validator(args): + self._reply_to_qq( + group_id, qqid, f"参数错误,格式:{trigger} {args_hint}") + return True + handler(group_id, qqid, args) + return True + return False + + def _handle_external_trigger( + self, + group_id: int, + qqid: int, + msg: str) -> bool: + """处理外部插件注册进来的自定义触发词。""" + for trigger in self.triggers: + matched = trigger.match(msg) + if not matched: + continue + + if trigger.op_only and not self.is_group_admin(group_id, qqid): + self._reply_permission_denied(group_id, qqid) + return True + + args = msg.removeprefix(matched).strip().split() + if not trigger.args_pd(len(args)): + self._reply_trigger_arg_error( + group_id, + qqid, + matched, + trigger.argument_hint, + ) + return True + + if trigger.accept_group: + trigger.func(group_id, qqid, args) + else: + trigger.func(qqid, args) + return True + return False + + def _reply_permission_denied(self, group_id: int, qqid: int): + """统一处理没有管理权限时的回复。""" + if easter_egg := EASTER_EGG_QQIDS.get(qqid): + _name, nickname = easter_egg + self._reply_to_qq(group_id, qqid, f"你没有权限执行此指令,即使你是 {nickname}..") + return + self._reply_to_qq(group_id, qqid, "你没有权限执行此指令") + + def _reply_trigger_arg_error( + self, + group_id: int, + qqid: int, + trigger: str, + argument_hint: str | None, + ): + """统一处理外部触发器参数不足时的回复。""" + suffix = f" {argument_hint}" if argument_hint else "" + self._reply_to_qq(group_id, qqid, f"参数错误,格式:{trigger}{suffix}") + + def _has_group_permission( + self, + group_id: int, + qqid: int, + permission_name: str) -> bool: + """Implement the has group permission operation.""" + if hasattr(self, "has_group_permission"): + return self.has_group_permission(group_id, qqid, permission_name) + return self.is_group_admin(group_id, qqid) + + def _has_any_group_permission( + self, + group_id: int, + qqid: int, + permission_names: tuple[str, ...], + ) -> bool: + """Implement the has any group permission operation.""" + return any( + self._has_group_permission(group_id, qqid, permission_name) + for permission_name in permission_names + ) + + def _run_permission_action( + self, + group_id: int, + qqid: int, + permission_name: str, + action, + ) -> bool: + """执行按配置权限控制的动作。""" + if not self._has_group_permission(group_id, qqid, permission_name): + self._reply_permission_denied(group_id, qqid) + return True + action() + return True + + def _run_admin_only_action(self, group_id: int, qqid: int, action) -> bool: + """执行仅群管理员可用的动作。""" + if not self.is_group_admin(group_id, qqid): + self._reply_to_qq(group_id, qqid, "你没有权限执行此指令") + return True + action() + return True + + def on_sendmsg_test(self, args: list[str]): + """供控制台快速验证群消息发送链路是否正常。""" + if not self.ws: + self.print_console_error("还没有连接到群服互通云链版Ultra版") + return + if not args: + self.print_console_error("请输入要发送的消息") + return + target_group = None + if len(args) >= 2: + maybe_gid = utils.try_int(args[0]) + if maybe_gid in self.group_cfgs: + target_group = maybe_gid + args = args[1:] + if target_group is not None: + self.sendmsg(target_group, " ".join(args)) + return + for group_id in self.group_order: + self.sendmsg(group_id, " ".join(args)) + + def sendmsg(self, group: int, msg: str, do_remove_cq_code=True): + """向目标群发消息。 + 这里顺手处理了两件事: + - 在还没连上云链时直接忽略发送,避免抛异常 + - at 消息后面补换行,让群里显示更自然 + """ + if self.ws is None: + raise RuntimeError("WebSocket 尚未初始化") + if not self.available: + self._print_cloud_status( + "群服互通 云链连接", + "忽略发送", + ["当前未连接云链", f"已忽略发送到群 {group} 的消息"], + level="warn", + ) + return + if msg.startswith("[CQ:at,qq="): + cq_end = msg.find("]") + if cq_end != -1: + head = msg[: cq_end + 1] + tail = msg[cq_end + 1:].lstrip() + msg = head if tail == "" else head + "\n" + tail + if do_remove_cq_code: + msg = remove_cq_code(msg) + payload = { + "action": "send_group_msg", + "params": {"group_id": group, "message": msg}, + } + self.ws.send(json.dumps(payload)) + + def api_send_group_msg( + self, + group_id: int | str, + message: str, + remove_cq_code: bool = True, + ) -> tuple[bool, str]: + """Send a QQ group message and return a stable result tuple.""" + try: + gid = int(str(group_id).strip()) + except (TypeError, ValueError): + return False, "群号无效" + if gid <= 0: + return False, "群号无效" + if self.ws is None: + return False, "WebSocket 尚未初始化" + if not self.available: + return False, "云链当前未连接" + try: + self.sendmsg( + gid, + str(message), + do_remove_cq_code=bool(remove_cq_code)) + except Exception as err: + return False, f"发送群消息失败: {err}" + return True, "已发送群消息" + + def api_reply_group_member( + self, + group_id: int | str, + qqid: int | str, + message: str, + ) -> tuple[bool, str]: + """Reply to a QQ group member with an at-mention.""" + try: + qid = int(str(qqid).strip()) + except (TypeError, ValueError): + return False, "QQ号无效" + if qid <= 0: + return False, "QQ号无效" + return self.api_send_group_msg( + group_id, + f"[CQ:at,qq={qid}] {message}", + remove_cq_code=False, + ) + + def api_send_private_msg( + self, + qqid: int | str, + message: str, + ) -> tuple[bool, str]: + """Send a private QQ message and return a stable result tuple.""" + try: + qid = int(str(qqid).strip()) + except (TypeError, ValueError): + return False, "QQ号无效" + if qid <= 0: + return False, "QQ号无效" + if self.ws is None: + return False, "WebSocket 尚未初始化" + if not self.available: + return False, "云链当前未连接" + try: + self.send_private_msg(qid, str(message)) + except Exception as err: + return False, f"发送私信失败: {err}" + return True, "已发送私信" diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/__init__.cpython-313.pyc" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/__init__.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..59760d65a7d01dc31d7a3e58db945dded6a51cae GIT binary patch literal 1293 zcmZuw&2Jk;6rXkc5ynXiDlH;J=qcsUpu`DjD^Nj5b`sQW8av7+jVf7Mjo0J#i1o}e zGsZQia04X7mpD-M$b}OJE+~gc{S$x#u!=+_DoSpk_QH*~w(ACok#^?2d2fI3_de#~ z^fV>74nF!*+?^og_f&bj@+0BlAqrm-k7(jKUS`92H3O+Mv5~npp|jU=IzP}e8-QVO`**2Y(smU_Kji49nmln$4o zVGd^Ly1Tkw_0|`WDW$tI;>LjH?{tO61RH%|G4cpl1KCBa2Us2!YK}ImQl336tdQXX z7}c^pra5g2W3^~>?SBe1;trVUNe3(ulQ38I!1d;!QubVLf$q6GS8KaFuvgyRE?0Nl zbq{LWuv)9GxjSyHirQOHuHJ+Vx4O0f9Ls}U;J0F(az}z9Egf%`sK?Xdj@vS5ZmOG@ z2cjhc%t(e^7IFyHyIf051Y)i`!lcbJNUcdD(Gix}f&Qf5<5nD}<2mmn&dc+eWNJKC z1zL$HY^jc{-as2B1>7?NMO%?cllJi+Zwvw!mW!71QN~ZmjAUZd^ zI|+q91xoXz=nuywrIt+kkqSdh0I`Qtj&>iDN;APgg)mYL7MX+^%d|D=P^C0j6!|{# z&q~(eCQWAfQ3v<+EA`FQ`gy1*Ti;PCTH}#r^{uFjF*cdTSG3r{+54ZH2Zvw(ap&Xv z2cIAB-@bqF+3^>L4?g^0H?o@HSsy>b`iZBROuo@k&2Gfs)aNjE%EHue`)k5+oWC>A zIN3)p5$Ei0qqLth3r)%H(-}v8q iKQER)x%8F$1FgJY_~%DjF-64u?)mae_U_Yp-2Vk_B$6%w literal 0 HcmV?d00001 diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_abnf.cpython-313.pyc" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_abnf.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..1740ee8ab5b4c6152b4e2c53150ff3352b4bf72c GIT binary patch literal 20595 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 Gi2pBy`Z*H- literal 0 HcmV?d00001 diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_app.cpython-313.pyc" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_app.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..2b81ce0a375b99b3a62e64421fee77136ae56a03 GIT binary patch literal 31309 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 literal 0 HcmV?d00001 diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_cookiejar.cpython-313.pyc" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_cookiejar.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..eac85c60b4d83565c0cab06e7eafa4f9a04dff72 GIT binary patch literal 4168 zcmcgv|8EmV7N7O5*Uly(0Vg4lgib?5anErm5SmNiq!8dpNgU1_2P&*?Hul6>SnrzI zH6c+e@zW{%P_ zoI}hDY;Zn z=o&F>tZP$LrE_@Nr1;{TJ!2Y@G-PJxsFs?s@zEnkk7Ht_jAO^BB#meZVpt@Jvqq9o zY|jupm{Ai@lt)5%f>28{4SY0w1RrJ!o#mm<;D9t|W^r1b!-i?&tVLh}&BD`~PB6Kd zAQ>BL22PmijIOChg5X)ro^dxRFAaELIVj^{xf7or zl!xVzbZL0}{8(%pUmA=?2P5OdXJtGV#Y1C}GsEM?90X?wO=}jrJPS-q zO1hTTY}Iz#oAfD2^xxPzDH7UiMHyu9Lm{ z(N%$ZWic+seO}O#d099v$Ue8<5BzxLf9i0rh zsDDqTFIr)}`99e;DFKYISYVfHXQ@%T+|4kUfwNjO>NNu6Rt64Ftgs`NbR!9_bQ)K( zIOE~Bwg<#rbOgna?hY`K;)0&}u3-Z}q3YUv$^DwqohE4$qN`*BW&s)bigS3Vfa?T{ z=+hS41FxGP&-TYhhvEnEY1596o2Gt-=(ZZasAp4}F{)CC;O;cweutj#AAb4W?>>J1 z^>1F>`t9?t@4Wcz;rF)}V!BOLDB~LmAigazIDSRZz+y#lTB{rwu8g#SSyl~*Tj<%o zwgus1{~9!qBU!{QFP11LAl8rAMY>Mn&e_;fZ;ozN%qp_LjQ>==?*33_iw3nYmS;ys^$H&JZd#v0ORYfly3{)>ch`rtGQg zy`c{l%AT083mx+qLJURgUZ1to?lGZyB^CVozsw)&`0ssK@sQz-d7}-lTO8XsCq|bq z?sO*b&+gafRn~nAhDQH@P%XkJ#i#h7FyA*yC?uCk2c&k&Wd zh-N@SWyy&Jha)CU262ZAM}jquz;d|bE3f0!ZAGag-ca={u^c`{Y`~+fQrlvbK^eI^ zVm6ahZQ^)!bC%GcNZAe3MmAJ$x!Ix&Ux&LvSwuTxc^eTLj6X1@?0y|yO(vbd)~zAK zgrV~d7`1*6;uiX@w)ts0UL47fthV>AwD&GwebhdybG13R(j0u!{IgHFLdSu{ zSU$GY_qpzxHa^=h+HB@p*U z?~E>WKW;iw=;^(8{?7T_1XKL{gGWuFSIwv;1e$d%lp$j05bmrF;f0?(gvbBCA$%GC z2;6rFlWlOH*ar7gQIgSBc(J$~%Sb1=iGAQ@)?LE2#hwn6lbgwNax(0qj|SixB0)cG zf|52f(Za-DCK#L1eM~TDQ-oPWUXL2U#BQOBmvf(ZYZ99_RrUUH_$G;x72l z5%Wb^%B=c;7Ws?(D4>;5!6?9sm6l-m4AJmb&Qr0a((VFFHnpZvP@M0Hx=*!@7_w(n z8(TAGR)?24ykgn^79fquNroSYAP2nwc%aCJperpcg+ns%MT4V z+CaF3QCj&m!T^>U09ET75D;(;fkL49*`B5aUjY(&Yrb{yK>om!eZkwq3+D=fz4!X> z^nZ48;Y^_+@X1HFKgvz4Hgv5tbUhH3uP@VO>6^p-g~q-2cHP;PvlnmXZ!UhA|M0=N zM{gW^(s=x(5AA;&2%xd)leyb-PwVy+8k?RrwycZjt?mco%kMruH1H3v(A@IZLj$>U zg|4^nk9;w5KaxL_8!UQJdq?hI?pL|%i?jLJoc5^o@L#%)zVxDf!EX-@tg%^ZytsSM znzyEY*BW2jC>2p{jr6Z|Ka5z6IR6;#9Xj4ZSqua{l!a0xSjQfkhMFeu(3BJoi ztJf*Y^{lFwM;a7mTBDY&YX&h)81^Yj(o84{WuT`Fp$-rKuqdNl+Q|gVi%zqm0QcKk zLQ(DV|1V2XzCtdsl`e!OM9Ku4VEq!ruhF-{>2<;5Z7=#8yeD!qOIAKzL{ME8)>%>P ztnr@8C5i}&rQ|v*iY+Z(DaRKP6ifUMtSIflR)z=0yHGfF?8Oo;za-pb?uhnW2L52V zCvdHx{h+yNp5s4Ra>*~~DHvc#wpd0l@*Ky#^z7z@wL^$&`ZH?#LHs!bWy_@`QX(ah3PD8jkSPq3mlP}zU@idD zR9>pL+e&r3k`jAGC7u!8c-HbXvr)I1R>^i#=h1Ec*xdmFIYbb(_H;e%cxV5hEW7D? zXLk1YopbNS1qsS_9&L`KgL}{Gp7Wh^zV|tLSX5*a5dQj`9Z&zzD+vFSUesV#9v=Qb zJiH`$1X=JHJjRm-*}#5{vXT9oWE1-}%VzwVPFhCu*IbRe!W{>5hb+kY(7`4f^ z(L%X!)Gpgudfv&R(PFuH)FC_Ad;ZCi(Nei|v`j8z@79y$qZM++Xr)}q-V07vjaJLm zqcw8PXsuj3S|`_y*30!Q&vvq5bceiSv{7yx-6`)J6=jj77oKbyb;?fmY(LpN+9J2$ zxyV!8W0YHK1gGF})Zu-vk&jf~Nk2yrrQvg)(KCWpEk(t3r1I&--kjr-)vZx z+)FFAWoaf7p1mMN#cuJeG~?mVv%!EAii*L&jO>?J#QCr+o>_^W4~J~Fqv53$Ik0d( zD(>06r(cvp3!%O~*=9Qxn3X~iX-^;-eXcmB!zKa;Cz5qbv4~fn}k2vCSibn=LBc3kX*^$W; z{yp50X&!m>DeWj`i>3; z2z1h%+vbr7xZ1R^$))$mk~ABb56ogjp@o=#K@u0j&q#6z0}+=bc`*>7$%~+CbGBe$ zF%b1f*?VTbwW)ZQmf@Jon6{2DQ6YaYZSjWi@U8(FE_2#Ecw}riUF4Y@ob-BpW8)L2 z2T!F-X2T(XEf$VNeDkt@QA*p{k8dV6KQGB>u1;H_XXJ1+JR1%Uv9*Dg(@rh#XgCy- zX6d^}gJG;ltqfYI_ju2o8K0OOI_8@gIyN#fbac|kdokom7o5k~BIo@VB$UiYZ>X8H zZAlJ4yW$Ik=EE*a+A=52#1_&PNtVNM+7gxhvr@V=5E%;5YM2`i%R?+DD`}ESVc4Y$ zQglw5_s4?KXkbwa$D(PoEX_WXHbj7YK5Nrwpl;EVWWgt@4y3wc1mGxQ69bYk9SvN~G3J z*H0YaZRq2EgQp5T+A-bOZ={}js?lro9Am9f#=6r}JKf0A%$_>dSJ6|?>NF{JsJz+J z!1A1|q|vj3y)~ot9ZKCsmeS(c!@k%!y@S^VY<5x(&~IywJW-0SHxo^Sb>X+*Q)OXtu`3FE{ZKuM^Vo-WU3hHP{ zAQYA4d29f;%{EC$eKzO^O2Wg8pXdd!9ibm>fs5F0Y;W71nv?zW(eCqsX9H3<$}Fq2 zN-fd7f1m9!1(ip`;b6oakfQSpm7k9;275k6{licBY__wKI17xc>?lIw)FbV4VnmXG z2?_JzF*MhMJu3yCk;FNF)bGZiBp}gXFuY8tQhL@8Oe@j&(4;hbK8yhqHO24`A3jWY zI4nmqjkCn%i1>IW$*sYn-KAu^mAqCaxMcR^Yi5a(l|Sw@Tp0sUZ*QBz7I%uREE(qc$j23HWdAfiSD^pw*sR!akt zJECj04Wu+YOT-p)L43<1MhcpY$3;f4+&03pK(Yg3G=_;{ZGGAwj7eZOP@Z!`)TUC- z%}OXQ;$kck6=y&}sV-X)5||uO3QCK_G108c3o-T#)`MzgH3h8BK0Ozhjk0!C0tp6S zHY$c^o(9R`{fGpWzKEy+kVM>=4@n#z{1nTdlfdR+#oOk=ln^n9a-umP&R{37wug}e zwq^Diku4BbjrEpFi;T9q#bcZvQO}3{!3cJ##7LQM5mX9X1c=R?uFVk)3iE>LnUrHv zml(td*`^RN-RHyLFCrHLOTJii{($cpe=vY8g1+k-@E}{HY%!DVwvDA7o5qnY+uDzG zMXttYRgy=TpLqR3{ChYDlJb%;r4SZDIM=L_9({~VT!)Mf>)TZtIwHVZ=UX3M0Z%2a;Q(|hIRQ?HPvfrS04R)1!1a8O`oVxpEAEr z0Yz9qAGO|mf;w8S7YtK`#97GMX0BXaZI|idW!p#y3@>K;q8^X)#SFBU8yWltw!+x3 zt5~kVP|{@@E9V0PObAZ`lf^hG1(Qm1X}caD$puu;g5tB!$TTNuTV_+G3;0F=Qc0T` zLuRIhpSIHOpeMNwHC$F=6XhFx5&8^CBNX0$8ZScw z)2EM4H;YHY(dkKS<6}}V>YqLnj4cF0r~NX-wC+U+H#t)N@q4fS!^kXRlzT9^?{`gM18(=Sq77;Y2)ZiEY_*UKPb@9C;$%auXS9`y% zBW16DXfo9Ip`hcR@&tS1{hFp*#a}B<*0f(f_T`cL4XwAvzBZO@=(>FDh12iUG=AO6 zlbv5bK`9T>3t?8``LqtZQdwgBkIc_hl0AGpAlZNh{9=Xr2VFOgUfKS zeURw27yyN02udVTCqsdO%pwtpKoR%r2Du6i;inrv5&DKG_abHhMD;BIWN8s9T#M*k zfbi|&vQ$~s4cB$oS37@V-|@gCILiM|gs{nTRW*h!VpfRS`gNYR+g zd8m-|Y?Kf_F(-3dN8^_Fp;dW5qD=##ikk<*)1IZm#fUHrNBPy2D=W``E^e)p3HHBg z&yVn6D#(?H@hkcgiX;-{YS`FJ0mddlrG$cOO7EqymriF9#h1=IWIv-|vo zD_?m2OL1%CBl=8mVbI3!<}jg=fJ~0MuGkFp>t=u{Eas%htQ=U1f}2p(4@^c5t`JW` zSV4-qT}HVD?WOamMku^_VDcbkmSo1oi(Nu$q!N)f8|#jp3CGT)qbY7};sey^7mCp{ zTTUf-cu4>a!!HjD2A9!)66+ZX0WbqPzKhG38r$U-NwdQEWAHxVMMCl_O9mb-L^~)f zyKSd|n1U-^V4cBA;4d*#Q7J--5OFCcFM%`MmKMxx7DK5OKO!dbu!;aoONuUS&33ZvVXHl4@&n6>W+Be5xV<6+x1tX~u;M}e)~|6)5%++3Elqo^jtc4)F9 z5{Tej#3cgG5N7~t(k0CY!=y;#>pqLaSLzOAm!ydSULrgd5!L3w{>7xMkML>16au_F zcQzagLQH`Ukn6M9hkLXM2b>wT&sW;qyo=H#aVUA|iVUzbsb2*kBE(+-)D=QW#Z@4S zbw^9W(Gs_|FrdpL$eq>0kD$#U{zW{{rkcR-=8$13L7B{(a|Jy!#PCQEAyTB__R`UJBx<}$PCVG!il&W2^K zw5{wBmZ{$&@@b4!KUtY7w~!O*LWIdG6Pntu7F{X2XWfx1bX+}n^Nu)%{NB*MySZf?2_w9O65J?W>Tq z5C(5dir-~be<4OW&G?y;a722|}i?M`t`RSUh#jVTv zk7z0)A_B!IbgUOT@i$doy{#oPZGvLYNWw$`4fFQ$~Ntb5>g(d#^qv6q;DDREid;gW-aZm@3^N=i{ zKFP3ib1|mVpq>5*u;eTCw!no@p^~THU76JHJsL8566Jg1_B}ZvL)eR9L}D+98_$T{ z*gHA)$&`lgCh|~aQ&5HD7NJR)A{mK=e8G=zh{8_nv3cQf(@p`Lyz!z6v*q(NK5ls0 z!fp{ivMj@DL%R^9M4UY3aSubCZwo{MAuJb|lo*DRiDyq2cs!>(j897!Xx4)*5X}oj zd=TyQTpCaL5!#3lIR>6Gn+j_0m+x4eyA`+@_-6UHcD%kLS-wAR-~Z3|9W@UHLqYA& z%4;@E`28?KQuTAqyM+H>>off)L!T*&>JUG`6dNZ11z;^>E_sI(7`fn`i@vB(&Rrv@ z&DxYkhd}AHRP;s(QzSUE;IZyTzm6I98PGdTDWT?)Ld9cJzq(gh0Wq`)6^6D7LME&p zO;Lir5l}V-uc622HGRskKTrFf5Y*PmRJmGwn_j?_tMx*m3+2KIAj*&#-y=?WQ^=)& znF3wlf#i}ojN&w~_BQp2N!b)Ud88K#(R}3?;e@7i$y8Hi4pGR=hx$L!=Z5f;r%!QR5~C;~XU(4Ka^Nzs4j%!yg6y-@`n> zsLgC?q}GhdK{rJVWoQJO5+X2WFG7Y1NN$m7;m?O7(E-xt!ypg|$K+Xw8KJX9r5F&* zg4m|$aNFEAXi&-44Yo#fa)j)Jp&eRfF6?x%Txx?#pd^IIW!NT|>?QIFiYb9v4Ky0} z$!rB&s3E@G?RL|T()8m~PA|+I-Gh+qpdvrLaz+j>K$q{_T7lI<GE7KTeg0BU9}mp8|g z*_@I#PcaK`DVE7nzsuK{4C{gVdmrdQa?HPAYwKAdr_;Y`DZA0Y@aGBFz`5SWR3HIw}r(#dpL~0V;LeFQV8}T zx4t-SBsMe5TOq{FBf46YpJlE%uv@7g3iB`WWyMy<+4E5C(XIl)*FFc?^SnO_`%tJ& z^)Zpi+W72BR!97?=y|_SVhaxRDExvV}DV6#59 z{Z#5cs}$-|fljz--)<7lNiK(ThPcGMbGk{n1d|YKdocaXMt#grgUNRcDr2$58JhNa zaFx6SKoZ78lQI;Rn~YQv(ZTW*Lr(Le{}~vZn3YanFvsw?iVJheSSa@8GqTi8>Sk^v zfEhIk&H19L46`vQ5Q=hEVJuy&c8?F>UE(d%R_NK8u6FWu!BvtrfpgV~mqb=%;v3SY zrSKBx<%%c`R2gc089ptNJV^;eJmhmMm6z!+T-zs{&=-U?`2!;dIsp859Ci<<}d8_bd;o6DYbIF=M=+GPXCag82 zcE7fFBd@Zc?EbEvR7u5+g6jpVePAKm`ch51-ebQVy=bAW@A9#G_U3mg>sI&QI&||; zylwy8(q#RipH@D0&7hM3N#?n?_spB+XHs>I>vg?}y59A=gNeF>cRLbwL)VU`Dr#?abz|2zFp`! zKvfEE7ObJG#-6H_%l#hv-E;5({a!ov(~8aql|t3yPnm8&bt zSZ_G4JMNV&4%ZsSz1nNAU2fVAD%%3d!d@1lZ|!?=gL&pF$9)kg4V;P;5Df#8m3_T-%!uM zZGfaU@)n@|s$%hN>|#o9GeqhP@4jYwhViuffYJ)#%2+5s!{P)QNSsTEltg%9{LInu zV?(~lp>vZQ&5x6qv>b@QcuN=^gpw3&yi26$Bom_2$5n`4pX#5MJ=pMgr*_0uc|3Wr_`qgYOtRow3%U& zY7j~**GrmyQqr{6mMqzG*+MJiOIN-ymqDZ@Q7 zGB!Bz&u$j$@mVeZaa_t=9`fc#|Cz5v+@<%kGw3c4Pe&$W0=W+7zM)gxIJ zbv-8fqoO;sY$AP3m!`QU2a90?CtE^-x}jM@KZ$l8SWxVH&+fnsvY7B9w+;o8I+ zfu|ICkGl0ZM)6p3cJVCzPE#G1*otBew6mByOM&xO{AO}Ddm0YO#LxkeZfYE!x07As zZq4WFmur7G92SrG<(zHzKBDbyXa^hF00RQ|?_tar2OA?l9B9n*YYPx<7ww&$*wio~ z`XXku`FCYisYd6mr*A%eyCT`>zE+s3u3tSBuXbHC|H2}aS6^%Sg-Ix`-mnQJ zRhNr?QHbX+KdHQN{oVKXwH(=H`oS*Sk-ho9VX_nATg)#iI9Tyn5ZkjbuWMWLkH%;) zJj+&!lEiGK+yf#*+~qF-{3;$N0-i;4IaO%ldZn#}TO}#^xozztT50h?_|72*mH1xAWoP2IK+EsV_@bLGj?=neeAqjEZr za~C<7iZdf)$K^jl!K_I!Q?K-l6upyf{$U@Af{`i{>{ZtmUXH96HYW<3Q&l@|gsz9y z&L*q6FCR}??Ndw4Vsr$xu%pe;==yrnJlnYat=OImC! z#sE+9ukCY>y@pE%GP9IYpSR3^6C4I+STrFR<1jG5{{l!b<28nXQI0E_VHwOf4roh z=a#QKx)Y9WK%><$ZqyA;>Bm%c z%td>P=Zzw%ck!xjs-{<9a$j|5{jvwcw3J)repRK8Gf)m~jq)jAt4@{SUjgKq49}Rn zO%gn@YI+G)Yv_+?%{3zzYpzkK-LYQNm8j{u{h4IVp({m~^HPq=8yBx%jBn%5M&z1J zyA2!tquo9smQiUIwg_N@5WqB{&11-jYA_pWNKUPtaV1rkRJK5-YYC!MF5{>A{p@-f zv1<+pa7q)_V6e&(2PHD8YA7<(tEm$8d4gE*O+lAST>7S<%himTnIiqBLwNCj7RXna z$YH?DWpK*~mJkSY*Miu)Ied&-I@^Fl&bGZqLOZ$hbn~)nYsO-}{YExx5Tt&Yw%M3|2kVd|ITp|gQL9Zs z-2)!;09Ku!yRh|aDumZZ*A|8GLfwkDsI6kH8`PosvF3A+ghTMS@icb0E6;x#c;(}u zZ|Fz;gy8~nB4gQAp$P)MN=Lg;0w)F*kibt4kn~Sd>u)ReXA-iTYhx7}@T;T$i-nz? z6F54=bpa0kqNxHdmW|<@whBqEV0f39l#4ZIh`Q;(ii3GLB@|k4)udsV0?)%rXVeCC z1a?W5{|zdq^L;)LB%e9_LusyXWJ>d@x=$aIfSLWjStJ$RhM7j(FRFc-6ssB?Es~Uh{sQP~98v8+t=} z<7~Y5bh2tJUNWX=J?@uOte1$15|KSP6D9b9lCqI+7KHX6k~Q{8am^VdJW=8ac(#7jDqZoX<>7k4Mb-QR9m-*Y6f z=SY0#(WK*Os=VgLW7i*hwST?2FVWnWEbqHtUAMaT*1*kyuYT!vd9u1oH6FFT*6~Wm zn>F3;;pj{{y5rVvHbNdvq7=D^`y=5{mW@jj9{(FkV9XHy2yf4U zCxB3eG#+v>#{+)}{0_cc&);P5G;^z=t-%6lZw|gGBP8Z>?7__yW;iv|tRQ%ewC!{~ z%E9rdWubntxbiN3kE>tUe&!Z7Xi~@mrU`4?oIYdTpc&hihkKqf!;)6Vs^ku9wirYoDeT~d@+)l*mqD30{hn4KJpfZ6FCr!*;J^Z1u z>BN}J%FRawr-sIkPoD7g?n~P~O|J9oo&j#Wru6~qc~p)8-I;Np#Rd6WNK(-lEe!eF zh_cbx9jc}Cif5`i*7u*#qC1UjV6ZB|UUqHp`GM7{SNmT({L0~EZSVcEhWL*DWZB-h zeQ!!R0mhX@FYdb0`IXMqxwYYBS?BHfL|Om0=hpWfPwYGXru770rE_R@){ICE+UhJp z>UKL)AWR~xA)WE>VJa&Y^Z^Q0k**E9o;?&FsCTDn23 zy(Y#9{;9x^;*p7RQ_%XK=gpg-(~_Hl&fb~>Vgt*-LxAb&Gw7KnL#S=4tHZy9Tumu8 z0q%Tr0KT!{`{L$w=A4>m7F6uwVb?{3^5L?WA2B{|92mg&u~I!&_Eawj<3{kWHB-%M zOP+!$O%Cwpt1B#9+8b~3=1=WXYp^k4>rT1Ul#kN}JL;Hg&E%SYv)T`lB%@rl2;puD zQ*Je3Vz>H~`ORuEM{RXZ=zt0wJUi5yaPY+bCUfEG85j-~W8tO}pe0md%&q3TYD7~61upZq)?QGJY268X;o z?qT^a5Wh{x{$F+d_-AisX3eeG=0O7Vbd7^ z{vN9-at#qkTMHSXzIgSEDSH|8laC;B8|LbQ($o%kC4G5JIcD_Y{EZ7=xvcC&NtPh+_44k}YKSj*)!J7xy>w6OQJ<0ms%cnLSPP%7z-LKtotM+TPU$4JB znkuWk-t+3@Yft|1li!%SGmvO~{Ehr%>+ubvy|fN)PPfKyjwc(suMMR*OZ{qez3o7v z?Le~nz|U&L`_=91)g6iIj{Cd1UR(U*#XHCDw*FPecRJqq)El3@+mqZi`s3cjuG6c9 z=+oMvH|zJ^Y58gWzWWVLYh&N;zgw8>I~LzF6n754*>F6yqy3wwlRFM>&Af-qirS4l z0ant?5vzuKcDFXJ#)Q4`QGvjMk6Nt z{@?elLP;&1eklE!y@XCblztdFhUxmwk@n*UgdaNH$J>M-l^F5-qq4y#@bX_v2Q7H} zn>O3=y{5nEHPLgwfu8rW=L5DA7SoT-2En){%ce(SAfaDFU8~Gw_ksFL{aZhVR}Wu3oO9OR#H``K+@p?m zla1mdfe2~_Afn{v@F=MYhXd#)4y_8idqbND213)tc1{I|yU%gLXK@ietxy95SKPP< z!o<-Q;Q$W6O~7KX)L(s|qNZAc78Zg6t<1r~tK(P3@7Z@i5~ceI7&dreY$IPgVyN{N zb8YAKDOTs&j@@vP)y>m61%p7{nvbAV~c>^^*&gm}wa6sUxJ_}$0 z4iSORy~i{r4$NWh61h&qX4nn3fPmO}U<*k5vhKu(>Edoxc>lN%#&WW-TOjzFA~Th& zEi8baNLM-^x70;&Ha-jbZIq)ULF{&s7J@#JQjj2XKHPZAt5?m;HFkfpyfbd^gyU=P zL&370anjxrS)S3D2~VBXNoPlp*H`FKdV z@5&n+8ylV+#sT)P(W_zD)lWBEkny+>fikRAlu`_q*l}-PT>51WF+GR4E>`KyL!ooM}^GB5%A0yCb z&QpPYih3!kS0kzuabrRJf$+fCYO!wMR9{scv}o{XO^L0k=C)L8#{-Ac(hA*OUE@X} zJ=t+5z(x^06$?9?KcL+AP8dv<#`kPSOZ|IRqh%*W&iAZEmZptHo27MiXsz{j`R%#5 zb5Ek7?|lJD8#RTN4y0OcNA4Vpw;xD2556xT=|P>~sJ?b_Z6I0fzI6OOv)NMdz%G>6 zuO3d8bzC~BCKOj)3nYugOT#STJ-fwH`=FTBOcpz--iX0cwsFoVG>F`LInx`rY040sU?`jh21z5Et-s8~w!321e4i8e8+;9*MW^P3+u9nSFOz zX5WT&hox!_&-mFmT!6ZJHw66L_PkF&52^|+-S}qnT8w@30}QbHL0yi-jk>>`c`+JSf<0sd>Aidc%TWa8#9b8~OBP6{>1Kpgc5Z zvh==ZvsjupijA1M)-@CS)NF~mPJ&142i5{h8DB~luz0c*eWZRP?DX&e@E{1aDfb!C z9!<=JG9h;vNOK@_>w4fjplrV2X=KV;$$YA3dq)Qj_Q zP8k9qodKj4loLSeWrBLWVmQgKD2M;KG zl7o!w5=-9zWO&RZ6W;l?mQerl1Lf?_2lDd3*-h~L%;$sVgoej&r*(@XIN65-AykL% zjHFuwSyOC+*!{3n$w@o-2ULCn)9&_PO6>#L=r`-& z2U0Er~ACecI?2-JJ}4tFE<)WO4hYYe;V=;L=|$@-zVx#nlZ6&pgnBQI4_4IjKzO-p?wQ9^=E6T5e{THR z(S*5qb^lMy&8fn&OJj^xb-Cm>X)rSseU~D#dB}f-D4iEwAx|8c^d*Ww==0Bn=D8@x zL@v^=F(Ecx=;Qwz0NsbnokWP=g?y@=!sq9EizwCNqtobIE_6_W)fWse;GTd5uA*j& zX&Az}4x4$TKz!v3iO(v@f?T^oLNdON`(AK^Cf!!ZZ;HvoO}Ds(mtFr!qA#c_q&+6y zG0~GvVYcv>wm!yX>BI8>LZjf+aab4;)Sw2#17oSd{BW0GDE_%n^mC!)=R)CIg7X)7 zg5kI!DGa|QoPA3;{FbomEn)mkVf-!Ov83?W-wF+XE7ZMRSQW47jN7^{<^LjY*lH+y cAW*yktBlLkZiYTrMxUp6WK9{^8^UH||9 literal 0 HcmV?d00001 diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_exceptions.cpython-313.pyc" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_exceptions.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..4d65430cb751bcc0d2d85e7a8e2057f95782be6c GIT binary patch literal 3445 zcmbVOPj4H?72g#}ky=Z#t=5SY8^%~RS`(3IL$ZO^aRN)SS`$tv!AsQ+!gMj*9g*uS zcj%c})8xrOfucF&&=v-QqQ_qH4T>D{84|z;m8Sp=8aO8hQkop|-t2O@0s5}ciR zZ}!dm&HKAQGh-R>e6jV7f3{>8|HZ}VDI}Bqf5YUNVH?7jHx>=se9f>Y8s_y}%oh`y zngeRSF_BVpnpyzrWQLm8)FMzz8EQdOr+{i@sFRv{0;tm&YEe^XfI6F@mNfMwP)}v3 zQ?1kGIm#vs!*bXYmvxoTLs{wetsZMi?tZ`&Stj>c(~jqE;ImMXz;6mF`ec_2vfWp0 z9$MB0@Aie?YAdpGb>$jiVJlo+6_)k3@3K%bk3^xz1W|29*1FV%SHf8$e_%qwxn!ks zm0ZRRiwSG7{D#%%5$VuA3ArMXWFWwoWY-TEi4`V(NL=3O20jg4MtZ(#>q8O|73c6DR3W^XQork1r6 z224sK*rUi70I=C7vN|HM>e)MUEc*nVJo67 zMq2za6CnU0T_!rdMB_htbhF#)OD8=cV$9wAuFF4wNHw}lH-K5 z!yIR5IZlUrQGoMl$9WXdAmN;JoLyf?75E_wIk1b4<8c=zQ;s78t|Uy)z!b5%4poT_ zfh;g7>?5>;E)1JD8t-g0E|PVw8gtCLl`%_iS zj+nXF>X)a*z5L?Y%Y&wL7IBkZ$q?y0srZrh)HO^mWz5Br1kC z7pr&@XLF+g<;oF~-(-;Bj$6%NU%x`YF9xolwLKt*Dn``dC7uZaS?XK zTW1s4$u)?}$SI=`s77u;C6pamgf%uWggg~GePWCbFCAf@%1EJ`uv6l3KRh)K=8UP? z4~t)v=0AS*dFjI5*`&F`JN{JgZVx3KuptWI9#oIXBQ1ksdpwOXxg2~E8osc7cPEz`j;;Y@P0XIyt43p5oZ54V2JZ9e(%CReeeK$eoosl&PZ#X zhYi!%x$lCA!JApy+aYk15Ifi=y1nGVW9Yb!2<{b-E^xqO(%}Am zCb*G_SFW#jD%C5l|5zAx(b3sj=Q*7!guYTO{BW@ymTtza&|Bg z6Q|Am!R4I!i;ou$40s((!|?fq)y#sdWESN&GK)i;^H&qjN%P|8i`NbecpqFsX=LR) I6s?W+Bj@ybmH+?% literal 0 HcmV?d00001 diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_handshake.cpython-313.pyc" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_handshake.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..53feeba1f473bb6dcd26a664bd757bb9f4f362b4 GIT binary patch literal 10099 zcmbU{YfxL+dG|_J?-xS6vEhO+SeT~)8-wFrjD>j^aJa~31C3Tl7tmTV^@^uoYQ{ZOBHxjU)D92Xc_Q zX{2D-iJZfQsE~Y{M_j{2sEDOnDbDgm8WnR^R5D|nXi>n+m(bdl|59qudS0pLOK9!P zepjjogS>&M{NN z6~NCqQ_B@X>6&3X&0J9h^#+67q{lT=)5&nfq@|9G!E;J-b4q8pWJ!xgQ3f%ezjm~nF+$rvKiw1ePs+mUUZG=^;$((Z>OLne@mKxI11pPIg8m^Xn zHP352?m!5DHv1O>LP%T)+~Hf6R?N%%tQZa6;bUwwdz+u-AqV(j_0^PMa&%nKbI$ZWn54)TJ?huF9f;t?BL;Mu;VKoE*t zPZN8KM`AcCu;*La*t2**ZLX`<(`#Od#@WTd3M)ioY+U4Ffw0KVg(E!6zZK+{Vr*Do zgVDvMNH`z_d3HG*TOga{mS{1*mRle?8wn^-YA7h4V>-W&>xF%+JS$BrCO4t5qWJv;!8C?K%4eH=T) z)v{OnxFN2|e0ykeaNIY^-tL>2=o_0H@^b9>1lvD8c5P^KXnYL52H3u_*VvJvv1?5% z561(yz`wPGFzf(e7#|%UYB6&>K3w%$$mQa1afuIx=fXk2NSKcY=6QBLdY4B6Ai^&3 zXfZ6}%M$^#kU0`w495a7@}0kL^(vlX-Wc=Hl7$z7(GWko65~bqG!ITr-sE6nTzm>L zhk_;thXLdJqtQEIemHx@z>qq^f(A~HjPnMM5#gnt zwnLV<)*lrxwZc_l9#cVKMRG_@Y!0O+eCn*g&qrh70Hz@^KD&gXv1l+FX_55uXvvTp z9aKQW5bEHkh2OyvWbac`1raFaG{r{nEs!+T7RgC8ikj9dtrI4tmM?vp2}}Mm{2BK{C+i+n5)3d91yP_Z}?WQHOH$RnV)mV{a4W6eQA5Mv%aa={j; z2r|iX2y4ltl8mGcghG;bo{xDLeASV;{2WvQ6GeO;nCqWMq2leC8SS5Ovsa_BnaOB0 za*dC~0y8%w@%gYY8bBZln-}3qj%oSTkH7aHAAaM>55M_z_1jN=xcT&3KmOJFnlBPV z0VrowNjh^x!b(QJKP-e}e!okBn^xFqHCW$-&6zVob+8CcK{zSma?pv){iM4aFn4ZuR@$ zj0YmQ9*5sQ7e-<%0;DHIq1)j1hoV8KXv0Db*e4u}p;NG+RP6U->Iw(_fmlw0iGKe_ zpc$bTRpJbjGs!SXi)E1gIrRxMw$JEv?MZQ;f?{X#x44ie;qj6-jv|tAAu7f&ND{LY zMKQ?;Tp5j{ATQ}<;s|2uRgwWJnDP#qh`lnMr`^6Ow@=rV8yg2YUYh6V2~71bB%fZP zPQf^>u03T`v^h&n<6TJNG`_>_Dku0TWeu{^w|%B*Vq@V+z3>ZwT{VqyBx&l~oVF_m zNgXxqP}{%n(@i*)uYAcd%IeB2vdfQrmb1A*Avl9+%oHSXI%PahAKukxRp!@G9)04< zO&)=SStOD_r`=U*B*NuxAQA?)ly!qhSP?i>7LqLqjm?~}V-3iQv7AsEldOmMQB0H# z31p2!X2c*7$R4v);s@@`vxOq5CFYH2SU}hiMfeOQCmDygRfZsZVuZRRlV5=mmE%Ty zQyv>|JlHGCQT_P$wW5bi1(YdMd9vprQ~$(Ow>_D0HNxv37lcHPeHA54EV{h%t-JeVmPN;Ac)*FUoqKDM)|n%<24a>{u5iRE;1Zf7uK=}IwO zgvo*`a{z>x{D=y`JPU?C?UM-=#5@o=Wv4i8hqjLLQ9vq8;>v9lSYn56-o3yo>?vZS z224S0CxTH1nhGoO938MgEMXv?1hEHNB)UhUrzQH25SC^hnp}#A!C}b>N_IaOTzCA+ z+UN>&eGz^lhHjODb2D1j7XI|BS!dx!_eNu~Z|$ooV^!ATS{)~-NerHfu)sVb9*odH zuuKAD%UA+tnhq_eZPf@mpO#}(_Eb;Dr%~8P(2p17MM}OM)_}QoszWR2eavw}g;Rx! z!lD5Xg23LYfC0#Ii8J^ZPLDfaMRjkM8ZSC=y#DA&A3dRpC}mZ+M;)gMDt~bt$vSz9*k+QC^W8 z-TwqEi(kYtRCwaKmndUTrZs@F7-N?odBVP>%7`z}f=QvHfcwO~%ee9hRUyI#x#Ux6 z@qdM_dZ)0fS>4qS%1~9RKJ#>evbv(u)sdF~KJ$dCIF)gLZ?5X1Zdq*9u$^!lmC9OzojY|LeA8H4lEhi2J_A^DhQ2Zn&eNfu5*XSiNTSm|#|!s#EN)zt zvMx(aO?7JO;51oE)CyL?GDmr;6R%#4ha=!AgUfkHi-_3i52?h7`8C7!sTs86AR3cc)CB{aQk!YF%CJuEoP6dHp09vjid#JrO95aa;2S<;CRIEYE*{vbHnG2Soo zLBD#;er!}3Zh0p-h(Vw>e5&TK7#6^1fG7<*gBx_=7{4e=dj72#tcOZ*n;s%NF&=pe zh@~c5Z}@^_>kaF6*=Qpk0`U#>BE*swd4#!@Ad9?sA^MI)-<52#!RnW>lWekuoBKqA zFi^yTLAB*_71^X~#Vg|MH9YS7z#U+P7^%XNjfIUbC09}oPwLz&Df_Fd*Rux8+DgVy z_SgkxS;p0{ZhTULO}LWARPnhT@N#wrQm&3QW7c5*+S~8Gy>VgJP?0uNWQ~q{_H}!* zWZST#|E?od-IgkCe`Gwrr?wlDiBxIhL*qHv*HyCR+4Lm)x1E_n_q!ut8d?hP^{)4( zO3&_EJZXyuOreeCjHPP(;!bzk-I;d3nzg#_jjfL*r?%&J7Si<>Q)jwUm6sk_d$Kmy zM*Ka0u0M6Ud1qngOR384N7hSwxjFuwsh#0e<;92A?k8q<#(ZYAAAo>P*fMJ^`mL6> zmOP^%%a%9Yzy4q_-RaGgdvhqI%A0n$9dD}i{3Bz>p1pWuA#JZtu^nl9=bCm;hWmv@ z+8!8wVoY}rWy*)tMcV$M^u8%o+VjxZo13)p&aR~{ZK+d+lr}vwHs{W3V>$Uwy0qyb zG?Z3ty}kMN_LWTO`IO=O>N}4N=am`d?f2dPc=qqkKDeAYKbUGCN|g>jG>+^!Wt)5| zR0bRGSok|6mQIZ7}~YC(-!w$?b+`)eYfd*Ezh-dNk1*swtY~SthwKJpruOB zZiKeNo8fKew*KRihEHnS_O+DF1&0Fz71-`RG*&$+I=wZzISM3F)UZ05Evnoa*&Nw6 zW{NzkqkGQct@6$C4=;dSe>!z`AX9lg;~Y#G1~DjuIpC9J+x6RJGDYk$mamPyJGRmN zkU0gEuy#4cl>Y9Z6z?fw$LVKPrV+jVXXi>FmkM%z4Ds1Ppo&;?sR_GS#6td8sJu@d zvUXLSABX-Wx4=x*5L*RAs84&GUdo4iz_Qk<+5_SNKI%BTH}6)1qn@+Al@cs(4W|+G zdHasj`ZQp7Gd@PKzQG2^cB9d!@i9IvxbawkL*nTvH`X!O#hn0e^ABBFqGJ?CR*9#n zdghQ;v4eyK0TpYGKukNfFqUu*DuMD`I1Z#Jn=r7==ira^=n%F?WmyScWbiJk!{0{8 zJOtG5~vYQD3%Dhi2+2w$pe-T_x2((I;+}3{BTCyp?`e&n|6%iZzN5mD5vLKu# z;g|P7o4Ow`nR8!AGiO%^k}cp)W(;StCAGUH_34uORKvwgN%xxNGfPSK6#Jc_4~8BW zxHd*I1?+)VbK1T>3M_xRW^MGb-LqHd-X7TQ25I)?RMX{bVR^DCS+%{q^T#{UeJ$;L z1^hFcD{HODTFbK5@&l*IVcw@qCi8v~Wi3ous#8q0?53GiB_SE&vVQhxPyjqu*rdk1&GAZl{X083HP<>R8^3N**X#Faj93TkssfnG37-NK?3Loo{QV@R;O z;L|AZ1>io8$0r{f6tq6AIv!p@K}h4s=nF~N-v{o@Eu)GHIEm4;SAB6vDQM~BO@=l8mJY_7RGQFp$p+uJwL z*VXL3)ZW?LdGT6b^QEr-zUKa}zN>B5`n$WXwzp4u><}3q^!8l?Kf^yc?(ZM;_K%=( z07i}kW5ES}3cOFiqhBPt4DWuJ9nctV*FrluALJpD$U4b78xZ*mopKX|6Bh$P3AF#> z61>#rgd%uCzyj1EZv(;y6q>}D53foiiGE9>!L0#x9fvm{;AINU<1RfG)O_fq2r{rM zg>d#$_=&Frt;3U#!?im2xS(dYpdnq*@Nt1>omr!?)jqO5lBC}oTcba76nx^U%AWQ7 z!0?gbzV>ge8Ee;HTesWq-Cn=_-fQ>#>;8{zRUcW`=#;f9Yq5T#2Sz`$QO?HyETD`< zSy#nY?`ChR`eMe_y=MH(QJU=j&Xo_YWGc^n;%wlpIDu#BF|3KPF1R~`=Rwx7JzV7Ze7{Dl5x4$jC;1Cd(-RFsq)s0t!;JSGmA6p zr~t3y-pu;UdtY81cw%sF)PLySHB_YyRas~0*6GdD$>of*VNDMvM}cdNPq{9ptlhiT zSJKv3eqzX22Uoq1ZEg39Gq#JX1CMRx$@+|~X4lq`wl!pIo?TmO+6H^IPqfI$Rr5YhOtjPiHM9zx(wi%68#*zqSG*#7_RHsbQd2|Ie-E0}c8hoV=Q_ ziow5wuRsP?vD$IGVQ|RurSeZm`A4B+m5Lf4dYM=ND^`oGSo|u=2G}_utcA!5JI}-O zFnRQS{q>{v0t6Rce_e^f6KksFDRy(w9mqU7NfQ;p^g_VP(M9wow1LsU%8G6WSKsWFjIQtd+L>yRHrJgulTNU4|NM8L`6})9wN|WY{#l353Oj{avE@mtp zyOv97%cYE^H^uZ4`_I#Y+;|-xX9#C+Lni5E-b47rWtbQoR6!uZAas!P*xv{sgzW;! z;+OyV1OKA|dG8_SwWRZtIoSS&=gvrU9)dUXOT;WxCeR2LQ#N2b{6cob_Hi= zFl@S59R3%A#3G1=k0PX%{QCqET|{ok_XAe!p+{KBcv*hxy@EEN1B41CE)a{k42Y4U`V>|0419)D z8SCj)({J@gy8Rgi*?vhOeIe^8+}Gl=szxq7O>)`YOUgZ6>2n?Q?>Fk`q9g}z$#l^+ z_Z%1d1()fvtlhb<#bsHgT)OM!vZY-vFTYC4J)QZv4#w?Y)llw^XS!0F-K(hHC*{S< zzrp4H1*)JjTUhx_r!59#ocmTi-LwJeu*}qRgasSM7T~I;S)OrKoSE#zjx$yB-vWlVi0v>ocSq%v{@5xyQeu}= zTeaW!8r^^t(3wo-75V!0dtbl%{l4qrvDIo~ApG<9J1+YVGR(i?iyUj{$)RX#=2GRi3D&8{MIMPI#Xxci;jWm;HNZUwD4b#H#RkaLXy&pz{fwb{8 z7u)%57q|Dad~GdrnI$_gr&rC_(YKv^{Y4kwaIu5Z==esO+sQZ8GGrIeUDQm2`W`d^`Pm_m*EvBil}?_ROlAnTxG@#gha{LvBS;ZUfDALT+VIZe0^I{{0!|n5K!DQMq>byfCpQ z?_4|{?T*fyt_zo95&sn-&UJC;g-g7Y_J@K(IL?KFmxzzdb2Aabot=-*MZzZ2KqNX( zg0pjRZtw2By_^u94e#4WOr|qIzYvZI0WJ{^2!xBz30!~F=Z8nWWEXc%AhBR1%xgMSlHjiT=DBbr&Lv_3R1l1DGr^F+2{-&gG|mOXoIi3k8VdTteu29l zjL*?V@>O)3UdUGvxfJ&Stq-W9^IT-6n2PhoO(qTk4!^Ib=lb>Q-9FmO?g*Lf2}xs! z^_&?VaF6ruF6gPrhW26$kqOr9}Lx3Cj1KgOL^!z!dqyTuVRC4f8GB$$oyA-(@;K zJT)}onc~j(PfqraPYt_yZeo%fm>3@%o*JGQhqqH)|M&~s=3{yfmIN0L<#mC zIvB*M69U~PUckv!mW3`ZzQ>}1KR6Tg!-&GO3E!;1%|@;XBn$)Lq5`=ZjN#(NplbnB zD0np(_r>XZVZD{5c!b+@+@%uDLf9V(2$$yLLhO+WGF=*Ro3hRN0|D%vo31a^C6+5i zg}iDd?<|Sjm{$s*CN0#Qm=)PT;%ZdX#);1_KyKxr`&7T@%#@d(7#MX=c_+@|{D?*( z_^)|G!LT5*F(DjqsYSz3|2emp=g)|mSS$n?wpk(Wjbbz2V0b1X8lyfE6TAr$f_hCw z1$uuZ92Wd>(Lw}Yz&i&K>}?=&(oPOYr7$EnArT^)q^{F+8BI?GuL_YwoEF188Kunm zr~ofo>E)DPI_y%6lZWAPJw)GQE;4}b3?e(Ax|*=OhG^kmH_N)TqzXtL?SW|F$SGea zbjjzx!bNdZoPb@x;}zhp&k12JN)L4xB0vmxVIu6i<_m^!V|TyJjK8fH4PI~9cUACu zMU&TiH4;dKFm3UA|0v-Ltt62CYViG&7)P~3O%Y$!1s43GH;z*N^&INT+2 ze(_IlefHOX{_r1Of3*0Q5C7rTqrdp4Uo0g(A=nh~ym(zkxr;LJ(Mh5aPE-up>wjea zmtlCrS@o)sw8JYc{CBTGHPrCv07MG| QTKeB>E$B3jlLqV-&Y09e;1LYLhG>M1 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 literal 0 HcmV?d00001 diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_logging.cpython-313.pyc" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_logging.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..319ac5e741580ff9374fcb5dd26ff3dc5d88a8b8 GIT binary patch literal 4245 zcmcgv-ER}w6`vW8V`m(bPY47;F3yJHD%ja<*lmGU3W+IRf&(6>bcNMuY~MIT#~%02 zc(chXq)f z_n!MP=i_(Iy|WvQDgubRV}dGQfPGk)oR_l^x9_KUDJER-7_GHLP=jj6gvdIQf*QIdvJjl# z_jan`DM{_jcdE+aE7eF|QM*)WDy&9_guC)xkww%a!0Yz-9cmBqS(n=Dab&fRbD};@ zKj$3raRxZ2+s8S|IXynkG0y2#yYihN@Xui-u0CHZk-9)7UCmG8wtKYA>5rMVT>ct4uF1+hlTL za*EJux%$pKOi||aBCT4qMC#QNWyD^lWV)smq48$M$xkV>bhAn(6BFbdE{J(kvG^rr z)vS|>wo0m|P3jhf19Xcl=>{eAqav-@M6Z&fS*aPiRxMJpqT9>-Bi<1S*MTpg_BTzV#zQB&tZQbk#)8q~4~ zqra@{3=}M^60HVu6tx0KXJ{(~%%hbVg*n?qIaio&gMG(|WiHt(8ly@{w``^t>h?YZ zd9oAG)V}~sq*Y05S|w>UMy^b&X?0w=oz7jGU&xW$)7k8FCYMgBWIjt~<}dL}ToKAiWe%}c#d$I)$`XM!(4o2-ds}=*kw16oH$Gg7=@<3P+?zAUW@q5r; z5YBlV?T3PJ2O`WZ*A>BzS-w6~(wyVFXN!X2JSk-s2a@J`=5r7S^U2i7bl z2Z9r`nlzVO$a`AqhHkGW6y>JIvf2;Upi#yY);}R2e?yM7n(!D$hlw5 zZJgMWPyRL3^XG8)PIzc5Jk)q^J3Puq$G5`AKO1~B_{G?h6BAFvZ#RQNxOW#e3xHYO zMxa$73?Zh9ZFIn-43#76bLc$Shby#f8&%W)4OObNAheJ{{x<|3f>kx(Lz!HblX)CqN5KH> z7!pK(Tw=r6KMlk}6B5EsA_D`5lgl{sE|7KMMW}xxy%RbOuK8{BH4Ji<=FRwq!g%0=&K0b|!g_=(*~Abt;J{eHxiP-2h?lH!7ROMI{6 zU4aN?F)pngoiodr&3Q&69tsi}P>3rRhJBAEb_Nzrvryb5RB(qR=`+vI1+q_E&P9^OnLTmm@6B(_Y|0~zx3*>1 z(r;MY0asiXiwA6s06Hiv=OW4A^cWJvz#Eoq*mJ*LbwdVjOwBl8Pe;{wJ{wbhG|EUt$5erU{ z4miby;C~d5UpW9fewn-`))Dv;L$VCjLix}IgN@FuNNiJ%aT()B;W4vVGK-6gu6`(^ z(+Lk4h=MYtbe_Y}ef$%+STl^pMJKA-jB1tE<44A~l@owp0QZ5xZ!MnOFra$(>!D{tXh-PV68b*5^y!bbg)`54PBboTcAr`A`lqZ!r1enqv~c$P&e+AT$1Xk@ zyZo&8__v`Raq?Mre=`8>^XR}%G`1CuHG?o}b_iYFO`LCrg%0JrPVAixif?ZOngTS9 zB5xj+Ug4LU enhpCEHq9Ya1| literal 0 HcmV?d00001 diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_socket.cpython-313.pyc" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_socket.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..df7d97cc656cefae370e3c9ac4ea617ad93ea6e4 GIT binary patch literal 8047 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 literal 0 HcmV?d00001 diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_wsdump.cpython-313.pyc" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_wsdump.cpython-313.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..8d234382959ff1c2c01fffcb7e191ffb8327b875 GIT binary patch literal 13091 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%0Ap_P>w#Yw2 z)!W$SF38jW!hL;7`Sq&rRlQm>b86LA{_^pep0vq!^~$%d%=62pZL8*z74!BF&D$44 z_smV{qT-o;tWI7{uG-7yhpt~(xUg(zXX*5gmbWjwbz#=DRNRmNnE0?3<_GI6Z5cH>b>1Kxf5N34iIvmX*f#RAW0-6s64dX`4fx1aw*5&c6Bl8_&Nd zq?|poj54#US5|A;TdrH&+vndp|1R^d_J6Xk>^qU#cj9jQ^1lA%hLg*+1DPRKEu|}# z<_|5+H;3MS?ycuimcDmGOP0R(!jPLefz&Qvy}Vk=E|)gWnLa6PUM_8!HLaQ*OGVB1 z%sbYY62s2dc6>@f@@dNsL&YB{h&H@5RkUrzvHe5G_M6Y99BuHzp{#PI@7rhLcK_;` zZ=LzJ_dXDwzB-*YTd$esOz=9-vS7JquD)+ATGN>(PbitFhUB4T<*Ov)4W} zQ8wpi8*mMZqGxe^Y%g7PZd<2x7T2o1_}Z!Oo|>1gzqIhuJ^K!*S~EcT=OTK!e&c93 zxsUqIKI_0v{cn!DA^wk68e)9Rhg%MB>t2Iq?gr&XRDJ)!;LR`jHWU+l#Hn08kz1Hk z7W!u06ilf5E>KzG!IK?uNZy^as#)O<0@OJsm z6S%CWNH10x)d#HVOA~ZqsjA><%y}^iV1&G?-pqUlaxyKDA7Kfijhts#HXm0X8FiBP zp#r#?5*dg=Z_)HaV+qZyw@`HPZz-GdXZ{EMK%1MDux3JUG`hXwPrQYwe!IZ z3X-*A0Q-1bwBmH9obKC4e$siTb7gmbYIpxq(a9Ox$F^-Rcg~nMfWCWn{91f2zCl5D zZD$)@KYwzitR+>}vO&R*PhX@RblF1+q7BwTH!oJ+>{w|!nrb?_0cFh_tetM0e{O?< zWTP%b)2=nIo?`5?$z^>Nm>@&R$7cI0MtBXvRDZVSH&JF6a0gwMcDnxdBz|JqpVuy! zD0?}SVq3>7f6Y8+{xeh=Nax L*MHD%fc*afe%I7| literal 0 HcmV?d00001 diff --git "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index 899dafb5..0bb728e0 100644 --- "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -1,2352 +1,2472 @@ -"""Land protection cloud interop ToolDelta plugin.""" - -import copy -import json -import time -import threading -import os -import re -import random -import math -import shutil -import uuid -from typing import Dict, List, Optional, Tuple, Any -from tooldelta import Plugin, cfg, fmts, Player, Chat, plugin_entry, utils, game_utils - -from .config import ( - CONFIG_FILE_DIR, - DYNAMIC_LOAD_DEFAULT_INTERVAL, - DYNAMIC_LOAD_ENABLED_KEY, - DYNAMIC_LOAD_INTERVAL_KEY, - DYNAMIC_LOAD_SETTINGS_KEY, - NO_CREATE_REGIONS_FILE, - default_config, - default_no_create_regions, -) -from .geometry import ( - box_radius_for_size, - bounds_from_center_size, - boxes_intersect, - distance_to_land, - land_overlaps_candidate, - sphere_intersects_box, -) -from .models import LandData, LandMember, LandRank - - -PLUGIN_NAME = "领地系统云链联动版" -LEGACY_PLUGIN_NAME = "领地系统" - - -class ConsoleMenuExit(Exception): - """Signal that the console menu should exit.""" - - pass - - -class LandPlugin(Plugin): - """ToolDelta plugin entrypoint for land protection cloud interop.""" - - name = PLUGIN_NAME - author = "小石潭记qwq" - version = (0, 1, 18) - - def __init__(self, frame): - super().__init__(frame) - self.ListenPreload(self.on_preload) - self._stop_event = threading.Event() - self._config_file_state = None - self._no_create_regions_file_state = None - self._cfg_default = default_config() - self._cfg_std = cfg.auto_to_std(self._cfg_default) - self.make_data_path() - self._migrate_legacy_data_path() - self.no_create_regions_file = self.format_data_path( - NO_CREATE_REGIONS_FILE) - self.cfg = self._load_config(self._cfg_default, self._cfg_std) - self.data_file = self.format_data_path(str(self.cfg["数据文件"])) - self._apply_runtime_config_fields(reload_data_file=False) - self.no_create_regions_raw = self._load_no_create_regions() - self.no_create_regions = self._normalize_no_create_regions( - self.no_create_regions_raw) - - # 数据 - self.lands: Dict[str, LandData] = {} # land_id -> LandData - # xuid -> list of land_id - self.player_land_cache: Dict[str, List[str]] = {} - self.xuid_getter = None - - self.coords: Dict[str, Tuple[float, float, float]] = {} - self.coords_lock = threading.Lock() - self.recent_tp: Dict[str, float] = {} # xuid -> last tp time - self.tp_cooldown = 5 - self._detection_started = False - - self._ensure_dirs() - self._load_data() - - # 事件监听 - self.ListenChat(self.on_chat) - self.ListenPlayerJoin(self.on_player_join) - self.ListenPlayerLeave(self.on_player_leave) - self.ListenActive(self.on_active) - self.ListenFrameExit(self.on_frame_exit) - self.refresh_config_file_state() - self.config_thread = utils.createThread( - self.config_reload_task, - usage="领地系统配置热更新任务", - ) - - # 控制台测试指令 - self.frame.add_console_cmd_trigger( - ["领地云链测试", "领地测试"], "[玩家]", "测试领地系统云链联动版", self.console_test) - self.frame.add_console_cmd_trigger( - ["领地系统云链联动版", "领地系统"], None, "打开领地系统云链联动版控制台管理菜单", self.console_manage) - - def on_preload(self): - self.xuid_getter = self.GetPluginAPI("XUID获取", (0, 0, 7)) - - def on_active(self): - if self.enabled: - self._start_detection() - - def on_frame_exit(self, _): - self._stop_event.set() - - @staticmethod - def _ui_border() -> str: - return "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧" - - @staticmethod - def _ui_title(title: str) -> str: - return f"§l§d❐§f 『§6领地系统云链联动版§f』 §b{title}" - - def _ui_menu(self, - title: str, - options: List[str], - hints: Optional[List[str]] = None) -> str: - lines = [self._ui_border(), self._ui_title(title)] - lines.extend(f"§l§b[ §e{i}§b ] §r§e{option}" for i, - option in enumerate(options, 1)) - lines.append(self._ui_border()) - for hint in hints or []: - lines.append(f"§a❀ §b{hint}") - return "\n".join(lines) - - def _ui_card(self, - title: str, - lines: List[str], - hints: Optional[List[str]] = None) -> str: - body = [self._ui_border(), self._ui_title(title)] - body.extend(f"§a❀ §b{line}" for line in lines) - body.append(self._ui_border()) - for hint in hints or []: - body.append(f"§a❀ §b{hint}") - return "\n".join(body) - - @staticmethod - def _success(text: str) -> str: - return f"§a❀ §b{text}" - - @staticmethod - def _error(text: str) -> str: - return f"§c❀ §e{text}" - - @staticmethod - def _warn(text: str) -> str: - return f"§6❀ §e{text}" - - @staticmethod - def _notice(text: str) -> str: - return f"§a❀ §b{text}" - - @staticmethod - def _normalize_wake_words(raw: Any) -> List[str]: - if isinstance(raw, str): - words = [raw] - elif isinstance(raw, list): - words = [str(item) for item in raw if isinstance(item, str)] - else: - words = [] - cleaned = [] - for word in words: - word = word.strip() - if word and word not in cleaned: - cleaned.append(word) - return cleaned or [".领地"] - - @classmethod - def _merge_config_with_default(cls, raw: Any, default: Any): - if isinstance(default, dict): - result = { - key: cls._merge_config_with_default( - raw.get(key) if isinstance(raw, dict) else None, - value, - ) - for key, value in default.items() - } - if isinstance(raw, dict): - for key, value in raw.items(): - if key not in result: - result[key] = copy.deepcopy(value) - return result - return copy.deepcopy( - raw) if raw is not None else copy.deepcopy(default) - - @staticmethod - def _trim_fixed_keys(raw: Any, default: Dict[str, Any]) -> Dict[str, Any]: - raw = raw if isinstance(raw, dict) else {} - return { - key: copy.deepcopy(raw.get(key, value)) - for key, value in default.items() - } - - @staticmethod - def _normalize_config_bool(value: Any, fallback: bool) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - text = value.strip().lower() - if text in ("true", "1", "yes", "y", "on", "启用", "是", "真"): - return True - if text in ("false", "0", "no", "n", "off", "禁用", "否", "假"): - return False - if isinstance(value, (int, float)) and not isinstance(value, bool): - return bool(value) - return bool(fallback) - - @staticmethod - def _normalize_config_positive_int(value: Any, fallback: int) -> int: - if isinstance(value, bool): - return fallback - try: - result = int(value) - except (TypeError, ValueError): - return fallback - return result if result > 0 else fallback - - @staticmethod - def _normalize_config_non_negative_int(value: Any, fallback: int) -> int: - if isinstance(value, bool): - return fallback - try: - result = int(value) - except (TypeError, ValueError): - return fallback - return result if result >= 0 else fallback - - @staticmethod - def _normalize_config_str( - value: Any, - fallback: str, - *, - allow_empty: bool = False) -> str: - if value is None: - return fallback - text = str(value) - if text or allow_empty: - return text - return fallback - - @classmethod - def _normalize_config_string_list( - cls, - value: Any, - fallback: List[str], - *, - allow_empty: bool = False, - ) -> List[str]: - if isinstance(value, str): - candidates = [value] - elif isinstance(value, list): - candidates = value - else: - return copy.deepcopy(fallback) - - result: List[str] = [] - for item in candidates: - text = cls._normalize_config_str( - item, "", allow_empty=True).strip() - if text and text not in result: - result.append(text) - if result or allow_empty: - return result - return copy.deepcopy(fallback) - - @classmethod - def _normalize_runtime_config( - cls, raw_cfg: Any, default_cfg: Dict[str, Any]) -> Dict[str, Any]: - merged_cfg = cls._merge_config_with_default(raw_cfg, default_cfg) - normalized = cls._trim_fixed_keys(merged_cfg, default_cfg) - - dynamic_default = default_cfg[DYNAMIC_LOAD_SETTINGS_KEY] - dynamic = cls._trim_fixed_keys( - normalized.get(DYNAMIC_LOAD_SETTINGS_KEY), - dynamic_default, - ) - dynamic[DYNAMIC_LOAD_ENABLED_KEY] = cls._normalize_config_bool( - dynamic.get(DYNAMIC_LOAD_ENABLED_KEY), - dynamic_default[DYNAMIC_LOAD_ENABLED_KEY], - ) - dynamic[DYNAMIC_LOAD_INTERVAL_KEY] = cls._normalize_config_positive_int( - dynamic.get(DYNAMIC_LOAD_INTERVAL_KEY), - dynamic_default[DYNAMIC_LOAD_INTERVAL_KEY], - ) - normalized[DYNAMIC_LOAD_SETTINGS_KEY] = dynamic - - normalized["是否启用"] = cls._normalize_config_bool( - normalized.get("是否启用"), - default_cfg["是否启用"], - ) - normalized["唤醒词"] = cls._normalize_config_string_list( - normalized.get("唤醒词"), - default_cfg["唤醒词"], - ) - normalized["数据文件"] = cls._normalize_config_str( - normalized.get("数据文件"), - default_cfg["数据文件"], - ) - for key in ( - "检测间隔", - "传送半径", - "最大领地半径", - "最大领地长", - "最大领地高", - "最大领地宽", - "最大领地数量", - ): - normalized[key] = cls._normalize_config_positive_int( - normalized.get(key), - default_cfg[key], - ) - normalized["缓冲区距离"] = cls._normalize_config_non_negative_int( - normalized.get("缓冲区距离"), - default_cfg["缓冲区距离"], - ) - normalized["白名单"] = cls._normalize_config_string_list( - normalized.get("白名单"), - default_cfg["白名单"], - allow_empty=True, - ) - return normalized - - def _load_config( - self, default_cfg: Dict[str, Any], cfg_std: Any) -> Dict[str, Any]: - try: - raw_cfg, _ = cfg.get_plugin_config_and_version( - self.name, - {}, - default_cfg, - self.version, - ) - merged_cfg = self._normalize_runtime_config(raw_cfg, default_cfg) - cfg.check_auto(cfg_std, merged_cfg) - except Exception as err: - fmts.print_err(f"领地系统云链联动版配置文件自动更新失败,已使用默认配置: {err}") - merged_cfg = self._normalize_runtime_config({}, default_cfg) - cfg.check_auto(cfg_std, merged_cfg) - cfg.upgrade_plugin_config(self.name, merged_cfg, self.version) - return merged_cfg - - def _apply_runtime_config_fields(self, reload_data_file: bool = False): - self.enabled = bool(self.cfg["是否启用"]) - self.wake_words = self._normalize_wake_words(self.cfg["唤醒词"]) - new_data_file = self.format_data_path(str(self.cfg["数据文件"])) - data_file_changed = getattr(self, "data_file", None) != new_data_file - self.data_file = new_data_file - self.check_interval = self._positive_float(self.cfg["检测间隔"], 2.0) - self.buffer_dist = self._non_negative_float(self.cfg["缓冲区距离"], 5.0) - self.tp_radius = self._positive_float(self.cfg["传送半径"], 5000.0) - self.max_radius = self._positive_int(self.cfg["最大领地半径"], 200) - self.max_length = self._positive_int(self.cfg["最大领地长"], 200) - self.max_height = self._positive_int(self.cfg["最大领地高"], 200) - self.max_width = self._positive_int(self.cfg["最大领地宽"], 200) - self.max_lands_per_player = self._positive_int(self.cfg["最大领地数量"], 4) - self.whitelist = {str(name).lower() for name in self.cfg["白名单"]} - if reload_data_file and data_file_changed: - self.lands.clear() - self.player_land_cache.clear() - self._ensure_dirs() - self._load_data() - - @staticmethod - def _positive_int(value: Any, fallback: int) -> int: - try: - result = int(value) - except (TypeError, ValueError): - return fallback - return result if result > 0 else fallback - - @staticmethod - def _positive_float(value: Any, fallback: float) -> float: - try: - result = float(value) - except (TypeError, ValueError): - return fallback - return result if result > 0 else fallback - - @staticmethod - def _non_negative_float(value: Any, fallback: float) -> float: - try: - result = float(value) - except (TypeError, ValueError): - return fallback - return result if result >= 0 else fallback - - def config_file_path(self) -> str: - return os.path.join(CONFIG_FILE_DIR, f"{self.name}.json") - - @staticmethod - def file_state(path: str) -> tuple[int, int] | None: - try: - stat = os.stat(path) - except OSError: - return None - return stat.st_mtime_ns, stat.st_size - - def refresh_config_file_state(self): - self._config_file_state = self.file_state(self.config_file_path()) - self._no_create_regions_file_state = self.file_state( - self.no_create_regions_file) - - def is_dynamic_config_reload_enabled(self) -> bool: - settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) - if not isinstance(settings, dict): - return True - return bool(settings.get(DYNAMIC_LOAD_ENABLED_KEY, True)) - - def dynamic_config_reload_interval(self) -> int: - settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) - if not isinstance(settings, dict): - return DYNAMIC_LOAD_DEFAULT_INTERVAL - try: - interval = int(settings.get(DYNAMIC_LOAD_INTERVAL_KEY, - DYNAMIC_LOAD_DEFAULT_INTERVAL)) - except (TypeError, ValueError): - return DYNAMIC_LOAD_DEFAULT_INTERVAL - return interval if interval > 0 else DYNAMIC_LOAD_DEFAULT_INTERVAL - - def apply_runtime_config(self, config_items: Dict[str, Any]) -> None: - """Apply already parsed config-center data to this plugin.""" - self.cfg = self._normalize_runtime_config( - config_items, self._cfg_default) - cfg.check_auto(self._cfg_std, self.cfg) - self._apply_runtime_config_fields(reload_data_file=True) - if self.enabled: - self._start_detection() - self.refresh_config_file_state() - - def reload_runtime_config(self, announce: bool = False): - self.cfg = self._load_config(self._cfg_default, self._cfg_std) - self._apply_runtime_config_fields(reload_data_file=True) - if self.enabled: - self._start_detection() - if announce: - fmts.print_suc(f"{self.name} 配置文件已热更新") - - def reload_no_create_regions_config(self, announce: bool = False): - self.no_create_regions_raw = self._load_no_create_regions() - self._reload_no_create_regions() - if announce: - fmts.print_suc(f"{self.name} 不可创建领地区域配置已热更新") - - def config_reload_task(self): - while not self._stop_event.wait(self.dynamic_config_reload_interval()): - if not self.is_dynamic_config_reload_enabled(): - self.refresh_config_file_state() - continue - current_config_state = self.file_state(self.config_file_path()) - current_regions_state = self.file_state( - self.no_create_regions_file) - if ( - current_config_state == self._config_file_state - and current_regions_state == self._no_create_regions_file_state - ): - continue - try: - if current_config_state != self._config_file_state: - self.reload_runtime_config(announce=True) - if current_regions_state != self._no_create_regions_file_state: - self.reload_no_create_regions_config(announce=True) - self.refresh_config_file_state() - except Exception as err: - self._config_file_state = current_config_state - self._no_create_regions_file_state = current_regions_state - fmts.print_err(f"{self.name} 配置文件热更新失败: {err}") - - def api_reload_land_config(self) -> Tuple[bool, str, Dict[str, Any]]: - try: - self.reload_runtime_config(announce=False) - self.reload_no_create_regions_config(announce=False) - self.refresh_config_file_state() - except Exception as err: - return False, f"领地系统配置重载失败: {err}", self.api_get_runtime_status() - return True, "领地系统配置已重载", self.api_get_runtime_status() - - def api_get_runtime_status(self) -> Dict[str, Any]: - return { - "enabled": self.enabled, - "wake_words": list(self.wake_words), - "data_file": self.data_file, - "check_interval": self.check_interval, - "land_count": len(self.lands), - "no_create_region_count": len(self.no_create_regions), - "dynamic_reload_enabled": self.is_dynamic_config_reload_enabled(), - "dynamic_reload_interval": self.dynamic_config_reload_interval(), - } - - def _load_no_create_regions(self) -> List[Dict[str, Any]]: - if not os.path.exists(self.no_create_regions_file): - regions = default_no_create_regions() - self._save_no_create_regions(regions) - return regions - try: - with open(self.no_create_regions_file, "r", encoding="utf-8") as f: - data = json.load(f) - if isinstance(data, list): - return data - except Exception as err: - fmts.print_err(f"读取不可创建领地区域数据失败: {err}") - regions = default_no_create_regions() - self._save_no_create_regions(regions) - return regions - - def _save_no_create_regions( - self, regions: Optional[List[Dict[str, Any]]] = None): - data = self.no_create_regions_raw if regions is None else regions - os.makedirs( - os.path.dirname( - self.no_create_regions_file), - exist_ok=True) - with open(self.no_create_regions_file, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - def _reload_no_create_regions(self): - self.no_create_regions = self._normalize_no_create_regions( - self.no_create_regions_raw) - - @staticmethod - def _as_float_pos(raw: Any) -> Optional[Tuple[float, float, float]]: - if not isinstance(raw, list) or len(raw) != 3: - return None - try: - return (float(raw[0]), float(raw[1]), float(raw[2])) - except (TypeError, ValueError): - return None - - def _normalize_no_create_regions(self, raw: Any) -> List[Dict[str, Any]]: - regions: List[Dict[str, Any]] = [] - if not isinstance(raw, list): - return regions - for index, item in enumerate(raw, 1): - if not isinstance(item, dict) or not item.get("启用", True): - continue - region_type = str(item.get("类型", "")).strip().lower() - name = str(item.get("名称", f"区域{index}")).strip() or f"区域{index}" - if region_type in ("圆形", "circle", "round"): - center = self._as_float_pos(item.get("中心")) - try: - radius = float(item.get("半径", 0)) - except (TypeError, ValueError): - radius = 0 - if center is None or radius <= 0: - fmts.print_war(f"领地系统云链联动版: 不可创建领地区域 {name} 配置无效,已忽略") - continue - regions.append({ - "名称": name, - "类型": "圆形", - "中心": center, - "半径": radius, - }) - elif region_type in ("方形", "矩形", "长方形", "square", "box", "立方体", "长方体"): - start = self._as_float_pos(item.get("起点")) - end = self._as_float_pos(item.get("终点")) - if start is None or end is None: - fmts.print_war(f"领地系统云链联动版: 不可创建领地区域 {name} 配置无效,已忽略") - continue - min_pos = tuple(min(start[i], end[i]) for i in range(3)) - max_pos = tuple(max(start[i], end[i]) for i in range(3)) - regions.append({ - "名称": name, - "类型": "方形", - "起点": min_pos, - "终点": max_pos, - }) - else: - fmts.print_war(f"领地系统云链联动版: 不可创建领地区域 {name} 类型未知,已忽略") - return regions - - def _get_no_create_overlap_reason( - self, - center: Tuple[float, float, float], - radius: int, - shape: str = "圆形", - size: Optional[Tuple[int, int, int]] = None, - ) -> Optional[str]: - shape = LandData.normalize_shape(shape) - box_bounds = None - if shape == "方形": - box_bounds = bounds_from_center_size( - center, LandData.normalize_size(size, radius)) - x, y, z = center - for region in self.no_create_regions: - if shape == "方形" and box_bounds is not None: - if region["类型"] == "圆形": - if sphere_intersects_box( - region["中心"], - region["半径"], - box_bounds[0], - box_bounds[1]): - return region["名称"] - elif region["类型"] == "方形": - if boxes_intersect( - box_bounds[0], - box_bounds[1], - region["起点"], - region["终点"]): - return region["名称"] - elif region["类型"] == "圆形": - rx, ry, rz = region["中心"] - if math.sqrt((x - rx) ** 2 + (y - ry) ** 2 + - (z - rz) ** 2) <= radius + region["半径"]: - return region["名称"] - elif region["类型"] == "方形": - if sphere_intersects_box( - center, radius, region["起点"], region["终点"]): - return region["名称"] - return None - - @staticmethod - def _is_exit_input(text: str) -> bool: - return text.strip().lower() in (".", "。", "q", "quit", "退出") - - def _wait_menu_input( - self, - player: Player, - timeout: int = 60) -> Optional[str]: - msg = game_utils.waitMsg(player.name, timeout) - if msg is None: - player.show(self._error("回复超时,已退出菜单")) - return None - msg = msg.strip() - if self._is_exit_input(msg): - player.show(self._success("已退出领地菜单")) - return None - return msg - - def _select_menu( - self, - player: Player, - title: str, - options: List[str], - timeout: int = 60) -> Optional[int]: - while True: - hints = [ - f"输入 §e[1-{len(options)}]§b 之间的数字以选择功能", - "输入 §c.§b 退出", - ] - player.show(self._ui_menu( - title, - options, - )) - player.show("\n".join(f"§a❀ §b{hint}" for hint in hints)) - msg = self._wait_menu_input(player, timeout) - if msg is None: - return None - choice = utils.try_int(msg.strip().strip("[]")) - if choice is None or choice not in range(1, len(options) + 1): - player.show(self._error("您的输入有误")) - continue - return choice - - def _prompt_text( - self, - player: Player, - title: str, - prompt: str, - timeout: int = 60) -> Optional[str]: - player.show(self._ui_card(title, [prompt], ["输入 §c.§b 退出"])) - return self._wait_menu_input(player, timeout) - - def _parse_box_size_input( - self, raw: str) -> Tuple[Optional[Tuple[int, int, int]], Optional[str]]: - parts = raw.replace(",", " ").replace(",", " ").split() - if len(parts) != 3: - return None, "格式错误,需要输入 长 高 宽 三个整数" - try: - length, height, width = ( - int(parts[0]), int(parts[1]), int(parts[2])) - except ValueError: - return None, "长、高、宽必须为整数" - if length <= 0 or height <= 0 or width <= 0: - return None, "长、高、宽必须大于 0" - if length > self.max_length: - return None, f"长不能超过 {self.max_length}" - if height > self.max_height: - return None, f"高不能超过 {self.max_height}" - if width > self.max_width: - return None, f"宽不能超过 {self.max_width}" - return (length, height, width), None - - def _get_xuid_by_name( - self, - playername: str, - allow_offline: bool = False) -> Optional[str]: - try: - return str( - self.xuid_getter.get_xuid_by_name( - playername, - allow_offline=allow_offline)) - except Exception as err: - self.print_war(f"无法获取玩家 {playername} 的 XUID: {err}") - return None - - def _get_player_xuid(self, player: Player) -> Optional[str]: - xuid = getattr(player, "xuid", None) - if xuid: - return str(xuid) - return self._get_xuid_by_name(player.name) - - def _make_member(self, playername: str, rank: LandRank, - allow_offline: bool = False) -> Optional[LandMember]: - xuid = self._get_xuid_by_name(playername, allow_offline=allow_offline) - if xuid is None: - return None - return LandMember(name=playername, xuid=xuid, rank=rank) - - def _select_land( - self, - player: Player, - title: str, - lands: List[LandData]) -> Optional[LandData]: - if not lands: - player.show(self._error("没有可选择的领地")) - return None - choice = self._select_menu( - player, - title, - [f"{land.name} §7- 领主: {land.owner}, {land.range_text()}" for land in lands], - ) - if choice is None: - return None - return lands[choice - 1] - - def _land_summary(self, land: LandData) -> Dict[str, Any]: - admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] - members = [m.name for m in land.members if m.rank == LandRank.MEMBER] - return { - "land_id": land.land_id, - "name": land.name, - "owner": land.owner, - "owner_xuid": land.owner_xuid, - "center": land.center, - "radius": land.radius, - "shape": land.shape, - "size": land.get_size() if land.is_box() else None, - "dimension": land.dimension, - "range_text": land.range_text(), - "admins": admins, - "members": members, - "member_count": len(land.members), - } - - def _find_land_by_name_or_id(self, query: str) -> Optional[LandData]: - query = str(query).strip() - if not query: - return None - if query in self.lands: - return self.lands[query] - return next((land for land in self.lands.values() - if land.name == query), None) - - def _migrate_legacy_data_path(self): - legacy_path = os.path.join( - os.path.dirname( - self.data_path), - LEGACY_PLUGIN_NAME) - if not os.path.isdir(legacy_path) or os.path.abspath( - legacy_path) == os.path.abspath(self.data_path): - return - os.makedirs(self.data_path, exist_ok=True) - for filename in ("领地数据.json", "不可创建领地区域.json"): - old_file = os.path.join(legacy_path, filename) - new_file = self.format_data_path(filename) - if os.path.isfile(old_file) and not os.path.exists(new_file): - try: - shutil.copy2(old_file, new_file) - except Exception as err: - self.print_war(f"迁移旧版领地系统数据文件 {filename} 失败: {err}") - - def _ensure_dirs(self): - os.makedirs(os.path.dirname(self.data_file) or ".", exist_ok=True) - - # ---------- 玩家-领地 缓存维护 ---------- - def _add_player_land(self, xuid: str, land_id: str): - if xuid not in self.player_land_cache: - self.player_land_cache[xuid] = [] - if land_id not in self.player_land_cache[xuid]: - self.player_land_cache[xuid].append(land_id) - - def _remove_player_land(self, xuid: str, land_id: str): - if xuid in self.player_land_cache: - lst = self.player_land_cache[xuid] - if land_id in lst: - lst.remove(land_id) - if not lst: - del self.player_land_cache[xuid] - - def _rebuild_player_land_cache(self): - self.player_land_cache.clear() - for land_id, land in self.lands.items(): - for member in land.members: - self._add_player_land(member.xuid, land_id) - - # ---------- 数据加载/保存 ---------- - def _load_data(self): - try: - if os.path.exists(self.data_file): - with open(self.data_file, "r", encoding="utf-8") as f: - raw = json.load(f) - self.player_land_cache.clear() - for land_id, data in raw.items(): - try: - land = LandData.from_dict(data) - self.lands[land_id] = land - for m in land.members: - self._add_player_land(m.xuid, land_id) - except Exception as e: - fmts.print_err(f"解析领地 {land_id} 失败: {e}") - except Exception as e: - fmts.print_err(f"加载数据失败: {e}") - - def _save_data(self): - try: - raw = {lid: land.to_dict() for lid, land in self.lands.items()} - with open(self.data_file, "w", encoding="utf-8") as f: - json.dump(raw, f, ensure_ascii=False, indent=2) - except Exception as e: - fmts.print_err(f"保存数据失败: {e}") - - # ---------- 坐标获取 ---------- - def _get_player_coord( - self, player: str) -> Optional[Tuple[float, float, float]]: - try: - pos_dict = game_utils.getPos(player) - if pos_dict and "position" in pos_dict: - p = pos_dict["position"] - return (p.get("x", 0), p.get("y", 0), p.get("z", 0)) - except Exception: - pass - cmd = f"/data get entity {player} Pos" - try: - resp = self.game_ctrl.sendwscmd_with_resp(cmd, timeout=2) - if resp.SuccessCount: - for out in resp.OutputMessages: - msg = out.Message - nums = re.findall(r"[-+]?\d*\.?\d+[df]?", msg) - if len(nums) >= 3: - x = float(nums[0].replace('d', '').replace('f', '')) - y = float(nums[1].replace('d', '').replace('f', '')) - z = float(nums[2].replace('d', '').replace('f', '')) - return (x, y, z) - except Exception: - pass - return None - - def _manual_coord( - self, player: Player) -> Optional[Tuple[float, float, float]]: - player.show(self._ui_card( - "手动坐标输入", - [ - "请输入你的当前坐标", - "格式:x y z,例如 100 64 200", - "请在聊天栏直接输入数字,用空格分隔", - ], - ["30 秒内回复有效"], - )) - msg = game_utils.waitMsg(player.name, 30) - if not msg: - player.show(self._error("输入超时")) - return None - parts = msg.strip().split() - if len(parts) != 3: - player.show(self._error("格式错误,需要三个数字")) - return None - try: - x = float(parts[0]) - y = float(parts[1]) - z = float(parts[2]) - return (x, y, z) - except ValueError: - player.show(self._error("请输入有效的数字")) - return None - - # ---------- 领地查找辅助 ---------- - def _find_land_at(self, - pos: Tuple[float, - float, - float], - dimension: int = 0) -> Optional[LandData]: - x, y, z = pos - for land in self.lands.values(): - if land.dimension != dimension: - continue - if land.contains_pos((x, y, z)): - return land - return None - - def _find_land_at_player(self, player: str) -> Optional[LandData]: - pos = self._get_player_coord(player) - if pos: - return self._find_land_at(pos) - return None - - # ---------- 检测线程 ---------- - def _start_detection(self): - if self._detection_started: - return - self._detection_started = True - - def loop(): - while not self._stop_event.wait(self.check_interval): - try: - self._update_coords() - self._check_lands() - except Exception as e: - fmts.print_err(f"检测异常: {e}") - threading.Thread(target=loop, daemon=True).start() - - def _update_coords(self): - if not self.enabled: - return - players = self.game_ctrl.allplayers - new_coords = {} - for name in players: - pos = self._get_player_coord(name) - if pos: - new_coords[name] = pos - with self.coords_lock: - self.coords = new_coords - - def _check_lands(self): - if not self.enabled: - return - with self.coords_lock: - players = dict(self.coords) - - now = time.time() - expired = [ - p for p, - t in self.recent_tp.items() if now - - t > self.tp_cooldown] - for p in expired: - del self.recent_tp[p] - - for name, (x, y, z) in players.items(): - if name.lower() in self.whitelist: - continue - xuid = self._get_xuid_by_name(name) - if xuid is None: - continue - - in_land = self._find_land_at((x, y, z)) - - if in_land: - if in_land.get_member(xuid): - continue - if xuid in self.recent_tp: - continue - self._tp_random(name, x, y, z) - self.recent_tp[xuid] = now - self.game_ctrl.say_to(name, self._error( - f"你闯入了 {in_land.owner} 的领地,已被传送离开")) - else: - for land in self.lands.values(): - if land.dimension != 0: - continue - dist = distance_to_land((x, y, z), land) - if 0 <= dist <= self.buffer_dist: - if land.get_member(xuid): - continue - self.game_ctrl.sendwocmd(f"/gamemode adventure {name}") - self.game_ctrl.say_to(name, self._warn( - f"你正在靠近 {land.owner} 的领地,请勿进入")) - break - - def _tp_random(self, player: str, x: float, y: float, z: float): - angle = random.uniform(0, 2 * math.pi) - dist = random.uniform(10, self.tp_radius) - nx = x + dist * math.cos(angle) - nz = z + dist * math.sin(angle) - ny = y - cmd = f"/tp {player} {nx} {ny} {nz}" - self.game_ctrl.sendwocmd(cmd) - - # ---------- 实体标记(可选) ---------- - def _spawn_entity(self, land: LandData): - try: - x, y, z = land.center - self._remove_entity(land) - cmd = f"summon area_effect_cloud {x} {y} {z} { - Duration:2147483647,WaitTime:2147483647,Tags:[\"land_{ - land.land_id}\"]} " - self.game_ctrl.sendwocmd(cmd) - except Exception: - pass - - def _remove_entity(self, land: LandData): - try: - cmd = f"kill @e[type=area_effect_cloud,tag=land_{land.land_id}]" - self.game_ctrl.sendwocmd(cmd) - except Exception: - pass - - # ---------- 命令处理 ---------- - def on_chat(self, chat: Chat): - if not self.enabled: - return - msg = chat.msg.strip() - if msg not in self.wake_words: - return - self._main_menu(chat.player) - - def _main_menu(self, player: Player): - choice = self._select_menu( - player, - "功能菜单", - [ - "创建领地", - "删除领地", - "查看领地信息", - "成员管理", - "管理员管理", - "传送到领地", - "查看领地列表", - "测试当前位置", - ], - timeout=90, - ) - if choice is None: - return - if choice == 1: - self._menu_create(player) - elif choice == 2: - self._menu_delete(player) - elif choice == 3: - self._menu_info(player) - elif choice == 4: - self._menu_member(player) - elif choice == 5: - self._menu_admin(player) - elif choice == 6: - self._menu_tp(player) - elif choice == 7: - self._list(player) - elif choice == 8: - self._test(player) - - def _menu_create(self, player: Player): - name = self._prompt_text(player, "创建领地", "请输入领地名称") - if name is None: - return - shape_choice = self._select_menu(player, "创建领地类型", ["圆形领地", "方形领地"]) - if shape_choice is None: - return - if shape_choice == 1: - radius = self._prompt_text( - player, "创建圆形领地", f"请输入领地半径,范围 1~{self.max_radius}") - if radius is None: - return - self._create(player, [name, "圆形", radius]) - else: - size_text = self._prompt_text( - player, - "创建方形领地", - f"请输入 长 高 宽,最大 { - self.max_length} { - self.max_height} { - self.max_width}", - ) - if size_text is None: - return - size, err = self._parse_box_size_input(size_text) - if size is None: - player.show(self._error(err or "方形领地尺寸无效")) - return - self._create( - player, [ - name, "方形", str( - size[0]), str( - size[1]), str( - size[2])]) - - def _menu_delete(self, player: Player): - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - owned = [land for land in self.lands.values( - ) if land.owner_xuid == player_xuid] - land = self._select_land(player, "删除领地", owned) - if land is None: - return - self._delete(player, [land.name]) - - def _menu_info(self, player: Player): - choice = self._select_menu( - player, - "查看领地信息", - ["查看当前所在领地", "从我的领地中选择", "从全部领地中选择", "输入领地名称"], - ) - if choice is None: - return - if choice == 1: - self._info(player, []) - elif choice == 2: - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - lands = [land for land in self.lands.values() if land.owner_xuid == - player_xuid] - land = self._select_land(player, "我的领地", lands) - if land: - self._info(player, [land.name]) - elif choice == 3: - land = self._select_land(player, "全部领地", list(self.lands.values())) - if land: - self._info(player, [land.name]) - elif choice == 4: - name = self._prompt_text(player, "查看领地信息", "请输入领地名称") - if name: - self._info(player, [name]) - - def _menu_member(self, player: Player): - choice = self._select_menu(player, "成员管理", ["添加成员", "移除成员", "查看成员列表"]) - if choice is None: - return - if choice == 3: - self._member(player, ["列表"]) - return - target = self._prompt_text( - player, - "成员管理", - "请输入玩家名", - ) - if target is None: - return - self._member(player, [["添加", "移除"][choice - 1], target]) - - def _menu_admin(self, player: Player): - choice = self._select_menu(player, "管理员管理", ["添加管理员", "移除管理员"]) - if choice is None: - return - target = self._prompt_text(player, "管理员管理", "请输入玩家名") - if target is None: - return - self._admin(player, [["添加", "移除"][choice - 1], target]) - - def _menu_tp(self, player: Player): - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - lands = [land for land in self.lands.values( - ) if land.has_permission(player_xuid, "tp")] - land = self._select_land(player, "传送到领地", lands) - if land is None: - return - self._tp(player, [land.name]) - - def _create(self, player: Player, args: List[str]): - if len(args) < 2: - player.show(self._error( - f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入创建菜单")) - return - name = args[0] - shape_arg = str(args[1]).strip().lower() - explicit_circle = shape_arg in ("圆形", "circle", "round") - explicit_box = LandData.normalize_shape(shape_arg) == "方形" - shape = "方形" if explicit_box else "圆形" - size = None - if shape == "方形": - if len(args) < 5: - player.show(self._error("方形领地需要输入 长 高 宽")) - return - size, err = self._parse_box_size_input(" ".join(args[2:5])) - if size is None: - player.show(self._error(err or "方形领地尺寸无效")) - return - radius = box_radius_for_size(size) - else: - radius_arg = args[2] if len( - args) >= 3 and explicit_circle else args[1] - try: - radius = int(radius_arg) - except ValueError: - player.show(self._error("半径必须为整数")) - return - if radius <= 0 or radius > self.max_radius: - player.show(self._error(f"半径必须在 1~{self.max_radius} 之间")) - return - - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - owned = [ - land_item for land_item in self.lands.values() - if land_item.owner_xuid == player_xuid - ] - if len(owned) >= self.max_lands_per_player: - player.show( - self._error( - f"你最多只能拥有 { - self.max_lands_per_player} 个领地")) - return - - pos = self._get_player_coord(player.name) - if not pos: - player.show(self._warn("无法自动获取坐标,请手动输入")) - pos = self._manual_coord(player) - if not pos: - return - x, y, z = pos - - overlap = self._get_land_candidate_overlap_reason( - (x, y, z), radius, shape, size) - if overlap: - player.show(self._error(overlap)) - return - - land_id = str(uuid.uuid4()) - member = LandMember( - name=player.name, - xuid=player_xuid, - rank=LandRank.OWNER) - land = LandData( - land_id=land_id, - name=name, - owner=player.name, - owner_xuid=player_xuid, - center=(x, y, z), - radius=radius, - shape=shape, - size=size, - members=[member] - ) - self.lands[land_id] = land - self._add_player_land(player_xuid, land_id) - self._save_data() - player.show(self._success(f"成功创建领地 '{name}',{land.range_text()}")) - self._spawn_entity(land) - - def _delete(self, player: Player, args: List[str]): - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - owned = [ - land_item for land_item in self.lands.values() - if land_item.owner_xuid == player_xuid - ] - if not owned: - player.show(self._error("你没有拥有任何领地")) - return - - if args: - name = args[0] - land = next( - (land_item for land_item in owned if land_item.name == name), - None, - ) - if not land: - player.show(self._error(f"你没有名为 '{name}' 的领地")) - return - else: - if len(owned) > 1: - names = "、".join(land_item.name for land_item in owned) - player.show(self._error(f"你拥有多个领地,请指定名称:{names}")) - return - land = owned[0] - - self._remove_entity(land) - for m in land.members: - self._remove_player_land(m.xuid, land.land_id) - del self.lands[land.land_id] - self._save_data() - player.show(self._success(f"已删除领地 '{land.name}'")) - - def _info(self, player: Player, args: List[str]): - land = None - if args: - name = args[0] - land = next( - ( - land_item for land_item in self.lands.values() - if land_item.name == name - ), - None, - ) - if not land: - player.show(self._error(f"领地 '{name}' 不存在")) - return - else: - land = self._find_land_at_player(player.name) - if not land: - player.show(self._error("你当前不在任何领地内,请指定领地名称")) - return - - admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] - members = [m.name for m in land.members if m.rank == LandRank.MEMBER] - player.show(self._ui_card( - f"领地信息 - {land.name}", - [ - f"中心:{land.center[0]}, {land.center[1]}, {land.center[2]}", - f"范围:{land.range_text()}", - f"领主:{land.owner}", - f"管理员:{', '.join(admins) or '无'}", - f"成员:{', '.join(members) or '无'}", - ], - )) - - def _member(self, player: Player, args: List[str]): - if len(args) < 1: - player.show(self._error( - f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入成员管理菜单")) - return - sub = args[0].lower() - if sub == "列表": - land = self._find_land_at_player(player.name) - if not land: - player.show(self._error("你当前不在任何领地内")) - return - self._info(player, [land.name]) - return - - if len(args) < 2: - player.show(self._error( - f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入成员管理菜单")) - return - target = args[1] - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - target_member = self._make_member( - target, LandRank.MEMBER, allow_offline=True) - if target_member is None: - player.show(self._error(f"无法获取 {target} 的 XUID")) - return - - land = self._find_land_at_player(player.name) - if not land: - player.show(self._error("你当前不在任何领地内")) - return - - if not land.has_permission(player_xuid, "manage_member"): - player.show(self._error("你没有权限管理成员")) - return - - if sub == "添加": - if land.get_member(target_member.xuid): - player.show(self._warn(f"{target} 已是成员")) - return - land.members.append(target_member) - self._add_player_land(target_member.xuid, land.land_id) - self._save_data() - player.show(self._success(f"已将 {target} 添加为成员")) - elif sub == "移除": - if not land.get_member(target_member.xuid): - player.show(self._error(f"{target} 不是成员")) - return - if not land.can_manage_member(player_xuid, target_member.xuid): - player.show(self._error("你不能移除该成员")) - return - land.members = [ - m for m in land.members if m.xuid != target_member.xuid] - self._remove_player_land(target_member.xuid, land.land_id) - self._save_data() - player.show(self._success(f"已将 {target} 移除成员")) - else: - player.show(self._error("未知子命令")) - - def _admin(self, player: Player, args: List[str]): - if len(args) < 2: - player.show(self._error( - f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入管理员管理菜单")) - return - sub = args[0].lower() - target = args[1] - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - target_xuid = self._get_xuid_by_name(target, allow_offline=True) - if target_xuid is None: - player.show(self._error(f"无法获取 {target} 的 XUID")) - return - - land = self._find_land_at_player(player.name) - if not land: - player.show(self._error("你当前不在任何领地内")) - return - - if land.owner_xuid != player_xuid: - player.show(self._error("只有领主可以管理管理员")) - return - - if sub == "添加": - member = land.get_member(target_xuid) - if not member: - player.show(self._error(f"{target} 不是成员")) - return - if member.rank == LandRank.ADMIN: - player.show(self._warn(f"{target} 已经是管理员")) - return - member.rank = LandRank.ADMIN - self._save_data() - player.show(self._success(f"已将 {target} 设为管理员")) - elif sub == "移除": - member = land.get_member(target_xuid) - if not member or member.rank != LandRank.ADMIN: - player.show(self._error(f"{target} 不是管理员")) - return - member.rank = LandRank.MEMBER - self._save_data() - player.show(self._success(f"已将 {target} 移除管理员")) - else: - player.show(self._error("未知子命令")) - - def _tp(self, player: Player, args: List[str]): - land = None - if args: - name = args[0] - land = next( - ( - land_item for land_item in self.lands.values() - if land_item.name == name - ), - None, - ) - if not land: - player.show(self._error(f"领地 '{name}' 不存在")) - return - else: - land = self._find_land_at_player(player.name) - if not land: - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - owned = [ - land_item for land_item in self.lands.values() - if land_item.owner_xuid == player_xuid - ] - if owned: - land = owned[0] - else: - player.show(self._error("你当前不在任何领地内,且你没有拥有领地,请指定名称")) - return - - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - if not land.has_permission(player_xuid, "tp"): - player.show(self._error("你没有权限传送到该领地")) - return - - cx, cy, cz = land.center - self.game_ctrl.sendwocmd(f"/tp {player.name} {cx} {cy + 1} {cz}") - player.show(self._success(f"已传送到领地 '{land.name}'")) - - def _list(self, player: Player): - if not self.lands: - player.show(self._error("暂无任何领地")) - return - options = [ - f"{land.name} §7- 领主: {land.owner}, {land.range_text()}" - for land in self.lands.values() - ] - player.show(self._ui_menu("领地列表", options, [f"共 {len(options)} 个领地"])) - - def _test(self, player: Player): - pos = self._get_player_coord(player.name) - lines = [] - if pos: - lines.append(f"当前坐标:{pos[0]:.1f}, {pos[1]:.1f}, {pos[2]:.1f}") - else: - lines.append("无法获取坐标") - - in_land = self._find_land_at_player(player.name) - if in_land: - lines.append(f"当前位置:位于领地 '{in_land.name}' 内") - else: - lines.append("当前位置:不在任何领地内") - - if self.lands: - lines.append( - "所有领地:" + "、".join(f"{land.name}({land.owner})" for land in self.lands.values())) - else: - lines.append("所有领地:无") - player.show(self._ui_card("测试信息", lines)) - - def on_player_join(self, player: Player): - if not self.enabled: - return - pass - - def on_player_leave(self, player: Player): - if not self.enabled: - return - pass - - # ---------- 外部插件 API ---------- - def api_list_lands(self) -> Tuple[bool, str, List[Dict[str, Any]]]: - lands = [self._land_summary(land) for land in self.lands.values()] - return True, f"共 {len(lands)} 个领地", lands - - def api_get_land( - self, land_query: str) -> Tuple[bool, str, Optional[Dict[str, Any]]]: - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - return True, "查询成功", self._land_summary(land) - - def api_add_land( - self, - owner: str, - name: str, - center: Tuple[float, float, float], - shape: str = "圆形", - radius: Optional[int] = None, - size: Optional[Tuple[int, int, int]] = None, - dimension: int = 0, - ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: - owner = str(owner).strip() - name = str(name).strip() - if not owner or not name: - return False, "领地主人和领地名称不能为空", None - if self._find_land_by_name_or_id(name): - return False, f"领地 '{name}' 已存在", None - owner_member = self._make_member( - owner, LandRank.OWNER, allow_offline=True) - if owner_member is None: - return False, f"无法获取 {owner} 的 XUID", None - if len([land for land in self.lands.values() if land.owner_xuid == - owner_member.xuid]) >= self.max_lands_per_player: - return False, f"{owner} 已达到最大领地数量 { - self.max_lands_per_player}", None - try: - center_tuple = ( - float( - center[0]), float( - center[1]), float( - center[2])) - except (TypeError, ValueError, IndexError): - return False, "中心坐标无效", None - - normalized_shape = LandData.normalize_shape(shape) - normalized_size = None - if normalized_shape == "方形": - if size is None: - return False, "方形领地需要提供 长 高 宽", None - normalized_size = LandData.normalize_size(size, radius or 1) - if normalized_size[0] > self.max_length: - return False, f"长不能超过 {self.max_length}", None - if normalized_size[1] > self.max_height: - return False, f"高不能超过 {self.max_height}", None - if normalized_size[2] > self.max_width: - return False, f"宽不能超过 {self.max_width}", None - land_radius = box_radius_for_size(normalized_size) - else: - try: - land_radius = int(radius) - except (TypeError, ValueError): - return False, "圆形领地半径必须为整数", None - if land_radius <= 0 or land_radius > self.max_radius: - return False, f"半径必须在 1~{self.max_radius} 之间", None - - overlap = self._get_land_candidate_overlap_reason( - center_tuple, - land_radius, - normalized_shape, - normalized_size, - dimension, - ) - if overlap: - return False, overlap, None - - land_id = str(uuid.uuid4()) - land = LandData( - land_id=land_id, - name=name, - owner=owner, - owner_xuid=owner_member.xuid, - center=center_tuple, - radius=land_radius, - shape=normalized_shape, - size=normalized_size, - dimension=dimension, - members=[owner_member], - ) - self.lands[land_id] = land - self._rebuild_player_land_cache() - self._save_data() - self._spawn_entity(land) - return True, f"已新增玩家 {owner} 的领地 '{name}',{ - land.range_text()}", self._land_summary(land) - - def api_delete_land(self, land_query: str) -> Tuple[bool, str, None]: - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - self._remove_entity(land) - del self.lands[land.land_id] - self._rebuild_player_land_cache() - self._save_data() - return True, f"已删除领地 '{land.name}'", None - - def api_add_member(self, - land_query: str, - player_name: str, - rank: str = "member") -> Tuple[bool, - str, - Optional[Dict[str, - Any]]]: - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - target_rank = LandRank.ADMIN if str(rank).lower() in ( - "admin", "管理员", "管理") else LandRank.MEMBER - member = self._make_member( - player_name, target_rank, allow_offline=True) - if member is None: - return False, f"无法获取 {player_name} 的 XUID", None - old_member = land.get_member(member.xuid) - if old_member: - if old_member.rank == LandRank.OWNER: - return False, "所有者不能被改为成员或管理员", self._land_summary(land) - old_member.name = player_name - old_member.rank = target_rank - else: - land.members.append(member) - self._rebuild_player_land_cache() - self._save_data() - role_name = "管理员" if target_rank == LandRank.ADMIN else "成员" - return True, f"已将 {player_name} 添加为领地 '{ - land.name}' 的{role_name}", self._land_summary(land) - - def api_set_member_rank( - self, - land_query: str, - player_name: str, - rank: str, - ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - target_rank = LandRank.ADMIN if str(rank).lower() in ( - "admin", "管理员", "管理") else LandRank.MEMBER - target_xuid = self._get_xuid_by_name(player_name, allow_offline=True) - if target_xuid is None: - return False, f"无法获取 {player_name} 的 XUID", None - member = land.get_member(target_xuid) - if member is None: - return False, f"{player_name} 不在领地 '{ - land.name}' 中", self._land_summary(land) - if member.rank == LandRank.OWNER: - return False, "所有者不能修改身份", self._land_summary(land) - member.name = player_name - member.rank = target_rank - self._save_data() - role_name = "管理员" if target_rank == LandRank.ADMIN else "成员" - return True, f"已将 {player_name} 设为领地 '{ - land.name}' 的{role_name}", self._land_summary(land) - - def api_remove_member(self, - land_query: str, - player_name: str) -> Tuple[bool, - str, - Optional[Dict[str, - Any]]]: - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - target_xuid = self._get_xuid_by_name(player_name, allow_offline=True) - if target_xuid is None: - return False, f"无法获取 {player_name} 的 XUID", None - member = land.get_member(target_xuid) - if not member: - return False, f"{player_name} 不在领地 '{ - land.name}' 中", self._land_summary(land) - if member.rank == LandRank.OWNER: - return False, "不能删除领地所有者,请先转移所有者", self._land_summary(land) - land.members = [m for m in land.members if m.xuid != target_xuid] - self._rebuild_player_land_cache() - self._save_data() - return True, f"已从领地 '{ - land.name}' 移除 {player_name}", self._land_summary(land) - - def api_transfer_owner( - self, land_query: str, owner: str) -> Tuple[bool, str, Optional[Dict[str, Any]]]: - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - owner_member_data = self._make_member( - owner, LandRank.OWNER, allow_offline=True) - if owner_member_data is None: - return False, f"无法获取 {owner} 的 XUID", None - land.owner = owner - land.owner_xuid = owner_member_data.xuid - for member in land.members: - if member.rank == LandRank.OWNER: - member.rank = LandRank.ADMIN - owner_member = land.get_member(owner_member_data.xuid) - if owner_member: - owner_member.name = owner - owner_member.rank = LandRank.OWNER - else: - land.members.append(owner_member_data) - self._rebuild_player_land_cache() - self._save_data() - return True, f"已修改领地 '{ - land.name}' 所有者为 {owner}", self._land_summary(land) - - def api_update_land_center( - self, - land_query: str, - center: Tuple[float, float, float], - ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - try: - center_tuple = ( - float( - center[0]), float( - center[1]), float( - center[2])) - except (TypeError, ValueError, IndexError): - return False, "中心坐标无效", self._land_summary(land) - overlap = self._get_land_edit_overlap_reason( - land, center_tuple, land.radius) - if overlap: - return False, overlap, self._land_summary(land) - land.center = center_tuple - self._save_data() - self._spawn_entity(land) - return True, f"已修改领地 '{land.name}' 中心点", self._land_summary(land) - - def api_update_land_range( - self, - land_query: str, - radius: Optional[int] = None, - size: Optional[Tuple[int, int, int]] = None, - ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - if land.is_box(): - if size is None: - return False, "方形领地需要提供 长 高 宽", self._land_summary(land) - normalized_size = LandData.normalize_size(size, land.radius) - if normalized_size[0] > self.max_length: - return False, f"长不能超过 { - self.max_length}", self._land_summary(land) - if normalized_size[1] > self.max_height: - return False, f"高不能超过 { - self.max_height}", self._land_summary(land) - if normalized_size[2] > self.max_width: - return False, f"宽不能超过 { - self.max_width}", self._land_summary(land) - land_radius = box_radius_for_size(normalized_size) - overlap = self._get_land_edit_overlap_reason( - land, land.center, land_radius, "方形", normalized_size) - if overlap: - return False, overlap, self._land_summary(land) - land.size = normalized_size - land.radius = land_radius - else: - try: - land_radius = int(radius) - except (TypeError, ValueError): - return False, "半径必须为整数", self._land_summary(land) - if land_radius <= 0 or land_radius > self.max_radius: - return False, f"半径必须在 1~{ - self.max_radius} 之间", self._land_summary(land) - overlap = self._get_land_edit_overlap_reason( - land, land.center, land_radius, "圆形", None) - if overlap: - return False, overlap, self._land_summary(land) - land.radius = land_radius - land.size = None - self._save_data() - return True, f"已修改领地 '{ - land.name}' 范围为 { - land.range_text()}", self._land_summary(land) - - def _console_print(self, text: str): - fmts.print_inf(text) - - def _console_prompt(self, prompt: str) -> Optional[str]: - value = input(fmts.fmt_info( - f"§a❀ §b{prompt} §7(输入 . 退出整个领地系统云链联动版管理菜单): ")).strip() - if self._is_exit_input(value): - raise ConsoleMenuExit - return value - - def _console_select(self, title: str, options: List[str]) -> Optional[int]: - while True: - self._console_print(self._ui_menu( - title, - options, - [f"输入 §e[1-{len(options)}]§b 之间的数字以选择", "输入 §c.§b 退出整个领地系统云链联动版管理菜单"], - )) - value = self._console_prompt("请输入序号") - if value is None: - return None - choice = utils.try_int(value.strip().strip("[]")) - if choice is None or choice not in range(1, len(options) + 1): - fmts.print_err(self._error("输入有误")) - continue - return choice - - def _console_prompt_float(self, prompt: str) -> Optional[float]: - value = self._console_prompt(prompt) - if value is None: - return None - try: - return float(value) - except ValueError: - fmts.print_err(self._error("请输入有效数字")) - return None - - def _console_prompt_int(self, prompt: str) -> Optional[int]: - value = self._console_prompt(prompt) - if value is None: - return None - try: - return int(value) - except ValueError: - fmts.print_err(self._error("请输入有效整数")) - return None - - def _console_prompt_pos(self, prompt: str) -> Optional[List[float]]: - value = self._console_prompt(f"{prompt},格式 x y z") - if value is None: - return None - parts = value.split() - if len(parts) != 3: - fmts.print_err(self._error("坐标格式错误,需要 x y z 三个数字")) - return None - try: - return [float(parts[0]), float(parts[1]), float(parts[2])] - except ValueError: - fmts.print_err(self._error("坐标必须是数字")) - return None - - def _console_select_land( - self, title: str, lands: Optional[List[LandData]] = None) -> Optional[LandData]: - lands = list(self.lands.values()) if lands is None else lands - if not lands: - fmts.print_err(self._error("暂无可选择的领地")) - return None - choice = self._console_select( - title, - [f"{land.name} - 领主: {land.owner}, {land.range_text()}" for land in lands], - ) - if choice is None: - return None - return lands[choice - 1] - - def console_manage(self, _args: List[str]): - try: - while True: - choice = self._console_select( - "控制台管理菜单", - ["新增不可创建领地区域", "删除不可创建领地区域", "新增玩家领地", "删除玩家领地", "管理玩家领地"], - ) - if choice is None: - fmts.print_inf(self._success("已退出领地系统云链联动版管理菜单")) - return - if choice == 1: - self._console_add_no_create_region() - elif choice == 2: - self._console_delete_no_create_region() - elif choice == 3: - self._console_add_land() - elif choice == 4: - self._console_delete_land() - elif choice == 5: - self._console_manage_land() - except ConsoleMenuExit: - fmts.print_inf(self._success("已退出领地系统云链联动版管理菜单")) - - def _console_add_no_create_region(self): - name = self._console_prompt("请输入区域名称") - if name is None: - return - region_choice = self._console_select("新增不可创建区域", ["圆形区域", "方形区域"]) - if region_choice is None: - return - if region_choice == 1: - center = self._console_prompt_pos("请输入圆形区域中心") - radius = self._console_prompt_float("请输入圆形区域半径") - if center is None or radius is None or radius <= 0: - fmts.print_err(self._error("区域参数无效")) - return - region = { - "名称": name, - "启用": True, - "类型": "圆形", - "中心": center, - "半径": radius} - else: - start = self._console_prompt_pos("请输入方形区域起点") - end = self._console_prompt_pos("请输入方形区域终点") - if start is None or end is None: - fmts.print_err(self._error("区域参数无效")) - return - region = { - "名称": name, - "启用": True, - "类型": "方形", - "起点": start, - "终点": end} - self.no_create_regions_raw.append(region) - self._save_no_create_regions() - self._reload_no_create_regions() - fmts.print_suc(self._success(f"已新增不可创建领地区域 '{name}'")) - - def _console_delete_no_create_region(self): - if not self.no_create_regions_raw: - fmts.print_err(self._error("暂无不可创建领地区域")) - return - choice = self._console_select( - "删除不可创建区域", - [ - f"{region.get('名称', f'区域{i}')} - {region.get('类型', '未知') - } - {'启用' if region.get('启用', True) else '禁用'}" - for i, region in enumerate(self.no_create_regions_raw, 1) - ], - ) - if choice is None: - return - region = self.no_create_regions_raw.pop(choice - 1) - self._save_no_create_regions() - self._reload_no_create_regions() - fmts.print_suc( - self._success( - f"已删除不可创建领地区域 '{ - region.get( - '名称', - choice)}'")) - - def _console_add_land(self): - owner = self._console_prompt("请输入领地主人玩家名") - name = self._console_prompt("请输入领地名称") - center = self._console_prompt_pos("请输入领地中心坐标") - shape_choice = self._console_select("请选择领地类型", ["圆形领地", "方形领地"]) - if owner is None or name is None or center is None or shape_choice is None: - return - owner_member = self._make_member( - owner, LandRank.OWNER, allow_offline=True) - if owner_member is None: - fmts.print_err(self._error(f"无法获取 {owner} 的 XUID")) - return - shape = "圆形" - size = None - if shape_choice == 1: - radius = self._console_prompt_int( - f"请输入领地半径,范围 1~{self.max_radius}") - if radius is None: - return - if radius <= 0 or radius > self.max_radius: - fmts.print_err(self._error(f"半径必须在 1~{self.max_radius} 之间")) - return - else: - size_text = self._console_prompt( - f"请输入方形领地 长 高 宽,最大 { - self.max_length} { - self.max_height} { - self.max_width}") - if size_text is None: - return - size, err = self._parse_box_size_input(size_text) - if size is None: - fmts.print_err(self._error(err or "方形领地尺寸无效")) - return - shape = "方形" - radius = box_radius_for_size(size) - overlap = self._get_land_candidate_overlap_reason( - tuple(center), radius, shape, size) - if overlap: - fmts.print_err(self._error(overlap)) - return - land_id = str(uuid.uuid4()) - land = LandData( - land_id=land_id, - name=name, - owner=owner, - owner_xuid=owner_member.xuid, - center=tuple(center), - radius=radius, - shape=shape, - size=size, - members=[owner_member], - ) - self.lands[land_id] = land - self._rebuild_player_land_cache() - self._save_data() - self._spawn_entity(land) - fmts.print_suc( - self._success( - f"已新增玩家 {owner} 的领地 '{name}',{ - land.range_text()}")) - - def _console_delete_land(self): - land = self._console_select_land("删除玩家领地") - if land is None: - return - self._remove_entity(land) - del self.lands[land.land_id] - self._rebuild_player_land_cache() - self._save_data() - fmts.print_suc(self._success(f"已删除领地 '{land.name}'")) - - def _console_manage_land(self): - land = self._console_select_land("管理玩家领地") - if land is None: - return - while True: - choice = self._console_select( - f"管理领地 - {land.name}", - ["管理用户", "管理管理人员", "管理所有者", "管理领地中心点", "管理领地范围", "查看领地信息"], - ) - if choice is None: - return - if choice == 1: - self._console_manage_land_users(land) - elif choice == 2: - self._console_manage_land_admins(land) - elif choice == 3: - self._console_manage_land_owner(land) - elif choice == 4: - self._console_manage_land_center(land) - elif choice == 5: - self._console_manage_land_radius(land) - elif choice == 6: - self._console_show_land_info(land) - - def _console_show_land_info(self, land: LandData): - admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] - members = [m.name for m in land.members if m.rank == LandRank.MEMBER] - fmts.print_inf(self._ui_card( - f"领地信息 - {land.name}", - [ - f"领主:{land.owner}", - f"中心:{land.center[0]}, {land.center[1]}, {land.center[2]}", - f"范围:{land.range_text()}", - f"管理员:{', '.join(admins) or '无'}", - f"用户:{', '.join(members) or '无'}", - ], - )) - - def _console_manage_land_users(self, land: LandData): - while True: - choice = self._console_select( - f"管理用户 - {land.name}", - ["添加用户", "删除用户", "查看用户列表"], - ) - if choice is None: - return - if choice == 1: - target = self._console_prompt("请输入要添加的玩家名") - if not target: - continue - member = self._make_member( - target, LandRank.MEMBER, allow_offline=True) - if member is None: - fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) - continue - if land.get_member(member.xuid): - fmts.print_err(self._warn(f"{target} 已在该领地中")) - continue - land.members.append(member) - self._rebuild_player_land_cache() - self._save_data() - fmts.print_suc(self._success(f"已添加用户 {target}")) - elif choice == 2: - target = self._console_prompt("请输入要删除的玩家名") - if not target: - continue - target_xuid = self._get_xuid_by_name( - target, allow_offline=True) - if target_xuid is None: - fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) - continue - member = land.get_member(target_xuid) - if not member: - fmts.print_err(self._error(f"{target} 不在该领地中")) - continue - if member.rank == LandRank.OWNER: - fmts.print_err(self._error("不能在用户管理中删除所有者,请先转移所有者")) - continue - land.members = [ - m for m in land.members if m.xuid != target_xuid] - self._rebuild_player_land_cache() - self._save_data() - fmts.print_suc(self._success(f"已删除用户 {target}")) - elif choice == 3: - users = [ - f"{m.name}({m.rank.display_name})" for m in land.members] - fmts.print_inf(self._ui_card( - f"用户列表 - {land.name}", - [", ".join(users) if users else "无用户"], - )) - - def _console_manage_land_admins(self, land: LandData): - while True: - choice = self._console_select( - f"管理管理人员 - {land.name}", - ["添加管理人员", "删除管理人员", "查看管理人员"], - ) - if choice is None: - return - if choice == 1: - target = self._console_prompt("请输入要设为管理人员的玩家名") - if not target: - continue - target_xuid = self._get_xuid_by_name( - target, allow_offline=True) - if target_xuid is None: - fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) - continue - member = land.get_member(target_xuid) - if member and member.rank == LandRank.OWNER: - fmts.print_err(self._error("所有者不能设为管理人员")) - continue - if member: - member.name = target - member.rank = LandRank.ADMIN - else: - land.members.append( - LandMember( - target, - target_xuid, - LandRank.ADMIN)) - self._rebuild_player_land_cache() - self._save_data() - fmts.print_suc(self._success(f"已将 {target} 设为管理人员")) - elif choice == 2: - target = self._console_prompt("请输入要删除管理权限的玩家名") - if not target: - continue - target_xuid = self._get_xuid_by_name( - target, allow_offline=True) - if target_xuid is None: - fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) - continue - member = land.get_member(target_xuid) - if not member or member.rank != LandRank.ADMIN: - fmts.print_err(self._error(f"{target} 不是管理人员")) - continue - member.rank = LandRank.MEMBER - self._rebuild_player_land_cache() - self._save_data() - fmts.print_suc(self._success(f"已删除 {target} 的管理权限")) - elif choice == 3: - admins = [ - m.name for m in land.members if m.rank == LandRank.ADMIN] - fmts.print_inf(self._ui_card( - f"管理人员 - {land.name}", - [", ".join(admins) if admins else "暂无管理人员"], - )) - - def _console_manage_land_owner(self, land: LandData): - while True: - choice = self._console_select( - f"管理所有者 - {land.name}", - ["修改所有者", "查看所有者"], - ) - if choice is None: - return - if choice == 1: - owner = self._console_prompt("请输入新所有者玩家名") - if not owner: - continue - owner_member_data = self._make_member( - owner, LandRank.OWNER, allow_offline=True) - if owner_member_data is None: - fmts.print_err(self._error(f"无法获取 {owner} 的 XUID")) - continue - land.owner = owner - land.owner_xuid = owner_member_data.xuid - for member in land.members: - if member.rank == LandRank.OWNER: - member.rank = LandRank.ADMIN - owner_member = land.get_member(owner_member_data.xuid) - if owner_member: - owner_member.name = owner - owner_member.rank = LandRank.OWNER - else: - land.members.append(owner_member_data) - self._rebuild_player_land_cache() - self._save_data() - fmts.print_suc(self._success(f"已修改所有者为 {owner}")) - elif choice == 2: - fmts.print_inf(self._ui_card( - f"所有者 - {land.name}", [land.owner])) - - def _console_manage_land_center(self, land: LandData): - while True: - choice = self._console_select( - f"管理领地中心点 - {land.name}", - ["修改中心点", "查看中心点"], - ) - if choice is None: - return - if choice == 1: - center = self._console_prompt_pos("请输入新的领地中心点") - if center is None: - continue - center_tuple = tuple(center) - overlap = self._get_land_edit_overlap_reason( - land, center_tuple, land.radius) - if overlap: - fmts.print_err(self._error(overlap)) - continue - land.center = center_tuple - self._save_data() - self._spawn_entity(land) - fmts.print_suc(self._success( - f"已修改领地中心点为 {center[0]}, {center[1]}, {center[2]}")) - elif choice == 2: - fmts.print_inf(self._ui_card( - f"领地中心点 - {land.name}", - [f"{land.center[0]}, {land.center[1]}, {land.center[2]}"], - )) - - def _console_manage_land_radius(self, land: LandData): - while True: - options = [ - "修改方形长高宽", - "查看方形长高宽"] if land.is_box() else [ - "修改范围半径", - "查看范围半径"] - choice = self._console_select( - f"管理领地范围 - {land.name}", - options, - ) - if choice is None: - return - if choice == 1: - if land.is_box(): - size_text = self._console_prompt( - f"请输入新的 长 高 宽,最大 { - self.max_length} { - self.max_height} { - self.max_width}") - if size_text is None: - continue - size, err = self._parse_box_size_input(size_text) - if size is None: - fmts.print_err(self._error(err or "方形领地尺寸无效")) - continue - radius = box_radius_for_size(size) - overlap = self._get_land_edit_overlap_reason( - land, land.center, radius, "方形", size) - if overlap: - fmts.print_err(self._error(overlap)) - continue - land.shape = "方形" - land.size = size - land.radius = radius - self._save_data() - fmts.print_suc(self._success( - f"已修改方形领地范围为 长:{size[0]}, 高:{size[1]}, 宽:{size[2]}")) - else: - radius = self._console_prompt_int( - f"请输入新范围半径,范围 1~{self.max_radius}") - if radius is None: - continue - if radius <= 0 or radius > self.max_radius: - fmts.print_err( - self._error( - f"半径必须在 1~{ - self.max_radius} 之间")) - continue - overlap = self._get_land_edit_overlap_reason( - land, land.center, radius, "圆形", None) - if overlap: - fmts.print_err(self._error(overlap)) - continue - land.shape = "圆形" - land.size = None - land.radius = radius - self._save_data() - fmts.print_suc(self._success(f"已修改领地范围半径为 {radius}")) - elif choice == 2: - fmts.print_inf(self._ui_card( - f"领地范围 - {land.name}", [land.range_text()])) - - def _get_land_candidate_overlap_reason( - self, - center: Tuple[float, float, float], - radius: int, - shape: str, - size: Optional[Tuple[int, int, int]] = None, - dimension: int = 0, - skip_land_id: Optional[str] = None, - ) -> Optional[str]: - blocked_region = self._get_no_create_overlap_reason( - center, radius, shape, size) - if blocked_region: - return f"领地不能与不可创建区域 '{blocked_region}' 重叠" - for other in self.lands.values(): - if other.land_id == skip_land_id or other.dimension != dimension: - continue - if land_overlaps_candidate(other, center, radius, shape, size): - return f"领地与 '{other.name}' 重叠" - return None - - def _get_land_edit_overlap_reason( - self, - land: LandData, - center: Tuple[float, float, float], - radius: int, - shape: Optional[str] = None, - size: Optional[Tuple[int, int, int]] = None, - ) -> Optional[str]: - edit_shape = shape or land.shape - edit_size = size if size is not None else land.size - return self._get_land_candidate_overlap_reason( - center, - radius, - edit_shape, - edit_size, - land.dimension, - land.land_id, - ) - - def console_test(self, args: List[str]): - if not self.enabled: - fmts.print_war(self._warn("领地系统云链联动版当前已在配置中禁用")) - return - if args: - target = args[0] - pos = self._get_player_coord(target) - if pos: - fmts.print_inf( - self._ui_card( - "控制台测试", [ - f"玩家 {target} 坐标:{pos}"])) - else: - fmts.print_err(self._error("无法获取坐标")) - else: - fmts.print_inf(self._notice("用法: 领地测试 <玩家名>")) - - -entry = plugin_entry(LandPlugin, "领地系统云链联动版", (0, 1, 18)) +"""Land protection cloud interop ToolDelta plugin.""" + +import copy +import json +import time +import threading +import os +import re +import random +import math +import shutil +import uuid +from typing import Dict, List, Optional, Tuple, Any +from tooldelta import Plugin, cfg, fmts, Player, Chat, plugin_entry, utils, game_utils + +from .config import ( + CONFIG_FILE_DIR, + DYNAMIC_LOAD_DEFAULT_INTERVAL, + DYNAMIC_LOAD_ENABLED_KEY, + DYNAMIC_LOAD_INTERVAL_KEY, + DYNAMIC_LOAD_SETTINGS_KEY, + NO_CREATE_REGIONS_FILE, + default_config, + default_no_create_regions, +) +from .geometry import ( + box_radius_for_size, + bounds_from_center_size, + boxes_intersect, + distance_to_land, + land_overlaps_candidate, + sphere_intersects_box, +) +from .models import LandData, LandMember, LandRank + + +PLUGIN_NAME = "领地系统云链联动版" +LEGACY_PLUGIN_NAME = "领地系统" + + +class ConsoleMenuExit(Exception): + """Signal that the console menu should exit.""" + + pass + + +class LandPlugin(Plugin): + """ToolDelta plugin entrypoint for land protection cloud interop.""" + + name = PLUGIN_NAME + author = "小石潭记qwq" + version = (0, 1, 18) + + def __init__(self, frame): + super().__init__(frame) + self.ListenPreload(self.on_preload) + self._stop_event = threading.Event() + self._config_file_state = None + self._no_create_regions_file_state = None + self._cfg_default = default_config() + self._cfg_std = cfg.auto_to_std(self._cfg_default) + self.make_data_path() + self._migrate_legacy_data_path() + self.no_create_regions_file = self.format_data_path( + NO_CREATE_REGIONS_FILE) + self.cfg = self._load_config(self._cfg_default, self._cfg_std) + self.data_file = self.format_data_path(str(self.cfg["数据文件"])) + self._apply_runtime_config_fields(reload_data_file=False) + self.no_create_regions_raw = self._load_no_create_regions() + self.no_create_regions = self._normalize_no_create_regions( + self.no_create_regions_raw) + + # 数据 + self.lands: Dict[str, LandData] = {} # land_id -> LandData + # xuid -> list of land_id + self.player_land_cache: Dict[str, List[str]] = {} + self.xuid_getter = None + + self.coords: Dict[str, Tuple[float, float, float]] = {} + self.coords_lock = threading.Lock() + self.recent_tp: Dict[str, float] = {} # xuid -> last tp time + self.tp_cooldown = 5 + self._detection_started = False + + self._ensure_dirs() + self._load_data() + + # 事件监听 + self.ListenChat(self.on_chat) + self.ListenPlayerJoin(self.on_player_join) + self.ListenPlayerLeave(self.on_player_leave) + self.ListenActive(self.on_active) + self.ListenFrameExit(self.on_frame_exit) + self.refresh_config_file_state() + self.config_thread = utils.createThread( + self.config_reload_task, + usage="领地系统配置热更新任务", + ) + + # 控制台测试指令 + self.frame.add_console_cmd_trigger( + ["领地云链测试", "领地测试"], "[玩家]", "测试领地系统云链联动版", self.console_test) + self.frame.add_console_cmd_trigger( + ["领地系统云链联动版", "领地系统"], None, "打开领地系统云链联动版控制台管理菜单", self.console_manage) + + def on_preload(self): + """Implement the on preload operation.""" + self.xuid_getter = self.GetPluginAPI("XUID获取", (0, 0, 7)) + + def on_active(self): + """Implement the on active operation.""" + if self.enabled: + self._start_detection() + + def on_frame_exit(self, _): + """Implement the on frame exit operation.""" + self._stop_event.set() + + @staticmethod + def _ui_border() -> str: + """Implement the ui border operation.""" + return "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧" + + @staticmethod + def _ui_title(title: str) -> str: + """Implement the ui title operation.""" + return f"§l§d❐§f 『§6领地系统云链联动版§f』 §b{title}" + + def _ui_menu(self, + title: str, + options: List[str], + hints: Optional[List[str]] = None) -> str: + """Implement the ui menu operation.""" + lines = [self._ui_border(), self._ui_title(title)] + lines.extend(f"§l§b[ §e{i}§b ] §r§e{option}" for i, + option in enumerate(options, 1)) + lines.append(self._ui_border()) + for hint in hints or []: + lines.append(f"§a❀ §b{hint}") + return "\n".join(lines) + + def _ui_card(self, + title: str, + lines: List[str], + hints: Optional[List[str]] = None) -> str: + """Implement the ui card operation.""" + body = [self._ui_border(), self._ui_title(title)] + body.extend(f"§a❀ §b{line}" for line in lines) + body.append(self._ui_border()) + for hint in hints or []: + body.append(f"§a❀ §b{hint}") + return "\n".join(body) + + @staticmethod + def _success(text: str) -> str: + """Implement the success operation.""" + return f"§a❀ §b{text}" + + @staticmethod + def _error(text: str) -> str: + """Implement the error operation.""" + return f"§c❀ §e{text}" + + @staticmethod + def _warn(text: str) -> str: + """Implement the warn operation.""" + return f"§6❀ §e{text}" + + @staticmethod + def _notice(text: str) -> str: + """Implement the notice operation.""" + return f"§a❀ §b{text}" + + @staticmethod + def _normalize_wake_words(raw: Any) -> List[str]: + """Normalize wake words values.""" + if isinstance(raw, str): + words = [raw] + elif isinstance(raw, list): + words = [str(item) for item in raw if isinstance(item, str)] + else: + words = [] + cleaned = [] + for word in words: + word = word.strip() + if word and word not in cleaned: + cleaned.append(word) + return cleaned or [".领地"] + + @classmethod + def _merge_config_with_default(cls, raw: Any, default: Any): + """Implement the merge config with default operation.""" + if isinstance(default, dict): + result = { + key: cls._merge_config_with_default( + raw.get(key) if isinstance(raw, dict) else None, + value, + ) + for key, value in default.items() + } + if isinstance(raw, dict): + for key, value in raw.items(): + if key not in result: + result[key] = copy.deepcopy(value) + return result + return copy.deepcopy( + raw) if raw is not None else copy.deepcopy(default) + + @staticmethod + def _trim_fixed_keys(raw: Any, default: Dict[str, Any]) -> Dict[str, Any]: + """Implement the trim fixed keys operation.""" + raw = raw if isinstance(raw, dict) else {} + return { + key: copy.deepcopy(raw.get(key, value)) + for key, value in default.items() + } + + @staticmethod + def _normalize_config_bool(value: Any, fallback: bool) -> bool: + """Normalize config bool values.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in ("true", "1", "yes", "y", "on", "启用", "是", "真"): + return True + if text in ("false", "0", "no", "n", "off", "禁用", "否", "假"): + return False + if isinstance(value, (int, float)) and not isinstance(value, bool): + return bool(value) + return bool(fallback) + + @staticmethod + def _normalize_config_positive_int(value: Any, fallback: int) -> int: + """Normalize config positive int values.""" + if isinstance(value, bool): + return fallback + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + @staticmethod + def _normalize_config_non_negative_int(value: Any, fallback: int) -> int: + """Normalize config non negative int values.""" + if isinstance(value, bool): + return fallback + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result >= 0 else fallback + + @staticmethod + def _normalize_config_str( + value: Any, + fallback: str, + *, + allow_empty: bool = False) -> str: + """Normalize config str values.""" + if value is None: + return fallback + text = str(value) + if text or allow_empty: + return text + return fallback + + @classmethod + def _normalize_config_string_list( + cls, + value: Any, + fallback: List[str], + *, + allow_empty: bool = False, + ) -> List[str]: + """Normalize config string list values.""" + if isinstance(value, str): + candidates = [value] + elif isinstance(value, list): + candidates = value + else: + return copy.deepcopy(fallback) + + result: List[str] = [] + for item in candidates: + text = cls._normalize_config_str( + item, "", allow_empty=True).strip() + if text and text not in result: + result.append(text) + if result or allow_empty: + return result + return copy.deepcopy(fallback) + + @classmethod + def _normalize_runtime_config( + cls, raw_cfg: Any, default_cfg: Dict[str, Any]) -> Dict[str, Any]: + """Normalize runtime config values.""" + merged_cfg = cls._merge_config_with_default(raw_cfg, default_cfg) + normalized = cls._trim_fixed_keys(merged_cfg, default_cfg) + + dynamic_default = default_cfg[DYNAMIC_LOAD_SETTINGS_KEY] + dynamic = cls._trim_fixed_keys( + normalized.get(DYNAMIC_LOAD_SETTINGS_KEY), + dynamic_default, + ) + dynamic[DYNAMIC_LOAD_ENABLED_KEY] = cls._normalize_config_bool( + dynamic.get(DYNAMIC_LOAD_ENABLED_KEY), + dynamic_default[DYNAMIC_LOAD_ENABLED_KEY], + ) + dynamic[DYNAMIC_LOAD_INTERVAL_KEY] = cls._normalize_config_positive_int( + dynamic.get(DYNAMIC_LOAD_INTERVAL_KEY), + dynamic_default[DYNAMIC_LOAD_INTERVAL_KEY], + ) + normalized[DYNAMIC_LOAD_SETTINGS_KEY] = dynamic + + normalized["是否启用"] = cls._normalize_config_bool( + normalized.get("是否启用"), + default_cfg["是否启用"], + ) + normalized["唤醒词"] = cls._normalize_config_string_list( + normalized.get("唤醒词"), + default_cfg["唤醒词"], + ) + normalized["数据文件"] = cls._normalize_config_str( + normalized.get("数据文件"), + default_cfg["数据文件"], + ) + for key in ( + "检测间隔", + "传送半径", + "最大领地半径", + "最大领地长", + "最大领地高", + "最大领地宽", + "最大领地数量", + ): + normalized[key] = cls._normalize_config_positive_int( + normalized.get(key), + default_cfg[key], + ) + normalized["缓冲区距离"] = cls._normalize_config_non_negative_int( + normalized.get("缓冲区距离"), + default_cfg["缓冲区距离"], + ) + normalized["白名单"] = cls._normalize_config_string_list( + normalized.get("白名单"), + default_cfg["白名单"], + allow_empty=True, + ) + return normalized + + def _load_config( + self, default_cfg: Dict[str, Any], cfg_std: Any) -> Dict[str, Any]: + """Load config data.""" + try: + raw_cfg, _ = cfg.get_plugin_config_and_version( + self.name, + {}, + default_cfg, + self.version, + ) + merged_cfg = self._normalize_runtime_config(raw_cfg, default_cfg) + cfg.check_auto(cfg_std, merged_cfg) + except Exception as err: + fmts.print_err(f"领地系统云链联动版配置文件自动更新失败,已使用默认配置: {err}") + merged_cfg = self._normalize_runtime_config({}, default_cfg) + cfg.check_auto(cfg_std, merged_cfg) + cfg.upgrade_plugin_config(self.name, merged_cfg, self.version) + return merged_cfg + + def _apply_runtime_config_fields(self, reload_data_file: bool = False): + """Implement the apply runtime config fields operation.""" + self.enabled = bool(self.cfg["是否启用"]) + self.wake_words = self._normalize_wake_words(self.cfg["唤醒词"]) + new_data_file = self.format_data_path(str(self.cfg["数据文件"])) + data_file_changed = getattr(self, "data_file", None) != new_data_file + self.data_file = new_data_file + self.check_interval = self._positive_float(self.cfg["检测间隔"], 2.0) + self.buffer_dist = self._non_negative_float(self.cfg["缓冲区距离"], 5.0) + self.tp_radius = self._positive_float(self.cfg["传送半径"], 5000.0) + self.max_radius = self._positive_int(self.cfg["最大领地半径"], 200) + self.max_length = self._positive_int(self.cfg["最大领地长"], 200) + self.max_height = self._positive_int(self.cfg["最大领地高"], 200) + self.max_width = self._positive_int(self.cfg["最大领地宽"], 200) + self.max_lands_per_player = self._positive_int(self.cfg["最大领地数量"], 4) + self.whitelist = {str(name).lower() for name in self.cfg["白名单"]} + if reload_data_file and data_file_changed: + self.lands.clear() + self.player_land_cache.clear() + self._ensure_dirs() + self._load_data() + + @staticmethod + def _positive_int(value: Any, fallback: int) -> int: + """Implement the positive int operation.""" + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + @staticmethod + def _positive_float(value: Any, fallback: float) -> float: + """Implement the positive float operation.""" + try: + result = float(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + @staticmethod + def _non_negative_float(value: Any, fallback: float) -> float: + """Implement the non negative float operation.""" + try: + result = float(value) + except (TypeError, ValueError): + return fallback + return result if result >= 0 else fallback + + def config_file_path(self) -> str: + """Implement the config file path operation.""" + return os.path.join(CONFIG_FILE_DIR, f"{self.name}.json") + + @staticmethod + def file_state(path: str) -> tuple[int, int] | None: + """Implement the file state operation.""" + try: + stat = os.stat(path) + except OSError: + return None + return stat.st_mtime_ns, stat.st_size + + def refresh_config_file_state(self): + """Implement the refresh config file state operation.""" + self._config_file_state = self.file_state(self.config_file_path()) + self._no_create_regions_file_state = self.file_state( + self.no_create_regions_file) + + def is_dynamic_config_reload_enabled(self) -> bool: + """Implement the is dynamic config reload enabled operation.""" + settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return True + return bool(settings.get(DYNAMIC_LOAD_ENABLED_KEY, True)) + + def dynamic_config_reload_interval(self) -> int: + """Implement the dynamic config reload interval operation.""" + settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return DYNAMIC_LOAD_DEFAULT_INTERVAL + try: + interval = int(settings.get(DYNAMIC_LOAD_INTERVAL_KEY, + DYNAMIC_LOAD_DEFAULT_INTERVAL)) + except (TypeError, ValueError): + return DYNAMIC_LOAD_DEFAULT_INTERVAL + return interval if interval > 0 else DYNAMIC_LOAD_DEFAULT_INTERVAL + + def apply_runtime_config(self, config_items: Dict[str, Any]) -> None: + """Apply already parsed config-center data to this plugin.""" + self.cfg = self._normalize_runtime_config( + config_items, self._cfg_default) + cfg.check_auto(self._cfg_std, self.cfg) + self._apply_runtime_config_fields(reload_data_file=True) + if self.enabled: + self._start_detection() + self.refresh_config_file_state() + + def reload_runtime_config(self, announce: bool = False): + """Implement the reload runtime config operation.""" + self.cfg = self._load_config(self._cfg_default, self._cfg_std) + self._apply_runtime_config_fields(reload_data_file=True) + if self.enabled: + self._start_detection() + if announce: + fmts.print_suc(f"{self.name} 配置文件已热更新") + + def reload_no_create_regions_config(self, announce: bool = False): + """Implement the reload no create regions config operation.""" + self.no_create_regions_raw = self._load_no_create_regions() + self._reload_no_create_regions() + if announce: + fmts.print_suc(f"{self.name} 不可创建领地区域配置已热更新") + + def config_reload_task(self): + """Implement the config reload task operation.""" + while not self._stop_event.wait(self.dynamic_config_reload_interval()): + if not self.is_dynamic_config_reload_enabled(): + self.refresh_config_file_state() + continue + current_config_state = self.file_state(self.config_file_path()) + current_regions_state = self.file_state( + self.no_create_regions_file) + if ( + current_config_state == self._config_file_state + and current_regions_state == self._no_create_regions_file_state + ): + continue + try: + if current_config_state != self._config_file_state: + self.reload_runtime_config(announce=True) + if current_regions_state != self._no_create_regions_file_state: + self.reload_no_create_regions_config(announce=True) + self.refresh_config_file_state() + except Exception as err: + self._config_file_state = current_config_state + self._no_create_regions_file_state = current_regions_state + fmts.print_err(f"{self.name} 配置文件热更新失败: {err}") + + def api_reload_land_config(self) -> Tuple[bool, str, Dict[str, Any]]: + """Expose the api reload land config API operation.""" + try: + self.reload_runtime_config(announce=False) + self.reload_no_create_regions_config(announce=False) + self.refresh_config_file_state() + except Exception as err: + return False, f"领地系统配置重载失败: {err}", self.api_get_runtime_status() + return True, "领地系统配置已重载", self.api_get_runtime_status() + + def api_get_runtime_status(self) -> Dict[str, Any]: + """Expose the api get runtime status API operation.""" + return { + "enabled": self.enabled, + "wake_words": list(self.wake_words), + "data_file": self.data_file, + "check_interval": self.check_interval, + "land_count": len(self.lands), + "no_create_region_count": len(self.no_create_regions), + "dynamic_reload_enabled": self.is_dynamic_config_reload_enabled(), + "dynamic_reload_interval": self.dynamic_config_reload_interval(), + } + + def _load_no_create_regions(self) -> List[Dict[str, Any]]: + """Load no create regions data.""" + if not os.path.exists(self.no_create_regions_file): + regions = default_no_create_regions() + self._save_no_create_regions(regions) + return regions + try: + with open(self.no_create_regions_file, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return data + except Exception as err: + fmts.print_err(f"读取不可创建领地区域数据失败: {err}") + regions = default_no_create_regions() + self._save_no_create_regions(regions) + return regions + + def _save_no_create_regions( + self, regions: Optional[List[Dict[str, Any]]] = None): + """Save no create regions data.""" + data = self.no_create_regions_raw if regions is None else regions + os.makedirs( + os.path.dirname( + self.no_create_regions_file), + exist_ok=True) + with open(self.no_create_regions_file, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def _reload_no_create_regions(self): + """Implement the reload no create regions operation.""" + self.no_create_regions = self._normalize_no_create_regions( + self.no_create_regions_raw) + + @staticmethod + def _as_float_pos(raw: Any) -> Optional[Tuple[float, float, float]]: + """Implement the as float pos operation.""" + if not isinstance(raw, list) or len(raw) != 3: + return None + try: + return (float(raw[0]), float(raw[1]), float(raw[2])) + except (TypeError, ValueError): + return None + + def _normalize_no_create_regions(self, raw: Any) -> List[Dict[str, Any]]: + """Normalize no create regions values.""" + regions: List[Dict[str, Any]] = [] + if not isinstance(raw, list): + return regions + for index, item in enumerate(raw, 1): + if not isinstance(item, dict) or not item.get("启用", True): + continue + region_type = str(item.get("类型", "")).strip().lower() + name = str(item.get("名称", f"区域{index}")).strip() or f"区域{index}" + if region_type in ("圆形", "circle", "round"): + center = self._as_float_pos(item.get("中心")) + try: + radius = float(item.get("半径", 0)) + except (TypeError, ValueError): + radius = 0 + if center is None or radius <= 0: + fmts.print_war(f"领地系统云链联动版: 不可创建领地区域 {name} 配置无效,已忽略") + continue + regions.append({ + "名称": name, + "类型": "圆形", + "中心": center, + "半径": radius, + }) + elif region_type in ("方形", "矩形", "长方形", "square", "box", "立方体", "长方体"): + start = self._as_float_pos(item.get("起点")) + end = self._as_float_pos(item.get("终点")) + if start is None or end is None: + fmts.print_war(f"领地系统云链联动版: 不可创建领地区域 {name} 配置无效,已忽略") + continue + min_pos = tuple(min(start[i], end[i]) for i in range(3)) + max_pos = tuple(max(start[i], end[i]) for i in range(3)) + regions.append({ + "名称": name, + "类型": "方形", + "起点": min_pos, + "终点": max_pos, + }) + else: + fmts.print_war(f"领地系统云链联动版: 不可创建领地区域 {name} 类型未知,已忽略") + return regions + + def _get_no_create_overlap_reason( + self, + center: Tuple[float, float, float], + radius: int, + shape: str = "圆形", + size: Optional[Tuple[int, int, int]] = None, + ) -> Optional[str]: + """Return no create overlap reason data.""" + shape = LandData.normalize_shape(shape) + box_bounds = None + if shape == "方形": + box_bounds = bounds_from_center_size( + center, LandData.normalize_size(size, radius)) + x, y, z = center + for region in self.no_create_regions: + if shape == "方形" and box_bounds is not None: + if region["类型"] == "圆形": + if sphere_intersects_box( + region["中心"], + region["半径"], + box_bounds[0], + box_bounds[1]): + return region["名称"] + elif region["类型"] == "方形": + if boxes_intersect( + box_bounds[0], + box_bounds[1], + region["起点"], + region["终点"]): + return region["名称"] + elif region["类型"] == "圆形": + rx, ry, rz = region["中心"] + if math.sqrt((x - rx) ** 2 + (y - ry) ** 2 + + (z - rz) ** 2) <= radius + region["半径"]: + return region["名称"] + elif region["类型"] == "方形": + if sphere_intersects_box( + center, radius, region["起点"], region["终点"]): + return region["名称"] + return None + + @staticmethod + def _is_exit_input(text: str) -> bool: + """Implement the is exit input operation.""" + return text.strip().lower() in (".", "。", "q", "quit", "退出") + + def _wait_menu_input( + self, + player: Player, + timeout: int = 60) -> Optional[str]: + """Implement the wait menu input operation.""" + msg = game_utils.waitMsg(player.name, timeout) + if msg is None: + player.show(self._error("回复超时,已退出菜单")) + return None + msg = msg.strip() + if self._is_exit_input(msg): + player.show(self._success("已退出领地菜单")) + return None + return msg + + def _select_menu( + self, + player: Player, + title: str, + options: List[str], + timeout: int = 60) -> Optional[int]: + """Implement the select menu operation.""" + while True: + hints = [ + f"输入 §e[1-{len(options)}]§b 之间的数字以选择功能", + "输入 §c.§b 退出", + ] + player.show(self._ui_menu( + title, + options, + )) + player.show("\n".join(f"§a❀ §b{hint}" for hint in hints)) + msg = self._wait_menu_input(player, timeout) + if msg is None: + return None + choice = utils.try_int(msg.strip().strip("[]")) + if choice is None or choice not in range(1, len(options) + 1): + player.show(self._error("您的输入有误")) + continue + return choice + + def _prompt_text( + self, + player: Player, + title: str, + prompt: str, + timeout: int = 60) -> Optional[str]: + """Implement the prompt text operation.""" + player.show(self._ui_card(title, [prompt], ["输入 §c.§b 退出"])) + return self._wait_menu_input(player, timeout) + + def _parse_box_size_input( + self, raw: str) -> Tuple[Optional[Tuple[int, int, int]], Optional[str]]: + """Implement the parse box size input operation.""" + parts = raw.replace(",", " ").replace(",", " ").split() + if len(parts) != 3: + return None, "格式错误,需要输入 长 高 宽 三个整数" + try: + length, height, width = ( + int(parts[0]), int(parts[1]), int(parts[2])) + except ValueError: + return None, "长、高、宽必须为整数" + if length <= 0 or height <= 0 or width <= 0: + return None, "长、高、宽必须大于 0" + if length > self.max_length: + return None, f"长不能超过 {self.max_length}" + if height > self.max_height: + return None, f"高不能超过 {self.max_height}" + if width > self.max_width: + return None, f"宽不能超过 {self.max_width}" + return (length, height, width), None + + def _get_xuid_by_name( + self, + playername: str, + allow_offline: bool = False) -> Optional[str]: + """Return xuid by name data.""" + try: + return str( + self.xuid_getter.get_xuid_by_name( + playername, + allow_offline=allow_offline)) + except Exception as err: + self.print_war(f"无法获取玩家 {playername} 的 XUID: {err}") + return None + + def _get_player_xuid(self, player: Player) -> Optional[str]: + """Return player xuid data.""" + xuid = getattr(player, "xuid", None) + if xuid: + return str(xuid) + return self._get_xuid_by_name(player.name) + + def _make_member(self, playername: str, rank: LandRank, + allow_offline: bool = False) -> Optional[LandMember]: + """Implement the make member operation.""" + xuid = self._get_xuid_by_name(playername, allow_offline=allow_offline) + if xuid is None: + return None + return LandMember(name=playername, xuid=xuid, rank=rank) + + def _select_land( + self, + player: Player, + title: str, + lands: List[LandData]) -> Optional[LandData]: + """Implement the select land operation.""" + if not lands: + player.show(self._error("没有可选择的领地")) + return None + choice = self._select_menu( + player, + title, + [f"{land.name} §7- 领主: {land.owner}, {land.range_text()}" for land in lands], + ) + if choice is None: + return None + return lands[choice - 1] + + def _land_summary(self, land: LandData) -> Dict[str, Any]: + """Implement the land summary operation.""" + admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] + members = [m.name for m in land.members if m.rank == LandRank.MEMBER] + return { + "land_id": land.land_id, + "name": land.name, + "owner": land.owner, + "owner_xuid": land.owner_xuid, + "center": land.center, + "radius": land.radius, + "shape": land.shape, + "size": land.get_size() if land.is_box() else None, + "dimension": land.dimension, + "range_text": land.range_text(), + "admins": admins, + "members": members, + "member_count": len(land.members), + } + + def _find_land_by_name_or_id(self, query: str) -> Optional[LandData]: + """Implement the find land by name or id operation.""" + query = str(query).strip() + if not query: + return None + if query in self.lands: + return self.lands[query] + return next((land for land in self.lands.values() + if land.name == query), None) + + def _migrate_legacy_data_path(self): + """Implement the migrate legacy data path operation.""" + legacy_path = os.path.join( + os.path.dirname( + self.data_path), + LEGACY_PLUGIN_NAME) + if not os.path.isdir(legacy_path) or os.path.abspath( + legacy_path) == os.path.abspath(self.data_path): + return + os.makedirs(self.data_path, exist_ok=True) + for filename in ("领地数据.json", "不可创建领地区域.json"): + old_file = os.path.join(legacy_path, filename) + new_file = self.format_data_path(filename) + if os.path.isfile(old_file) and not os.path.exists(new_file): + try: + shutil.copy2(old_file, new_file) + except Exception as err: + self.print_war(f"迁移旧版领地系统数据文件 {filename} 失败: {err}") + + def _ensure_dirs(self): + """Implement the ensure dirs operation.""" + os.makedirs(os.path.dirname(self.data_file) or ".", exist_ok=True) + + # ---------- 玩家-领地 缓存维护 ---------- + def _add_player_land(self, xuid: str, land_id: str): + """Implement the add player land operation.""" + if xuid not in self.player_land_cache: + self.player_land_cache[xuid] = [] + if land_id not in self.player_land_cache[xuid]: + self.player_land_cache[xuid].append(land_id) + + def _remove_player_land(self, xuid: str, land_id: str): + """Implement the remove player land operation.""" + if xuid in self.player_land_cache: + lst = self.player_land_cache[xuid] + if land_id in lst: + lst.remove(land_id) + if not lst: + del self.player_land_cache[xuid] + + def _rebuild_player_land_cache(self): + """Implement the rebuild player land cache operation.""" + self.player_land_cache.clear() + for land_id, land in self.lands.items(): + for member in land.members: + self._add_player_land(member.xuid, land_id) + + # ---------- 数据加载/保存 ---------- + def _load_data(self): + """Load data data.""" + try: + if os.path.exists(self.data_file): + with open(self.data_file, "r", encoding="utf-8") as f: + raw = json.load(f) + self.player_land_cache.clear() + for land_id, data in raw.items(): + try: + land = LandData.from_dict(data) + self.lands[land_id] = land + for m in land.members: + self._add_player_land(m.xuid, land_id) + except Exception as e: + fmts.print_err(f"解析领地 {land_id} 失败: {e}") + except Exception as e: + fmts.print_err(f"加载数据失败: {e}") + + def _save_data(self): + """Save data data.""" + try: + raw = {lid: land.to_dict() for lid, land in self.lands.items()} + with open(self.data_file, "w", encoding="utf-8") as f: + json.dump(raw, f, ensure_ascii=False, indent=2) + except Exception as e: + fmts.print_err(f"保存数据失败: {e}") + + # ---------- 坐标获取 ---------- + def _get_player_coord( + self, player: str) -> Optional[Tuple[float, float, float]]: + """Return player coord data.""" + try: + pos_dict = game_utils.getPos(player) + if pos_dict and "position" in pos_dict: + p = pos_dict["position"] + return (p.get("x", 0), p.get("y", 0), p.get("z", 0)) + except Exception: + pass + cmd = f"/data get entity {player} Pos" + try: + resp = self.game_ctrl.sendwscmd_with_resp(cmd, timeout=2) + if resp.SuccessCount: + for out in resp.OutputMessages: + msg = out.Message + nums = re.findall(r"[-+]?\d*\.?\d+[df]?", msg) + if len(nums) >= 3: + x = float(nums[0].replace('d', '').replace('f', '')) + y = float(nums[1].replace('d', '').replace('f', '')) + z = float(nums[2].replace('d', '').replace('f', '')) + return (x, y, z) + except Exception: + pass + return None + + def _manual_coord( + self, player: Player) -> Optional[Tuple[float, float, float]]: + """Implement the manual coord operation.""" + player.show(self._ui_card( + "手动坐标输入", + [ + "请输入你的当前坐标", + "格式:x y z,例如 100 64 200", + "请在聊天栏直接输入数字,用空格分隔", + ], + ["30 秒内回复有效"], + )) + msg = game_utils.waitMsg(player.name, 30) + if not msg: + player.show(self._error("输入超时")) + return None + parts = msg.strip().split() + if len(parts) != 3: + player.show(self._error("格式错误,需要三个数字")) + return None + try: + x = float(parts[0]) + y = float(parts[1]) + z = float(parts[2]) + return (x, y, z) + except ValueError: + player.show(self._error("请输入有效的数字")) + return None + + # ---------- 领地查找辅助 ---------- + def _find_land_at(self, + pos: Tuple[float, + float, + float], + dimension: int = 0) -> Optional[LandData]: + """Implement the find land at operation.""" + x, y, z = pos + for land in self.lands.values(): + if land.dimension != dimension: + continue + if land.contains_pos((x, y, z)): + return land + return None + + def _find_land_at_player(self, player: str) -> Optional[LandData]: + """Implement the find land at player operation.""" + pos = self._get_player_coord(player) + if pos: + return self._find_land_at(pos) + return None + + # ---------- 检测线程 ---------- + def _start_detection(self): + """Implement the start detection operation.""" + if self._detection_started: + return + self._detection_started = True + + def loop(): + """Implement the loop operation.""" + while not self._stop_event.wait(self.check_interval): + try: + self._update_coords() + self._check_lands() + except Exception as e: + fmts.print_err(f"检测异常: {e}") + threading.Thread(target=loop, daemon=True).start() + + def _update_coords(self): + """Implement the update coords operation.""" + if not self.enabled: + return + players = self.game_ctrl.allplayers + new_coords = {} + for name in players: + pos = self._get_player_coord(name) + if pos: + new_coords[name] = pos + with self.coords_lock: + self.coords = new_coords + + def _check_lands(self): + """Implement the check lands operation.""" + if not self.enabled: + return + with self.coords_lock: + players = dict(self.coords) + + now = time.time() + expired = [ + p for p, + t in self.recent_tp.items() if now - + t > self.tp_cooldown] + for p in expired: + del self.recent_tp[p] + + for name, (x, y, z) in players.items(): + if name.lower() in self.whitelist: + continue + xuid = self._get_xuid_by_name(name) + if xuid is None: + continue + + in_land = self._find_land_at((x, y, z)) + + if in_land: + if in_land.get_member(xuid): + continue + if xuid in self.recent_tp: + continue + self._tp_random(name, x, y, z) + self.recent_tp[xuid] = now + self.game_ctrl.say_to(name, self._error( + f"你闯入了 {in_land.owner} 的领地,已被传送离开")) + else: + for land in self.lands.values(): + if land.dimension != 0: + continue + dist = distance_to_land((x, y, z), land) + if 0 <= dist <= self.buffer_dist: + if land.get_member(xuid): + continue + self.game_ctrl.sendwocmd(f"/gamemode adventure {name}") + self.game_ctrl.say_to(name, self._warn( + f"你正在靠近 {land.owner} 的领地,请勿进入")) + break + + def _tp_random(self, player: str, x: float, y: float, z: float): + """Implement the tp random operation.""" + angle = random.uniform(0, 2 * math.pi) + dist = random.uniform(10, self.tp_radius) + nx = x + dist * math.cos(angle) + nz = z + dist * math.sin(angle) + ny = y + cmd = f"/tp {player} {nx} {ny} {nz}" + self.game_ctrl.sendwocmd(cmd) + + # ---------- 实体标记(可选) ---------- + def _spawn_entity(self, land: LandData): + """Implement the spawn entity operation.""" + try: + x, y, z = land.center + self._remove_entity(land) + cmd = f"summon area_effect_cloud {x} {y} {z} { + Duration:2147483647,WaitTime:2147483647,Tags:[\"land_{ + land.land_id}\"]} " + self.game_ctrl.sendwocmd(cmd) + except Exception: + pass + + def _remove_entity(self, land: LandData): + """Implement the remove entity operation.""" + try: + cmd = f"kill @e[type=area_effect_cloud,tag=land_{land.land_id}]" + self.game_ctrl.sendwocmd(cmd) + except Exception: + pass + + # ---------- 命令处理 ---------- + def on_chat(self, chat: Chat): + """Implement the on chat operation.""" + if not self.enabled: + return + msg = chat.msg.strip() + if msg not in self.wake_words: + return + self._main_menu(chat.player) + + def _main_menu(self, player: Player): + """Implement the main menu operation.""" + choice = self._select_menu( + player, + "功能菜单", + [ + "创建领地", + "删除领地", + "查看领地信息", + "成员管理", + "管理员管理", + "传送到领地", + "查看领地列表", + "测试当前位置", + ], + timeout=90, + ) + if choice is None: + return + if choice == 1: + self._menu_create(player) + elif choice == 2: + self._menu_delete(player) + elif choice == 3: + self._menu_info(player) + elif choice == 4: + self._menu_member(player) + elif choice == 5: + self._menu_admin(player) + elif choice == 6: + self._menu_tp(player) + elif choice == 7: + self._list(player) + elif choice == 8: + self._test(player) + + def _menu_create(self, player: Player): + """Implement the menu create operation.""" + name = self._prompt_text(player, "创建领地", "请输入领地名称") + if name is None: + return + shape_choice = self._select_menu(player, "创建领地类型", ["圆形领地", "方形领地"]) + if shape_choice is None: + return + if shape_choice == 1: + radius = self._prompt_text( + player, "创建圆形领地", f"请输入领地半径,范围 1~{self.max_radius}") + if radius is None: + return + self._create(player, [name, "圆形", radius]) + else: + size_text = self._prompt_text( + player, + "创建方形领地", + f"请输入 长 高 宽,最大 { + self.max_length} { + self.max_height} { + self.max_width}", + ) + if size_text is None: + return + size, err = self._parse_box_size_input(size_text) + if size is None: + player.show(self._error(err or "方形领地尺寸无效")) + return + self._create( + player, [ + name, "方形", str( + size[0]), str( + size[1]), str( + size[2])]) + + def _menu_delete(self, player: Player): + """Implement the menu delete operation.""" + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + owned = [land for land in self.lands.values( + ) if land.owner_xuid == player_xuid] + land = self._select_land(player, "删除领地", owned) + if land is None: + return + self._delete(player, [land.name]) + + def _menu_info(self, player: Player): + """Implement the menu info operation.""" + choice = self._select_menu( + player, + "查看领地信息", + ["查看当前所在领地", "从我的领地中选择", "从全部领地中选择", "输入领地名称"], + ) + if choice is None: + return + if choice == 1: + self._info(player, []) + elif choice == 2: + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + lands = [land for land in self.lands.values() if land.owner_xuid == + player_xuid] + land = self._select_land(player, "我的领地", lands) + if land: + self._info(player, [land.name]) + elif choice == 3: + land = self._select_land(player, "全部领地", list(self.lands.values())) + if land: + self._info(player, [land.name]) + elif choice == 4: + name = self._prompt_text(player, "查看领地信息", "请输入领地名称") + if name: + self._info(player, [name]) + + def _menu_member(self, player: Player): + """Implement the menu member operation.""" + choice = self._select_menu(player, "成员管理", ["添加成员", "移除成员", "查看成员列表"]) + if choice is None: + return + if choice == 3: + self._member(player, ["列表"]) + return + target = self._prompt_text( + player, + "成员管理", + "请输入玩家名", + ) + if target is None: + return + self._member(player, [["添加", "移除"][choice - 1], target]) + + def _menu_admin(self, player: Player): + """Implement the menu admin operation.""" + choice = self._select_menu(player, "管理员管理", ["添加管理员", "移除管理员"]) + if choice is None: + return + target = self._prompt_text(player, "管理员管理", "请输入玩家名") + if target is None: + return + self._admin(player, [["添加", "移除"][choice - 1], target]) + + def _menu_tp(self, player: Player): + """Implement the menu tp operation.""" + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + lands = [land for land in self.lands.values( + ) if land.has_permission(player_xuid, "tp")] + land = self._select_land(player, "传送到领地", lands) + if land is None: + return + self._tp(player, [land.name]) + + def _create(self, player: Player, args: List[str]): + """Implement the create operation.""" + if len(args) < 2: + player.show(self._error( + f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入创建菜单")) + return + name = args[0] + shape_arg = str(args[1]).strip().lower() + explicit_circle = shape_arg in ("圆形", "circle", "round") + explicit_box = LandData.normalize_shape(shape_arg) == "方形" + shape = "方形" if explicit_box else "圆形" + size = None + if shape == "方形": + if len(args) < 5: + player.show(self._error("方形领地需要输入 长 高 宽")) + return + size, err = self._parse_box_size_input(" ".join(args[2:5])) + if size is None: + player.show(self._error(err or "方形领地尺寸无效")) + return + radius = box_radius_for_size(size) + else: + radius_arg = args[2] if len( + args) >= 3 and explicit_circle else args[1] + try: + radius = int(radius_arg) + except ValueError: + player.show(self._error("半径必须为整数")) + return + if radius <= 0 or radius > self.max_radius: + player.show(self._error(f"半径必须在 1~{self.max_radius} 之间")) + return + + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + owned = [ + land_item for land_item in self.lands.values() + if land_item.owner_xuid == player_xuid + ] + if len(owned) >= self.max_lands_per_player: + player.show( + self._error( + f"你最多只能拥有 { + self.max_lands_per_player} 个领地")) + return + + pos = self._get_player_coord(player.name) + if not pos: + player.show(self._warn("无法自动获取坐标,请手动输入")) + pos = self._manual_coord(player) + if not pos: + return + x, y, z = pos + + overlap = self._get_land_candidate_overlap_reason( + (x, y, z), radius, shape, size) + if overlap: + player.show(self._error(overlap)) + return + + land_id = str(uuid.uuid4()) + member = LandMember( + name=player.name, + xuid=player_xuid, + rank=LandRank.OWNER) + land = LandData( + land_id=land_id, + name=name, + owner=player.name, + owner_xuid=player_xuid, + center=(x, y, z), + radius=radius, + shape=shape, + size=size, + members=[member] + ) + self.lands[land_id] = land + self._add_player_land(player_xuid, land_id) + self._save_data() + player.show(self._success(f"成功创建领地 '{name}',{land.range_text()}")) + self._spawn_entity(land) + + def _delete(self, player: Player, args: List[str]): + """Implement the delete operation.""" + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + owned = [ + land_item for land_item in self.lands.values() + if land_item.owner_xuid == player_xuid + ] + if not owned: + player.show(self._error("你没有拥有任何领地")) + return + + if args: + name = args[0] + land = next( + (land_item for land_item in owned if land_item.name == name), + None, + ) + if not land: + player.show(self._error(f"你没有名为 '{name}' 的领地")) + return + else: + if len(owned) > 1: + names = "、".join(land_item.name for land_item in owned) + player.show(self._error(f"你拥有多个领地,请指定名称:{names}")) + return + land = owned[0] + + self._remove_entity(land) + for m in land.members: + self._remove_player_land(m.xuid, land.land_id) + del self.lands[land.land_id] + self._save_data() + player.show(self._success(f"已删除领地 '{land.name}'")) + + def _info(self, player: Player, args: List[str]): + """Implement the info operation.""" + land = None + if args: + name = args[0] + land = next( + ( + land_item for land_item in self.lands.values() + if land_item.name == name + ), + None, + ) + if not land: + player.show(self._error(f"领地 '{name}' 不存在")) + return + else: + land = self._find_land_at_player(player.name) + if not land: + player.show(self._error("你当前不在任何领地内,请指定领地名称")) + return + + admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] + members = [m.name for m in land.members if m.rank == LandRank.MEMBER] + player.show(self._ui_card( + f"领地信息 - {land.name}", + [ + f"中心:{land.center[0]}, {land.center[1]}, {land.center[2]}", + f"范围:{land.range_text()}", + f"领主:{land.owner}", + f"管理员:{', '.join(admins) or '无'}", + f"成员:{', '.join(members) or '无'}", + ], + )) + + def _member(self, player: Player, args: List[str]): + """Implement the member operation.""" + if len(args) < 1: + player.show(self._error( + f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入成员管理菜单")) + return + sub = args[0].lower() + if sub == "列表": + land = self._find_land_at_player(player.name) + if not land: + player.show(self._error("你当前不在任何领地内")) + return + self._info(player, [land.name]) + return + + if len(args) < 2: + player.show(self._error( + f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入成员管理菜单")) + return + target = args[1] + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + target_member = self._make_member( + target, LandRank.MEMBER, allow_offline=True) + if target_member is None: + player.show(self._error(f"无法获取 {target} 的 XUID")) + return + + land = self._find_land_at_player(player.name) + if not land: + player.show(self._error("你当前不在任何领地内")) + return + + if not land.has_permission(player_xuid, "manage_member"): + player.show(self._error("你没有权限管理成员")) + return + + if sub == "添加": + if land.get_member(target_member.xuid): + player.show(self._warn(f"{target} 已是成员")) + return + land.members.append(target_member) + self._add_player_land(target_member.xuid, land.land_id) + self._save_data() + player.show(self._success(f"已将 {target} 添加为成员")) + elif sub == "移除": + if not land.get_member(target_member.xuid): + player.show(self._error(f"{target} 不是成员")) + return + if not land.can_manage_member(player_xuid, target_member.xuid): + player.show(self._error("你不能移除该成员")) + return + land.members = [ + m for m in land.members if m.xuid != target_member.xuid] + self._remove_player_land(target_member.xuid, land.land_id) + self._save_data() + player.show(self._success(f"已将 {target} 移除成员")) + else: + player.show(self._error("未知子命令")) + + def _admin(self, player: Player, args: List[str]): + """Implement the admin operation.""" + if len(args) < 2: + player.show(self._error( + f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入管理员管理菜单")) + return + sub = args[0].lower() + target = args[1] + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + target_xuid = self._get_xuid_by_name(target, allow_offline=True) + if target_xuid is None: + player.show(self._error(f"无法获取 {target} 的 XUID")) + return + + land = self._find_land_at_player(player.name) + if not land: + player.show(self._error("你当前不在任何领地内")) + return + + if land.owner_xuid != player_xuid: + player.show(self._error("只有领主可以管理管理员")) + return + + if sub == "添加": + member = land.get_member(target_xuid) + if not member: + player.show(self._error(f"{target} 不是成员")) + return + if member.rank == LandRank.ADMIN: + player.show(self._warn(f"{target} 已经是管理员")) + return + member.rank = LandRank.ADMIN + self._save_data() + player.show(self._success(f"已将 {target} 设为管理员")) + elif sub == "移除": + member = land.get_member(target_xuid) + if not member or member.rank != LandRank.ADMIN: + player.show(self._error(f"{target} 不是管理员")) + return + member.rank = LandRank.MEMBER + self._save_data() + player.show(self._success(f"已将 {target} 移除管理员")) + else: + player.show(self._error("未知子命令")) + + def _tp(self, player: Player, args: List[str]): + """Implement the tp operation.""" + land = None + if args: + name = args[0] + land = next( + ( + land_item for land_item in self.lands.values() + if land_item.name == name + ), + None, + ) + if not land: + player.show(self._error(f"领地 '{name}' 不存在")) + return + else: + land = self._find_land_at_player(player.name) + if not land: + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + owned = [ + land_item for land_item in self.lands.values() + if land_item.owner_xuid == player_xuid + ] + if owned: + land = owned[0] + else: + player.show(self._error("你当前不在任何领地内,且你没有拥有领地,请指定名称")) + return + + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + if not land.has_permission(player_xuid, "tp"): + player.show(self._error("你没有权限传送到该领地")) + return + + cx, cy, cz = land.center + self.game_ctrl.sendwocmd(f"/tp {player.name} {cx} {cy + 1} {cz}") + player.show(self._success(f"已传送到领地 '{land.name}'")) + + def _list(self, player: Player): + """Implement the list operation.""" + if not self.lands: + player.show(self._error("暂无任何领地")) + return + options = [ + f"{land.name} §7- 领主: {land.owner}, {land.range_text()}" + for land in self.lands.values() + ] + player.show(self._ui_menu("领地列表", options, [f"共 {len(options)} 个领地"])) + + def _test(self, player: Player): + """Implement the test operation.""" + pos = self._get_player_coord(player.name) + lines = [] + if pos: + lines.append(f"当前坐标:{pos[0]:.1f}, {pos[1]:.1f}, {pos[2]:.1f}") + else: + lines.append("无法获取坐标") + + in_land = self._find_land_at_player(player.name) + if in_land: + lines.append(f"当前位置:位于领地 '{in_land.name}' 内") + else: + lines.append("当前位置:不在任何领地内") + + if self.lands: + lines.append( + "所有领地:" + "、".join(f"{land.name}({land.owner})" for land in self.lands.values())) + else: + lines.append("所有领地:无") + player.show(self._ui_card("测试信息", lines)) + + def on_player_join(self, player: Player): + """Implement the on player join operation.""" + if not self.enabled: + return + pass + + def on_player_leave(self, player: Player): + """Implement the on player leave operation.""" + if not self.enabled: + return + pass + + # ---------- 外部插件 API ---------- + def api_list_lands(self) -> Tuple[bool, str, List[Dict[str, Any]]]: + """Expose the api list lands API operation.""" + lands = [self._land_summary(land) for land in self.lands.values()] + return True, f"共 {len(lands)} 个领地", lands + + def api_get_land( + self, land_query: str) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Expose the api get land API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + return True, "查询成功", self._land_summary(land) + + def api_add_land( + self, + owner: str, + name: str, + center: Tuple[float, float, float], + shape: str = "圆形", + radius: Optional[int] = None, + size: Optional[Tuple[int, int, int]] = None, + dimension: int = 0, + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Expose the api add land API operation.""" + owner = str(owner).strip() + name = str(name).strip() + if not owner or not name: + return False, "领地主人和领地名称不能为空", None + if self._find_land_by_name_or_id(name): + return False, f"领地 '{name}' 已存在", None + owner_member = self._make_member( + owner, LandRank.OWNER, allow_offline=True) + if owner_member is None: + return False, f"无法获取 {owner} 的 XUID", None + if len([land for land in self.lands.values() if land.owner_xuid == + owner_member.xuid]) >= self.max_lands_per_player: + return False, f"{owner} 已达到最大领地数量 { + self.max_lands_per_player}", None + try: + center_tuple = ( + float( + center[0]), float( + center[1]), float( + center[2])) + except (TypeError, ValueError, IndexError): + return False, "中心坐标无效", None + + normalized_shape = LandData.normalize_shape(shape) + normalized_size = None + if normalized_shape == "方形": + if size is None: + return False, "方形领地需要提供 长 高 宽", None + normalized_size = LandData.normalize_size(size, radius or 1) + if normalized_size[0] > self.max_length: + return False, f"长不能超过 {self.max_length}", None + if normalized_size[1] > self.max_height: + return False, f"高不能超过 {self.max_height}", None + if normalized_size[2] > self.max_width: + return False, f"宽不能超过 {self.max_width}", None + land_radius = box_radius_for_size(normalized_size) + else: + try: + land_radius = int(radius) + except (TypeError, ValueError): + return False, "圆形领地半径必须为整数", None + if land_radius <= 0 or land_radius > self.max_radius: + return False, f"半径必须在 1~{self.max_radius} 之间", None + + overlap = self._get_land_candidate_overlap_reason( + center_tuple, + land_radius, + normalized_shape, + normalized_size, + dimension, + ) + if overlap: + return False, overlap, None + + land_id = str(uuid.uuid4()) + land = LandData( + land_id=land_id, + name=name, + owner=owner, + owner_xuid=owner_member.xuid, + center=center_tuple, + radius=land_radius, + shape=normalized_shape, + size=normalized_size, + dimension=dimension, + members=[owner_member], + ) + self.lands[land_id] = land + self._rebuild_player_land_cache() + self._save_data() + self._spawn_entity(land) + return True, f"已新增玩家 {owner} 的领地 '{name}',{ + land.range_text()}", self._land_summary(land) + + def api_delete_land(self, land_query: str) -> Tuple[bool, str, None]: + """Expose the api delete land API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + self._remove_entity(land) + del self.lands[land.land_id] + self._rebuild_player_land_cache() + self._save_data() + return True, f"已删除领地 '{land.name}'", None + + def api_add_member(self, + land_query: str, + player_name: str, + rank: str = "member") -> Tuple[bool, + str, + Optional[Dict[str, + Any]]]: + """Expose the api add member API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + target_rank = LandRank.ADMIN if str(rank).lower() in ( + "admin", "管理员", "管理") else LandRank.MEMBER + member = self._make_member( + player_name, target_rank, allow_offline=True) + if member is None: + return False, f"无法获取 {player_name} 的 XUID", None + old_member = land.get_member(member.xuid) + if old_member: + if old_member.rank == LandRank.OWNER: + return False, "所有者不能被改为成员或管理员", self._land_summary(land) + old_member.name = player_name + old_member.rank = target_rank + else: + land.members.append(member) + self._rebuild_player_land_cache() + self._save_data() + role_name = "管理员" if target_rank == LandRank.ADMIN else "成员" + return True, f"已将 {player_name} 添加为领地 '{ + land.name}' 的{role_name}", self._land_summary(land) + + def api_set_member_rank( + self, + land_query: str, + player_name: str, + rank: str, + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Expose the api set member rank API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + target_rank = LandRank.ADMIN if str(rank).lower() in ( + "admin", "管理员", "管理") else LandRank.MEMBER + target_xuid = self._get_xuid_by_name(player_name, allow_offline=True) + if target_xuid is None: + return False, f"无法获取 {player_name} 的 XUID", None + member = land.get_member(target_xuid) + if member is None: + return False, f"{player_name} 不在领地 '{ + land.name}' 中", self._land_summary(land) + if member.rank == LandRank.OWNER: + return False, "所有者不能修改身份", self._land_summary(land) + member.name = player_name + member.rank = target_rank + self._save_data() + role_name = "管理员" if target_rank == LandRank.ADMIN else "成员" + return True, f"已将 {player_name} 设为领地 '{ + land.name}' 的{role_name}", self._land_summary(land) + + def api_remove_member(self, + land_query: str, + player_name: str) -> Tuple[bool, + str, + Optional[Dict[str, + Any]]]: + """Expose the api remove member API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + target_xuid = self._get_xuid_by_name(player_name, allow_offline=True) + if target_xuid is None: + return False, f"无法获取 {player_name} 的 XUID", None + member = land.get_member(target_xuid) + if not member: + return False, f"{player_name} 不在领地 '{ + land.name}' 中", self._land_summary(land) + if member.rank == LandRank.OWNER: + return False, "不能删除领地所有者,请先转移所有者", self._land_summary(land) + land.members = [m for m in land.members if m.xuid != target_xuid] + self._rebuild_player_land_cache() + self._save_data() + return True, f"已从领地 '{ + land.name}' 移除 {player_name}", self._land_summary(land) + + def api_transfer_owner( + self, land_query: str, owner: str) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Expose the api transfer owner API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + owner_member_data = self._make_member( + owner, LandRank.OWNER, allow_offline=True) + if owner_member_data is None: + return False, f"无法获取 {owner} 的 XUID", None + land.owner = owner + land.owner_xuid = owner_member_data.xuid + for member in land.members: + if member.rank == LandRank.OWNER: + member.rank = LandRank.ADMIN + owner_member = land.get_member(owner_member_data.xuid) + if owner_member: + owner_member.name = owner + owner_member.rank = LandRank.OWNER + else: + land.members.append(owner_member_data) + self._rebuild_player_land_cache() + self._save_data() + return True, f"已修改领地 '{ + land.name}' 所有者为 {owner}", self._land_summary(land) + + def api_update_land_center( + self, + land_query: str, + center: Tuple[float, float, float], + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Expose the api update land center API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + try: + center_tuple = ( + float( + center[0]), float( + center[1]), float( + center[2])) + except (TypeError, ValueError, IndexError): + return False, "中心坐标无效", self._land_summary(land) + overlap = self._get_land_edit_overlap_reason( + land, center_tuple, land.radius) + if overlap: + return False, overlap, self._land_summary(land) + land.center = center_tuple + self._save_data() + self._spawn_entity(land) + return True, f"已修改领地 '{land.name}' 中心点", self._land_summary(land) + + def api_update_land_range( + self, + land_query: str, + radius: Optional[int] = None, + size: Optional[Tuple[int, int, int]] = None, + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Expose the api update land range API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + if land.is_box(): + if size is None: + return False, "方形领地需要提供 长 高 宽", self._land_summary(land) + normalized_size = LandData.normalize_size(size, land.radius) + if normalized_size[0] > self.max_length: + return False, f"长不能超过 { + self.max_length}", self._land_summary(land) + if normalized_size[1] > self.max_height: + return False, f"高不能超过 { + self.max_height}", self._land_summary(land) + if normalized_size[2] > self.max_width: + return False, f"宽不能超过 { + self.max_width}", self._land_summary(land) + land_radius = box_radius_for_size(normalized_size) + overlap = self._get_land_edit_overlap_reason( + land, land.center, land_radius, "方形", normalized_size) + if overlap: + return False, overlap, self._land_summary(land) + land.size = normalized_size + land.radius = land_radius + else: + try: + land_radius = int(radius) + except (TypeError, ValueError): + return False, "半径必须为整数", self._land_summary(land) + if land_radius <= 0 or land_radius > self.max_radius: + return False, f"半径必须在 1~{ + self.max_radius} 之间", self._land_summary(land) + overlap = self._get_land_edit_overlap_reason( + land, land.center, land_radius, "圆形", None) + if overlap: + return False, overlap, self._land_summary(land) + land.radius = land_radius + land.size = None + self._save_data() + return True, f"已修改领地 '{ + land.name}' 范围为 { + land.range_text()}", self._land_summary(land) + + def _console_print(self, text: str): + """Implement the console print operation.""" + fmts.print_inf(text) + + def _console_prompt(self, prompt: str) -> Optional[str]: + """Implement the console prompt operation.""" + value = input(fmts.fmt_info( + f"§a❀ §b{prompt} §7(输入 . 退出整个领地系统云链联动版管理菜单): ")).strip() + if self._is_exit_input(value): + raise ConsoleMenuExit + return value + + def _console_select(self, title: str, options: List[str]) -> Optional[int]: + """Implement the console select operation.""" + while True: + self._console_print(self._ui_menu( + title, + options, + [f"输入 §e[1-{len(options)}]§b 之间的数字以选择", "输入 §c.§b 退出整个领地系统云链联动版管理菜单"], + )) + value = self._console_prompt("请输入序号") + if value is None: + return None + choice = utils.try_int(value.strip().strip("[]")) + if choice is None or choice not in range(1, len(options) + 1): + fmts.print_err(self._error("输入有误")) + continue + return choice + + def _console_prompt_float(self, prompt: str) -> Optional[float]: + """Implement the console prompt float operation.""" + value = self._console_prompt(prompt) + if value is None: + return None + try: + return float(value) + except ValueError: + fmts.print_err(self._error("请输入有效数字")) + return None + + def _console_prompt_int(self, prompt: str) -> Optional[int]: + """Implement the console prompt int operation.""" + value = self._console_prompt(prompt) + if value is None: + return None + try: + return int(value) + except ValueError: + fmts.print_err(self._error("请输入有效整数")) + return None + + def _console_prompt_pos(self, prompt: str) -> Optional[List[float]]: + """Implement the console prompt pos operation.""" + value = self._console_prompt(f"{prompt},格式 x y z") + if value is None: + return None + parts = value.split() + if len(parts) != 3: + fmts.print_err(self._error("坐标格式错误,需要 x y z 三个数字")) + return None + try: + return [float(parts[0]), float(parts[1]), float(parts[2])] + except ValueError: + fmts.print_err(self._error("坐标必须是数字")) + return None + + def _console_select_land( + self, title: str, lands: Optional[List[LandData]] = None) -> Optional[LandData]: + """Implement the console select land operation.""" + lands = list(self.lands.values()) if lands is None else lands + if not lands: + fmts.print_err(self._error("暂无可选择的领地")) + return None + choice = self._console_select( + title, + [f"{land.name} - 领主: {land.owner}, {land.range_text()}" for land in lands], + ) + if choice is None: + return None + return lands[choice - 1] + + def console_manage(self, _args: List[str]): + """Implement the console manage operation.""" + try: + while True: + choice = self._console_select( + "控制台管理菜单", + ["新增不可创建领地区域", "删除不可创建领地区域", "新增玩家领地", "删除玩家领地", "管理玩家领地"], + ) + if choice is None: + fmts.print_inf(self._success("已退出领地系统云链联动版管理菜单")) + return + if choice == 1: + self._console_add_no_create_region() + elif choice == 2: + self._console_delete_no_create_region() + elif choice == 3: + self._console_add_land() + elif choice == 4: + self._console_delete_land() + elif choice == 5: + self._console_manage_land() + except ConsoleMenuExit: + fmts.print_inf(self._success("已退出领地系统云链联动版管理菜单")) + + def _console_add_no_create_region(self): + """Implement the console add no create region operation.""" + name = self._console_prompt("请输入区域名称") + if name is None: + return + region_choice = self._console_select("新增不可创建区域", ["圆形区域", "方形区域"]) + if region_choice is None: + return + if region_choice == 1: + center = self._console_prompt_pos("请输入圆形区域中心") + radius = self._console_prompt_float("请输入圆形区域半径") + if center is None or radius is None or radius <= 0: + fmts.print_err(self._error("区域参数无效")) + return + region = { + "名称": name, + "启用": True, + "类型": "圆形", + "中心": center, + "半径": radius} + else: + start = self._console_prompt_pos("请输入方形区域起点") + end = self._console_prompt_pos("请输入方形区域终点") + if start is None or end is None: + fmts.print_err(self._error("区域参数无效")) + return + region = { + "名称": name, + "启用": True, + "类型": "方形", + "起点": start, + "终点": end} + self.no_create_regions_raw.append(region) + self._save_no_create_regions() + self._reload_no_create_regions() + fmts.print_suc(self._success(f"已新增不可创建领地区域 '{name}'")) + + def _console_delete_no_create_region(self): + """Implement the console delete no create region operation.""" + if not self.no_create_regions_raw: + fmts.print_err(self._error("暂无不可创建领地区域")) + return + choice = self._console_select( + "删除不可创建区域", + [ + f"{region.get('名称', f'区域{i}')} - {region.get('类型', '未知') + } - {'启用' if region.get('启用', True) else '禁用'}" + for i, region in enumerate(self.no_create_regions_raw, 1) + ], + ) + if choice is None: + return + region = self.no_create_regions_raw.pop(choice - 1) + self._save_no_create_regions() + self._reload_no_create_regions() + fmts.print_suc( + self._success( + f"已删除不可创建领地区域 '{ + region.get( + '名称', + choice)}'")) + + def _console_add_land(self): + """Implement the console add land operation.""" + owner = self._console_prompt("请输入领地主人玩家名") + name = self._console_prompt("请输入领地名称") + center = self._console_prompt_pos("请输入领地中心坐标") + shape_choice = self._console_select("请选择领地类型", ["圆形领地", "方形领地"]) + if owner is None or name is None or center is None or shape_choice is None: + return + owner_member = self._make_member( + owner, LandRank.OWNER, allow_offline=True) + if owner_member is None: + fmts.print_err(self._error(f"无法获取 {owner} 的 XUID")) + return + shape = "圆形" + size = None + if shape_choice == 1: + radius = self._console_prompt_int( + f"请输入领地半径,范围 1~{self.max_radius}") + if radius is None: + return + if radius <= 0 or radius > self.max_radius: + fmts.print_err(self._error(f"半径必须在 1~{self.max_radius} 之间")) + return + else: + size_text = self._console_prompt( + f"请输入方形领地 长 高 宽,最大 { + self.max_length} { + self.max_height} { + self.max_width}") + if size_text is None: + return + size, err = self._parse_box_size_input(size_text) + if size is None: + fmts.print_err(self._error(err or "方形领地尺寸无效")) + return + shape = "方形" + radius = box_radius_for_size(size) + overlap = self._get_land_candidate_overlap_reason( + tuple(center), radius, shape, size) + if overlap: + fmts.print_err(self._error(overlap)) + return + land_id = str(uuid.uuid4()) + land = LandData( + land_id=land_id, + name=name, + owner=owner, + owner_xuid=owner_member.xuid, + center=tuple(center), + radius=radius, + shape=shape, + size=size, + members=[owner_member], + ) + self.lands[land_id] = land + self._rebuild_player_land_cache() + self._save_data() + self._spawn_entity(land) + fmts.print_suc( + self._success( + f"已新增玩家 {owner} 的领地 '{name}',{ + land.range_text()}")) + + def _console_delete_land(self): + """Implement the console delete land operation.""" + land = self._console_select_land("删除玩家领地") + if land is None: + return + self._remove_entity(land) + del self.lands[land.land_id] + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已删除领地 '{land.name}'")) + + def _console_manage_land(self): + """Implement the console manage land operation.""" + land = self._console_select_land("管理玩家领地") + if land is None: + return + while True: + choice = self._console_select( + f"管理领地 - {land.name}", + ["管理用户", "管理管理人员", "管理所有者", "管理领地中心点", "管理领地范围", "查看领地信息"], + ) + if choice is None: + return + if choice == 1: + self._console_manage_land_users(land) + elif choice == 2: + self._console_manage_land_admins(land) + elif choice == 3: + self._console_manage_land_owner(land) + elif choice == 4: + self._console_manage_land_center(land) + elif choice == 5: + self._console_manage_land_radius(land) + elif choice == 6: + self._console_show_land_info(land) + + def _console_show_land_info(self, land: LandData): + """Implement the console show land info operation.""" + admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] + members = [m.name for m in land.members if m.rank == LandRank.MEMBER] + fmts.print_inf(self._ui_card( + f"领地信息 - {land.name}", + [ + f"领主:{land.owner}", + f"中心:{land.center[0]}, {land.center[1]}, {land.center[2]}", + f"范围:{land.range_text()}", + f"管理员:{', '.join(admins) or '无'}", + f"用户:{', '.join(members) or '无'}", + ], + )) + + def _console_manage_land_users(self, land: LandData): + """Implement the console manage land users operation.""" + while True: + choice = self._console_select( + f"管理用户 - {land.name}", + ["添加用户", "删除用户", "查看用户列表"], + ) + if choice is None: + return + if choice == 1: + target = self._console_prompt("请输入要添加的玩家名") + if not target: + continue + member = self._make_member( + target, LandRank.MEMBER, allow_offline=True) + if member is None: + fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) + continue + if land.get_member(member.xuid): + fmts.print_err(self._warn(f"{target} 已在该领地中")) + continue + land.members.append(member) + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已添加用户 {target}")) + elif choice == 2: + target = self._console_prompt("请输入要删除的玩家名") + if not target: + continue + target_xuid = self._get_xuid_by_name( + target, allow_offline=True) + if target_xuid is None: + fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) + continue + member = land.get_member(target_xuid) + if not member: + fmts.print_err(self._error(f"{target} 不在该领地中")) + continue + if member.rank == LandRank.OWNER: + fmts.print_err(self._error("不能在用户管理中删除所有者,请先转移所有者")) + continue + land.members = [ + m for m in land.members if m.xuid != target_xuid] + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已删除用户 {target}")) + elif choice == 3: + users = [ + f"{m.name}({m.rank.display_name})" for m in land.members] + fmts.print_inf(self._ui_card( + f"用户列表 - {land.name}", + [", ".join(users) if users else "无用户"], + )) + + def _console_manage_land_admins(self, land: LandData): + """Implement the console manage land admins operation.""" + while True: + choice = self._console_select( + f"管理管理人员 - {land.name}", + ["添加管理人员", "删除管理人员", "查看管理人员"], + ) + if choice is None: + return + if choice == 1: + target = self._console_prompt("请输入要设为管理人员的玩家名") + if not target: + continue + target_xuid = self._get_xuid_by_name( + target, allow_offline=True) + if target_xuid is None: + fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) + continue + member = land.get_member(target_xuid) + if member and member.rank == LandRank.OWNER: + fmts.print_err(self._error("所有者不能设为管理人员")) + continue + if member: + member.name = target + member.rank = LandRank.ADMIN + else: + land.members.append( + LandMember( + target, + target_xuid, + LandRank.ADMIN)) + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已将 {target} 设为管理人员")) + elif choice == 2: + target = self._console_prompt("请输入要删除管理权限的玩家名") + if not target: + continue + target_xuid = self._get_xuid_by_name( + target, allow_offline=True) + if target_xuid is None: + fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) + continue + member = land.get_member(target_xuid) + if not member or member.rank != LandRank.ADMIN: + fmts.print_err(self._error(f"{target} 不是管理人员")) + continue + member.rank = LandRank.MEMBER + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已删除 {target} 的管理权限")) + elif choice == 3: + admins = [ + m.name for m in land.members if m.rank == LandRank.ADMIN] + fmts.print_inf(self._ui_card( + f"管理人员 - {land.name}", + [", ".join(admins) if admins else "暂无管理人员"], + )) + + def _console_manage_land_owner(self, land: LandData): + """Implement the console manage land owner operation.""" + while True: + choice = self._console_select( + f"管理所有者 - {land.name}", + ["修改所有者", "查看所有者"], + ) + if choice is None: + return + if choice == 1: + owner = self._console_prompt("请输入新所有者玩家名") + if not owner: + continue + owner_member_data = self._make_member( + owner, LandRank.OWNER, allow_offline=True) + if owner_member_data is None: + fmts.print_err(self._error(f"无法获取 {owner} 的 XUID")) + continue + land.owner = owner + land.owner_xuid = owner_member_data.xuid + for member in land.members: + if member.rank == LandRank.OWNER: + member.rank = LandRank.ADMIN + owner_member = land.get_member(owner_member_data.xuid) + if owner_member: + owner_member.name = owner + owner_member.rank = LandRank.OWNER + else: + land.members.append(owner_member_data) + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已修改所有者为 {owner}")) + elif choice == 2: + fmts.print_inf(self._ui_card( + f"所有者 - {land.name}", [land.owner])) + + def _console_manage_land_center(self, land: LandData): + """Implement the console manage land center operation.""" + while True: + choice = self._console_select( + f"管理领地中心点 - {land.name}", + ["修改中心点", "查看中心点"], + ) + if choice is None: + return + if choice == 1: + center = self._console_prompt_pos("请输入新的领地中心点") + if center is None: + continue + center_tuple = tuple(center) + overlap = self._get_land_edit_overlap_reason( + land, center_tuple, land.radius) + if overlap: + fmts.print_err(self._error(overlap)) + continue + land.center = center_tuple + self._save_data() + self._spawn_entity(land) + fmts.print_suc(self._success( + f"已修改领地中心点为 {center[0]}, {center[1]}, {center[2]}")) + elif choice == 2: + fmts.print_inf(self._ui_card( + f"领地中心点 - {land.name}", + [f"{land.center[0]}, {land.center[1]}, {land.center[2]}"], + )) + + def _console_manage_land_radius(self, land: LandData): + """Implement the console manage land radius operation.""" + while True: + options = [ + "修改方形长高宽", + "查看方形长高宽"] if land.is_box() else [ + "修改范围半径", + "查看范围半径"] + choice = self._console_select( + f"管理领地范围 - {land.name}", + options, + ) + if choice is None: + return + if choice == 1: + if land.is_box(): + size_text = self._console_prompt( + f"请输入新的 长 高 宽,最大 { + self.max_length} { + self.max_height} { + self.max_width}") + if size_text is None: + continue + size, err = self._parse_box_size_input(size_text) + if size is None: + fmts.print_err(self._error(err or "方形领地尺寸无效")) + continue + radius = box_radius_for_size(size) + overlap = self._get_land_edit_overlap_reason( + land, land.center, radius, "方形", size) + if overlap: + fmts.print_err(self._error(overlap)) + continue + land.shape = "方形" + land.size = size + land.radius = radius + self._save_data() + fmts.print_suc(self._success( + f"已修改方形领地范围为 长:{size[0]}, 高:{size[1]}, 宽:{size[2]}")) + else: + radius = self._console_prompt_int( + f"请输入新范围半径,范围 1~{self.max_radius}") + if radius is None: + continue + if radius <= 0 or radius > self.max_radius: + fmts.print_err( + self._error( + f"半径必须在 1~{ + self.max_radius} 之间")) + continue + overlap = self._get_land_edit_overlap_reason( + land, land.center, radius, "圆形", None) + if overlap: + fmts.print_err(self._error(overlap)) + continue + land.shape = "圆形" + land.size = None + land.radius = radius + self._save_data() + fmts.print_suc(self._success(f"已修改领地范围半径为 {radius}")) + elif choice == 2: + fmts.print_inf(self._ui_card( + f"领地范围 - {land.name}", [land.range_text()])) + + def _get_land_candidate_overlap_reason( + self, + center: Tuple[float, float, float], + radius: int, + shape: str, + size: Optional[Tuple[int, int, int]] = None, + dimension: int = 0, + skip_land_id: Optional[str] = None, + ) -> Optional[str]: + """Return land candidate overlap reason data.""" + blocked_region = self._get_no_create_overlap_reason( + center, radius, shape, size) + if blocked_region: + return f"领地不能与不可创建区域 '{blocked_region}' 重叠" + for other in self.lands.values(): + if other.land_id == skip_land_id or other.dimension != dimension: + continue + if land_overlaps_candidate(other, center, radius, shape, size): + return f"领地与 '{other.name}' 重叠" + return None + + def _get_land_edit_overlap_reason( + self, + land: LandData, + center: Tuple[float, float, float], + radius: int, + shape: Optional[str] = None, + size: Optional[Tuple[int, int, int]] = None, + ) -> Optional[str]: + """Return land edit overlap reason data.""" + edit_shape = shape or land.shape + edit_size = size if size is not None else land.size + return self._get_land_candidate_overlap_reason( + center, + radius, + edit_shape, + edit_size, + land.dimension, + land.land_id, + ) + + def console_test(self, args: List[str]): + """Implement the console test operation.""" + if not self.enabled: + fmts.print_war(self._warn("领地系统云链联动版当前已在配置中禁用")) + return + if args: + target = args[0] + pos = self._get_player_coord(target) + if pos: + fmts.print_inf( + self._ui_card( + "控制台测试", [ + f"玩家 {target} 坐标:{pos}"])) + else: + fmts.print_err(self._error("无法获取坐标")) + else: + fmts.print_inf(self._notice("用法: 领地测试 <玩家名>")) + + +entry = plugin_entry(LandPlugin, "领地系统云链联动版", (0, 1, 18)) diff --git "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/config.py" "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/config.py" index d6af627b..5d1f4071 100644 --- "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/config.py" +++ "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/config.py" @@ -10,6 +10,7 @@ def default_config() -> Dict[str, Any]: + """Implement the default config operation.""" return { DYNAMIC_LOAD_SETTINGS_KEY: { DYNAMIC_LOAD_ENABLED_KEY: True, @@ -31,6 +32,7 @@ def default_config() -> Dict[str, Any]: def default_no_create_regions() -> List[Dict[str, Any]]: + """Implement the default no create regions operation.""" return [ { "名称": "主城保护范围", diff --git "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/geometry.py" "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/geometry.py" index 4a9263da..6253fdf7 100644 --- "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/geometry.py" +++ "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/geometry.py" @@ -1,98 +1,105 @@ -"""Geometry helpers for land overlap and distance checks.""" - -import math -from typing import Optional, Tuple - -from .models import LandData - - -Position = Tuple[float, float, float] -Bounds = Tuple[Position, Position] - - -def sphere_intersects_box( - center: Position, - radius: float, - min_pos: Position, - max_pos: Position) -> bool: - distance_sq = 0.0 - for i in range(3): - if center[i] < min_pos[i]: - distance_sq += (min_pos[i] - center[i]) ** 2 - elif center[i] > max_pos[i]: - distance_sq += (center[i] - max_pos[i]) ** 2 - return distance_sq <= radius ** 2 - - -def boxes_intersect( - min_a: Position, - max_a: Position, - min_b: Position, - max_b: Position) -> bool: - return all(min_a[i] <= max_b[i] and max_a[i] >= min_b[i] for i in range(3)) - - -def box_distance(pos: Position, min_pos: Position, max_pos: Position) -> float: - distance_sq = 0.0 - for i in range(3): - if pos[i] < min_pos[i]: - distance_sq += (min_pos[i] - pos[i]) ** 2 - elif pos[i] > max_pos[i]: - distance_sq += (pos[i] - max_pos[i]) ** 2 - return math.sqrt(distance_sq) - - -def bounds_from_center_size( - center: Position, size: Tuple[int, int, int]) -> Bounds: - length, height, width = size - cx, cy, cz = center - half = (length / 2, height / 2, width / 2) - return ( - (cx - half[0], cy - half[1], cz - half[2]), - (cx + half[0], cy + half[1], cz + half[2]), - ) - - -def distance_to_land(pos: Position, land: LandData) -> float: - if land.is_box(): - min_pos, max_pos = land.get_bounds() - return box_distance(pos, min_pos, max_pos) - x, y, z = pos - cx, cy, cz = land.center - return math.sqrt((x - cx) ** 2 + (y - cy) ** - 2 + (z - cz) ** 2) - land.radius - - -def land_overlaps_candidate( - land: LandData, - center: Position, - radius: int, - shape: str, - size: Optional[Tuple[int, int, int]] = None, -) -> bool: - shape = LandData.normalize_shape(shape) - if shape == "方形": - candidate_bounds = bounds_from_center_size( - center, LandData.normalize_size(size, radius)) - if land.is_box(): - return boxes_intersect( - candidate_bounds[0], - candidate_bounds[1], - *land.get_bounds()) - return sphere_intersects_box( - land.center, - land.radius, - candidate_bounds[0], - candidate_bounds[1]) - if land.is_box(): - min_pos, max_pos = land.get_bounds() - return sphere_intersects_box(center, radius, min_pos, max_pos) - lx, ly, lz = land.center - return ( - math.sqrt((center[0] - lx) ** 2 + (center[1] - ly) ** 2 + (center[2] - lz) ** 2) - <= radius + land.radius - ) - - -def box_radius_for_size(size: Tuple[int, int, int]) -> int: - return max(1, math.ceil(max(size) / 2)) +"""Geometry helpers for land overlap and distance checks.""" + +import math +from typing import Optional, Tuple + +from .models import LandData + + +Position = Tuple[float, float, float] +Bounds = Tuple[Position, Position] + + +def sphere_intersects_box( + center: Position, + radius: float, + min_pos: Position, + max_pos: Position) -> bool: + """Implement the sphere intersects box operation.""" + distance_sq = 0.0 + for i in range(3): + if center[i] < min_pos[i]: + distance_sq += (min_pos[i] - center[i]) ** 2 + elif center[i] > max_pos[i]: + distance_sq += (center[i] - max_pos[i]) ** 2 + return distance_sq <= radius ** 2 + + +def boxes_intersect( + min_a: Position, + max_a: Position, + min_b: Position, + max_b: Position) -> bool: + """Implement the boxes intersect operation.""" + return all(min_a[i] <= max_b[i] and max_a[i] >= min_b[i] for i in range(3)) + + +def box_distance(pos: Position, min_pos: Position, max_pos: Position) -> float: + """Implement the box distance operation.""" + distance_sq = 0.0 + for i in range(3): + if pos[i] < min_pos[i]: + distance_sq += (min_pos[i] - pos[i]) ** 2 + elif pos[i] > max_pos[i]: + distance_sq += (pos[i] - max_pos[i]) ** 2 + return math.sqrt(distance_sq) + + +def bounds_from_center_size( + center: Position, size: Tuple[int, int, int]) -> Bounds: + """Implement the bounds from center size operation.""" + length, height, width = size + cx, cy, cz = center + half = (length / 2, height / 2, width / 2) + return ( + (cx - half[0], cy - half[1], cz - half[2]), + (cx + half[0], cy + half[1], cz + half[2]), + ) + + +def distance_to_land(pos: Position, land: LandData) -> float: + """Implement the distance to land operation.""" + if land.is_box(): + min_pos, max_pos = land.get_bounds() + return box_distance(pos, min_pos, max_pos) + x, y, z = pos + cx, cy, cz = land.center + return math.sqrt((x - cx) ** 2 + (y - cy) ** + 2 + (z - cz) ** 2) - land.radius + + +def land_overlaps_candidate( + land: LandData, + center: Position, + radius: int, + shape: str, + size: Optional[Tuple[int, int, int]] = None, +) -> bool: + """Implement the land overlaps candidate operation.""" + shape = LandData.normalize_shape(shape) + if shape == "方形": + candidate_bounds = bounds_from_center_size( + center, LandData.normalize_size(size, radius)) + if land.is_box(): + return boxes_intersect( + candidate_bounds[0], + candidate_bounds[1], + *land.get_bounds()) + return sphere_intersects_box( + land.center, + land.radius, + candidate_bounds[0], + candidate_bounds[1]) + if land.is_box(): + min_pos, max_pos = land.get_bounds() + return sphere_intersects_box(center, radius, min_pos, max_pos) + lx, ly, lz = land.center + return ( + math.sqrt((center[0] - lx) ** 2 + (center[1] - ly) ** 2 + (center[2] - lz) ** 2) + <= radius + land.radius + ) + + +def box_radius_for_size(size: Tuple[int, int, int]) -> int: + """Implement the box radius for size operation.""" + return max(1, math.ceil(max(size) / 2)) diff --git "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/models.py" "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/models.py" index ce1c24a9..768eb839 100644 --- "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/models.py" +++ "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/models.py" @@ -1,193 +1,207 @@ -"""Data models for the land protection cloud interop plugin.""" - -import time -from dataclasses import dataclass, field -from enum import Enum -from typing import Any, List, Optional, Tuple - - -class LandRank(Enum): - """Land membership roles.""" - - OWNER = "owner" - ADMIN = "admin" - MEMBER = "member" - - @property - def display_name(self): - return { - "owner": "§c领主", - "admin": "§6管理员", - "member": "§a成员", - }[self.value] - - -@dataclass -class LandMember: - """One member entry inside a land claim.""" - - name: str - xuid: str - rank: LandRank - join_time: float = field(default_factory=time.time) - - def to_dict(self): - return { - "name": self.name, - "xuid": self.xuid, - "rank": self.rank.value, - "join_time": self.join_time, - } - - @classmethod - def from_dict(cls, d): - return cls( - name=d["name"], - xuid=str(d["xuid"]), - rank=LandRank(d["rank"]), - join_time=d.get("join_time", time.time()), - ) - - -@dataclass -class LandData: - """Persisted land claim data.""" - - land_id: str - name: str - owner: str - owner_xuid: str - center: Tuple[float, float, float] - radius: int - shape: str = "圆形" - dimension: int = 0 - size: Optional[Tuple[int, int, int]] = None - members: List[LandMember] = field(default_factory=list) - create_time: float = field(default_factory=time.time) - - def to_dict(self): - return { - "land_id": self.land_id, - "name": self.name, - "owner": self.owner, - "owner_xuid": self.owner_xuid, - "center": list(self.center), - "radius": self.radius, - "shape": self.shape, - "size": list(self.get_size()) if self.is_box() else None, - "dimension": self.dimension, - "members": [m.to_dict() for m in self.members], - "create_time": self.create_time, - } - - @classmethod - def from_dict(cls, d): - members = [LandMember.from_dict(m) for m in d.get("members", [])] - shape = cls.normalize_shape(d.get("shape", "圆形")) - radius = d["radius"] - size = cls.normalize_size( - d.get("size"), - radius) if shape == "方形" else None - return cls( - land_id=d["land_id"], - name=d["name"], - owner=d["owner"], - owner_xuid=str(d["owner_xuid"]), - center=tuple(d["center"]), - radius=radius, - shape=shape, - dimension=d.get("dimension", 0), - size=size, - members=members, - create_time=d.get("create_time", time.time()), - ) - - @staticmethod - def normalize_shape(raw: Any) -> str: - shape = str(raw or "圆形").strip().lower() - if shape in ( - "方形", - "矩形", - "长方形", - "square", - "box", - "立方体", - "长方体", - "cuboid"): - return "方形" - return "圆形" - - @staticmethod - def normalize_size( - raw: Any, fallback_radius: int = 1) -> Tuple[int, int, int]: - if isinstance(raw, (list, tuple)) and len(raw) == 3: - try: - length = max(1, int(raw[0])) - height = max(1, int(raw[1])) - width = max(1, int(raw[2])) - return (length, height, width) - except (TypeError, ValueError): - pass - fallback = max(1, int(fallback_radius) * 2) - return (fallback, fallback, fallback) - - def is_box(self) -> bool: - return self.normalize_shape(self.shape) == "方形" - - def get_size(self) -> Tuple[int, int, int]: - return self.normalize_size(self.size, self.radius) - - def get_bounds(self) -> Tuple[Tuple[float, - float, float], Tuple[float, float, float]]: - length, height, width = self.get_size() - cx, cy, cz = self.center - half = (length / 2, height / 2, width / 2) - return ( - (cx - half[0], cy - half[1], cz - half[2]), - (cx + half[0], cy + half[1], cz + half[2]), - ) - - def contains_pos(self, pos: Tuple[float, float, float]) -> bool: - x, y, z = pos - if self.is_box(): - min_pos, max_pos = self.get_bounds() - return all(min_pos[i] <= pos[i] <= max_pos[i] for i in range(3)) - cx, cy, cz = self.center - return (x - cx) ** 2 + (y - cy) ** 2 + \ - (z - cz) ** 2 <= self.radius ** 2 - - def range_text(self) -> str: - if self.is_box(): - length, height, width = self.get_size() - return f"方形 长:{length}, 高:{height}, 宽:{width}" - return f"圆形 半径:{self.radius}" - - def get_member(self, xuid: str) -> Optional[LandMember]: - xuid = str(xuid) - for member in self.members: - if member.xuid == xuid: - return member - return None - - def has_permission(self, xuid: str, perm: str) -> bool: - member = self.get_member(xuid) - if not member: - return False - if member.rank == LandRank.OWNER: - return True - if member.rank == LandRank.ADMIN and perm in ["manage_member"]: - return True - if member.rank == LandRank.MEMBER and perm == "tp": - return True - return False - - def can_manage_member(self, manager_xuid: str, target_xuid: str) -> bool: - manager = self.get_member(manager_xuid) - target = self.get_member(target_xuid) - if not manager or not target: - return False - if manager.rank == LandRank.OWNER: - return True - if manager.rank == LandRank.ADMIN and target.rank == LandRank.MEMBER: - return True - return False +"""Data models for the land protection cloud interop plugin.""" + +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, List, Optional, Tuple + + +class LandRank(Enum): + """Land membership roles.""" + + OWNER = "owner" + ADMIN = "admin" + MEMBER = "member" + + @property + def display_name(self): + return { + "owner": "§c领主", + "admin": "§6管理员", + "member": "§a成员", + }[self.value] + + +@dataclass +class LandMember: + """One member entry inside a land claim.""" + + name: str + xuid: str + rank: LandRank + join_time: float = field(default_factory=time.time) + + def to_dict(self): + """Implement the to dict operation.""" + return { + "name": self.name, + "xuid": self.xuid, + "rank": self.rank.value, + "join_time": self.join_time, + } + + @classmethod + def from_dict(cls, d): + """Implement the from dict operation.""" + return cls( + name=d["name"], + xuid=str(d["xuid"]), + rank=LandRank(d["rank"]), + join_time=d.get("join_time", time.time()), + ) + + +@dataclass +class LandData: + """Persisted land claim data.""" + + land_id: str + name: str + owner: str + owner_xuid: str + center: Tuple[float, float, float] + radius: int + shape: str = "圆形" + dimension: int = 0 + size: Optional[Tuple[int, int, int]] = None + members: List[LandMember] = field(default_factory=list) + create_time: float = field(default_factory=time.time) + + def to_dict(self): + """Implement the to dict operation.""" + return { + "land_id": self.land_id, + "name": self.name, + "owner": self.owner, + "owner_xuid": self.owner_xuid, + "center": list(self.center), + "radius": self.radius, + "shape": self.shape, + "size": list(self.get_size()) if self.is_box() else None, + "dimension": self.dimension, + "members": [m.to_dict() for m in self.members], + "create_time": self.create_time, + } + + @classmethod + def from_dict(cls, d): + """Implement the from dict operation.""" + members = [LandMember.from_dict(m) for m in d.get("members", [])] + shape = cls.normalize_shape(d.get("shape", "圆形")) + radius = d["radius"] + size = cls.normalize_size( + d.get("size"), + radius) if shape == "方形" else None + return cls( + land_id=d["land_id"], + name=d["name"], + owner=d["owner"], + owner_xuid=str(d["owner_xuid"]), + center=tuple(d["center"]), + radius=radius, + shape=shape, + dimension=d.get("dimension", 0), + size=size, + members=members, + create_time=d.get("create_time", time.time()), + ) + + @staticmethod + def normalize_shape(raw: Any) -> str: + """Implement the normalize shape operation.""" + shape = str(raw or "圆形").strip().lower() + if shape in ( + "方形", + "矩形", + "长方形", + "square", + "box", + "立方体", + "长方体", + "cuboid"): + return "方形" + return "圆形" + + @staticmethod + def normalize_size( + raw: Any, fallback_radius: int = 1) -> Tuple[int, int, int]: + """Implement the normalize size operation.""" + if isinstance(raw, (list, tuple)) and len(raw) == 3: + try: + length = max(1, int(raw[0])) + height = max(1, int(raw[1])) + width = max(1, int(raw[2])) + return (length, height, width) + except (TypeError, ValueError): + pass + fallback = max(1, int(fallback_radius) * 2) + return (fallback, fallback, fallback) + + def is_box(self) -> bool: + """Implement the is box operation.""" + return self.normalize_shape(self.shape) == "方形" + + def get_size(self) -> Tuple[int, int, int]: + """Return size data.""" + return self.normalize_size(self.size, self.radius) + + def get_bounds(self) -> Tuple[Tuple[float, + float, float], Tuple[float, float, float]]: + """Return bounds data.""" + length, height, width = self.get_size() + cx, cy, cz = self.center + half = (length / 2, height / 2, width / 2) + return ( + (cx - half[0], cy - half[1], cz - half[2]), + (cx + half[0], cy + half[1], cz + half[2]), + ) + + def contains_pos(self, pos: Tuple[float, float, float]) -> bool: + """Implement the contains pos operation.""" + x, y, z = pos + if self.is_box(): + min_pos, max_pos = self.get_bounds() + return all(min_pos[i] <= pos[i] <= max_pos[i] for i in range(3)) + cx, cy, cz = self.center + return (x - cx) ** 2 + (y - cy) ** 2 + \ + (z - cz) ** 2 <= self.radius ** 2 + + def range_text(self) -> str: + """Implement the range text operation.""" + if self.is_box(): + length, height, width = self.get_size() + return f"方形 长:{length}, 高:{height}, 宽:{width}" + return f"圆形 半径:{self.radius}" + + def get_member(self, xuid: str) -> Optional[LandMember]: + """Return member data.""" + xuid = str(xuid) + for member in self.members: + if member.xuid == xuid: + return member + return None + + def has_permission(self, xuid: str, perm: str) -> bool: + """Implement the has permission operation.""" + member = self.get_member(xuid) + if not member: + return False + if member.rank == LandRank.OWNER: + return True + if member.rank == LandRank.ADMIN and perm in ["manage_member"]: + return True + if member.rank == LandRank.MEMBER and perm == "tp": + return True + return False + + def can_manage_member(self, manager_xuid: str, target_xuid: str) -> bool: + """Implement the can manage member operation.""" + manager = self.get_member(manager_xuid) + target = self.get_member(target_xuid) + if not manager or not target: + return False + if manager.rank == LandRank.OWNER: + return True + if manager.rank == LandRank.ADMIN and target.rank == LandRank.MEMBER: + return True + return False From 25c19c59622308fe1af3d4227921478b1922d6a0 Mon Sep 17 00:00:00 2001 From: ljxbx Date: Fri, 12 Jun 2026 13:37:18 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E5=A4=84=E7=90=86=E5=8D=AB=E7=94=9F?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__init__.py" | 9 +- .../__init__.py" | 15 +- .../__pycache__/api.cpython-312.pyc" | Bin 0 -> 94985 bytes .../__pycache__/config.cpython-312.pyc" | Bin 0 -> 84548 bytes .../config_watcher.cpython-312.pyc" | Bin 0 -> 3934 bytes .../__pycache__/control.cpython-312.pyc" | Bin 0 -> 21074 bytes .../__pycache__/handlers.cpython-312.pyc" | Bin 0 -> 117095 bytes .../handlers_quick.cpython-312.pyc" | Bin 0 -> 22178 bytes .../__pycache__/logic.cpython-312.pyc" | Bin 0 -> 57564 bytes .../__pycache__/matchers.cpython-312.pyc" | Bin 0 -> 6402 bytes .../__pycache__/models.cpython-312.pyc" | Bin 0 -> 32438 bytes .../__pycache__/prompts.cpython-312.pyc" | Bin 0 -> 4149 bytes .../__pycache__/service.cpython-312.pyc" | Bin 0 -> 2752 bytes .../__pycache__/ui.cpython-312.pyc" | Bin 0 -> 11007 bytes .../__pycache__/validators.cpython-312.pyc" | Bin 0 -> 2661 bytes .../guild_cloud_interop/api.py" | 3642 ++++++------- .../guild_cloud_interop/config.py" | 14 +- .../guild_cloud_interop/control.py" | 823 +-- .../guild_cloud_interop/handlers.py" | 4741 +++++++++-------- .../guild_cloud_interop/handlers_quick.py" | 931 ++-- .../guild_cloud_interop/logic.py" | 30 +- .../guild_cloud_interop/models.py" | 9 +- .../guild_cloud_interop/service.py" | 1 + .../guild_cloud_interop/ui.py" | 10 +- .../__init__.py" | 1 + .../binding_mixin.py" | 4 + .../config_editor_mixin.py" | 10 +- .../config_mixin.py" | 10 +- .../orion_mixin.py" | 1885 +++---- .../qq_mixin.py" | 129 +- .../runtime_mixin.py" | 20 +- .../__pycache__/_abnf.cpython-312.pyc" | Bin 0 -> 20201 bytes .../__pycache__/_app.cpython-312.pyc" | Bin 0 -> 31267 bytes .../__pycache__/_core.cpython-312.pyc" | Bin 0 -> 26549 bytes .../__pycache__/_handshake.cpython-312.pyc" | Bin 0 -> 9814 bytes .../__pycache__/_http.cpython-312.pyc" | Bin 0 -> 15380 bytes .../__pycache__/_socket.cpython-312.pyc" | Bin 0 -> 7797 bytes .../__pycache__/_url.cpython-312.pyc" | Bin 0 -> 7222 bytes .../__pycache__/_utils.cpython-312.pyc" | Bin 0 -> 5880 bytes .../__pycache__/_wsdump.cpython-312.pyc" | Bin 0 -> 12742 bytes .../websocket/_app.py" | 1382 ++--- .../websocket/_utils.py" | 943 ++-- .../websocket/_wsdump.py" | 549 +- .../__init__.py" | 34 +- .../models.py" | 1 + 45 files changed, 7756 insertions(+), 7437 deletions(-) create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/api.cpython-312.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/config.cpython-312.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/config_watcher.cpython-312.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/control.cpython-312.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/handlers.cpython-312.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/handlers_quick.cpython-312.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/logic.cpython-312.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/matchers.cpython-312.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/models.cpython-312.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/prompts.cpython-312.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/service.cpython-312.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/ui.cpython-312.pyc" create mode 100644 "\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/validators.cpython-312.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_abnf.cpython-312.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_app.cpython-312.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_core.cpython-312.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_handshake.cpython-312.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_http.cpython-312.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_socket.cpython-312.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_url.cpython-312.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_utils.cpython-312.pyc" create mode 100644 "\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_wsdump.cpython-312.pyc" diff --git "a/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index c083ba7e..01ef06f5 100644 --- "a/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -104,7 +104,10 @@ def __init__(self, frame): "§7在15s内输入§f任务前的序号§r§7可以提交此任务 §f其他§7以退出", ], "接到新任务时执行的指令": [ - "/execute as @a[name=[玩家名]] at @s run playsound note.pling @s ~~~ 1 1.4", + ( + "/execute as @a[name=[玩家名]] at @s run playsound " + "note.pling @s ~~~ 1 1.4" + ), ( '/tellraw @a[name=[玩家名]] ' '{"rawtext":[{"text":"§d▶ §e收到新任务 §f[任务显示名]\n' @@ -339,7 +342,7 @@ def load_runtime_config(self, announce: bool = False): if announce: fmts.print_suc(f"{self.name} 主配置文件已热更新") - def load_quest_configs(self, announce: bool = False): + def load_quest_configs(self, announce: bool = False): # skipcq: PY-R1000 """Load quest configs data.""" quests: dict[str, Quest] = {} total_quest_files = 0 @@ -690,7 +693,7 @@ def is_quest_in_progress(self, player: Player, quest: Quest) -> bool: o = self.read_player_quest_data(player) return quest.tag_name in o["in_quests"] - def detect_quest( + def detect_quest( # skipcq: PY-R1000 self, player: Player, quest: Quest, allow_command_block: bool = False ) -> tuple[bool, str]: """ diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index de9365a8..aa0bf4ff 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -3,7 +3,15 @@ from threading import Event from typing import Dict -from tooldelta import plugin_entry, Plugin, ToolDelta, TYPE_CHECKING, utils, Player, FrameExit +from tooldelta import ( + FrameExit, + Player, + Plugin, + ToolDelta, + TYPE_CHECKING, + plugin_entry, + utils, +) from tooldelta.constants import PacketIDS from tooldelta.utils import tempjson @@ -14,7 +22,10 @@ from guild_cloud_interop.api import guild_api_functions from guild_cloud_interop.control import GuildManager from guild_cloud_interop.config import Config, PLUGIN_ENABLED_KEY -from guild_cloud_interop.config_watcher import config_reload_task, refresh_config_file_state +from guild_cloud_interop.config_watcher import ( + config_reload_task, + refresh_config_file_state, +) from guild_cloud_interop.ui import wrap_player diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/api.cpython-312.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/api.cpython-312.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..9ca9871a48856757c73fe04333b2e0db6e969fac GIT binary patch literal 94985 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 TT@U^~+LS;qIB3aYTf%<_(No${ literal 0 HcmV?d00001 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/config.cpython-312.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/config.cpython-312.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..8ad9b02b50893d69e808945a8ca997ff3a9adb83 GIT binary patch literal 84548 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 literal 0 HcmV?d00001 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/control.cpython-312.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/control.cpython-312.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..1136fd559efdff4053e64bdd393dee1bab9703c5 GIT binary patch literal 21074 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 literal 0 HcmV?d00001 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/handlers.cpython-312.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/handlers.cpython-312.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..30205e8f91baa07a864dce7d0735357fe440fd90 GIT binary patch literal 117095 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 O=Br)30_Vm2z3(42j#U%@ literal 0 HcmV?d00001 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/handlers_quick.cpython-312.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/handlers_quick.cpython-312.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..f593f81cc535aea146df3210d274a57981b450db GIT binary patch literal 22178 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 literal 0 HcmV?d00001 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/logic.cpython-312.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/logic.cpython-312.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..fac91fec96a6923763bce4187b3f9d97a2b21141 GIT binary patch literal 57564 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

  • Yu?l?&_qo%?;1%01|{|c5uiRbZwGKT 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Μ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 H=RW*5tVJ?$ literal 0 HcmV?d00001 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/matchers.cpython-312.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/matchers.cpython-312.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..80bcbd202195ec7ea05f6c9741bfecf6061aca99 GIT binary patch literal 6402 zcmb7IZEzdK8Q%Mn6kCoQ$(CbBj%CYstR&bmNgU$127@!;kQk_;A12I=(4CDO$#(8e zNvz7#FbyE6X`BhgHenit89+24c*;z1fI?{h^y5c;I3VRFEn$d1@~a|->Cj(&cTZAe zMNQisdv~?_?z?;M=d=5lqM`x><>zNN4*b=M&{z0Jy0EFvavO9ekbruTKnb*;3edfD zfazrdY%fdUF~-jY47~;l(dd38u*Zqv>)u34~L zyFsk`uvA4UxDI$NOufJKv)?T~|JMA;r{~9CTzqqSe(b{aXI`A2ig_uRPZtAp82DfQ zGgK2uNsps}l=3nI&86Wb} zPnZze54(<1qjZ=KQzz-M0v36hQF@euT{3%*04bcX#aD-J=y@>l0;>$JQuoW-`^Wx2vNECXF7y&m+Sy=L5mYT9HrSl_5U01lyu4 z`ujBMfJPmXvFw~q=eInx5Br1tzQKJS>42!*5%Bm1_uo1)|IE2-AHK5q?gxt>oWAz{ ziR&jnT6+4W`4?h~&qwwX2jIQ_p`gI~1{F~n8s1Ml1w5p2ctkVtFzi!!KD0JR<&EhX zZ29uTP#r~ADqZo)7PYb^!d@~}Tq&!Jmu*zbHb&Sn^VO!U@um*7sUzNWzuI*FrKYY+ zm3!2tt}{yXV61x56J0YU%r

    MVYVsPn5?yw#?YLT-&_hwvOF3q^l$F1l8fQByA#WA?OKyLXbc) zB#EG&j1DEK=RZ!;1mRP{d4k%h&q#6+E)$G|tAraIzUrySDbeTOvgO_9>A$Fa!f{qO zyXIWnMCnXX!(X)cV2k{tr(S3NmiDM4YY$0HSloUIzQ;e?)8RJ8cPvfXXn^bo98t<< G7XJb3h~z2& delta 2090 zcmY+EYfKbZ6oBW>>>IX#ym5If;kEJ-(M8@uK~{N0d=Mq#X4qMlm1S4&?rK$tn6#;B z@s*QCV_Q))F!ui&I74U(}k19c-UsBCCF5mGZe(6e+>l* zf`|8U9)2L7l)L0bS!Wru&R&lB64{;W(PZydE~%i4HCs z4+wfSMyg;O(?h@MtYC<}TgD7vGardXykXuFlWHt!+5Bsd@gpTH0zbsBW4RFa8yDoQ zmOuD7Bh6t!Da($7A#0`1h{AEJEAAVX%mnw`z%59TUs!JvOG&=Vth*kkbD7~@*5<3y?DPa8{kBgMTcXk^uC4o{_N*2h5^0!EiCpHqQ zp}?5TelJ%r9OWV$I67@`a%n0&O^B;GM|vCr9bro}z@V6X-pFXgKk8+tfQm4>%A>jJ zT0=k_9hAhT-AiC+W!~b7hCs+82Et8D&=#KI4h%NIof$mqJLP*++5!GAO#OA%BpD@Vy=ORdm4piZnZ(fg@f!b!@U? z4U0qG$S$`h6ddyV=sRGp946a=Ru&rWvi0zs{Ar~`EcY@SzLxgKKQwhhO4UGCN0n|q zGquCX&pXX7yMDeo!)4RovguuRIGK?S(^WUg-}3O1T#~<$Iax`47*&`Y3FB@xa6S5H=F5i?+C)ffz7Io+ag|Aqw5Fed_*4kAh8zyUac4I_E5QDquDfD4Q zyoD%3v?Jmf6r(6f?m)<=Chv${=F_-*2BBpEYwG$)ihQ>2Ja5z*a2_@`RvOf@rr|*2 zqB_+h4kW#ZP%ocaFgE3tU)7sh^<^|tuSPHY)!1%c!e%S{EmOiwUAo-YbcxSXdl$vT z^PWyjJb8ci$$J-mxq93Yr*-oL6t_4^)nISp7_>=HLulSH&U!=*>}hEt-7wqo6X}&d zZoP=TT-}ztS3OIM=2V?PHTPNEQ;K-gH4LW2OaAb-fVj&Y6i2;U*GyVe^;@HczKZ)W zzGC%6DfI>;ZeP?Nkkm{RQz#(0sTdpyo0tjw+SXvkTN?fx-?03)_PfMz0)5OvR!6!0 zIEt9n{|4teYF~ffl3`DWlkAZ%cRV1HwwJ9d#$hpx7fdmRV>j)!Gx`a7jOBg{jjiFR z$Kwr$+2x4_BNR(fEJJ=TOBah(^gF~r)cY#t(^Wm>19|!CA4sw`=0>fD>Li?k%&vv@ z-54QuCV2a_6YoYTmiUIQF2ghyKL~Rz88Fwig-k$B_u9SnXw-zjkCx_n6H1#AZiF5& zh>#E%jBZD$C1Q0{ZNxN6mk?JFUn0~)y@paXVjf{c+(A6x;Lq-*`Q`eOA9ISI3;17F zHB)d!cV*od=9$WSWvwq!`C^OQ-m`*Fe1v<{@8*7#+To)$H?kBI?TB0k#ndnwlA-}` T6MYP{w>F{QK diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/handlers.cpython-312.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/handlers.cpython-312.pyc" index 30205e8f91baa07a864dce7d0735357fe440fd90..5ad844da6ab2bc5c095fa77647d1847a373872c9 100644 GIT binary patch delta 1430 zcmaKrdrZ?;6vuzxw!i*pOIr#GbW!O86k_qM3{;4Mb6~(1aj^LS`4!}$GcW>ZRWQM+ z5$JY}t&fc|Vyr_2y(T}5WTImk`1~;?VD@lko9+)WHOy@$X4$PSX4$gcKfdSW-gD09 z+?#VBekimML4QrJ*K%yRMyHnDJ*&?R@%SR3BbsMcvSRK8WvCcQPK7jh?j<9XBx_PV zX(`Z^jlCy^d)G9TkY*$lF1{dibN zm~J7gBuCc}VRMCvYBPhgIJ+yHY3!j5uoFJ?b?WN>sQ?acT?hgy1OMO+95=$*Ve;B%Vu_P0D9 zQ%AvXzylh)@j^5r>37>|BQSx!)qIDAv^(5JET)OAdlV4q`L>?`J3Z2#tw9t0`P62G zcCV5#9x~7~PeU^UyE@Zh7WD3j-8;PFhs}wD=0tySXZgwU?vy*`H|R)Ly%J|={`ou| z?$UFYzEBBQgB9*8J@?}f+x|(P1}ka%4VywpW&fnA2dsM=eSB-RQC%C{xI*O7Tzd50 zbOUbEYokqS#F6}8h7d!iKiy@-5~)*#r@VGH&ti8mc}5!g#z|MS&_-jSbi)R{5(CoL zak!^LjdXD)27nT&aW;18&>`_@Xi~u~k#u;&p^_YHu+xAdsYAp*0jH(871)o$PJtDF z^_z_0ZmT=Oy~Jy6oqTw*H`*8FH+4pxjPh&P2+p#MSga?6Ho;pb$0zsLy9+OsUMRgd zO-|12H(j4MysT(&S&^J2%3?8_K>eoR1ZuRBWeZLN$2~XS#ad{ksatU{1W%;iTKE+h zmP)r{h)0x^)`0zLIHarZqm@U2RMrFogykWuy~V+Yvr4{ZJlCU6`sf&z8^KGX9XO)G zU1@I@4(YH;ieV3|5Yja)qcJ!rHuCtENqY1dlK9k8Aj}sT;DXklG4{d+@{|kM?z{lU ej9(lJn!rw&kmz#;IA*$(V?h&$mkRr!=Klors_AL~ delta 1434 zcmaJ=ZBSHI7{1Tm-M#yDSy(;>WLftUTg9&wN17Bd44g4+6$QT%a3iHOQ*&`d7G@eX zM`iIe?UFJqqmAfDEghSSX$ppkW7e7WI`PiboM$ELr1HcEYG)*>aS8rhQS%kpAS7LHtdIs+%mYt?0*)J zG;xK6+orGA2l(C9-jO#Sd|WNRg!92Yret_B>HXMDKNIJIrWp&ALF_~(X}URuzMHJp zRuX2sv7A8zC#yN5DPRjE2j+yFq1Rf*wB@wqge+lS$EHZZ5nsm|nZtBz%<2q#+J(bn zyBP9EC*=3>J%#;Ay+!Be_s+jEt2COwD7vUj<{>VD%=4m-8nTvPSu1lGTJXO%{>Y~8 zf-YYqZZix2@##_aczT8NVz1ck@2`p*EcnZy*D?n(uiq!3?Iae^h_qDPD&eG+dZ#5PWgPbpDYqik`ay`sp9#TY$54^U`ls_?ebg{07{n z^QumyBbBPR)+Qs9`uARCl7poIBMRuEw(Sb6qBq)a19p1$;B#8+riqPk^{FTI*s{6SX(k51Znyj+hM{rb!~B`(l#-&if4rx!2T6|B9xUu-oBJ|%1K>?7?F z(PO8d+?<#oT#Bcw9+M7QT4AKi?z+@)lE!=2V4-Ehej^H{0VRf2cv8Bg!NhhB{$OK+0U5xq!b(O%3KMxL`hE;mt zg;&5aX~iqph06EhnTI2$PuN+xb74Rgs0>y&R|m>NWns_Zvi7p@Tz2m#m}BOwwxX7z zP+ruTdwf;5?Tq_`o34xI>>=Z5}9EdAVq1^x2ANMSKFZ-uMJ))l%^~bOMKh_g}?w zsHKJL@sNbP&! zNDyk{(ld`aM2Q{Z{RAkb)MI!@iI^0L;B7rTQqd_81!ha%oW^?9%+)|*I8El_+)+3* o!t!E%CW}s!IYu4cp*YF26-JB?zbJEzBK%OCWT*6IAJnQp0X&J{6951J diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/handlers_quick.cpython-312.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/handlers_quick.cpython-312.pyc" index f593f81cc535aea146df3210d274a57981b450db..252b3674db6fd329fb2025e01d31fea5da61add9 100644 GIT binary patch delta 47 zcmZ3qmT}QqM&8rByj%=GQ2R*Ge=NNHA)kSiRao3o{xdD+-`Cx|xHZ~mgOP8R@X C-w%xd delta 47 zcmZ3qmT}QqM&8rByj%=Gka0mP>%m6eNNHBFkSiQvo3o{xdD%GmCzy80Z~mgOP8R@P Ce-9e~ diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/logic.cpython-312.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/logic.cpython-312.pyc" index fac91fec96a6923763bce4187b3f9d97a2b21141..7bb6acfaa159f51eddc033ecb79d45ddf1eecd29 100644 GIT binary patch delta 8571 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 delta 8447 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 p$7;18_yYrwT9FK zVq~n?{8eNXBV+sG1!9cCEUa29ls_;4sSV1XLDUDH$(zN4rFdAa76gA_08%TG!PJJ- a&mi6hnaK(g@A-Y07!^M;fXE^lpcMc=;Wb78 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/models.cpython-312.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/models.cpython-312.pyc" index cdcce6dae89f8d44dea2fd9b2d6ca89ae4bb9027..2460e10c21f9bd58a9ac6ff708f1bd0cd93dc677 100644 GIT binary patch delta 960 zcmaKqZ%9*77{>2=)~0Q{yXtgpOE%q>Nap;r6{u;HPSFsxKju|RN<;jEEA_+7VHjkC z)~EAfR?A9)RuN_gA`l8B0x_a6$s}k}pM+pq8G%yg%$pKa_rt?^cz*BUy$|QnDm-5W zev0Rn9R0c$GTMS;e7_2+uwNB`FtMpu08WrpO+7#d_6m=MI?8pssGR5KHzkKjah!ZAz-w}@8%8Q=j161NJ@$v zgeQz>*&v*$9t7XWed{P74)C#U?}Y`1h!R2e5b5ob0o$#&>tpdXj#52I4R0|)JROTpc^W`#GV33*UEq8;gb z;&Hr~#z&2z69Lx?UYt{wgb#~)oUwaFF~}~xiZcsOXYxMJ8g7mFQ8@sur)g`c@@D%HNeuN+cagH5=j)Sxq4mzawv zxI{xFhMkn$W}XB}kqqX^mGXcLev;hEMFcuxs~$&?M#2Z~0ptMF4~ckb@bNccx`e_8=W&MC(dXv zBK1yE(NKV(gA5*>mBRpOY^qViZ8GK_KyZUNx<1H-lk8`Mvh@d<>KT=v)qo+wQTZa{ zkScsSld~ delta 962 zcmaKqZAep57{~8(=BCYEulYK4>YdsYDV_6$a&x{+Gm%P5(rH$rAZoBuid8Eyh)j%v zLoQ>P2GYpF>VQZQksk{2L%tB+BFLydR#U+!GCD_>_My5T{+vJm-*e7$o_n4x!^kr5 z6Fjfv=+`uv-2C`Hf5snn;ckB?NF+h^1K<$R22=wyV}~#*R8p?3h{`$cosUK$mOv|l zc5*#v9N+?O40$aaXGG~bQ5HIepq~V*KLOmq5V<5=VMLRch?9sS>KlUR4`N~wydp4#h22HBL4CRGem8_2KlKqxz7`bzLTxrQhe4Fd)om^!K(-Ym8D% z+znf=&24u#xtk|rwsjU>-0hKZ(WXulp2~Rzy=2IIgboU8^2d=LVWB*M_!lIA(&5*j zTsbfn=U6fV9DXhPX)AC799(k{p3e@(Go`^~&>|wm2$=f-PfG38s zM+lMM!@rH7;94VX3kKhUxYD&7iacfd+X xB1@=v6J{$`lg1bpQSnC17OY0;ETQ5}m_59j{Ij(x1*>7YQZ$d)vQ str: + """Return text without Minecraft color codes.""" return COLOR_CODE_RE.sub("", str(text)) def _now() -> float: + """Return the current unix timestamp.""" return time.time() def _actor(actor: str | None) -> str: + """Return a normalized actor label.""" text = str(actor or "").strip() return text or "QQ管理" def _to_int(value: object, field_name: str, minimum: int | None = None) -> tuple[bool, str, int]: + """Parse an integer API argument.""" try: parsed = int(str(value).strip()) except (TypeError, ValueError): @@ -54,6 +58,7 @@ def _to_int(value: object, field_name: str, minimum: int | def _to_float(value: object, field_name: str) -> tuple[bool, str, float]: + """Parse a float API argument.""" try: return True, "", float(str(value).strip()) except (TypeError, ValueError): @@ -61,12 +66,14 @@ def _to_float(value: object, field_name: str) -> tuple[bool, str, float]: def _ensure_settings(guild: GuildData) -> dict[str, Any]: + """Ensure guild settings are stored as a dictionary.""" if not isinstance(guild.settings, dict): guild.settings = {} return guild.settings def _rebuild_player_cache(self, guilds: dict[str, GuildData]) -> None: + """Rebuild the guild player cache.""" self.guild_manager.rebuild_player_cache(guilds) @@ -74,6 +81,7 @@ def _save_guilds(self, guilds: dict[str, GuildData], force: bool = True) -> bool: + """Save guild data and refresh the manager cache.""" _rebuild_player_cache(self, guilds) ok = self.guild_manager.save_guilds(guilds, force=force) if ok: @@ -82,14 +90,16 @@ def _save_guilds(self, def _load_guilds(self) -> dict[str, GuildData]: + """Load guild data with a fresh cache read.""" return self.guild_manager.load_guilds(force_reload=True) -def _find_guild( +def _find_guild( # skipcq: PY-R1000 self, guild_query: object, guilds: Optional[dict[str, GuildData]] = None, ) -> tuple[Optional[GuildData], str]: + """Find a guild by ID, exact name, or fuzzy text.""" query = str(guild_query or "").strip() if not query: return None, "公会不能为空" @@ -128,6 +138,7 @@ def _find_player_guild( player_name: object, guilds: Optional[dict[str, GuildData]] = None, ) -> tuple[Optional[GuildData], str]: + """Find the guild that contains a player.""" name = str(player_name or "").strip() if not name: return None, "玩家名不能为空" @@ -144,6 +155,7 @@ def _find_player_context( player_name: object, guilds: Optional[dict[str, GuildData]] = None, ) -> tuple[str, Optional[GuildData], Optional[GuildMember], str]: + """Return normalized player, guild, member, and error context.""" name = str(player_name or "").strip() if not name: return "", None, None, "玩家名不能为空" @@ -158,6 +170,7 @@ def _find_player_context( def _find_task(guild: GuildData, task_query: object) -> tuple[Optional[GuildTask], str]: + """Find a guild task by ID, exact name, or fuzzy text.""" query = str(task_query or "").strip() if not query: return None, "任务不能为空" @@ -180,6 +193,7 @@ def _find_task(guild: GuildData, def _member_summary(member: GuildMember) -> dict[str, Any]: + """Build a serializable member summary.""" return { "name": member.name, "rank": member.rank.value, @@ -191,6 +205,7 @@ def _member_summary(member: GuildMember) -> dict[str, Any]: def _base_summary(base: GuildBase | None) -> dict[str, Any] | None: + """Build a serializable base summary.""" if base is None: return None return { @@ -203,6 +218,7 @@ def _base_summary(base: GuildBase | None) -> dict[str, Any] | None: def _vault_item_summary(item: VaultItem, index: int | None = None) -> dict[str, Any]: + """Build a serializable vault item summary.""" data = item.to_dict() if index is not None: data["index"] = index @@ -210,10 +226,12 @@ def _vault_item_summary(item: VaultItem, index: int | def _task_summary(task: GuildTask) -> dict[str, Any]: + """Build a serializable task summary.""" return task.to_dict() def _guild_summary(guild: GuildData) -> dict[str, Any]: + """Build a serializable guild summary.""" settings = _ensure_settings(guild) return { "guild_id": guild.guild_id, @@ -240,6 +258,7 @@ def _guild_summary(guild: GuildData) -> dict[str, Any]: def _apply_level_ups(guild: GuildData) -> list[int]: + """Apply pending guild level ups and return gained levels.""" level_ups: list[int] = [] next_level = guild.level + 1 required = Config.GUILD_LEVEL_EXP.get(next_level) @@ -254,6 +273,7 @@ def _apply_level_ups(guild: GuildData) -> list[int]: def _activity_multiplier(self, key: str) -> float: + """Return an active guild event multiplier.""" event = getattr(self, "_guild_runtime_events", {}).get(key) if not isinstance(event, dict): return 1.0 @@ -287,6 +307,7 @@ def guild_apply_reward_multipliers( def guild_is_frozen(self, guild: GuildData | None) -> bool: """Return whether frozen.""" + _ = self if guild is None: return False settings = getattr(guild, "settings", {}) @@ -295,6 +316,7 @@ def guild_is_frozen(self, guild: GuildData | None) -> bool: def guild_frozen_message(self, guild: GuildData | None) -> str: """Run guild frozen message.""" + _ = self if guild is None: return "公会已冻结" settings = getattr(guild, "settings", {}) @@ -1000,6 +1022,7 @@ def api_export_guild_vault( def _make_task_from_template( template: dict[str, Any], prefix: str = "auto") -> GuildTask: + """Create a guild task from a configured template.""" now = _now() deadline_seconds = int(getattr(Config, "GUILD_TASK_CONFIG", {}).get("自动任务默认有效期秒", 172800)) @@ -1628,7 +1651,7 @@ def api_backup_guild_data(self) -> tuple[bool, str, Optional[str]]: return True, "公会数据备份已创建", backup_path -def api_repair_guild_data( +def api_repair_guild_data( # skipcq: PY-R1000 self, actor: str = "QQ管理") -> tuple[bool, str, dict[str, Any]]: """Repair api repair guild data.""" guilds = _load_guilds(self) @@ -1824,6 +1847,7 @@ def api_broadcast_guild_announcement( def _get_guild_rankings( self, sort_by: str = "level") -> list[tuple[GuildData, Any]]: + """Return guild rankings using the runtime implementation.""" return self.get_guild_rankings(sort_by) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" index 23733357..f9747fff 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" @@ -31,6 +31,7 @@ def __init__(self, file_path: str): def validate_guild_data( self, guild_data: GuildData) -> Tuple[bool, List[str]]: """验证公会数据的完整性""" + _ = self errors = [] # 检查基本字段 @@ -234,7 +235,7 @@ def _backup_before_save(self, force: bool = False) -> None: for name in os.listdir(backup_dir) if name.startswith("公会数据文件-") and name.endswith(".json") ] - backups.sort(key=lambda path: os.path.getmtime(path), reverse=True) + backups.sort(key=os.path.getmtime, reverse=True) for old_path in backups[max_backups:]: try: os.remove(old_path) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" index 6722b975..85da56fc 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" @@ -23,6 +23,7 @@ def _handle_effect(self, player: Player) -> bool: # skipcq: PY-R1000 + """Handle guild effect purchases.""" guild = self.guild_manager.get_guild_by_player(player.name) if not guild: player.show("§l§a公会 §d>> §r你尚未加入任何公会") @@ -147,9 +148,9 @@ def _handle_rankings(self, player: Player) -> bool: def formatter(i, data): """Format one menu item for display.""" return ( - f"§e{i}. §r{data[0].name} §7Lv.{data[1]}\n" - f" §7会长: §f{data[0].owner} §7| 成员: §a{len(data[0].members)}\n" - ) + f"§e{i}. §r{data[0].name} §7Lv.{data[1]}\n" + f" §7会长: §f{data[0].owner} §7| 成员: §a{len(data[0].members)}\n" + ) elif choice == "2": rankings = self.get_guild_rankings("members") title = "公会成员数排行榜" @@ -157,9 +158,9 @@ def formatter(i, data): def formatter(i, data): """Format one menu item for display.""" return ( - f"§e{i}. §r{data[0].name} §7成员: §a{data[1]}\n" - f" §7会长: §f{data[0].owner} §7| 等级: §e{data[0].level}\n" - ) + f"§e{i}. §r{data[0].name} §7成员: §a{data[1]}\n" + f" §7会长: §f{data[0].owner} §7| 等级: §e{data[0].level}\n" + ) elif choice == "3": rankings = self.get_guild_rankings("contribution") title = "公会贡献度排行榜" @@ -167,9 +168,9 @@ def formatter(i, data): def formatter(i, data): """Format one menu item for display.""" return ( - f"§e{i}. §r{data[0].name} §7贡献: §b{data[1]}\n" - f" §7会长: §f{data[0].owner} §7| 等级: §e{data[0].level}\n" - ) + f"§e{i}. §r{data[0].name} §7贡献: §b{data[1]}\n" + f" §7会长: §f{data[0].owner} §7| 等级: §e{data[0].level}\n" + ) elif choice == "4": rankings = self.get_guild_rankings("activity") title = "公会活跃度排行榜" @@ -177,11 +178,9 @@ def formatter(i, data): def formatter(i, data): """Format one menu item for display.""" return ( - f"§e{i}. §r{ - data[0].name}\n" f" §7会长: §f{ - data[0].owner} §7| 最近活跃: §a{ - self._format_time_ago( - data[1])}\n") + f"§e{i}. §r{data[0].name}\n" + f" §7会长: §f{data[0].owner} §7| 最近活跃: §a{self._format_time_ago(data[1])}\n" + ) else: player.show("§c无效选项") return True @@ -196,6 +195,8 @@ def formatter(i, data): def _format_time_ago(self, timestamp: float) -> str: """格式化时间差显示""" + if self is None: + return "从未" if timestamp == 0: return "从未" @@ -204,12 +205,11 @@ def _format_time_ago(self, timestamp: float) -> str: if diff < 60: return "刚刚" - elif diff < 3600: + if diff < 3600: return f"{int(diff // 60)}分钟前" - elif diff < 86400: + if diff < 86400: return f"{int(diff // 3600)}小时前" - else: - return f"{int(diff // 86400)}天前" + return f"{int(diff // 86400)}天前" def _handle_view_guild(self, player: Player) -> bool: @@ -703,7 +703,7 @@ def _handle_create_task( # skipcq: PY-R1000 return True -def _handle_generate_auto_tasks( +def _handle_generate_auto_tasks( # skipcq: PY-R1000 self, player: Player, guild: GuildData) -> bool: @@ -900,7 +900,7 @@ def formatter(i, task: GuildTask): return True -def _handle_manage_members(self, player: Player) -> bool: +def _handle_manage_members(self, player: Player) -> bool: # skipcq: PY-R1000 """管理公会成员""" guild = self.guild_manager.get_guild_by_player(player.name) member = guild.get_member(player.name) if guild else None @@ -928,11 +928,11 @@ def _handle_manage_members(self, player: Player) -> bool: if choice == "1" and guild.has_permission(player.name, "kick"): return self._handle_kick_member(player) - elif choice == "2" and guild.has_permission(player.name, "set_rank"): + if choice == "2" and guild.has_permission(player.name, "set_rank"): return self._handle_set_rank(player) - elif choice == "3" and guild.has_permission(player.name, "transfer_owner"): + if choice == "3" and guild.has_permission(player.name, "transfer_owner"): return self._handle_transfer_ownership(player) - elif choice == "4" and guild.has_permission(player.name, "join_queue"): + if choice == "4" and guild.has_permission(player.name, "join_queue"): return self._handle_join_request_queue(player, guild) return True @@ -1966,9 +1966,9 @@ def _handle_list_guilds(self, player: Player) -> bool: def formatter(i, g): """Format one menu item for display.""" return ( - f"§e{i}. §r{g.name} §7Lv.{g.level}\n" - f" §7会长: §f{g.owner} §7| 成员: §a{len(g.members)}/{Config.MAX_GUILD_MEMBERS}\n" - ) + f"§e{i}. §r{g.name} §7Lv.{g.level}\n" + f" §7会长: §f{g.owner} §7| 成员: §a{len(g.members)}/{Config.MAX_GUILD_MEMBERS}\n" + ) page = 1 max_page = (len(guilds) + Config.ITEMS_PER_PAGE - diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" index 35c83301..9e9a3206 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" @@ -93,7 +93,7 @@ def quick_join_guild(self, player: Player, args: tuple): # skipcq: PY-R1000 if len(matched_guilds) == 1: target_guild = matched_guilds[0] else: - player.show(f"§l§a公会 §d>> §r找到多个匹配的公会,请选择:") + player.show("§l§a公会 §d>> §r找到多个匹配的公会,请选择:") for i, guild in enumerate(matched_guilds, 1): player.show(f"§e{i}. §f{guild.name} §7(会长: {guild.owner})") @@ -198,7 +198,7 @@ def quick_base_action(self, player: Player, args: tuple): player.show("§l§a公会 §d>> §r公会尚未设置据点,请先设置") return True return self._handle_return_base(player) - elif action == "set": + if action == "set": # 检查权限 - 只有会长可以设置据点 member = guild.get_member(player.name) if not member or member.rank != GuildRank.OWNER: @@ -207,34 +207,34 @@ def quick_base_action(self, player: Player, args: tuple): (member.rank.display_name if member else "无")) return True return self._handle_set_base(player) + + # 显示据点信息 + member = guild.get_member(player.name) + is_owner = member and member.rank == GuildRank.OWNER + + if guild.base: + base = guild.base + dim_name = Config.DIMENSION_NAMES.get( + base.dimension, f"维度{base.dimension}") + player.show( + f"§l§a公会据点§r\n§7位置: §f{dim_name} ({ + base.x:.1f}, { + base.y:.1f}, { + base.z:.1f})") + player.show("§7使用 §f.公会据点 tp §7传送到据点") + if is_owner: + player.show("§7使用 §f.公会据点 set §7重新设置据点") else: - # 显示据点信息 - member = guild.get_member(player.name) - is_owner = member and member.rank == GuildRank.OWNER - - if guild.base: - base = guild.base - dim_name = Config.DIMENSION_NAMES.get( - base.dimension, f"维度{base.dimension}") - player.show( - f"§l§a公会据点§r\n§7位置: §f{dim_name} ({ - base.x:.1f}, { - base.y:.1f}, { - base.z:.1f})") - player.show("§7使用 §f.公会据点 tp §7传送到据点") - if is_owner: - player.show("§7使用 §f.公会据点 set §7重新设置据点") + player.show("§l§a公会 §d>> §r公会尚未设置据点") + if is_owner: + player.show("§7使用 §f公会据点 set §7设置据点") else: - player.show("§l§a公会 §d>> §r公会尚未设置据点") - if is_owner: - player.show("§7使用 §f公会据点 set §7设置据点") - else: - player.show("§7只有会长才能设置据点") + player.show("§7只有会长才能设置据点") return True -def quick_donate(self, player: Player, args: tuple): +def quick_donate(self, player: Player, args: tuple): # skipcq: PY-R1000 """快捷捐献物品""" guild = self.guild_manager.get_guild_by_player(player.name) if not guild: diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" index 3ee8a52f..ccbd77b1 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" @@ -3,6 +3,7 @@ # pylint: disable=protected-access import os +import shutil import time from typing import List, Optional, Tuple, Any @@ -53,13 +54,12 @@ def _menu_item_name(group: str, key: str, fallback_name: str) -> str: return _menu_item(group, key, fallback_name, "")[0] -def _show_menu( +def _show_menu( # skipcq: PY-R1000 self, player: Player, guild: Optional[GuildData], member: Optional[GuildMember]) -> Optional[str]: """显示公会菜单并返回用户选择 - 增强版本""" - # 数据完整性检查 if guild and not member: # 公会存在但成员不存在,尝试重新获取 @@ -90,7 +90,7 @@ def _show_menu( guild.name}, 成员职位: { member.rank.value if member else 'None'}, 是否会长: {is_owner}") - menu_config = _menu_config() + menu_config = getattr(self, "_guild_menu_config_override", None) or _menu_config() base_items = [ ("创建", "创建自己的公会", not is_member), ("列表", "查看所有公会", True), @@ -252,8 +252,7 @@ def guild_menu_cb(self, player: Player, args: tuple): handler = handlers.get(subcommand) if handler: return handler() - else: - player.show(render_config_prompt("无效指令提示词")) + player.show(render_config_prompt("无效指令提示词")) return True @@ -264,6 +263,8 @@ def _create_progress_bar( total: int, length: int = 10) -> str: """创建进度条""" + if self is None: + return "" if total == 0: return "§7[§c无效§7]" @@ -279,14 +280,15 @@ def _create_progress_bar( def _format_time_duration(self, seconds: float) -> str: """格式化时间长度""" + if self is None: + return "" if seconds < 60: return f"{int(seconds)}秒" - elif seconds < 3600: + if seconds < 3600: return f"{int(seconds // 60)}分钟" - elif seconds < 86400: + if seconds < 86400: return f"{int(seconds // 3600)}小时" - else: - return f"{int(seconds // 86400)}天" + return f"{int(seconds // 86400)}天" def _get_item_display_name(self, item_id: str) -> str: @@ -300,7 +302,8 @@ def _has_inventory_space( item_id: str, count: int) -> bool: """检查玩家背包是否有足够空间""" - _ = (player, item_id, count) + if self is None or player is None or not item_id or count <= 0: + return False return True @@ -340,7 +343,7 @@ def _handle_base_menu(self, player: Player) -> bool: if choice == "1" and guild.base and can_return: return self._handle_return_base(player) - elif choice == "2" and can_set: + if choice == "2" and can_set: return self._handle_set_base(player) return True @@ -613,12 +616,10 @@ def update_online_task(self): def on_player_action(self, packet): """监听玩家行为,用于任务进度跟踪""" - _ = packet - try: - # TODO 等待具体的数据包格式 - pass - except Exception as e: - fmts.print_err(f"处理玩家行为事件出错: {e}") + if self is None or packet is None: + return + # 等待具体的数据包格式后再处理任务进度。 + return def update_task_progress( @@ -694,21 +695,19 @@ def get_guild_rankings( if sort_by == "level": guild_list.sort(key=lambda g: (g.level, g.exp), reverse=True) return [(g, g.level) for g in guild_list] - elif sort_by == "members": + if sort_by == "members": guild_list.sort(key=lambda g: len(g.members), reverse=True) return [(g, len(g.members)) for g in guild_list] - elif sort_by == "contribution": + if sort_by == "contribution": guild_list.sort(key=lambda g: g.stats.total_contribution, reverse=True) return [(g, g.stats.total_contribution) for g in guild_list] - elif sort_by == "activity": + if sort_by == "activity": # 基于最近活跃度排序 - current_time = time.time() guild_list.sort(key=lambda g: max( [m.last_online for m in g.members] + [0]), reverse=True) return [(g, max([m.last_online for m in g.members] + [0])) for g in guild_list] - else: - return [(g, 0) for g in guild_list] + return [(g, 0) for g in guild_list] def get_member_rankings( @@ -728,15 +727,14 @@ def get_member_rankings( if sort_by == "contribution": members.sort(key=lambda m: m.contribution, reverse=True) return [(m, m.contribution) for m in members] - elif sort_by == "online_time": + if sort_by == "online_time": current_time = time.time() members.sort(key=lambda m: current_time - m.last_online) return [(m, current_time - m.last_online) for m in members] - elif sort_by == "join_time": + if sort_by == "join_time": members.sort(key=lambda m: m.join_time) return [(m, m.join_time) for m in members] - else: - return [(m, 0) for m in members] + return [(m, 0) for m in members] def _paginate_display( @@ -747,6 +745,8 @@ def _paginate_display( formatter, allow_selection: bool = False) -> Optional[int]: """分页显示通用函数""" + if self is None: + return None if not items: player.show(render_config_prompt("通用分页为空提示词", title=title)) return None @@ -774,7 +774,7 @@ def _paginate_display( if choice is None: player.show(render_config_prompt("通用分页超时提示词")) return None - elif choice == "+": + if choice == "+": page = min(page + 1, max_page) elif choice == "-": page = max(page - 1, 1) @@ -785,8 +785,7 @@ def _paginate_display( idx = int(choice) if 1 <= idx <= len(items): return idx - 1 - else: - player.show(render_config_prompt("通用分页无效选择提示词")) + player.show(render_config_prompt("通用分页无效选择提示词")) def custom_vault_sell(self, player: Player, args: tuple): # skipcq: PY-R1000 @@ -851,7 +850,7 @@ def custom_vault_sell(self, player: Player, args: tuple): # skipcq: PY-R1000 # 确认出售 item_name = self._get_item_display_name(item_id) - player.show(f"§l§a公会仓库 §d>> §r确认以自定义价格出售?") + player.show("§l§a公会仓库 §d>> §r确认以自定义价格出售?") player.show(f"§7物品: §f{item_name} x{count}") player.show(f"§7自定义价格: §e{custom_price}贡献点") player.show("§7输入 '确认' 继续出售,其他任意键取消") @@ -902,6 +901,8 @@ def custom_vault_sell(self, player: Player, args: tuple): # skipcq: PY-R1000 def show_item_list(self, player: Player, args: tuple): """显示支持的物品名称列表""" + if self is None: + return False _ = args player.show("§r========== §a支持的物品名称§r ==========") player.show("§7以下是系统支持的物品名称,您可以在各种功能中使用:") @@ -962,9 +963,6 @@ def admin_clear_guild_data(self, player: Player, args: tuple): try: # 备份当前数据 - import shutil - import time - backup_file = f"{self.guilds_file}.backup_{int(time.time())}" if os.path.exists(self.guilds_file): shutil.copy2(self.guilds_file, backup_file) @@ -1101,7 +1099,7 @@ def debug_base_function(self, player: Player, args: tuple): player.show("§7据点信息:") if guild.base: base = guild.base - player.show(f" 据点存在: §a是") + player.show(" 据点存在: §a是") player.show(f" 维度: §f{base.dimension}") player.show(f" 坐标: §f({base.x}, {base.y}, {base.z})") player.show( @@ -1123,7 +1121,7 @@ def debug_base_function(self, player: Player, args: tuple): # 验证维度有效性 valid_dimensions = [0, -1, 1] if base.dimension in valid_dimensions: - player.show(f" 维度有效性: §a有效") + player.show(" 维度有效性: §a有效") else: player.show(f" 维度有效性: §c无效 (应为 {valid_dimensions})") @@ -1136,8 +1134,8 @@ def debug_base_function(self, player: Player, args: tuple): player.show(f" 方法{i + 1}({method}): §f{cmd}") else: - player.show(f" 据点存在: §c否") - player.show(f" 原因: 公会未设置据点") + player.show(" 据点存在: §c否") + player.show(" 原因: 公会未设置据点") else: player.show("§c公会信息不存在!") diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/matchers.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/matchers.py" index 92bbf18d..a051af2a 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/matchers.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/matchers.py" @@ -15,6 +15,8 @@ def __init__(self): def normalize_input(self, input_text: str) -> str: """标准化输入文本""" + if self is None: + return "" if not input_text: return "" return input_text.strip().lower() @@ -98,6 +100,8 @@ def _calculate_match_score(self, user_input: str, target: str) -> float: def _string_similarity(self, s1: str, s2: str) -> float: """计算字符串相似度""" + if self is None: + return 0.0 if not s1 or not s2: return 0.0 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" index 722c34d9..02abd1cd 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" @@ -70,6 +70,7 @@ def config_key(self): @dataclass class GuildBase: """公会据点信息""" + dimension: int x: float y: float @@ -87,6 +88,7 @@ def to_dict(self): @dataclass class VaultItem: """仓库物品信息""" + item_id: str count: int price: int # 贡献点价格 @@ -118,6 +120,7 @@ def from_dict(cls, data): @dataclass class GuildMember: """公会成员信息""" + name: str rank: GuildRank join_time: float @@ -149,6 +152,7 @@ def from_dict(cls, data): @dataclass class GuildJoinRequest: """公会加入申请记录""" + player_name: str reason: str = "" create_time: float = field(default_factory=time.time) @@ -186,6 +190,7 @@ def from_dict(cls, data): @dataclass class VaultTradeLog: """公会仓库交易日志""" + action: str item_id: str count: int @@ -229,6 +234,7 @@ def from_dict(cls, data): @dataclass class GuildAuditLog: """公会审计日志""" + action: str actor: str target: str = "" @@ -263,6 +269,7 @@ def from_dict(cls, data): @dataclass class GuildTask: """公会任务""" + task_id: str name: str description: str @@ -318,6 +325,7 @@ def from_dict(cls, data): @dataclass class GuildStats: """公会统计数据""" + total_contribution: int = 0 total_trades: int = 0 total_online_time: int = 0 @@ -349,6 +357,7 @@ def from_dict(cls, data): @dataclass class GuildData: """公会完整数据""" + guild_id: str name: str owner: str @@ -403,7 +412,7 @@ def add_audit_log( {}).get( "审计日志保留数量", 200)) - if max_logs > 0 and len(self.audit_logs) > max_logs: + if 0 < max_logs < len(self.audit_logs): self.audit_logs = self.audit_logs[-max_logs:] def get_member(self, name: str) -> Optional[GuildMember]: @@ -454,9 +463,9 @@ def add_join_request( return False max_pending = int(join_config.get("每个公会最多待处理申请数", 30)) - if max_pending > 0 and len( + if 0 < max_pending <= len( self.pending_join_requests( - now=current_time)) >= max_pending: + now=current_time)): return False self.join_requests.append(GuildJoinRequest( @@ -605,7 +614,7 @@ def add_vault_trade_log( {}).get( "交易日志保留数量", 120)) - if max_logs > 0 and len(self.vault_trade_logs) > max_logs: + if 0 < max_logs < len(self.vault_trade_logs): self.vault_trade_logs = self.vault_trade_logs[-max_logs:] def cancel_vault_item( diff --git "a/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index b73004dd..e8210245 100644 --- "a/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -2,7 +2,6 @@ import copy import os -import time import threading from typing import Any diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" index e71400ea..479df461 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" @@ -359,6 +359,8 @@ def _remove_binding_relation( xuid: str, ) -> bool: """Implement the remove binding relation operation.""" + if self is None: + return False changed = False qq_xuids = data["qq_to_xuids"].get(qq_key, []) if xuid in qq_xuids: @@ -506,6 +508,8 @@ def _render_binding_text( code: str, timeout_minutes: int) -> str: """Implement the render binding text operation.""" + if self is None: + return str(text) return ( text .replace("{auth_code}", code) diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" index 20682e29..cb2e30e3 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" @@ -41,6 +41,7 @@ def on_console_config_center(self, _args: list[str]): def _config_group_id(self, ctx: dict[str, Any]) -> int | None: """Implement the config group id operation.""" + _ = self return int(ctx["group_id"]) if ctx.get( "mode") == "qq" and "group_id" in ctx else None @@ -115,7 +116,7 @@ def _config_file_mode_menu(self, ctx: dict[str, Any]): if choice is self.CONFIG_EXIT: return self.CONFIG_EXIT if choice is self.CONFIG_BACK: - return + return self.CONFIG_BACK if choice == "1": result = self._config_file_select_menu(ctx) if result is self.CONFIG_EXIT: @@ -137,7 +138,7 @@ def _config_file_select_menu(self, ctx: dict[str, Any]): self._config_error( ctx, f"未找到 { self.CONFIG_FILE_DIR}/*.json 配置文件") - return + return self.CONFIG_BACK per_page = self.get_group_config_file_items_per_page( self._config_group_id(ctx)) total_pages, start_index, end_index = self.simple_paginate( @@ -166,7 +167,7 @@ def _config_file_select_menu(self, ctx: dict[str, Any]): if choice is self.CONFIG_EXIT: return self.CONFIG_EXIT if choice is self.CONFIG_BACK: - return + return self.CONFIG_BACK if choice == "+": if page < total_pages: page += 1 @@ -198,12 +199,13 @@ def _config_file_select_menu(self, ctx: dict[str, Any]): def _edit_config_file_whole( # skipcq: PY-R1000 self, ctx: dict[str, Any], item: dict[str, str]): """Implement the edit config file whole operation.""" + item_path = item["path"] try: - with open(item["path"], "r", encoding="utf-8-sig") as file: + with open(item_path, "r", encoding="utf-8-sig") as file: content = file.read() except Exception as err: self._config_error(ctx, f"读取配置文件失败: {err}") - return + return self.CONFIG_BACK try: original_config = json.loads(content) except json.JSONDecodeError: @@ -243,38 +245,39 @@ def _edit_config_file_whole( # skipcq: PY-R1000 if raw is self.CONFIG_EXIT: return self.CONFIG_EXIT if raw is self.CONFIG_BACK: - return + return self.CONFIG_BACK try: parsed = json.loads(self._normalize_config_json_text(str(raw))) except json.JSONDecodeError as err: self._config_error(ctx, f"JSON 格式错误,未替换配置文件: {err}") - return + return self.CONFIG_BACK if not isinstance(parsed, dict): self._config_error(ctx, "配置文件根节点必须是 JSON 对象,未替换配置文件") - return + return self.CONFIG_BACK if not self._config_file_shape_matches(original_config, parsed): self._config_error(ctx, "请发送完整配置文件,不能只发送配置项内容") - return + return self.CONFIG_BACK try: - if not self._is_safe_config_path(item["path"]): + if not self._is_safe_config_path(item_path): self._config_error(ctx, "配置文件路径不在允许的插件配置目录内") - return + return self.CONFIG_BACK backup = self._backup_config_file(item) - with open(item["path"], "w", encoding="utf-8") as file: + with open(item_path, "w", encoding="utf-8") as file: json.dump(parsed, file, ensure_ascii=False, indent=4) file.write("\n") except Exception as err: self._config_error(ctx, f"替换配置文件失败: {err}") - return + return self.CONFIG_BACK apply_msg = self._apply_runtime_config_file(item, parsed) self._config_success( ctx, f"配置文件已替换,备份编号 {backup['id']}。{apply_msg}", ) + return self.CONFIG_BACK def _config_whole_file_prompt_text( self, @@ -303,7 +306,7 @@ def _config_restore_backup_menu(self, ctx: dict[str, Any]): item.get("backup_path", ""))] if not backups: self._config_error(ctx, "暂无可还原的配置文件备份") - return + return self.CONFIG_BACK backups = list(reversed(backups[-30:])) options = [ f"{item['id']} / {item['config_name']} / {item.get('created_at', '')}" @@ -324,13 +327,13 @@ def _config_restore_backup_menu(self, ctx: dict[str, Any]): if choice is self.CONFIG_EXIT: return self.CONFIG_EXIT if choice is self.CONFIG_BACK: - return + return self.CONFIG_BACK selected = self.parse_displayed_menu_choice(choice, len(backups)) if selected is None: self._config_error(ctx, "输入有误") continue self._restore_config_backup(ctx, backups[selected - 1]) - return + return self.CONFIG_BACK def _restore_config_backup( self, ctx: dict[str, Any], backup: dict[str, str]): diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" index 6f214723..37357d0a 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" @@ -1067,7 +1067,6 @@ def save_group_state(self, group_id: int, state: dict[str, list[int]]): permission_cfg = self._permission_cfg_for_group(group_id) if permission_cfg is None: return - owner_qq = self.get_group_owner_qq(group_id) normalized = { "admins": self.normalize_int_list( state.get( diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" index b43be832..0d0cee1e 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" @@ -1,6 +1,5 @@ """QQ group menu and command handlers for Ultra.""" -import re import time from typing import Any @@ -228,7 +227,7 @@ def _parse_help_choice( self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") return selected - def _prompt_paginated_help_actions( + def _prompt_paginated_help_actions( # skipcq: PY-R1000 self, group_id: int, qqid: int, @@ -622,7 +621,7 @@ def qq_help_build_all_reference_lines( # skipcq: PY-R1000 ): lines.append( f"{' / '.join(self.get_group_player_list_triggers(group_id))} - 查看玩家列表" - ) + ) lines.append( ( f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " @@ -783,7 +782,7 @@ def qq_help_show_basic_reference(self, group_id: int, qqid: int): ): options.append( f"{' / '.join(self.get_group_player_list_triggers(group_id))} - 查看玩家列表" - ) + ) options.append( ( f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " @@ -1799,7 +1798,7 @@ def _qq_guild_player_state( return None return state - def qq_guild_player_menu(self, group_id: int, qqid: int): + def qq_guild_player_menu(self, group_id: int, qqid: int): # skipcq: PY-R1000 """普通群成员可使用的公会菜单,要求 QQ 已绑定游戏账号。""" if not self.ensure_guild_system(group_id, qqid): return @@ -2465,7 +2464,8 @@ def qq_guild_show_logs(self, group_id: int, qqid: int): if not ok or not data: self._reply_result(group_id, qqid, False, msg) return - logs = [str(item) for item in data.get("logs", [])[-limit:]] + logs = [str(item) for item in data.get("logs", [])] + logs = logs[-int(limit):] self._reply_guild_lines( group_id, qqid, @@ -3316,7 +3316,7 @@ def qq_land_system_menu(self, group_id: int, qqid: int): return handler(group_id, qqid) - def qq_land_list(self, group_id: int, qqid: int): + def qq_land_list(self, group_id: int, qqid: int): # skipcq: PY-R1000 """Handle the qq land list QQ menu operation.""" if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): return diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" index 6a1c6ef5..211df380 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" @@ -133,12 +133,15 @@ def connect_to_websocket(self): session_id = self._start_ws_session() def _on_message(ws_obj, message, sid=session_id): + """Forward websocket messages to the active session handler.""" return self.on_ws_message(ws_obj, message, sid) and None def _on_error(ws_obj, error, sid=session_id): + """Forward websocket errors to the active session handler.""" return self.on_ws_error(ws_obj, error, sid) def _on_close(ws_obj, code, reason, sid=session_id): + """Forward websocket close events to the active session handler.""" return self.on_ws_close(ws_obj, code, reason, sid) ws_app = websocket.WebSocketApp( @@ -634,7 +637,7 @@ def on_player_message(self, chat: Chat): player = chat.player.name msg = chat.msg if not self.ws: - return + return False for group_id, group_cfg in self.iter_game_to_group_targets(): can_send, filtered_msg = self.should_forward_game_message( msg, group_cfg) @@ -647,6 +650,7 @@ def on_player_message(self, chat: Chat): group_cfg["游戏到群"]["转发格式"], ), ) + return False def execute_triggers(self, group_id: int, qqid: int, msg: str): """对一条群消息做内置命令和外挂命令的统一分发。""" @@ -958,7 +962,7 @@ def api_send_group_msg( self, group_id: int | str, message: str, - remove_cq_code: bool = True, + strip_cq_code: bool = True, ) -> tuple[bool, str]: """Send a QQ group message and return a stable result tuple.""" try: @@ -975,7 +979,7 @@ def api_send_group_msg( self.sendmsg( gid, str(message), - do_remove_cq_code=bool(remove_cq_code)) + do_remove_cq_code=bool(strip_cq_code)) except Exception as err: return False, f"发送群消息失败: {err}" return True, "已发送群消息" diff --git "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index 085f468e..988bfc75 100644 --- "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -41,8 +41,6 @@ class ConsoleMenuExit(Exception): """Signal that the console menu should exit.""" - pass - class LandPlugin(Plugin): """ToolDelta plugin entrypoint for land protection cloud interop.""" @@ -786,6 +784,7 @@ def _select_land( def _land_summary(self, land: LandData) -> Dict[str, Any]: """Implement the land summary operation.""" + _ = self admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] members = [m.name for m in land.members if m.rank == LandRank.MEMBER] return { @@ -1059,9 +1058,14 @@ def _spawn_entity(self, land: LandData): try: x, y, z = land.center self._remove_entity(land) - cmd = f"summon area_effect_cloud {x} {y} {z} { - Duration:2147483647,WaitTime:2147483647,Tags:[\"land_{ - land.land_id}\"]} " + nbt = ( + "{" + "Duration:2147483647,WaitTime:2147483647,Tags:[\"land_" + + land.land_id + + "\"]" + "}" + ) + cmd = f"summon area_effect_cloud {x} {y} {z} {nbt}" self.game_ctrl.sendwocmd(cmd) except Exception: pass @@ -1394,7 +1398,7 @@ def _info(self, player: Player, args: List[str]): ], )) - def _member(self, player: Player, args: List[str]): + def _member(self, player: Player, args: List[str]): # skipcq: PY-R1000 """Implement the member operation.""" if len(args) < 1: player.show(self._error( @@ -1590,14 +1594,14 @@ def on_player_join(self, player: Player): _ = player if not self.enabled: return - pass + return def on_player_leave(self, player: Player): """Implement the on player leave operation.""" _ = player if not self.enabled: return - pass + return # ---------- 外部插件 API ---------- def api_list_lands(self) -> Tuple[bool, str, List[Dict[str, Any]]]: @@ -1899,6 +1903,7 @@ def api_update_land_range( def _console_print(self, text: str): """Implement the console print operation.""" + _ = self fmts.print_inf(text) def _console_prompt(self, prompt: str) -> Optional[str]: @@ -2184,7 +2189,7 @@ def _console_show_land_info(self, land: LandData): ], )) - def _console_manage_land_users(self, land: LandData): + def _console_manage_land_users(self, land: LandData): # skipcq: PY-R1000 """Implement the console manage land users operation.""" while True: choice = self._console_select( @@ -2238,7 +2243,7 @@ def _console_manage_land_users(self, land: LandData): [", ".join(users) if users else "无用户"], )) - def _console_manage_land_admins(self, land: LandData): + def _console_manage_land_admins(self, land: LandData): # skipcq: PY-R1000 """Implement the console manage land admins operation.""" while True: choice = self._console_select( From 14da33eaa0b677d1bed1b7d399427cf516e75a3e Mon Sep 17 00:00:00 2001 From: ljxbx Date: Sat, 13 Jun 2026 14:24:11 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8D=AB=E7=94=9F?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__init__.py" | 3 ++- .../guild_cloud_interop/handlers.py" | 6 ++++-- .../runtime_mixin.py" | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index 5fa5f195..9dd1c2f3 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -153,9 +153,10 @@ def _find_guild_menu_chatbar_entry(self): chatbar_triggers = getattr(chatbar, "chatbar_triggers", None) if not isinstance(chatbar_triggers, list): return None + triggers = list(chatbar_triggers) callback = getattr(self, "_guild_menu_callback", None) - for candidate in chatbar_triggers: + for candidate in triggers: if getattr(candidate, "usage", None) != "公会系统指令": continue if callback is not None and getattr( diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" index 85da56fc..3e7c8cff 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" @@ -179,7 +179,8 @@ def formatter(i, data): """Format one menu item for display.""" return ( f"§e{i}. §r{data[0].name}\n" - f" §7会长: §f{data[0].owner} §7| 最近活跃: §a{self._format_time_ago(data[1])}\n" + f" §7会长: §f{data[0].owner} " + f"§7| 最近活跃: §a{self._format_time_ago(data[1])}\n" ) else: player.show("§c无效选项") @@ -1967,7 +1968,8 @@ def formatter(i, g): """Format one menu item for display.""" return ( f"§e{i}. §r{g.name} §7Lv.{g.level}\n" - f" §7会长: §f{g.owner} §7| 成员: §a{len(g.members)}/{Config.MAX_GUILD_MEMBERS}\n" + f" §7会长: §f{g.owner} " + f"§7| 成员: §a{len(g.members)}/{Config.MAX_GUILD_MEMBERS}\n" ) page = 1 diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" index 211df380..74d1aeb2 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" @@ -1000,7 +1000,7 @@ def api_reply_group_member( return self.api_send_group_msg( group_id, f"[CQ:at,qq={qid}] {message}", - remove_cq_code=False, + strip_cq_code=False, ) def api_send_private_msg( From bee5bbd5409f661e1d71759910145d3ee03a9228 Mon Sep 17 00:00:00 2001 From: ljxbx Date: Sat, 13 Jun 2026 17:08:42 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=80=E4=BA=9BBUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__init__.py" | 2 +- .../datas.json" | 2 +- .../__init__.py" | 2 +- .../datas.json" | 2 +- .../guild_cloud_interop/api.py" | 3818 ++++---- .../guild_cloud_interop/config.py" | 17 +- .../guild_cloud_interop/control.py" | 857 +- .../guild_cloud_interop/handlers.py" | 4899 +++++----- .../guild_cloud_interop/handlers_quick.py" | 980 +- .../guild_cloud_interop/logic.py" | 2336 +++-- .../guild_cloud_interop/models.py" | 1530 ++-- .../guild_cloud_interop/ui.py" | 474 +- .../__init__.py" | 2 +- .../datas.json" | 2 +- .../config_editor_mixin.py" | 4 +- .../orion_mixin.py" | 1888 ++-- .../qq_mixin.py" | 8099 ++++++++--------- .../__init__.py" | 4961 +++++----- .../datas.json" | 2 +- 19 files changed, 14839 insertions(+), 15038 deletions(-) diff --git "a/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index 9966fc31..b52f4ddc 100644 --- "a/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -65,7 +65,7 @@ class TaskSystemCloudInterop(Plugin): """ToolDelta plugin that manages cloud-linked quest workflows.""" name = "任务系统云链联动版" - author = "SuperScript" + author = "SuperScript & 小六神" version = (0, 0, 5) def __init__(self, frame): diff --git "a/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" index f34db710..855f2387 100644 --- "a/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" +++ "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" @@ -1,5 +1,5 @@ { - "author": "任务系统", + "author": "SuperScript & 小六神", "version": "0.0.5", "plugin-type": "classic", "description": "为租赁服添加一套自定义的任务(云链互通版),支持配置文件与任务文件热载入。", diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index 9dd1c2f3..5e00ad98 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -48,7 +48,7 @@ class GuildPlugin(Plugin): """ToolDelta plugin entrypoint for guild cloud interop.""" name = "公会系统云链联动版" - author = "星林 & 夏至" + author = "星林 & 夏至 & 小六神" version = (0, 1, 7) def __init__(self, frame: ToolDelta): diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" index 607f74de..21ac1952 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" @@ -1,5 +1,5 @@ { - "author": "星林 & 夏至", + "author": "星林 & 夏至 & 小六神", "version": "0.1.7", "description": "允许玩家创建公会,云链联动版", "pre-plugins": { diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/api.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/api.py" index c2dd17a5..7da174b6 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/api.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/api.py" @@ -1,1919 +1,1899 @@ -"""Public QQ and plugin API operations for guild cloud interop.""" - -# pylint: disable=protected-access - -import copy -import json -import os -import re -import shutil -import time -import uuid -from typing import Any, Optional - -from tooldelta import fmts - -from guild_cloud_interop.config import Config -from guild_cloud_interop.models import ( - GuildBase, - GuildData, - GuildMember, - GuildRank, - GuildStats, - GuildTask, - VaultItem, -) -from guild_cloud_interop.validators import InputValidator - - -COLOR_CODE_RE = re.compile(r"§.") - - -def _plain(text: object) -> str: - """Return text without Minecraft color codes.""" - return COLOR_CODE_RE.sub("", str(text)) - - -def _now() -> float: - """Return the current unix timestamp.""" - return time.time() - - -def _actor(actor: str | None) -> str: - """Return a normalized actor label.""" - text = str(actor or "").strip() - return text or "QQ管理" - - -def _to_int(value: object, field_name: str, minimum: int | - None = None) -> tuple[bool, str, int]: - """Parse an integer API argument.""" - try: - parsed = int(str(value).strip()) - except (TypeError, ValueError): - return False, f"{field_name}必须是整数", 0 - if minimum is not None and parsed < minimum: - return False, f"{field_name}不能小于 {minimum}", 0 - return True, "", parsed - - -def _to_float(value: object, field_name: str) -> tuple[bool, str, float]: - """Parse a float API argument.""" - try: - return True, "", float(str(value).strip()) - except (TypeError, ValueError): - return False, f"{field_name}必须是数字", 0.0 - - -def _ensure_settings(guild: GuildData) -> dict[str, Any]: - """Ensure guild settings are stored as a dictionary.""" - if not isinstance(guild.settings, dict): - guild.settings = {} - return guild.settings - - -def _rebuild_player_cache(self, guilds: dict[str, GuildData]) -> None: - """Rebuild the guild player cache.""" - self.guild_manager.rebuild_player_cache(guilds) - - -def _save_guilds(self, - guilds: dict[str, - GuildData], - force: bool = True) -> bool: - """Save guild data and refresh the manager cache.""" - _rebuild_player_cache(self, guilds) - ok = self.guild_manager.save_guilds(guilds, force=force) - if ok: - self.guild_manager.load_guilds(force_reload=True) - return ok - - -def _load_guilds(self) -> dict[str, GuildData]: - """Load guild data with a fresh cache read.""" - return self.guild_manager.load_guilds(force_reload=True) - - -def _find_guild( # skipcq: PY-R1000 - self, - guild_query: object, - guilds: Optional[dict[str, GuildData]] = None, -) -> tuple[Optional[GuildData], str]: - """Find a guild by ID, exact name, or fuzzy text.""" - query = str(guild_query or "").strip() - if not query: - return None, "公会不能为空" - - guild_map = guilds if guilds is not None else _load_guilds(self) - if query in guild_map: - return guild_map[query], "" - - exact = [guild for guild in guild_map.values() if guild.name == query] - if len(exact) == 1: - return exact[0], "" - if len(exact) > 1: - return None, f"存在多个同名公会:{query},请使用公会ID" - - query_lower = query.casefold() - fuzzy = [ - guild - for guild in guild_map.values() - if ( - query_lower in guild.name.casefold() - or query_lower in guild.guild_id.casefold() - ) - ] - if len(fuzzy) == 1: - return fuzzy[0], "" - if len(fuzzy) > 1: - names = "、".join(guild.name for guild in fuzzy[:5]) - if len(fuzzy) > 5: - names += "……" - return None, f"匹配到多个公会:{names}" - return None, f"公会不存在:{query}" - - -def _find_player_guild( - self, - player_name: object, - guilds: Optional[dict[str, GuildData]] = None, -) -> tuple[Optional[GuildData], str]: - """Find the guild that contains a player.""" - name = str(player_name or "").strip() - if not name: - return None, "玩家名不能为空" - - guild_map = guilds if guilds is not None else _load_guilds(self) - for guild in guild_map.values(): - if guild.get_member(name): - return guild, "" - return None, f"玩家 {name} 不在任何公会" - - -def _find_player_context( - self, - player_name: object, - guilds: Optional[dict[str, GuildData]] = None, -) -> tuple[str, Optional[GuildData], Optional[GuildMember], str]: - """Return normalized player, guild, member, and error context.""" - name = str(player_name or "").strip() - if not name: - return "", None, None, "玩家名不能为空" - guild, err = _find_player_guild(self, name, guilds) - if guild is None: - return name, None, None, err - member = guild.get_member(name) - if member is None: - return name, guild, None, "成员数据异常" - return name, guild, member, "" - - -def _find_task(guild: GuildData, - task_query: object) -> tuple[Optional[GuildTask], str]: - """Find a guild task by ID, exact name, or fuzzy text.""" - query = str(task_query or "").strip() - if not query: - return None, "任务不能为空" - for task in guild.tasks: - if query in (task.task_id, task.name): - return task, "" - query_lower = query.casefold() - matched = [ - task - for task in guild.tasks - if query_lower in task.task_id.casefold() or query_lower in task.name.casefold() - ] - if len(matched) == 1: - return matched[0], "" - if len(matched) > 1: - names = "、".join( - f"{task.name}({task.task_id})" for task in matched[:5]) - return None, f"匹配到多个任务:{names}" - return None, f"任务不存在:{query}" - - -def _member_summary(member: GuildMember) -> dict[str, Any]: - """Build a serializable member summary.""" - return { - "name": member.name, - "rank": member.rank.value, - "rank_name": _plain(member.rank.display_name), - "join_time": member.join_time, - "contribution": member.contribution, - "last_online": member.last_online, - } - - -def _base_summary(base: GuildBase | None) -> dict[str, Any] | None: - """Build a serializable base summary.""" - if base is None: - return None - return { - "dimension": base.dimension, - "x": base.x, - "y": base.y, - "z": base.z, - } - - -def _vault_item_summary(item: VaultItem, index: int | - None = None) -> dict[str, Any]: - """Build a serializable vault item summary.""" - data = item.to_dict() - if index is not None: - data["index"] = index - return data - - -def _task_summary(task: GuildTask) -> dict[str, Any]: - """Build a serializable task summary.""" - return task.to_dict() - - -def _guild_summary(guild: GuildData) -> dict[str, Any]: - """Build a serializable guild summary.""" - settings = _ensure_settings(guild) - return { - "guild_id": guild.guild_id, - "name": guild.name, - "owner": guild.owner, - "level": guild.level, - "exp": guild.exp, - "create_time": guild.create_time, - "member_count": len(guild.members), - "max_members": Config.MAX_GUILD_MEMBERS, - "base": _base_summary(guild.base), - "vault_count": len(guild.vault_items), - "vault_capacity": Config.VAULT_INITIAL_SLOTS, - "announcement": guild.announcement, - "purchased_effects": dict(guild.purchased_effects), - "funds": int(settings.get("funds", 0) or 0), - "frozen": bool(settings.get("frozen", False)), - "frozen_reason": str(settings.get("frozen_reason", "")), - "base_locked": bool(settings.get("base_locked", False)), - "active_tasks": len([task for task in guild.tasks if not task.completed]), - "completed_tasks": len([task for task in guild.tasks if task.completed]), - "total_contribution": guild.stats.total_contribution, - } - - -def _apply_level_ups(guild: GuildData) -> list[int]: - """Apply pending guild level ups and return gained levels.""" - level_ups: list[int] = [] - next_level = guild.level + 1 - required = Config.GUILD_LEVEL_EXP.get(next_level) - while required and guild.exp >= required: - guild.exp -= required - guild.level = next_level - level_ups.append(next_level) - guild.add_log(f"公会升级到 {next_level} 级") - next_level = guild.level + 1 - required = Config.GUILD_LEVEL_EXP.get(next_level) - return level_ups - - -def _activity_multiplier(self, key: str) -> float: - """Return an active guild event multiplier.""" - event = getattr(self, "_guild_runtime_events", {}).get(key) - if not isinstance(event, dict): - return 1.0 - expires_at = float(event.get("expires_at", 0) or 0) - if 0 < expires_at <= _now(): - getattr(self, "_guild_runtime_events", {}).pop(key, None) - return 1.0 - try: - multiplier = float(event.get("multiplier", 1.0)) - except (TypeError, ValueError): - return 1.0 - return max(1.0, multiplier) - - -def guild_get_activity_multiplier(self, key: str) -> float: - """Return guild get activity multiplier.""" - return _activity_multiplier(self, key) - - -def guild_apply_reward_multipliers( - self, - exp: int | float = 0, - contribution: int | float = 0, -) -> tuple[int, int]: - """Apply guild apply reward multipliers.""" - exp_out = int(float(exp) * _activity_multiplier(self, "exp")) - contribution_out = int(float(contribution) * - _activity_multiplier(self, "contribution")) - return max(0, exp_out), max(0, contribution_out) - - -def guild_is_frozen(self, guild: GuildData | None) -> bool: - """Return whether frozen.""" - _ = self - if guild is None: - return False - settings = getattr(guild, "settings", {}) - return isinstance(settings, dict) and bool(settings.get("frozen", False)) - - -def guild_frozen_message(self, guild: GuildData | None) -> str: - """Run guild frozen message.""" - _ = self - if guild is None: - return "公会已冻结" - settings = getattr(guild, "settings", {}) - reason = "" - if isinstance(settings, dict): - reason = str(settings.get("frozen_reason", "") or "").strip() - suffix = f":{reason}" if reason else "" - return f"公会 {guild.name} 已被冻结{suffix}" - - -def show_guild_frozen(self, player, guild: GuildData | None) -> None: - """Show guild frozen.""" - player.show(f"§l§a公会 §d>> §c{self.guild_frozen_message(guild)}") - - -def api_list_guilds(self) -> tuple[bool, str, list[dict[str, Any]]]: - """Return api list guilds.""" - guilds = _load_guilds(self) - data = [_guild_summary(guild) for guild in guilds.values()] - data.sort(key=lambda item: (-int(item["level"]), - - int(item["member_count"]), item["name"])) - return True, f"共 {len(data)} 个公会", data - - -def api_get_guild( - self, guild_query: str) -> tuple[bool, str, Optional[dict[str, Any]]]: - """Return api get guild.""" - guild, err = _find_guild(self, guild_query) - if guild is None: - return False, err, None - data = _guild_summary(guild) - data["members"] = [_member_summary(member) for member in guild.members] - data["tasks"] = [_task_summary(task) for task in guild.tasks] - return True, "查询成功", data - - -def api_get_player_record( - self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: - """Return api get player record.""" - guilds = _load_guilds(self) - guild, err = _find_player_guild(self, player_name, guilds) - if guild is None: - return False, err, None - member = guild.get_member(str(player_name).strip()) - if member is None: - return False, "成员数据异常", None - records = [ - log.to_dict() - for log in guild.audit_logs - if member.name in (log.actor, log.target) - ] - vault_records = [ - log.to_dict() - for log in guild.vault_trade_logs - if member.name in (log.actor, log.seller, log.buyer) - ] - return True, "查询成功", { - "guild": _guild_summary(guild), - "member": _member_summary(member), - "audit_logs": records[-50:], - "vault_trade_logs": vault_records[-50:], - } - - -def api_get_player_guild_menu_state( - self, player_name: str) -> tuple[bool, str, dict[str, Any]]: - """Return safe QQ-side guild menu state for one player identity.""" - guilds = _load_guilds(self) - name, guild, member, err = _find_player_context(self, player_name, guilds) - if not name: - return False, err, {} - if guild is None: - return True, f"玩家 {name} 暂未加入公会", { - "player_name": name, - "in_guild": False, - "guild": None, - "member": None, - "permissions": [], - "is_owner": False, - "is_frozen": False, - } - if member is None: - return False, err, {} - return True, "查询成功", { - "player_name": name, - "in_guild": True, - "guild": _guild_summary(guild), - "member": _member_summary(member), - "permissions": guild.get_member_permissions(name), - "is_owner": member.rank == GuildRank.OWNER, - "is_frozen": bool(_ensure_settings(guild).get("frozen", False)), - } - - -def api_get_own_guild_logs(self, - player_name: str, - limit: int = 20) -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Return logs for the guild that the player belongs to.""" - ok, _err, parsed_limit = _to_int(limit, "日志数量", 1) - if not ok: - parsed_limit = 20 - guilds = _load_guilds(self) - name, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err, None - data: dict[str, Any] = {"logs": guild.logs[-parsed_limit:]} - if guild.has_permission(name, "audit_log"): - data["audit_logs"] = [log.to_dict() - for log in guild.audit_logs[-parsed_limit:]] - data["vault_trade_logs"] = [log.to_dict() - for log in guild.vault_trade_logs[-parsed_limit:]] - else: - data["audit_logs"] = [] - data["vault_trade_logs"] = [] - return True, "查询成功", data - - -def api_get_own_guild_vault( - self, player_name: str) -> tuple[bool, str, Optional[list[dict[str, Any]]]]: - """Return the current player's guild vault after normal guild permission checks.""" - guilds = _load_guilds(self) - name, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err, None - if not guild.has_permission(name, "vault"): - return False, "你没有使用仓库权限", None - data = [_vault_item_summary(item, index) - for index, item in enumerate(guild.vault_items, start=1)] - return True, f"{guild.name} 仓库共有 {len(data)} 件上架物品", data - - -def api_get_own_guild_tasks( - self, player_name: str) -> tuple[bool, str, Optional[list[dict[str, Any]]]]: - """Return the task list for the guild that the player belongs to.""" - guilds = _load_guilds(self) - _name, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err, None - return True, f"{guild.name} 共有 {len(guild.tasks)} 个任务", [ - _task_summary(task) for task in guild.tasks] - - -def api_request_join_guild_as_player( - self, - player_name: str, - guild_query: str, - reason: str = "", -) -> tuple[bool, str, Optional[dict[str, Any]]]: - """Submit a normal player join request for a guild.""" - name = str(player_name or "").strip() - if not name: - return False, "玩家名不能为空", None - guilds = _load_guilds(self) - current_guild, _ = _find_player_guild(self, name, guilds) - if current_guild is not None: - return False, f"你已经加入了公会 { - current_guild.name}", _guild_summary(current_guild) - target_guild, err = _find_guild(self, guild_query, guilds) - if target_guild is None: - return False, err, None - if _ensure_settings(target_guild).get("frozen", False): - return False, f"公会 { - target_guild.name} 已被冻结,暂不能提交申请", _guild_summary(target_guild) - if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: - return False, "该公会已满员", _guild_summary(target_guild) - if not target_guild.add_join_request(name, reason): - return False, "申请提交失败,可能已有待处理申请或队列已满", _guild_summary(target_guild) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - notify = getattr(self, "_notify_join_request_admins", None) - if callable(notify): - notify(target_guild, name) - return True, f"申请已提交至 { - target_guild.name} 的申请队列", _guild_summary(target_guild) - - -def api_leave_guild_as_player( - self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: - """Leave the current guild as a normal member.""" - guilds = _load_guilds(self) - name, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err, None - if member.rank == GuildRank.OWNER: - return False, "会长不能退出公会,只能解散公会", _guild_summary(guild) - guild.members = [item for item in guild.members if item.name != name] - guild.add_log(f"{name} 退出公会") - guild.add_audit_log("member_leave", name, target=name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已退出公会 {guild.name}", _guild_summary(guild) - - -def api_disband_owned_guild_as_player( - self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: - """Disband the player's own guild, only when the player is the owner.""" - guilds = _load_guilds(self) - _, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err, None - if member.rank != GuildRank.OWNER: - return False, "你不是当前公会的会长", _guild_summary(guild) - summary = _guild_summary(guild) - guild_name = guild.name - online_members = [ - item.name - for item in guild.members - if item.name in getattr(self.game_ctrl, "allplayers", []) - ] - del guilds[guild.guild_id] - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - for member_name in online_members: - message = f"§l§a公会 §d>> §r公会 §e{guild_name}§r 已被解散" - self.game_ctrl.sendcmd( - f'/tellraw {member_name} {{"rawtext":[{{"text":"{message}"}}]}}' - ) - return True, f"已解散公会 {guild_name}", summary - - -def api_set_announcement_as_player( - self, - player_name: str, - announcement: str, -) -> tuple[bool, str, Optional[dict[str, Any]]]: - """Set the current guild announcement through normal guild permission checks.""" - text = str(announcement or "").strip() - is_valid, error_msg = InputValidator.validate_announcement(text) - if not is_valid: - return False, error_msg, None - guilds = _load_guilds(self) - name, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err, None - if _ensure_settings(guild).get("frozen", False): - return False, guild_frozen_message(self, guild), _guild_summary(guild) - if not guild.has_permission(name, "announce"): - return False, "你没有设置公会公告权限", _guild_summary(guild) - guild.announcement = text - guild.add_log(f"{name} 更新了公告") - guild.add_audit_log("announcement_set", name, detail=text) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - message = "§l§a公会 §d>> §r公告已更新,输入 .公会 公告 查看" - for member_item in guild.members: - if member_item.name in getattr(self.game_ctrl, "allplayers", []): - self.game_ctrl.sendcmd( - f'/tellraw {member_item.name} {{"rawtext":[{{"text":"{message}"}}]}}' - ) - return True, "公告已更新", _guild_summary(guild) - - -def api_join_guild_task_as_player( - self, - player_name: str, - task_query: str, -) -> tuple[bool, str, Optional[dict[str, Any]]]: - """Join an active guild task as the current player.""" - guilds = _load_guilds(self) - name, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err, None - if _ensure_settings(guild).get("frozen", False): - return False, guild_frozen_message(self, guild), None - task, err = _find_task(guild, task_query) - if task is None: - return False, err, None - if task.completed: - return False, "该任务已完成", _task_summary(task) - if name in task.participants: - return True, f"你已经参与了任务:{task.name}", _task_summary(task) - task.participants.append(name) - guild.add_log(f"{name} 参与了任务: {task.name}") - guild.add_audit_log("task_join", name, detail=task.name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已参与任务:{task.name}", _task_summary(task) - - -def api_return_to_guild_base_as_player( - self, player_name: str) -> tuple[bool, str]: - """Teleport the player to their guild base after normal permission checks.""" - guilds = _load_guilds(self) - name, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err - if name not in getattr(self.game_ctrl, "allplayers", []): - return False, f"玩家 {name} 当前不在线,无法传送" - if _ensure_settings(guild).get("frozen", False): - return False, guild_frozen_message(self, guild) - if not guild.has_permission(name, "return_base"): - return False, "你没有返回公会据点权限" - if _ensure_settings(guild).get("base_locked", False): - return False, f"公会 {guild.name} 据点已锁定" - if not guild.base: - return False, f"公会 {guild.name} 尚未设置据点" - base = guild.base - self.game_ctrl.sendwocmd( - f"tp {name} {float(base.x)} {float(base.y)} {float(base.z)}") - return True, f"已传送到公会 {guild.name} 据点" - - -def api_force_disband_guild(self, guild_query: str, - actor: str = "QQ管理") -> tuple[bool, str]: - """Force api force disband guild.""" - _ = actor - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err - name = guild.name - del guilds[guild.guild_id] - if not _save_guilds(self, guilds): - return False, "保存公会数据失败" - return True, f"已强制解散公会 {name}" - - -def api_rename_guild(self, guild_query: str, new_name: str, - actor: str = "QQ管理") -> tuple[bool, str, Optional[dict[str, Any]]]: - """Run api rename guild.""" - new_name = str(new_name or "").strip() - if not new_name: - return False, "新公会名不能为空", None - if len(new_name) < 2 or len(new_name) > 20: - return False, "新公会名长度必须在 2-20 之间", None - guilds = _load_guilds(self) - if any(guild.name == new_name for guild in guilds.values()): - return False, f"公会名已存在:{new_name}", None - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - old_name = guild.name - guild.name = new_name - guild.add_log(f"{_actor(actor)} 将公会名从 {old_name} 修改为 {new_name}") - guild.add_audit_log( - "guild_rename", - _actor(actor), - target=old_name, - detail=new_name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已修改公会名称:{old_name} -> {new_name}", _guild_summary(guild) - - -def api_set_guild_level(self, - guild_query: str, - level: int, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Update api set guild level.""" - ok, err, parsed = _to_int(level, "公会等级", 1) - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - old = guild.level - guild.level = parsed - guild.add_log(f"{_actor(actor)} 将公会等级从 {old} 修改为 {parsed}") - guild.add_audit_log( - "guild_set_level", - _actor(actor), - detail=f"{old}->{parsed}") - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已设置 {guild.name} 等级为 {parsed}", _guild_summary(guild) - - -def api_set_guild_exp( - self, - guild_query: str, - exp: int, - actor: str = "QQ管理", -) -> tuple[bool, str, Optional[dict[str, Any]]]: - """Update api set guild exp.""" - ok, err, parsed = _to_int(exp, "公会经验", 0) - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - old = guild.exp - guild.exp = parsed - level_ups = _apply_level_ups(guild) - guild.add_log(f"{_actor(actor)} 将公会经验从 {old} 修改为 {parsed}") - guild.add_audit_log( - "guild_set_exp", - _actor(actor), - detail=f"{old}->{parsed}") - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - suffix = f",触发升级到 {level_ups[-1]} 级" if level_ups else "" - return True, f"已设置 { - guild.name} 经验为 { - guild.exp}{suffix}", _guild_summary(guild) - - -def api_transfer_guild_owner(self, - guild_query: str, - new_owner: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Run api transfer guild owner.""" - target_name = str(new_owner or "").strip() - if not target_name: - return False, "新会长不能为空", None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - target = guild.get_member(target_name) - if target is None: - target = GuildMember( - name=target_name, - rank=GuildRank.MEMBER, - join_time=_now()) - guild.members.append(target) - for member in guild.members: - if member.rank == GuildRank.OWNER and member.name != target_name: - member.rank = GuildRank.DEPUTY - target.rank = GuildRank.OWNER - old_owner = guild.owner - guild.owner = target.name - guild.add_log(f"{_actor(actor)} 强制将会长从 {old_owner} 转让给 {target.name}") - guild.add_audit_log("guild_force_transfer_owner", _actor(actor), - target=target.name, detail=old_owner) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已将 {guild.name} 会长转让给 {target.name}", _guild_summary(guild) - - -def api_force_join_guild(self, - guild_query: str, - player_name: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Force api force join guild.""" - name = str(player_name or "").strip() - if not name: - return False, "玩家名不能为空", None - guilds = _load_guilds(self) - target_guild, err = _find_guild(self, guild_query, guilds) - if target_guild is None: - return False, err, None - old_guild, _ = _find_player_guild(self, name, guilds) - if old_guild and old_guild.guild_id == target_guild.guild_id: - return True, f"{name} 已在公会 { - target_guild.name}", _guild_summary(target_guild) - if old_guild: - old_guild.members = [ - member for member in old_guild.members if member.name != name] - old_guild.add_log(f"{_actor(actor)} 强制移出 {name}") - old_guild.add_audit_log("guild_force_leave", _actor( - actor), target=name, detail=target_guild.name) - target_guild.members.append(GuildMember( - name=name, rank=GuildRank.MEMBER, join_time=_now())) - target_guild.add_log(f"{_actor(actor)} 强制加入成员 {name}") - target_guild.add_audit_log("guild_force_join", _actor(actor), target=name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已将 {name} 加入公会 { - target_guild.name}", _guild_summary(target_guild) - - -def api_force_leave_guild(self, - player_name: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Force api force leave guild.""" - name = str(player_name or "").strip() - if not name: - return False, "玩家名不能为空", None - guilds = _load_guilds(self) - guild, err = _find_player_guild(self, name, guilds) - if guild is None: - return False, err, None - guild.members = [member for member in guild.members if member.name != name] - if not guild.members: - old_name = guild.name - del guilds[guild.guild_id] - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已移除 {name},公会 {old_name} 已无成员并被解散", None - if guild.owner == name or not any( - member.rank == GuildRank.OWNER for member in guild.members): - new_owner = guild.members[0] - new_owner.rank = GuildRank.OWNER - guild.owner = new_owner.name - guild.add_log(f"{_actor(actor)} 强制移出成员 {name}") - guild.add_audit_log("guild_force_leave", _actor(actor), target=name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已将 {name} 移出公会 {guild.name}", _guild_summary(guild) - - -def api_force_kick_member(self, - player_name: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Force api force kick member.""" - return api_force_leave_guild(self, player_name, actor) - - -def api_set_guild_frozen(self, - guild_query: str, - frozen: bool, - reason: str = "", - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Update api set guild frozen.""" - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - settings = _ensure_settings(guild) - settings["frozen"] = bool(frozen) - settings["frozen_reason"] = str(reason or "") - settings["frozen_at"] = _now() if frozen else 0 - action = "冻结" if frozen else "解冻" - guild.add_log(f"{_actor(actor)} {action}了公会") - guild.add_audit_log("guild_freeze" if frozen else "guild_unfreeze", - _actor(actor), detail=str(reason or "")) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已{action}公会 {guild.name}", _guild_summary(guild) - - -def api_get_guild_vault( - self, guild_query: str) -> tuple[bool, str, Optional[list[dict[str, Any]]]]: - """Return api get guild vault.""" - guild, err = _find_guild(self, guild_query) - if guild is None: - return False, err, None - data = [_vault_item_summary(item, index) - for index, item in enumerate(guild.vault_items, start=1)] - return True, f"{guild.name} 仓库共有 {len(data)} 件上架物品", data - - -def api_backup_guild_vault(self, - guild_query: str, - label: str = "", - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Back up api backup guild vault.""" - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - settings = _ensure_settings(guild) - backups = settings.setdefault("vault_backups", []) - if not isinstance(backups, list): - backups = [] - settings["vault_backups"] = backups - backup = { - "label": str(label or ""), - "actor": _actor(actor), - "created_at": _now(), - "items": [item.to_dict() for item in guild.vault_items], - } - backups.insert(0, backup) - settings["vault_backups"] = backups[:10] - guild.add_audit_log("vault_backup", _actor(actor), detail=str(label or "")) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已备份 { - guild.name} 仓库,当前保留 { - len( - settings['vault_backups'])} 份", backup - - -def api_clear_guild_vault(self, - guild_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Clear api clear guild vault.""" - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - api_backup_guild_vault(self, guild.guild_id, "clear-before", actor) - guilds = _load_guilds(self) - guild = guilds[guild.guild_id] - removed = len(guild.vault_items) - guild.vault_items = [] - guild.add_log(f"{_actor(actor)} 清空了公会仓库") - guild.add_audit_log( - "vault_clear", - _actor(actor), - detail=f"removed={removed}") - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已清空 { - guild.name} 仓库,共删除 {removed} 件物品", _guild_summary(guild) - - -def api_delete_guild_vault_item(self, - guild_query: str, - index: int, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Delete api delete guild vault item.""" - ok, err, parsed = _to_int(index, "仓库序号", 1) - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - zero_index = parsed - 1 - if zero_index >= len(guild.vault_items): - return False, f"仓库序号超出范围:{parsed}", None - item = guild.vault_items.pop(zero_index) - guild.add_vault_trade_log( - "admin_delete", - item, - _actor(actor), - detail="管理员删除") - guild.add_audit_log("vault_item_delete", _actor( - actor), target=item.seller, detail=item.item_id) - guild.add_log(f"{_actor(actor)} 删除了仓库物品 {item.item_id} x{item.count}") - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已删除 { - guild.name} 仓库第 {parsed} 件物品", _vault_item_summary( - item, parsed) - - -def api_rollback_guild_vault(self, - guild_query: str, - backup_index: int = 1, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Run api rollback guild vault.""" - ok, err, parsed = _to_int(backup_index, "备份序号", 1) - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - backups = _ensure_settings(guild).get("vault_backups", []) - if not isinstance(backups, list) or parsed > len(backups): - return False, f"仓库备份不存在:{parsed}", None - selected_backup = copy.deepcopy(backups[parsed - 1]) - api_backup_guild_vault(self, guild.guild_id, "rollback-before", actor) - guilds = _load_guilds(self) - guild = guilds[guild.guild_id] - guild.vault_items = [ - VaultItem.from_dict(item) - for item in selected_backup.get("items", []) - if isinstance(item, dict) - ] - guild.add_log(f"{_actor(actor)} 回滚了公会仓库") - guild.add_audit_log("vault_rollback", _actor(actor), detail=str(parsed)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已回滚 {guild.name} 仓库到备份 {parsed}", { - "guild": _guild_summary(guild), - "backup": selected_backup, - } - - -def api_export_guild_vault( - self, guild_query: str) -> tuple[bool, str, Optional[dict[str, Any]]]: - """Export api export guild vault.""" - guild, err = _find_guild(self, guild_query) - if guild is None: - return False, err, None - data = { - "guild": _guild_summary(guild), "vault_items": [ - _vault_item_summary( - item, index) for index, item in enumerate( - guild.vault_items, start=1)], "vault_trade_logs": [ - log.to_dict() for log in guild.vault_trade_logs], } - data["json"] = json.dumps(data, ensure_ascii=False, indent=2) - return True, f"已导出 {guild.name} 仓库数据", data - - -def _make_task_from_template( - template: dict[str, Any], prefix: str = "auto") -> GuildTask: - """Create a guild task from a configured template.""" - now = _now() - deadline_seconds = int(getattr(Config, "GUILD_TASK_CONFIG", - {}).get("自动任务默认有效期秒", 172800)) - return GuildTask( - task_id=f"{prefix} -{uuid.uuid4().hex[: 8]} ", - name=str(template.get("name", "公会任务"))[: 20], - description=str(template.get("description", ""))[: 100], - task_type=str(template.get("task_type", "trade")), - target=str(template.get("target", "trade_count")), - target_count=max(1, int(template.get("target_count", 1))), - current_count=max(0, int(template.get("current_count", 0))), - reward_exp=max(0, int(template.get("reward_exp", 0))), - reward_contribution=max( - 0, int(template.get("reward_contribution", 0))), - create_time=now, deadline=now + deadline_seconds - if deadline_seconds > 0 else 0,) - - -def api_refresh_guild_tasks( - self, - guild_query: str, - actor: str = "QQ管理", -) -> tuple[bool, str, list[dict[str, Any]]]: - """Run api refresh guild tasks.""" - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, [] - templates = getattr(Config, "GUILD_TASK_CONFIG", {}).get("自动任务模板列表", []) - if not templates: - return False, "没有配置自动任务模板", [] - active_keys = {(task.name, task.task_type, task.target) - for task in guild.tasks if not task.completed} - created: list[GuildTask] = [] - for template in templates: - key = ( - template.get("name"), - template.get("task_type"), - template.get("target")) - if key in active_keys: - continue - task = _make_task_from_template(template) - guild.tasks.append(task) - created.append(task) - guild.add_log(f"{_actor(actor)} 刷新了公会任务,新增 {len(created)} 个") - guild.add_audit_log( - "task_refresh", - _actor(actor), - detail=str( - len(created))) - if created and not _save_guilds(self, guilds): - return False, "保存公会数据失败", [] - return True, f"已为 { - guild.name} 刷新任务,新增 { - len(created)} 个", [ - _task_summary(task) for task in created] - - -def api_create_global_task( - self, - name: str, - task_type: str, - target: str, - target_count: int, - reward_exp: int = 0, - reward_contribution: int = 0, - description: str = "", - deadline_seconds: int = 0, - actor: str = "QQ管理", -) -> tuple[bool, str, list[dict[str, Any]]]: - """Create api create global task.""" - task_name = str(name or "").strip() - if not task_name: - return False, "任务名称不能为空", [] - ok, err, count = _to_int(target_count, "目标数量", 1) - if not ok: - return False, err, [] - _, _, exp = _to_int(reward_exp, "经验奖励", 0) - _, _, contribution = _to_int(reward_contribution, "贡献奖励", 0) - _, _, seconds = _to_int(deadline_seconds, "截止秒数", 0) - guilds = _load_guilds(self) - now = _now() - created: list[dict[str, Any]] = [] - for guild in guilds.values(): - task = GuildTask( - task_id=f"global-{uuid.uuid4().hex[:8]}", - name=task_name[:20], - description=str(description or task_name)[:100], - task_type=str(task_type or "trade"), - target=str(target or "trade_count"), - target_count=count, - reward_exp=exp, - reward_contribution=contribution, - create_time=now, - deadline=now + seconds if seconds > 0 else 0, - ) - guild.tasks.append(task) - guild.add_log(f"{_actor(actor)} 创建了全服任务 {task.name}") - created.append({"guild_id": guild.guild_id, - "guild_name": guild.name, "task": _task_summary(task)}) - if created and not _save_guilds(self, guilds): - return False, "保存公会数据失败", [] - return True, f"已向 {len(created)} 个公会创建全服任务", created - - -def api_delete_guild_task(self, - guild_query: str, - task_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Delete api delete guild task.""" - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - task, err = _find_task(guild, task_query) - if task is None: - return False, err, None - guild.tasks = [ - item for item in guild.tasks if item.task_id != task.task_id] - guild.add_log(f"{_actor(actor)} 删除了任务 {task.name}") - guild.add_audit_log("task_delete", _actor(actor), detail=task.name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已删除任务 {task.name}", _task_summary(task) - - -def api_reset_guild_task_progress(self, - guild_query: str, - task_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Reset api reset guild task progress.""" - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - task, err = _find_task(guild, task_query) - if task is None: - return False, err, None - task.current_count = 0 - task.completed = False - task.participants = [] - guild.add_log(f"{_actor(actor)} 重置了任务 {task.name}") - guild.add_audit_log("task_reset", _actor(actor), detail=task.name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已重置任务 {task.name}", _task_summary(task) - - -def api_force_complete_guild_task(self, - guild_query: str, - task_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Force api force complete guild task.""" - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - task, err = _find_task(guild, task_query) - if task is None: - return False, err, None - task.current_count = task.target_count - task.completed = True - guild.add_log(f"{_actor(actor)} 强制完成了任务 {task.name}") - guild.add_audit_log("task_force_complete", _actor(actor), detail=task.name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已强制完成任务 {task.name}", _task_summary(task) - - -def api_teleport_player_to_guild_base( - self, player_name: str, guild_query: str | None = None) -> tuple[bool, str]: - """Run api teleport player to guild base.""" - name = str(player_name or "").strip() - if not name: - return False, "玩家名不能为空" - guilds = _load_guilds(self) - if guild_query: - guild, err = _find_guild(self, guild_query, guilds) - else: - guild, err = _find_player_guild(self, name, guilds) - if guild is None: - return False, err - if _ensure_settings(guild).get("base_locked", False): - return False, f"公会 {guild.name} 据点已锁定" - if not guild.base: - return False, f"公会 {guild.name} 尚未设置据点" - base = guild.base - self.game_ctrl.sendwocmd( - f"tp {name} {float(base.x)} {float(base.y)} {float(base.z)}") - return True, f"已传送 {name} 到公会 {guild.name} 据点" - - -def api_delete_guild_base(self, - guild_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Delete api delete guild base.""" - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - guild.base = None - guild.add_log(f"{_actor(actor)} 删除了公会据点") - guild.add_audit_log("base_delete", _actor(actor)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已删除 {guild.name} 据点", _guild_summary(guild) - - -def api_set_guild_base(self, - guild_query: str, - dimension: int, - x: float, - y: float, - z: float, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Update api set guild base.""" - ok, err, dim = _to_int(dimension, "维度") - if not ok: - return False, err, None - coord_values = [] - for field_name, value in (("x", x), ("y", y), ("z", z)): - ok, err, parsed = _to_float(value, field_name) - if not ok: - return False, err, None - coord_values.append(parsed) - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - guild.base = GuildBase( - dim, - coord_values[0], - coord_values[1], - coord_values[2]) - guild.add_log(f"{_actor(actor)} 修改了公会据点") - guild.add_audit_log("base_set", _actor( - actor), detail=f"{dim},{coord_values}") - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已设置 {guild.name} 据点", _guild_summary(guild) - - -def api_set_guild_base_locked(self, - guild_query: str, - locked: bool, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Update api set guild base locked.""" - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - _ensure_settings(guild)["base_locked"] = bool(locked) - action = "锁定" if locked else "解锁" - guild.add_log(f"{_actor(actor)} {action}了公会据点") - guild.add_audit_log( - "base_lock" if locked else "base_unlock", - _actor(actor)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已{action} {guild.name} 据点", _guild_summary(guild) - - -def api_clear_guild_effects(self, - guild_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Clear api clear guild effects.""" - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - guild.purchased_effects = {} - guild.add_log(f"{_actor(actor)} 清空了公会效果") - guild.add_audit_log("effect_clear", _actor(actor)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已清空 {guild.name} 效果", _guild_summary(guild) - - -def api_set_guild_effect(self, - guild_query: str, - effect_key: str, - level: int, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Update api set guild effect.""" - key = str(effect_key or "").strip() - if key not in Config.EFFECTS_CONFIG: - names = { - str(value.get("name", "")): effect_id - for effect_id, value in Config.EFFECTS_CONFIG.items() - if isinstance(value, dict) - } - key = names.get(key, key) - if key not in Config.EFFECTS_CONFIG: - return False, f"效果不存在:{effect_key}", None - ok, err, parsed_level = _to_int(level, "效果等级", 0) - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - if parsed_level <= 0: - guild.purchased_effects.pop(key, None) - else: - guild.purchased_effects[key] = parsed_level - guild.add_log(f"{_actor(actor)} 设置效果 {key} 为 {parsed_level} 级") - guild.add_audit_log( - "effect_set", - _actor(actor), - detail=f"{key}={parsed_level}") - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已设置 { - guild.name} 效果 {key} 为 {parsed_level} 级", _guild_summary(guild) - - -def api_add_guild_funds(self, - guild_query: str, - amount: int, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Add api add guild funds.""" - ok, err, parsed = _to_int(amount, "资金数量") - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - settings = _ensure_settings(guild) - settings["funds"] = int(settings.get("funds", 0) or 0) + parsed - guild.add_log(f"{_actor(actor)} 调整公会资金 {parsed:+d}") - guild.add_audit_log("funds_add", _actor(actor), detail=str(parsed)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已调整 { - guild.name} 资金,当前 { - settings['funds']}", _guild_summary(guild) - - -def api_set_guild_funds(self, - guild_query: str, - amount: int, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Update api set guild funds.""" - ok, err, parsed = _to_int(amount, "资金余额", 0) - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - _ensure_settings(guild)["funds"] = parsed - guild.add_log(f"{_actor(actor)} 设置公会资金为 {parsed}") - guild.add_audit_log("funds_set", _actor(actor), detail=str(parsed)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已设置 {guild.name} 资金为 {parsed}", _guild_summary(guild) - - -def api_add_member_contribution(self, - player_name: str, - amount: int, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Add api add member contribution.""" - ok, err, parsed = _to_int(amount, "贡献值") - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_player_guild(self, player_name, guilds) - if guild is None: - return False, err, None - member = guild.get_member(str(player_name).strip()) - if member is None: - return False, "成员数据异常", None - member.contribution += parsed - guild.stats.total_contribution += max(0, parsed) - guild.add_log(f"{_actor(actor)} 调整 {member.name} 贡献 {parsed:+d}") - guild.add_audit_log("member_contribution_add", _actor(actor), - target=member.name, detail=str(parsed)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已调整 { - member.name} 贡献,当前 { - member.contribution}", _member_summary(member) - - -def api_set_member_contribution(self, - player_name: str, - amount: int, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Update api set member contribution.""" - ok, err, parsed = _to_int(amount, "贡献值", 0) - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_player_guild(self, player_name, guilds) - if guild is None: - return False, err, None - member = guild.get_member(str(player_name).strip()) - if member is None: - return False, "成员数据异常", None - old = member.contribution - member.contribution = parsed - guild.add_log(f"{_actor(actor)} 将 {member.name} 贡献从 {old} 设置为 {parsed}") - guild.add_audit_log("member_contribution_set", _actor( - actor), target=member.name, detail=f"{old}->{parsed}") - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已设置 {member.name} 贡献为 {parsed}", _member_summary(member) - - -def api_reset_guild_contributions(self, - guild_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Reset api reset guild contributions.""" - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - for member in guild.members: - member.contribution = 0 - guild.stats.total_contribution = 0 - guild.add_log(f"{_actor(actor)} 重置了所有成员贡献") - guild.add_audit_log("member_contribution_reset_all", _actor(actor)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已重置 {guild.name} 所有成员贡献", _guild_summary(guild) - - -def api_reset_market_prices(self, - guild_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Reset api reset market prices.""" - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - removed = len(guild.custom_item_values) - guild.custom_item_values = {} - guild.add_log(f"{_actor(actor)} 重置了市场价格") - guild.add_audit_log( - "market_price_reset", - _actor(actor), - detail=str(removed)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已重置 { - guild.name} 市场价格,删除 {removed} 条自定义价格", _guild_summary(guild) - - -def api_get_guild_logs(self, guild_query: str, - limit: int = 20) -> tuple[bool, str, Optional[dict[str, Any]]]: - """Return api get guild logs.""" - ok, _err, parsed_limit = _to_int(limit, "日志数量", 1) - if not ok: - parsed_limit = 20 - guild, err = _find_guild(self, guild_query) - if guild is None: - return False, err, None - return True, "查询成功", { - "logs": guild.logs[-parsed_limit:], - "audit_logs": [log.to_dict() for log in guild.audit_logs[-parsed_limit:]], - "vault_trade_logs": [ - log.to_dict() for log in guild.vault_trade_logs[-parsed_limit:] - ], - } - - -def api_get_abnormal_trades(self, - guild_query: str | None = None, - ratio: float = 3.0) -> tuple[bool, - str, - list[dict[str, - Any]]]: - """Return api get abnormal trades.""" - try: - threshold = float(ratio) - except (TypeError, ValueError): - threshold = 3.0 - guilds = _load_guilds(self) - target_guilds: list[GuildData] - if guild_query: - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, [] - target_guilds = [guild] - else: - target_guilds = list(guilds.values()) - results = [] - for guild in target_guilds: - for log in guild.vault_trade_logs: - suggested = guild.get_item_value( - log.item_id) * max(1, int(log.count or 1)) - if suggested > 0 and log.price >= suggested * threshold: - data = log.to_dict() - data["guild_id"] = guild.guild_id - data["guild_name"] = guild.name - data["suggested_price"] = suggested - data["ratio"] = round(log.price / suggested, 2) - results.append(data) - results.sort(key=lambda item: item.get("timestamp", 0), reverse=True) - return True, f"共 {len(results)} 条异常交易记录", results - - -def api_get_donation_rankings(self, - guild_query: str | None = None, - limit: int = 10) -> tuple[bool, - str, - list[dict[str, - Any]]]: - """Return api get donation rankings.""" - ok, _err, parsed_limit = _to_int(limit, "排行数量", 1) - if not ok: - parsed_limit = 10 - guilds = _load_guilds(self) - records = [] - for guild in guilds.values(): - if guild_query and guild.name != guild_query and guild.guild_id != guild_query: - continue - for member in guild.members: - records.append({ - "guild_id": guild.guild_id, - "guild_name": guild.name, - "player_name": member.name, - "rank": member.rank.value, - "contribution": member.contribution, - }) - records.sort(key=lambda item: int(item["contribution"]), reverse=True) - return True, f"贡献排行前 {min(parsed_limit, len(records))} 名", records[:parsed_limit] - - -def api_get_guild_rankings(self, sort_by: str = "level", - limit: int = 10) -> tuple[bool, str, list[dict[str, Any]]]: - """返回适合外部插件消费的公会排行榜数据。""" - raw_sort_by = str(sort_by or "level").strip() - sort_key = { - "等级": "level", - "level": "level", - "成员": "members", - "members": "members", - "贡献": "contribution", - "contribution": "contribution", - "活跃": "activity", - "activity": "activity", - }.get(raw_sort_by, raw_sort_by) - ok, _err, parsed_limit = _to_int(limit, "排行数量", 1) - if not ok: - parsed_limit = 10 - rankings = _get_guild_rankings(self, sort_key)[:parsed_limit] - data: list[dict[str, Any]] = [] - for index, (guild, score) in enumerate(rankings, start=1): - item = _guild_summary(guild) - item["rank"] = index - item["score"] = score - item["sort_by"] = sort_key - data.append(item) - return True, f"公会排行前 {len(data)} 名", data - - -def api_reload_guild_config(self) -> tuple[bool, str, dict[str, Any]]: - """Reload api reload guild config.""" - self.config = Config.load(self.name, self.version) - return True, "公会系统配置已重新加载", copy.deepcopy(self.config) - - -def api_save_guild_data(self) -> tuple[bool, str]: - """Save api save guild data.""" - guilds = _load_guilds(self) - if not _save_guilds(self, guilds, force=True): - return False, "保存公会数据失败" - return True, "公会数据已强制保存" - - -def api_backup_guild_data(self) -> tuple[bool, str, Optional[str]]: - """Back up api backup guild data.""" - if not os.path.exists(self.guilds_file): - return False, "公会数据文件不存在", None - data_dir = os.path.dirname(self.guilds_file) - backup_dir = os.path.join(data_dir, "公会数据备份") - os.makedirs(backup_dir, exist_ok=True) - backup_path = os.path.join( - backup_dir, f"公会数据文件-api-{time.strftime('%Y%m%d-%H%M%S')}.json") - shutil.copy2(self.guilds_file, backup_path) - return True, "公会数据备份已创建", backup_path - - -def api_repair_guild_data( # skipcq: PY-R1000 - self, actor: str = "QQ管理") -> tuple[bool, str, dict[str, Any]]: - """Repair api repair guild data.""" - guilds = _load_guilds(self) - fixed = {"guild_id": 0, "level": 0, "exp": 0, - "owner": 0, "vault": 0, "removed_empty": 0} - for outer_id in list(guilds.keys()): - guild = guilds[outer_id] - if not guild.members: - del guilds[outer_id] - fixed["removed_empty"] += 1 - continue - if guild.guild_id != outer_id: - guild.guild_id = outer_id - fixed["guild_id"] += 1 - if not isinstance(guild.level, int) or guild.level < 1: - guild.level = 1 - fixed["level"] += 1 - if not isinstance(guild.exp, (int, float)) or guild.exp < 0: - guild.exp = 0 - fixed["exp"] += 1 - owners = [member for member in guild.members - if member.rank == GuildRank.OWNER] - if len(owners) != 1: - preferred = guild.get_member(guild.owner) or guild.members[0] - for member in guild.members: - member.rank = GuildRank.MEMBER - preferred.rank = GuildRank.OWNER - guild.owner = preferred.name - fixed["owner"] += 1 - if len(guild.vault_items) > Config.VAULT_INITIAL_SLOTS: - guild.vault_items = guild.vault_items[:Config.VAULT_INITIAL_SLOTS] - fixed["vault"] += 1 - if not isinstance(guild.stats, GuildStats): - guild.stats = GuildStats() - guild.add_audit_log("data_repair", _actor( - actor), detail=json.dumps(fixed, ensure_ascii=False)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", fixed - return True, "公会数据修复完成", fixed - - -def api_get_guild_statistics(self) -> tuple[bool, str, dict[str, Any]]: - """Return api get guild statistics.""" - guilds = _load_guilds(self) - total_members = sum(len(guild.members) for guild in guilds.values()) - total_vault_items = sum(len(guild.vault_items) - for guild in guilds.values()) - total_tasks = sum(len(guild.tasks) for guild in guilds.values()) - active_tasks = sum( - len([task for task in guild.tasks if not task.completed]) - for guild in guilds.values()) - frozen_count = sum(1 for guild in guilds.values() - if _ensure_settings(guild).get("frozen", False)) - data = { - "guild_count": len(guilds), - "member_count": total_members, - "vault_item_count": total_vault_items, - "task_count": total_tasks, - "active_task_count": active_tasks, - "frozen_guild_count": frozen_count, - "activity_status": api_get_guild_activity_status(self)[2], - } - return True, "查询成功", data - - -def api_start_guild_activity( - self, - activity: str, - duration_seconds: int = 3600, - multiplier: float = 2.0, - actor: str = "QQ管理", -) -> tuple[bool, str, dict[str, Any]]: - """Start api start guild activity.""" - activity_key = str(activity or "").strip().lower() - aliases = { - "双倍经验": "exp", - "经验": "exp", - "exp": "exp", - "双倍贡献": "contribution", - "贡献": "contribution", - "contribution": "contribution", - "公会争霸": "contest", - "争霸": "contest", - "contest": "contest", - } - activity_key = aliases.get(activity_key, activity_key) - if activity_key not in ("exp", "contribution", "contest"): - return False, f"未知活动类型:{activity}", {} - ok, err, seconds = _to_int(duration_seconds, "活动时长秒", 1) - if not ok: - return False, err, {} - try: - parsed_multiplier = max(1.0, float(multiplier)) - except (TypeError, ValueError): - parsed_multiplier = 2.0 - if not hasattr(self, "_guild_runtime_events"): - self._guild_runtime_events = {} - event = { - "activity": activity_key, - "multiplier": parsed_multiplier, - "started_at": _now(), - "expires_at": _now() + seconds, - "actor": _actor(actor), - } - self._guild_runtime_events[activity_key] = event - return ( - True, - f"已开启 {activity_key} 活动 {seconds} 秒,倍率 {parsed_multiplier}", - copy.deepcopy(event), - ) - - -def api_stop_guild_activity(self, activity: str) -> tuple[bool, str]: - """Stop api stop guild activity.""" - activity_key = str(activity or "").strip().lower() - aliases = {"经验": "exp", "双倍经验": "exp", "贡献": "contribution", - "双倍贡献": "contribution", "争霸": "contest", "公会争霸": "contest"} - activity_key = aliases.get(activity_key, activity_key) - removed = getattr( - self, - "_guild_runtime_events", - {}).pop( - activity_key, - None) - if removed is None: - return False, f"活动未开启:{activity}" - return True, f"已停止活动 {activity_key}" - - -def api_get_guild_activity_status(self) -> tuple[bool, str, dict[str, Any]]: - """Return api get guild activity status.""" - events = getattr(self, "_guild_runtime_events", {}) - now = _now() - active = {} - for key, event in list(events.items()): - expires_at = float(event.get("expires_at", 0) or 0) - if 0 < expires_at <= now: - events.pop(key, None) - continue - active[key] = copy.deepcopy(event) - active[key]["remaining_seconds"] = max( - 0, int(expires_at - now)) if expires_at > 0 else 0 - return True, f"当前 {len(active)} 个活动运行中", active - - -def api_settle_guild_ranking_rewards( - self, - sort_by: str = "level", - top: int = 3, - reward_exp: int = 0, - reward_funds: int = 0, - actor: str = "QQ管理", -) -> tuple[bool, str, list[dict[str, Any]]]: - """Update api settle guild ranking rewards.""" - ok, err, parsed_top = _to_int(top, "排行数量", 1) - if not ok: - return False, err, [] - _, _, exp = _to_int(reward_exp, "经验奖励", 0) - _, _, funds = _to_int(reward_funds, "资金奖励", 0) - guilds = _load_guilds(self) - ranking = _get_guild_rankings(self, sort_by) - rewarded = [] - for guild, score in ranking[:parsed_top]: - latest = guilds.get(guild.guild_id) - if latest is None: - continue - latest.exp += exp - _apply_level_ups(latest) - settings = _ensure_settings(latest) - settings["funds"] = int(settings.get("funds", 0) or 0) + funds - latest.add_log(f"{_actor(actor)} 发放排行榜奖励:经验 {exp},资金 {funds}") - latest.add_audit_log("ranking_reward", _actor(actor), - detail=f"{sort_by}:{score}") - rewarded.append({"guild_id": latest.guild_id, - "guild_name": latest.name, "score": score}) - if rewarded and not _save_guilds(self, guilds): - return False, "保存公会数据失败", [] - return True, f"已结算 {len(rewarded)} 个公会的排行榜奖励", rewarded - - -def api_broadcast_guild_announcement( - self, message: str, actor: str = "QQ管理") -> tuple[bool, str]: - """Broadcast api broadcast guild announcement.""" - text = str(message or "").strip() - if not text: - return False, "公告内容不能为空" - payload = json.dumps( - {"rawtext": [{"text": f"§l§a公会公告 §d>> §r{text}"}]}, ensure_ascii=False) - self.game_ctrl.sendcmd(f"/tellraw @a {payload}") - fmts.print_inf(f"{_actor(actor)} 发布公会全服公告:{text}") - return True, "全服公告已发送" - - -def _get_guild_rankings( - self, sort_by: str = "level") -> list[tuple[GuildData, Any]]: - """Return guild rankings using the runtime implementation.""" - return self.get_guild_rankings(sort_by) - - -guild_api_functions = { - "guild_get_activity_multiplier": guild_get_activity_multiplier, - "guild_apply_reward_multipliers": guild_apply_reward_multipliers, - "guild_is_frozen": guild_is_frozen, - "guild_frozen_message": guild_frozen_message, - "show_guild_frozen": show_guild_frozen, - "api_list_guilds": api_list_guilds, - "api_get_guild": api_get_guild, - "api_get_player_record": api_get_player_record, - "api_get_player_guild_menu_state": api_get_player_guild_menu_state, - "api_get_own_guild_logs": api_get_own_guild_logs, - "api_get_own_guild_vault": api_get_own_guild_vault, - "api_get_own_guild_tasks": api_get_own_guild_tasks, - "api_request_join_guild_as_player": api_request_join_guild_as_player, - "api_leave_guild_as_player": api_leave_guild_as_player, - "api_disband_owned_guild_as_player": api_disband_owned_guild_as_player, - "api_set_announcement_as_player": api_set_announcement_as_player, - "api_join_guild_task_as_player": api_join_guild_task_as_player, - "api_return_to_guild_base_as_player": api_return_to_guild_base_as_player, - "api_force_disband_guild": api_force_disband_guild, - "api_rename_guild": api_rename_guild, - "api_set_guild_level": api_set_guild_level, - "api_set_guild_exp": api_set_guild_exp, - "api_transfer_guild_owner": api_transfer_guild_owner, - "api_force_join_guild": api_force_join_guild, - "api_force_leave_guild": api_force_leave_guild, - "api_force_kick_member": api_force_kick_member, - "api_set_guild_frozen": api_set_guild_frozen, - "api_get_guild_vault": api_get_guild_vault, - "api_backup_guild_vault": api_backup_guild_vault, - "api_clear_guild_vault": api_clear_guild_vault, - "api_delete_guild_vault_item": api_delete_guild_vault_item, - "api_rollback_guild_vault": api_rollback_guild_vault, - "api_export_guild_vault": api_export_guild_vault, - "api_refresh_guild_tasks": api_refresh_guild_tasks, - "api_create_global_task": api_create_global_task, - "api_delete_guild_task": api_delete_guild_task, - "api_reset_guild_task_progress": api_reset_guild_task_progress, - "api_force_complete_guild_task": api_force_complete_guild_task, - "api_teleport_player_to_guild_base": api_teleport_player_to_guild_base, - "api_delete_guild_base": api_delete_guild_base, - "api_set_guild_base": api_set_guild_base, - "api_set_guild_base_locked": api_set_guild_base_locked, - "api_clear_guild_effects": api_clear_guild_effects, - "api_set_guild_effect": api_set_guild_effect, - "api_add_guild_funds": api_add_guild_funds, - "api_set_guild_funds": api_set_guild_funds, - "api_add_member_contribution": api_add_member_contribution, - "api_set_member_contribution": api_set_member_contribution, - "api_reset_guild_contributions": api_reset_guild_contributions, - "api_reset_market_prices": api_reset_market_prices, - "api_get_guild_logs": api_get_guild_logs, - "api_get_abnormal_trades": api_get_abnormal_trades, - "api_get_donation_rankings": api_get_donation_rankings, - "api_get_guild_rankings": api_get_guild_rankings, - "api_reload_guild_config": api_reload_guild_config, - "api_save_guild_data": api_save_guild_data, - "api_backup_guild_data": api_backup_guild_data, - "api_repair_guild_data": api_repair_guild_data, - "api_get_guild_statistics": api_get_guild_statistics, - "api_start_guild_activity": api_start_guild_activity, - "api_stop_guild_activity": api_stop_guild_activity, - "api_get_guild_activity_status": api_get_guild_activity_status, - "api_settle_guild_ranking_rewards": api_settle_guild_ranking_rewards, - "api_broadcast_guild_announcement": api_broadcast_guild_announcement, -} +"""Public QQ and plugin API operations for guild cloud interop.""" + +# pylint: disable=protected-access + +import copy +import json +import os +import re +import shutil +import time +import uuid +from typing import Any, Optional + +from tooldelta import fmts + +from guild_cloud_interop.config import Config +from guild_cloud_interop.models import ( + GuildBase, + GuildData, + GuildMember, + GuildRank, + GuildStats, + GuildTask, + VaultItem, +) +from guild_cloud_interop.validators import InputValidator + + +COLOR_CODE_RE = re.compile(r"§.") + + +def _plain(text: object) -> str: + """Return text without Minecraft color codes.""" + return COLOR_CODE_RE.sub("", str(text)) + + +def _now() -> float: + """Return the current unix timestamp.""" + return time.time() + + +def _actor(actor: str | None) -> str: + """Return a normalized actor label.""" + text = str(actor or "").strip() + return text or "QQ管理" + + +def _to_int(value: object, field_name: str, minimum: int | + None = None) -> tuple[bool, str, int]: + """Parse an integer API argument.""" + try: + parsed = int(str(value).strip()) + except (TypeError, ValueError): + return False, f"{field_name}必须是整数", 0 + if minimum is not None and parsed < minimum: + return False, f"{field_name}不能小于 {minimum}", 0 + return True, "", parsed + + +def _to_float(value: object, field_name: str) -> tuple[bool, str, float]: + """Parse a float API argument.""" + try: + return True, "", float(str(value).strip()) + except (TypeError, ValueError): + return False, f"{field_name}必须是数字", 0.0 + + +def _ensure_settings(guild: GuildData) -> dict[str, Any]: + """Ensure guild settings are stored as a dictionary.""" + if not isinstance(guild.settings, dict): + guild.settings = {} + return guild.settings + + +def _rebuild_player_cache(self, guilds: dict[str, GuildData]) -> None: + """Rebuild the guild player cache.""" + self.guild_manager.rebuild_player_cache(guilds) + + +def _save_guilds(self, + guilds: dict[str, + GuildData], + force: bool = True) -> bool: + """Save guild data and refresh the manager cache.""" + _rebuild_player_cache(self, guilds) + ok = self.guild_manager.save_guilds(guilds, force=force) + if ok: + self.guild_manager.load_guilds(force_reload=True) + return ok + + +def _load_guilds(self) -> dict[str, GuildData]: + """Load guild data with a fresh cache read.""" + return self.guild_manager.load_guilds(force_reload=True) + + +def _find_guild( # skipcq: PY-R1000 + self, + guild_query: object, + guilds: Optional[dict[str, GuildData]] = None, +) -> tuple[Optional[GuildData], str]: + """Find a guild by ID, exact name, or fuzzy text.""" + query = str(guild_query or "").strip() + if not query: + return None, "公会不能为空" + + guild_map = guilds if guilds is not None else _load_guilds(self) + if query in guild_map: + return guild_map[query], "" + + exact = [guild for guild in guild_map.values() if guild.name == query] + if len(exact) == 1: + return exact[0], "" + if len(exact) > 1: + return None, f"存在多个同名公会:{query},请使用公会ID" + + query_lower = query.casefold() + fuzzy = [ + guild + for guild in guild_map.values() + if ( + query_lower in guild.name.casefold() + or query_lower in guild.guild_id.casefold() + ) + ] + if len(fuzzy) == 1: + return fuzzy[0], "" + if len(fuzzy) > 1: + names = "、".join(guild.name for guild in fuzzy[:5]) + if len(fuzzy) > 5: + names += "……" + return None, f"匹配到多个公会:{names}" + return None, f"公会不存在:{query}" + + +def _find_player_guild( + self, + player_name: object, + guilds: Optional[dict[str, GuildData]] = None, +) -> tuple[Optional[GuildData], str]: + """Find the guild that contains a player.""" + name = str(player_name or "").strip() + if not name: + return None, "玩家名不能为空" + + guild_map = guilds if guilds is not None else _load_guilds(self) + for guild in guild_map.values(): + if guild.get_member(name): + return guild, "" + return None, f"玩家 {name} 不在任何公会" + + +def _find_player_context( + self, + player_name: object, + guilds: Optional[dict[str, GuildData]] = None, +) -> tuple[str, Optional[GuildData], Optional[GuildMember], str]: + """Return normalized player, guild, member, and error context.""" + name = str(player_name or "").strip() + if not name: + return "", None, None, "玩家名不能为空" + guild, err = _find_player_guild(self, name, guilds) + if guild is None: + return name, None, None, err + member = guild.get_member(name) + if member is None: + return name, guild, None, "成员数据异常" + return name, guild, member, "" + + +def _find_task(guild: GuildData, + task_query: object) -> tuple[Optional[GuildTask], str]: + """Find a guild task by ID, exact name, or fuzzy text.""" + query = str(task_query or "").strip() + if not query: + return None, "任务不能为空" + for task in guild.tasks: + if query in (task.task_id, task.name): + return task, "" + query_lower = query.casefold() + matched = [ + task + for task in guild.tasks + if query_lower in task.task_id.casefold() or query_lower in task.name.casefold() + ] + if len(matched) == 1: + return matched[0], "" + if len(matched) > 1: + names = "、".join( + f"{task.name}({task.task_id})" for task in matched[:5]) + return None, f"匹配到多个任务:{names}" + return None, f"任务不存在:{query}" + + +def _member_summary(member: GuildMember) -> dict[str, Any]: + """Build a serializable member summary.""" + return { + "name": member.name, + "rank": member.rank.value, + "rank_name": _plain(member.rank.display_name), + "join_time": member.join_time, + "contribution": member.contribution, + "last_online": member.last_online, + } + + +def _base_summary(base: GuildBase | None) -> dict[str, Any] | None: + """Build a serializable base summary.""" + if base is None: + return None + return { + "dimension": base.dimension, + "x": base.x, + "y": base.y, + "z": base.z, + } + + +def _vault_item_summary(item: VaultItem, index: int | + None = None) -> dict[str, Any]: + """Build a serializable vault item summary.""" + data = item.to_dict() + if index is not None: + data["index"] = index + return data + + +def _task_summary(task: GuildTask) -> dict[str, Any]: + """Build a serializable task summary.""" + return task.to_dict() + + +def _guild_summary(guild: GuildData) -> dict[str, Any]: + """Build a serializable guild summary.""" + settings = _ensure_settings(guild) + return { + "guild_id": guild.guild_id, + "name": guild.name, + "owner": guild.owner, + "level": guild.level, + "exp": guild.exp, + "create_time": guild.create_time, + "member_count": len(guild.members), + "max_members": Config.MAX_GUILD_MEMBERS, + "base": _base_summary(guild.base), + "vault_count": len(guild.vault_items), + "vault_capacity": Config.VAULT_INITIAL_SLOTS, + "announcement": guild.announcement, + "purchased_effects": dict(guild.purchased_effects), + "funds": int(settings.get("funds", 0) or 0), + "frozen": bool(settings.get("frozen", False)), + "frozen_reason": str(settings.get("frozen_reason", "")), + "base_locked": bool(settings.get("base_locked", False)), + "active_tasks": len([task for task in guild.tasks if not task.completed]), + "completed_tasks": len([task for task in guild.tasks if task.completed]), + "total_contribution": guild.stats.total_contribution, + } + + +def _apply_level_ups(guild: GuildData) -> list[int]: + """Apply pending guild level ups and return gained levels.""" + level_ups: list[int] = [] + next_level = guild.level + 1 + required = Config.GUILD_LEVEL_EXP.get(next_level) + while required and guild.exp >= required: + guild.exp -= required + guild.level = next_level + level_ups.append(next_level) + guild.add_log(f"公会升级到 {next_level} 级") + next_level = guild.level + 1 + required = Config.GUILD_LEVEL_EXP.get(next_level) + return level_ups + + +def _activity_multiplier(self, key: str) -> float: + """Return an active guild event multiplier.""" + event = getattr(self, "_guild_runtime_events", {}).get(key) + if not isinstance(event, dict): + return 1.0 + expires_at = float(event.get("expires_at", 0) or 0) + if 0 < expires_at <= _now(): + getattr(self, "_guild_runtime_events", {}).pop(key, None) + return 1.0 + try: + multiplier = float(event.get("multiplier", 1.0)) + except (TypeError, ValueError): + return 1.0 + return max(1.0, multiplier) + + +def guild_get_activity_multiplier(self, key: str) -> float: + """Return guild get activity multiplier.""" + return _activity_multiplier(self, key) + + +def guild_apply_reward_multipliers( + self, + exp: int | float = 0, + contribution: int | float = 0, +) -> tuple[int, int]: + """Apply guild apply reward multipliers.""" + exp_out = int(float(exp) * _activity_multiplier(self, "exp")) + contribution_out = int(float(contribution) * + _activity_multiplier(self, "contribution")) + return max(0, exp_out), max(0, contribution_out) + + +def guild_is_frozen(self, guild: GuildData | None) -> bool: + """Return whether frozen.""" + _ = self + if guild is None: + return False + settings = getattr(guild, "settings", {}) + return isinstance(settings, dict) and bool(settings.get("frozen", False)) + + +def guild_frozen_message(self, guild: GuildData | None) -> str: + """Run guild frozen message.""" + _ = self + if guild is None: + return "公会已冻结" + settings = getattr(guild, "settings", {}) + reason = "" + if isinstance(settings, dict): + reason = str(settings.get("frozen_reason", "") or "").strip() + suffix = f":{reason}" if reason else "" + return f"公会 {guild.name} 已被冻结{suffix}" + + +def show_guild_frozen(self, player, guild: GuildData | None) -> None: + """Show guild frozen.""" + player.show(f"§l§a公会 §d>> §c{self.guild_frozen_message(guild)}") + + +def api_list_guilds(self) -> tuple[bool, str, list[dict[str, Any]]]: + """Return api list guilds.""" + guilds = _load_guilds(self) + data = [_guild_summary(guild) for guild in guilds.values()] + data.sort(key=lambda item: (-int(item["level"]), - + int(item["member_count"]), item["name"])) + return True, f"共 {len(data)} 个公会", data + + +def api_get_guild( + self, guild_query: str) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Return api get guild.""" + guild, err = _find_guild(self, guild_query) + if guild is None: + return False, err, None + data = _guild_summary(guild) + data["members"] = [_member_summary(member) for member in guild.members] + data["tasks"] = [_task_summary(task) for task in guild.tasks] + return True, "查询成功", data + + +def api_get_player_record( + self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Return api get player record.""" + guilds = _load_guilds(self) + guild, err = _find_player_guild(self, player_name, guilds) + if guild is None: + return False, err, None + member = guild.get_member(str(player_name).strip()) + if member is None: + return False, "成员数据异常", None + records = [ + log.to_dict() + for log in guild.audit_logs + if member.name in (log.actor, log.target) + ] + vault_records = [ + log.to_dict() + for log in guild.vault_trade_logs + if member.name in (log.actor, log.seller, log.buyer) + ] + return True, "查询成功", { + "guild": _guild_summary(guild), + "member": _member_summary(member), + "audit_logs": records[-50:], + "vault_trade_logs": vault_records[-50:], + } + + +def api_get_player_guild_menu_state( + self, player_name: str) -> tuple[bool, str, dict[str, Any]]: + """Return safe QQ-side guild menu state for one player identity.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if not name: + return False, err, {} + if guild is None: + return True, f"玩家 {name} 暂未加入公会", { + "player_name": name, + "in_guild": False, + "guild": None, + "member": None, + "permissions": [], + "is_owner": False, + "is_frozen": False, + } + if member is None: + return False, err, {} + return True, "查询成功", { + "player_name": name, + "in_guild": True, + "guild": _guild_summary(guild), + "member": _member_summary(member), + "permissions": guild.get_member_permissions(name), + "is_owner": member.rank == GuildRank.OWNER, + "is_frozen": bool(_ensure_settings(guild).get("frozen", False)), + } + + +def api_get_own_guild_logs(self, + player_name: str, + limit: int = 20) -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Return logs for the guild that the player belongs to.""" + ok, _err, parsed_limit = _to_int(limit, "日志数量", 1) + if not ok: + parsed_limit = 20 + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + data: dict[str, Any] = {"logs": guild.logs[-parsed_limit:]} + if guild.has_permission(name, "audit_log"): + data["audit_logs"] = [log.to_dict() + for log in guild.audit_logs[-parsed_limit:]] + data["vault_trade_logs"] = [log.to_dict() + for log in guild.vault_trade_logs[-parsed_limit:]] + else: + data["audit_logs"] = [] + data["vault_trade_logs"] = [] + return True, "查询成功", data + + +def api_get_own_guild_vault( + self, player_name: str) -> tuple[bool, str, Optional[list[dict[str, Any]]]]: + """Return the current player's guild vault after normal guild permission checks.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + if not guild.has_permission(name, "vault"): + return False, "你没有使用仓库权限", None + data = [_vault_item_summary(item, index) + for index, item in enumerate(guild.vault_items, start=1)] + return True, f"{guild.name} 仓库共有 {len(data)} 件上架物品", data + + +def api_get_own_guild_tasks( + self, player_name: str) -> tuple[bool, str, Optional[list[dict[str, Any]]]]: + """Return the task list for the guild that the player belongs to.""" + guilds = _load_guilds(self) + _name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + return True, f"{guild.name} 共有 {len(guild.tasks)} 个任务", [ + _task_summary(task) for task in guild.tasks] + + +def api_request_join_guild_as_player( + self, + player_name: str, + guild_query: str, + reason: str = "", +) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Submit a normal player join request for a guild.""" + name = str(player_name or "").strip() + if not name: + return False, "玩家名不能为空", None + guilds = _load_guilds(self) + current_guild, _ = _find_player_guild(self, name, guilds) + if current_guild is not None: + return False, f"你已经加入了公会 {current_guild.name}", _guild_summary(current_guild) + target_guild, err = _find_guild(self, guild_query, guilds) + if target_guild is None: + return False, err, None + if _ensure_settings(target_guild).get("frozen", False): + return False, f"公会 {target_guild.name} 已被冻结,暂不能提交申请", _guild_summary(target_guild) + if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: + return False, "该公会已满员", _guild_summary(target_guild) + if not target_guild.add_join_request(name, reason): + return False, "申请提交失败,可能已有待处理申请或队列已满", _guild_summary(target_guild) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + notify = getattr(self, "_notify_join_request_admins", None) + if callable(notify): + notify(target_guild, name) + return True, f"申请已提交至 {target_guild.name} 的申请队列", _guild_summary(target_guild) + + +def api_leave_guild_as_player( + self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Leave the current guild as a normal member.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + if member.rank == GuildRank.OWNER: + return False, "会长不能退出公会,只能解散公会", _guild_summary(guild) + guild.members = [item for item in guild.members if item.name != name] + guild.add_log(f"{name} 退出公会") + guild.add_audit_log("member_leave", name, target=name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已退出公会 {guild.name}", _guild_summary(guild) + + +def api_disband_owned_guild_as_player( + self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Disband the player's own guild, only when the player is the owner.""" + guilds = _load_guilds(self) + _, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + if member.rank != GuildRank.OWNER: + return False, "你不是当前公会的会长", _guild_summary(guild) + summary = _guild_summary(guild) + guild_name = guild.name + online_members = [ + item.name + for item in guild.members + if item.name in getattr(self.game_ctrl, "allplayers", []) + ] + del guilds[guild.guild_id] + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + for member_name in online_members: + message = f"§l§a公会 §d>> §r公会 §e{guild_name}§r 已被解散" + self.game_ctrl.sendcmd( + f'/tellraw {member_name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + return True, f"已解散公会 {guild_name}", summary + + +def api_set_announcement_as_player( + self, + player_name: str, + announcement: str, +) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Set the current guild announcement through normal guild permission checks.""" + text = str(announcement or "").strip() + is_valid, error_msg = InputValidator.validate_announcement(text) + if not is_valid: + return False, error_msg, None + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + if _ensure_settings(guild).get("frozen", False): + return False, guild_frozen_message(self, guild), _guild_summary(guild) + if not guild.has_permission(name, "announce"): + return False, "你没有设置公会公告权限", _guild_summary(guild) + guild.announcement = text + guild.add_log(f"{name} 更新了公告") + guild.add_audit_log("announcement_set", name, detail=text) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + message = "§l§a公会 §d>> §r公告已更新,输入 .公会 公告 查看" + for member_item in guild.members: + if member_item.name in getattr(self.game_ctrl, "allplayers", []): + self.game_ctrl.sendcmd( + f'/tellraw {member_item.name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + return True, "公告已更新", _guild_summary(guild) + + +def api_join_guild_task_as_player( + self, + player_name: str, + task_query: str, +) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Join an active guild task as the current player.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + if _ensure_settings(guild).get("frozen", False): + return False, guild_frozen_message(self, guild), None + task, err = _find_task(guild, task_query) + if task is None: + return False, err, None + if task.completed: + return False, "该任务已完成", _task_summary(task) + if name in task.participants: + return True, f"你已经参与了任务:{task.name}", _task_summary(task) + task.participants.append(name) + guild.add_log(f"{name} 参与了任务: {task.name}") + guild.add_audit_log("task_join", name, detail=task.name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已参与任务:{task.name}", _task_summary(task) + + +def api_return_to_guild_base_as_player( + self, player_name: str) -> tuple[bool, str]: + """Teleport the player to their guild base after normal permission checks.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err + if name not in getattr(self.game_ctrl, "allplayers", []): + return False, f"玩家 {name} 当前不在线,无法传送" + if _ensure_settings(guild).get("frozen", False): + return False, guild_frozen_message(self, guild) + if not guild.has_permission(name, "return_base"): + return False, "你没有返回公会据点权限" + if _ensure_settings(guild).get("base_locked", False): + return False, f"公会 {guild.name} 据点已锁定" + if not guild.base: + return False, f"公会 {guild.name} 尚未设置据点" + base = guild.base + self.game_ctrl.sendwocmd( + f"tp {name} {float(base.x)} {float(base.y)} {float(base.z)}") + return True, f"已传送到公会 {guild.name} 据点" + + +def api_force_disband_guild(self, guild_query: str, + actor: str = "QQ管理") -> tuple[bool, str]: + """Force api force disband guild.""" + _ = actor + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err + name = guild.name + del guilds[guild.guild_id] + if not _save_guilds(self, guilds): + return False, "保存公会数据失败" + return True, f"已强制解散公会 {name}" + + +def api_rename_guild(self, guild_query: str, new_name: str, + actor: str = "QQ管理") -> tuple[bool, str, Optional[dict[str, Any]]]: + """Run api rename guild.""" + new_name = str(new_name or "").strip() + if not new_name: + return False, "新公会名不能为空", None + if len(new_name) < 2 or len(new_name) > 20: + return False, "新公会名长度必须在 2-20 之间", None + guilds = _load_guilds(self) + if any(guild.name == new_name for guild in guilds.values()): + return False, f"公会名已存在:{new_name}", None + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + old_name = guild.name + guild.name = new_name + guild.add_log(f"{_actor(actor)} 将公会名从 {old_name} 修改为 {new_name}") + guild.add_audit_log( + "guild_rename", + _actor(actor), + target=old_name, + detail=new_name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已修改公会名称:{old_name} -> {new_name}", _guild_summary(guild) + + +def api_set_guild_level(self, + guild_query: str, + level: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Update api set guild level.""" + ok, err, parsed = _to_int(level, "公会等级", 1) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + old = guild.level + guild.level = parsed + guild.add_log(f"{_actor(actor)} 将公会等级从 {old} 修改为 {parsed}") + guild.add_audit_log( + "guild_set_level", + _actor(actor), + detail=f"{old}->{parsed}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已设置 {guild.name} 等级为 {parsed}", _guild_summary(guild) + + +def api_set_guild_exp( + self, + guild_query: str, + exp: int, + actor: str = "QQ管理", +) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Update api set guild exp.""" + ok, err, parsed = _to_int(exp, "公会经验", 0) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + old = guild.exp + guild.exp = parsed + level_ups = _apply_level_ups(guild) + guild.add_log(f"{_actor(actor)} 将公会经验从 {old} 修改为 {parsed}") + guild.add_audit_log( + "guild_set_exp", + _actor(actor), + detail=f"{old}->{parsed}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + suffix = f",触发升级到 {level_ups[-1]} 级" if level_ups else "" + return True, f"已设置 {guild.name} 经验为 {guild.exp}{suffix}", _guild_summary(guild) + + +def api_transfer_guild_owner(self, + guild_query: str, + new_owner: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Run api transfer guild owner.""" + target_name = str(new_owner or "").strip() + if not target_name: + return False, "新会长不能为空", None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + target = guild.get_member(target_name) + if target is None: + target = GuildMember( + name=target_name, + rank=GuildRank.MEMBER, + join_time=_now()) + guild.members.append(target) + for member in guild.members: + if member.rank == GuildRank.OWNER and member.name != target_name: + member.rank = GuildRank.DEPUTY + target.rank = GuildRank.OWNER + old_owner = guild.owner + guild.owner = target.name + guild.add_log(f"{_actor(actor)} 强制将会长从 {old_owner} 转让给 {target.name}") + guild.add_audit_log("guild_force_transfer_owner", _actor(actor), + target=target.name, detail=old_owner) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已将 {guild.name} 会长转让给 {target.name}", _guild_summary(guild) + + +def api_force_join_guild(self, + guild_query: str, + player_name: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Force api force join guild.""" + name = str(player_name or "").strip() + if not name: + return False, "玩家名不能为空", None + guilds = _load_guilds(self) + target_guild, err = _find_guild(self, guild_query, guilds) + if target_guild is None: + return False, err, None + old_guild, _ = _find_player_guild(self, name, guilds) + if old_guild and old_guild.guild_id == target_guild.guild_id: + return True, f"{name} 已在公会 {target_guild.name}", _guild_summary(target_guild) + if old_guild: + old_guild.members = [ + member for member in old_guild.members if member.name != name] + old_guild.add_log(f"{_actor(actor)} 强制移出 {name}") + old_guild.add_audit_log("guild_force_leave", _actor( + actor), target=name, detail=target_guild.name) + target_guild.members.append(GuildMember( + name=name, rank=GuildRank.MEMBER, join_time=_now())) + target_guild.add_log(f"{_actor(actor)} 强制加入成员 {name}") + target_guild.add_audit_log("guild_force_join", _actor(actor), target=name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已将 {name} 加入公会 {target_guild.name}", _guild_summary(target_guild) + + +def api_force_leave_guild(self, + player_name: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Force api force leave guild.""" + name = str(player_name or "").strip() + if not name: + return False, "玩家名不能为空", None + guilds = _load_guilds(self) + guild, err = _find_player_guild(self, name, guilds) + if guild is None: + return False, err, None + guild.members = [member for member in guild.members if member.name != name] + if not guild.members: + old_name = guild.name + del guilds[guild.guild_id] + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已移除 {name},公会 {old_name} 已无成员并被解散", None + if guild.owner == name or not any( + member.rank == GuildRank.OWNER for member in guild.members): + new_owner = guild.members[0] + new_owner.rank = GuildRank.OWNER + guild.owner = new_owner.name + guild.add_log(f"{_actor(actor)} 强制移出成员 {name}") + guild.add_audit_log("guild_force_leave", _actor(actor), target=name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已将 {name} 移出公会 {guild.name}", _guild_summary(guild) + + +def api_force_kick_member(self, + player_name: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Force api force kick member.""" + return api_force_leave_guild(self, player_name, actor) + + +def api_set_guild_frozen(self, + guild_query: str, + frozen: bool, + reason: str = "", + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Update api set guild frozen.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + settings = _ensure_settings(guild) + settings["frozen"] = bool(frozen) + settings["frozen_reason"] = str(reason or "") + settings["frozen_at"] = _now() if frozen else 0 + action = "冻结" if frozen else "解冻" + guild.add_log(f"{_actor(actor)} {action}了公会") + guild.add_audit_log("guild_freeze" if frozen else "guild_unfreeze", + _actor(actor), detail=str(reason or "")) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已{action}公会 {guild.name}", _guild_summary(guild) + + +def api_get_guild_vault( + self, guild_query: str) -> tuple[bool, str, Optional[list[dict[str, Any]]]]: + """Return api get guild vault.""" + guild, err = _find_guild(self, guild_query) + if guild is None: + return False, err, None + data = [_vault_item_summary(item, index) + for index, item in enumerate(guild.vault_items, start=1)] + return True, f"{guild.name} 仓库共有 {len(data)} 件上架物品", data + + +def api_backup_guild_vault(self, + guild_query: str, + label: str = "", + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Back up api backup guild vault.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + settings = _ensure_settings(guild) + backups = settings.setdefault("vault_backups", []) + if not isinstance(backups, list): + backups = [] + settings["vault_backups"] = backups + backup = { + "label": str(label or ""), + "actor": _actor(actor), + "created_at": _now(), + "items": [item.to_dict() for item in guild.vault_items], + } + backups.insert(0, backup) + settings["vault_backups"] = backups[:10] + guild.add_audit_log("vault_backup", _actor(actor), detail=str(label or "")) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已备份 {guild.name} 仓库,当前保留 {len(settings['vault_backups'])} 份", backup + + +def api_clear_guild_vault(self, + guild_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Clear api clear guild vault.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + api_backup_guild_vault(self, guild.guild_id, "clear-before", actor) + guilds = _load_guilds(self) + guild = guilds[guild.guild_id] + removed = len(guild.vault_items) + guild.vault_items = [] + guild.add_log(f"{_actor(actor)} 清空了公会仓库") + guild.add_audit_log( + "vault_clear", + _actor(actor), + detail=f"removed={removed}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已清空 {guild.name} 仓库,共删除 {removed} 件物品", _guild_summary(guild) + + +def api_delete_guild_vault_item(self, + guild_query: str, + index: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Delete api delete guild vault item.""" + ok, err, parsed = _to_int(index, "仓库序号", 1) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + zero_index = parsed - 1 + if zero_index >= len(guild.vault_items): + return False, f"仓库序号超出范围:{parsed}", None + item = guild.vault_items.pop(zero_index) + guild.add_vault_trade_log( + "admin_delete", + item, + _actor(actor), + detail="管理员删除") + guild.add_audit_log("vault_item_delete", _actor( + actor), target=item.seller, detail=item.item_id) + guild.add_log(f"{_actor(actor)} 删除了仓库物品 {item.item_id} x{item.count}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已删除 {guild.name} 仓库第 {parsed} 件物品", _vault_item_summary( + item, parsed) + + +def api_rollback_guild_vault(self, + guild_query: str, + backup_index: int = 1, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Run api rollback guild vault.""" + ok, err, parsed = _to_int(backup_index, "备份序号", 1) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + backups = _ensure_settings(guild).get("vault_backups", []) + if not isinstance(backups, list) or parsed > len(backups): + return False, f"仓库备份不存在:{parsed}", None + selected_backup = copy.deepcopy(backups[parsed - 1]) + api_backup_guild_vault(self, guild.guild_id, "rollback-before", actor) + guilds = _load_guilds(self) + guild = guilds[guild.guild_id] + guild.vault_items = [ + VaultItem.from_dict(item) + for item in selected_backup.get("items", []) + if isinstance(item, dict) + ] + guild.add_log(f"{_actor(actor)} 回滚了公会仓库") + guild.add_audit_log("vault_rollback", _actor(actor), detail=str(parsed)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已回滚 {guild.name} 仓库到备份 {parsed}", { + "guild": _guild_summary(guild), + "backup": selected_backup, + } + + +def api_export_guild_vault( + self, guild_query: str) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Export api export guild vault.""" + guild, err = _find_guild(self, guild_query) + if guild is None: + return False, err, None + data = { + "guild": _guild_summary(guild), "vault_items": [ + _vault_item_summary( + item, index) for index, item in enumerate( + guild.vault_items, start=1)], "vault_trade_logs": [ + log.to_dict() for log in guild.vault_trade_logs], } + data["json"] = json.dumps(data, ensure_ascii=False, indent=2) + return True, f"已导出 {guild.name} 仓库数据", data + + +def _make_task_from_template( + template: dict[str, Any], prefix: str = "auto") -> GuildTask: + """Create a guild task from a configured template.""" + now = _now() + deadline_seconds = int(getattr(Config, "GUILD_TASK_CONFIG", + {}).get("自动任务默认有效期秒", 172800)) + return GuildTask( + task_id=f"{prefix} -{uuid.uuid4().hex[: 8]} ", + name=str(template.get("name", "公会任务"))[: 20], + description=str(template.get("description", ""))[: 100], + task_type=str(template.get("task_type", "trade")), + target=str(template.get("target", "trade_count")), + target_count=max(1, int(template.get("target_count", 1))), + current_count=max(0, int(template.get("current_count", 0))), + reward_exp=max(0, int(template.get("reward_exp", 0))), + reward_contribution=max( + 0, int(template.get("reward_contribution", 0))), + create_time=now, deadline=now + deadline_seconds + if deadline_seconds > 0 else 0,) + + +def api_refresh_guild_tasks( + self, + guild_query: str, + actor: str = "QQ管理", +) -> tuple[bool, str, list[dict[str, Any]]]: + """Run api refresh guild tasks.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, [] + templates = getattr(Config, "GUILD_TASK_CONFIG", {}).get("自动任务模板列表", []) + if not templates: + return False, "没有配置自动任务模板", [] + active_keys = {(task.name, task.task_type, task.target) + for task in guild.tasks if not task.completed} + created: list[GuildTask] = [] + for template in templates: + key = ( + template.get("name"), + template.get("task_type"), + template.get("target")) + if key in active_keys: + continue + task = _make_task_from_template(template) + guild.tasks.append(task) + created.append(task) + guild.add_log(f"{_actor(actor)} 刷新了公会任务,新增 {len(created)} 个") + guild.add_audit_log( + "task_refresh", + _actor(actor), + detail=str( + len(created))) + if created and not _save_guilds(self, guilds): + return False, "保存公会数据失败", [] + return True, f"已为 {guild.name} 刷新任务,新增 {len(created)} 个", [ + _task_summary(task) for task in created] + + +def api_create_global_task( + self, + name: str, + task_type: str, + target: str, + target_count: int, + reward_exp: int = 0, + reward_contribution: int = 0, + description: str = "", + deadline_seconds: int = 0, + actor: str = "QQ管理", +) -> tuple[bool, str, list[dict[str, Any]]]: + """Create api create global task.""" + task_name = str(name or "").strip() + if not task_name: + return False, "任务名称不能为空", [] + ok, err, count = _to_int(target_count, "目标数量", 1) + if not ok: + return False, err, [] + _, _, exp = _to_int(reward_exp, "经验奖励", 0) + _, _, contribution = _to_int(reward_contribution, "贡献奖励", 0) + _, _, seconds = _to_int(deadline_seconds, "截止秒数", 0) + guilds = _load_guilds(self) + now = _now() + created: list[dict[str, Any]] = [] + for guild in guilds.values(): + task = GuildTask( + task_id=f"global-{uuid.uuid4().hex[:8]}", + name=task_name[:20], + description=str(description or task_name)[:100], + task_type=str(task_type or "trade"), + target=str(target or "trade_count"), + target_count=count, + reward_exp=exp, + reward_contribution=contribution, + create_time=now, + deadline=now + seconds if seconds > 0 else 0, + ) + guild.tasks.append(task) + guild.add_log(f"{_actor(actor)} 创建了全服任务 {task.name}") + created.append({"guild_id": guild.guild_id, + "guild_name": guild.name, "task": _task_summary(task)}) + if created and not _save_guilds(self, guilds): + return False, "保存公会数据失败", [] + return True, f"已向 {len(created)} 个公会创建全服任务", created + + +def api_delete_guild_task(self, + guild_query: str, + task_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Delete api delete guild task.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + task, err = _find_task(guild, task_query) + if task is None: + return False, err, None + guild.tasks = [ + item for item in guild.tasks if item.task_id != task.task_id] + guild.add_log(f"{_actor(actor)} 删除了任务 {task.name}") + guild.add_audit_log("task_delete", _actor(actor), detail=task.name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已删除任务 {task.name}", _task_summary(task) + + +def api_reset_guild_task_progress(self, + guild_query: str, + task_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Reset api reset guild task progress.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + task, err = _find_task(guild, task_query) + if task is None: + return False, err, None + task.current_count = 0 + task.completed = False + task.participants = [] + guild.add_log(f"{_actor(actor)} 重置了任务 {task.name}") + guild.add_audit_log("task_reset", _actor(actor), detail=task.name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已重置任务 {task.name}", _task_summary(task) + + +def api_force_complete_guild_task(self, + guild_query: str, + task_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Force api force complete guild task.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + task, err = _find_task(guild, task_query) + if task is None: + return False, err, None + task.current_count = task.target_count + task.completed = True + guild.add_log(f"{_actor(actor)} 强制完成了任务 {task.name}") + guild.add_audit_log("task_force_complete", _actor(actor), detail=task.name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已强制完成任务 {task.name}", _task_summary(task) + + +def api_teleport_player_to_guild_base( + self, player_name: str, guild_query: str | None = None) -> tuple[bool, str]: + """Run api teleport player to guild base.""" + name = str(player_name or "").strip() + if not name: + return False, "玩家名不能为空" + guilds = _load_guilds(self) + if guild_query: + guild, err = _find_guild(self, guild_query, guilds) + else: + guild, err = _find_player_guild(self, name, guilds) + if guild is None: + return False, err + if _ensure_settings(guild).get("base_locked", False): + return False, f"公会 {guild.name} 据点已锁定" + if not guild.base: + return False, f"公会 {guild.name} 尚未设置据点" + base = guild.base + self.game_ctrl.sendwocmd( + f"tp {name} {float(base.x)} {float(base.y)} {float(base.z)}") + return True, f"已传送 {name} 到公会 {guild.name} 据点" + + +def api_delete_guild_base(self, + guild_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Delete api delete guild base.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + guild.base = None + guild.add_log(f"{_actor(actor)} 删除了公会据点") + guild.add_audit_log("base_delete", _actor(actor)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已删除 {guild.name} 据点", _guild_summary(guild) + + +def api_set_guild_base(self, + guild_query: str, + dimension: int, + x: float, + y: float, + z: float, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Update api set guild base.""" + ok, err, dim = _to_int(dimension, "维度") + if not ok: + return False, err, None + coord_values = [] + for field_name, value in (("x", x), ("y", y), ("z", z)): + ok, err, parsed = _to_float(value, field_name) + if not ok: + return False, err, None + coord_values.append(parsed) + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + guild.base = GuildBase( + dim, + coord_values[0], + coord_values[1], + coord_values[2]) + guild.add_log(f"{_actor(actor)} 修改了公会据点") + guild.add_audit_log("base_set", _actor( + actor), detail=f"{dim},{coord_values}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已设置 {guild.name} 据点", _guild_summary(guild) + + +def api_set_guild_base_locked(self, + guild_query: str, + locked: bool, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Update api set guild base locked.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + _ensure_settings(guild)["base_locked"] = bool(locked) + action = "锁定" if locked else "解锁" + guild.add_log(f"{_actor(actor)} {action}了公会据点") + guild.add_audit_log( + "base_lock" if locked else "base_unlock", + _actor(actor)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已{action} {guild.name} 据点", _guild_summary(guild) + + +def api_clear_guild_effects(self, + guild_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Clear api clear guild effects.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + guild.purchased_effects = {} + guild.add_log(f"{_actor(actor)} 清空了公会效果") + guild.add_audit_log("effect_clear", _actor(actor)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已清空 {guild.name} 效果", _guild_summary(guild) + + +def api_set_guild_effect(self, + guild_query: str, + effect_key: str, + level: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Update api set guild effect.""" + key = str(effect_key or "").strip() + if key not in Config.EFFECTS_CONFIG: + names = { + str(value.get("name", "")): effect_id + for effect_id, value in Config.EFFECTS_CONFIG.items() + if isinstance(value, dict) + } + key = names.get(key, key) + if key not in Config.EFFECTS_CONFIG: + return False, f"效果不存在:{effect_key}", None + ok, err, parsed_level = _to_int(level, "效果等级", 0) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + if parsed_level <= 0: + guild.purchased_effects.pop(key, None) + else: + guild.purchased_effects[key] = parsed_level + guild.add_log(f"{_actor(actor)} 设置效果 {key} 为 {parsed_level} 级") + guild.add_audit_log( + "effect_set", + _actor(actor), + detail=f"{key}={parsed_level}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已设置 {guild.name} 效果 {key} 为 {parsed_level} 级", _guild_summary(guild) + + +def api_add_guild_funds(self, + guild_query: str, + amount: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Add api add guild funds.""" + ok, err, parsed = _to_int(amount, "资金数量") + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + settings = _ensure_settings(guild) + settings["funds"] = int(settings.get("funds", 0) or 0) + parsed + guild.add_log(f"{_actor(actor)} 调整公会资金 {parsed:+d}") + guild.add_audit_log("funds_add", _actor(actor), detail=str(parsed)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已调整 {guild.name} 资金,当前 {settings['funds']}", _guild_summary(guild) + + +def api_set_guild_funds(self, + guild_query: str, + amount: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Update api set guild funds.""" + ok, err, parsed = _to_int(amount, "资金余额", 0) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + _ensure_settings(guild)["funds"] = parsed + guild.add_log(f"{_actor(actor)} 设置公会资金为 {parsed}") + guild.add_audit_log("funds_set", _actor(actor), detail=str(parsed)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已设置 {guild.name} 资金为 {parsed}", _guild_summary(guild) + + +def api_add_member_contribution(self, + player_name: str, + amount: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Add api add member contribution.""" + ok, err, parsed = _to_int(amount, "贡献值") + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_player_guild(self, player_name, guilds) + if guild is None: + return False, err, None + member = guild.get_member(str(player_name).strip()) + if member is None: + return False, "成员数据异常", None + member.contribution += parsed + guild.stats.total_contribution += max(0, parsed) + guild.add_log(f"{_actor(actor)} 调整 {member.name} 贡献 {parsed:+d}") + guild.add_audit_log("member_contribution_add", _actor(actor), + target=member.name, detail=str(parsed)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已调整 {member.name} 贡献,当前 {member.contribution}", _member_summary(member) + + +def api_set_member_contribution(self, + player_name: str, + amount: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Update api set member contribution.""" + ok, err, parsed = _to_int(amount, "贡献值", 0) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_player_guild(self, player_name, guilds) + if guild is None: + return False, err, None + member = guild.get_member(str(player_name).strip()) + if member is None: + return False, "成员数据异常", None + old = member.contribution + member.contribution = parsed + guild.add_log(f"{_actor(actor)} 将 {member.name} 贡献从 {old} 设置为 {parsed}") + guild.add_audit_log("member_contribution_set", _actor( + actor), target=member.name, detail=f"{old}->{parsed}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已设置 {member.name} 贡献为 {parsed}", _member_summary(member) + + +def api_reset_guild_contributions(self, + guild_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Reset api reset guild contributions.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + for member in guild.members: + member.contribution = 0 + guild.stats.total_contribution = 0 + guild.add_log(f"{_actor(actor)} 重置了所有成员贡献") + guild.add_audit_log("member_contribution_reset_all", _actor(actor)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已重置 {guild.name} 所有成员贡献", _guild_summary(guild) + + +def api_reset_market_prices(self, + guild_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Reset api reset market prices.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + removed = len(guild.custom_item_values) + guild.custom_item_values = {} + guild.add_log(f"{_actor(actor)} 重置了市场价格") + guild.add_audit_log( + "market_price_reset", + _actor(actor), + detail=str(removed)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已重置 {guild.name} 市场价格,删除 {removed} 条自定义价格", _guild_summary(guild) + + +def api_get_guild_logs(self, guild_query: str, + limit: int = 20) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Return api get guild logs.""" + ok, _err, parsed_limit = _to_int(limit, "日志数量", 1) + if not ok: + parsed_limit = 20 + guild, err = _find_guild(self, guild_query) + if guild is None: + return False, err, None + return True, "查询成功", { + "logs": guild.logs[-parsed_limit:], + "audit_logs": [log.to_dict() for log in guild.audit_logs[-parsed_limit:]], + "vault_trade_logs": [ + log.to_dict() for log in guild.vault_trade_logs[-parsed_limit:] + ], + } + + +def api_get_abnormal_trades(self, + guild_query: str | None = None, + ratio: float = 3.0) -> tuple[bool, + str, + list[dict[str, + Any]]]: + """Return api get abnormal trades.""" + try: + threshold = float(ratio) + except (TypeError, ValueError): + threshold = 3.0 + guilds = _load_guilds(self) + target_guilds: list[GuildData] + if guild_query: + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, [] + target_guilds = [guild] + else: + target_guilds = list(guilds.values()) + results = [] + for guild in target_guilds: + for log in guild.vault_trade_logs: + suggested = guild.get_item_value( + log.item_id) * max(1, int(log.count or 1)) + if suggested > 0 and log.price >= suggested * threshold: + data = log.to_dict() + data["guild_id"] = guild.guild_id + data["guild_name"] = guild.name + data["suggested_price"] = suggested + data["ratio"] = round(log.price / suggested, 2) + results.append(data) + results.sort(key=lambda item: item.get("timestamp", 0), reverse=True) + return True, f"共 {len(results)} 条异常交易记录", results + + +def api_get_donation_rankings(self, + guild_query: str | None = None, + limit: int = 10) -> tuple[bool, + str, + list[dict[str, + Any]]]: + """Return api get donation rankings.""" + ok, _err, parsed_limit = _to_int(limit, "排行数量", 1) + if not ok: + parsed_limit = 10 + guilds = _load_guilds(self) + records = [] + for guild in guilds.values(): + if guild_query and guild.name != guild_query and guild.guild_id != guild_query: + continue + for member in guild.members: + records.append({ + "guild_id": guild.guild_id, + "guild_name": guild.name, + "player_name": member.name, + "rank": member.rank.value, + "contribution": member.contribution, + }) + records.sort(key=lambda item: int(item["contribution"]), reverse=True) + return True, f"贡献排行前 {min(parsed_limit, len(records))} 名", records[:parsed_limit] + + +def api_get_guild_rankings(self, sort_by: str = "level", + limit: int = 10) -> tuple[bool, str, list[dict[str, Any]]]: + """返回适合外部插件消费的公会排行榜数据。""" + raw_sort_by = str(sort_by or "level").strip() + sort_key = { + "等级": "level", + "level": "level", + "成员": "members", + "members": "members", + "贡献": "contribution", + "contribution": "contribution", + "活跃": "activity", + "activity": "activity", + }.get(raw_sort_by, raw_sort_by) + ok, _err, parsed_limit = _to_int(limit, "排行数量", 1) + if not ok: + parsed_limit = 10 + rankings = _get_guild_rankings(self, sort_key)[:parsed_limit] + data: list[dict[str, Any]] = [] + for index, (guild, score) in enumerate(rankings, start=1): + item = _guild_summary(guild) + item["rank"] = index + item["score"] = score + item["sort_by"] = sort_key + data.append(item) + return True, f"公会排行前 {len(data)} 名", data + + +def api_reload_guild_config(self) -> tuple[bool, str, dict[str, Any]]: + """Reload api reload guild config.""" + self.config = Config.load(self.name, self.version) + return True, "公会系统配置已重新加载", copy.deepcopy(self.config) + + +def api_save_guild_data(self) -> tuple[bool, str]: + """Save api save guild data.""" + guilds = _load_guilds(self) + if not _save_guilds(self, guilds, force=True): + return False, "保存公会数据失败" + return True, "公会数据已强制保存" + + +def api_backup_guild_data(self) -> tuple[bool, str, Optional[str]]: + """Back up api backup guild data.""" + if not os.path.exists(self.guilds_file): + return False, "公会数据文件不存在", None + data_dir = os.path.dirname(self.guilds_file) + backup_dir = os.path.join(data_dir, "公会数据备份") + os.makedirs(backup_dir, exist_ok=True) + backup_path = os.path.join( + backup_dir, f"公会数据文件-api-{time.strftime('%Y%m%d-%H%M%S')}.json") + shutil.copy2(self.guilds_file, backup_path) + return True, "公会数据备份已创建", backup_path + + +def api_repair_guild_data( # skipcq: PY-R1000 + self, actor: str = "QQ管理") -> tuple[bool, str, dict[str, Any]]: + """Repair api repair guild data.""" + guilds = _load_guilds(self) + fixed = {"guild_id": 0, "level": 0, "exp": 0, + "owner": 0, "vault": 0, "removed_empty": 0} + for outer_id in list(guilds.keys()): + guild = guilds[outer_id] + if not guild.members: + del guilds[outer_id] + fixed["removed_empty"] += 1 + continue + if guild.guild_id != outer_id: + guild.guild_id = outer_id + fixed["guild_id"] += 1 + if not isinstance(guild.level, int) or guild.level < 1: + guild.level = 1 + fixed["level"] += 1 + if not isinstance(guild.exp, (int, float)) or guild.exp < 0: + guild.exp = 0 + fixed["exp"] += 1 + owners = [member for member in guild.members + if member.rank == GuildRank.OWNER] + if len(owners) != 1: + preferred = guild.get_member(guild.owner) or guild.members[0] + for member in guild.members: + member.rank = GuildRank.MEMBER + preferred.rank = GuildRank.OWNER + guild.owner = preferred.name + fixed["owner"] += 1 + if len(guild.vault_items) > Config.VAULT_INITIAL_SLOTS: + guild.vault_items = guild.vault_items[:Config.VAULT_INITIAL_SLOTS] + fixed["vault"] += 1 + if not isinstance(guild.stats, GuildStats): + guild.stats = GuildStats() + guild.add_audit_log("data_repair", _actor( + actor), detail=json.dumps(fixed, ensure_ascii=False)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", fixed + return True, "公会数据修复完成", fixed + + +def api_get_guild_statistics(self) -> tuple[bool, str, dict[str, Any]]: + """Return api get guild statistics.""" + guilds = _load_guilds(self) + total_members = sum(len(guild.members) for guild in guilds.values()) + total_vault_items = sum(len(guild.vault_items) + for guild in guilds.values()) + total_tasks = sum(len(guild.tasks) for guild in guilds.values()) + active_tasks = sum( + len([task for task in guild.tasks if not task.completed]) + for guild in guilds.values()) + frozen_count = sum(1 for guild in guilds.values() + if _ensure_settings(guild).get("frozen", False)) + data = { + "guild_count": len(guilds), + "member_count": total_members, + "vault_item_count": total_vault_items, + "task_count": total_tasks, + "active_task_count": active_tasks, + "frozen_guild_count": frozen_count, + "activity_status": api_get_guild_activity_status(self)[2], + } + return True, "查询成功", data + + +def api_start_guild_activity( + self, + activity: str, + duration_seconds: int = 3600, + multiplier: float = 2.0, + actor: str = "QQ管理", +) -> tuple[bool, str, dict[str, Any]]: + """Start api start guild activity.""" + activity_key = str(activity or "").strip().lower() + aliases = { + "双倍经验": "exp", + "经验": "exp", + "exp": "exp", + "双倍贡献": "contribution", + "贡献": "contribution", + "contribution": "contribution", + "公会争霸": "contest", + "争霸": "contest", + "contest": "contest", + } + activity_key = aliases.get(activity_key, activity_key) + if activity_key not in ("exp", "contribution", "contest"): + return False, f"未知活动类型:{activity}", {} + ok, err, seconds = _to_int(duration_seconds, "活动时长秒", 1) + if not ok: + return False, err, {} + try: + parsed_multiplier = max(1.0, float(multiplier)) + except (TypeError, ValueError): + parsed_multiplier = 2.0 + if not hasattr(self, "_guild_runtime_events"): + self._guild_runtime_events = {} + event = { + "activity": activity_key, + "multiplier": parsed_multiplier, + "started_at": _now(), + "expires_at": _now() + seconds, + "actor": _actor(actor), + } + self._guild_runtime_events[activity_key] = event + return ( + True, + f"已开启 {activity_key} 活动 {seconds} 秒,倍率 {parsed_multiplier}", + copy.deepcopy(event), + ) + + +def api_stop_guild_activity(self, activity: str) -> tuple[bool, str]: + """Stop api stop guild activity.""" + activity_key = str(activity or "").strip().lower() + aliases = {"经验": "exp", "双倍经验": "exp", "贡献": "contribution", + "双倍贡献": "contribution", "争霸": "contest", "公会争霸": "contest"} + activity_key = aliases.get(activity_key, activity_key) + removed = getattr( + self, + "_guild_runtime_events", + {}).pop( + activity_key, + None) + if removed is None: + return False, f"活动未开启:{activity}" + return True, f"已停止活动 {activity_key}" + + +def api_get_guild_activity_status(self) -> tuple[bool, str, dict[str, Any]]: + """Return api get guild activity status.""" + events = getattr(self, "_guild_runtime_events", {}) + now = _now() + active = {} + for key, event in list(events.items()): + expires_at = float(event.get("expires_at", 0) or 0) + if 0 < expires_at <= now: + events.pop(key, None) + continue + active[key] = copy.deepcopy(event) + active[key]["remaining_seconds"] = max( + 0, int(expires_at - now)) if expires_at > 0 else 0 + return True, f"当前 {len(active)} 个活动运行中", active + + +def api_settle_guild_ranking_rewards( + self, + sort_by: str = "level", + top: int = 3, + reward_exp: int = 0, + reward_funds: int = 0, + actor: str = "QQ管理", +) -> tuple[bool, str, list[dict[str, Any]]]: + """Update api settle guild ranking rewards.""" + ok, err, parsed_top = _to_int(top, "排行数量", 1) + if not ok: + return False, err, [] + _, _, exp = _to_int(reward_exp, "经验奖励", 0) + _, _, funds = _to_int(reward_funds, "资金奖励", 0) + guilds = _load_guilds(self) + ranking = _get_guild_rankings(self, sort_by) + rewarded = [] + for guild, score in ranking[:parsed_top]: + latest = guilds.get(guild.guild_id) + if latest is None: + continue + latest.exp += exp + _apply_level_ups(latest) + settings = _ensure_settings(latest) + settings["funds"] = int(settings.get("funds", 0) or 0) + funds + latest.add_log(f"{_actor(actor)} 发放排行榜奖励:经验 {exp},资金 {funds}") + latest.add_audit_log("ranking_reward", _actor(actor), + detail=f"{sort_by}:{score}") + rewarded.append({"guild_id": latest.guild_id, + "guild_name": latest.name, "score": score}) + if rewarded and not _save_guilds(self, guilds): + return False, "保存公会数据失败", [] + return True, f"已结算 {len(rewarded)} 个公会的排行榜奖励", rewarded + + +def api_broadcast_guild_announcement( + self, message: str, actor: str = "QQ管理") -> tuple[bool, str]: + """Broadcast api broadcast guild announcement.""" + text = str(message or "").strip() + if not text: + return False, "公告内容不能为空" + payload = json.dumps( + {"rawtext": [{"text": f"§l§a公会公告 §d>> §r{text}"}]}, ensure_ascii=False) + self.game_ctrl.sendcmd(f"/tellraw @a {payload}") + fmts.print_inf(f"{_actor(actor)} 发布公会全服公告:{text}") + return True, "全服公告已发送" + + +def _get_guild_rankings( + self, sort_by: str = "level") -> list[tuple[GuildData, Any]]: + """Return guild rankings using the runtime implementation.""" + return self.get_guild_rankings(sort_by) + + +guild_api_functions = { + "guild_get_activity_multiplier": guild_get_activity_multiplier, + "guild_apply_reward_multipliers": guild_apply_reward_multipliers, + "guild_is_frozen": guild_is_frozen, + "guild_frozen_message": guild_frozen_message, + "show_guild_frozen": show_guild_frozen, + "api_list_guilds": api_list_guilds, + "api_get_guild": api_get_guild, + "api_get_player_record": api_get_player_record, + "api_get_player_guild_menu_state": api_get_player_guild_menu_state, + "api_get_own_guild_logs": api_get_own_guild_logs, + "api_get_own_guild_vault": api_get_own_guild_vault, + "api_get_own_guild_tasks": api_get_own_guild_tasks, + "api_request_join_guild_as_player": api_request_join_guild_as_player, + "api_leave_guild_as_player": api_leave_guild_as_player, + "api_disband_owned_guild_as_player": api_disband_owned_guild_as_player, + "api_set_announcement_as_player": api_set_announcement_as_player, + "api_join_guild_task_as_player": api_join_guild_task_as_player, + "api_return_to_guild_base_as_player": api_return_to_guild_base_as_player, + "api_force_disband_guild": api_force_disband_guild, + "api_rename_guild": api_rename_guild, + "api_set_guild_level": api_set_guild_level, + "api_set_guild_exp": api_set_guild_exp, + "api_transfer_guild_owner": api_transfer_guild_owner, + "api_force_join_guild": api_force_join_guild, + "api_force_leave_guild": api_force_leave_guild, + "api_force_kick_member": api_force_kick_member, + "api_set_guild_frozen": api_set_guild_frozen, + "api_get_guild_vault": api_get_guild_vault, + "api_backup_guild_vault": api_backup_guild_vault, + "api_clear_guild_vault": api_clear_guild_vault, + "api_delete_guild_vault_item": api_delete_guild_vault_item, + "api_rollback_guild_vault": api_rollback_guild_vault, + "api_export_guild_vault": api_export_guild_vault, + "api_refresh_guild_tasks": api_refresh_guild_tasks, + "api_create_global_task": api_create_global_task, + "api_delete_guild_task": api_delete_guild_task, + "api_reset_guild_task_progress": api_reset_guild_task_progress, + "api_force_complete_guild_task": api_force_complete_guild_task, + "api_teleport_player_to_guild_base": api_teleport_player_to_guild_base, + "api_delete_guild_base": api_delete_guild_base, + "api_set_guild_base": api_set_guild_base, + "api_set_guild_base_locked": api_set_guild_base_locked, + "api_clear_guild_effects": api_clear_guild_effects, + "api_set_guild_effect": api_set_guild_effect, + "api_add_guild_funds": api_add_guild_funds, + "api_set_guild_funds": api_set_guild_funds, + "api_add_member_contribution": api_add_member_contribution, + "api_set_member_contribution": api_set_member_contribution, + "api_reset_guild_contributions": api_reset_guild_contributions, + "api_reset_market_prices": api_reset_market_prices, + "api_get_guild_logs": api_get_guild_logs, + "api_get_abnormal_trades": api_get_abnormal_trades, + "api_get_donation_rankings": api_get_donation_rankings, + "api_get_guild_rankings": api_get_guild_rankings, + "api_reload_guild_config": api_reload_guild_config, + "api_save_guild_data": api_save_guild_data, + "api_backup_guild_data": api_backup_guild_data, + "api_repair_guild_data": api_repair_guild_data, + "api_get_guild_statistics": api_get_guild_statistics, + "api_start_guild_activity": api_start_guild_activity, + "api_stop_guild_activity": api_stop_guild_activity, + "api_get_guild_activity_status": api_get_guild_activity_status, + "api_settle_guild_ranking_rewards": api_settle_guild_ranking_rewards, + "api_broadcast_guild_announcement": api_broadcast_guild_announcement, +} diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" index 81df85b1..94687813 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" @@ -357,24 +357,13 @@ "已有公会提示词": "§c❀ §r你已经有公会了", "快捷创建缺少名称提示词": "§a❀ §r请输入公会名称,例如: 公会创建 我的公会", "快捷创建名称长度无效提示词": "§c❀ §r公会名必须在2-16个字符之间", - "创建公会余额不足提示词": ( - "§c❀ §r创建公会需要 §e{consume}§r 点 " - "§b{scoreboard}§r 计分板积分\n" - "§c❀ §r当前余额: §f{balance}" - ), - "创建公会提示词": ( - "§a❀ §r创建公会将消耗 §e{consume} §b{scoreboard} \n" - "§a❀ §r当前余额: §f{balance}\n" - "§a❀ §r输入 §a确认§7 继续创建,输入 §cq§7 取消" - ), + "创建公会余额不足提示词": "§c❀ §r创建公会需要 §e{consume}§r 点 §b{scoreboard}§r 计分板积分\n§c❀ §r当前余额: §f{balance}", + "创建公会提示词": "§a❀ §r创建公会将消耗 §e{consume} §b{scoreboard} \n§a❀ §r当前余额: §f{balance}\n§a❀ §r输入 §a确认§7 继续创建,输入 §cq§7 取消", "创建公会回复超时提示词": "§c❀ §r回复超时,已取消创建公会", "创建公会取消提示词": "§c❀ §r已取消创建公会", "创建公会输入名称提示词": "§a❀ §r请输入公会名字:\n§a❀ §r要求: 2-20个字符,不能包含特殊符号", "创建公会名称无效提示词": "§c❀ §r{error}", - "创建公会二次余额不足提示词": ( - "§c❀ §r当前 §b{scoreboard}§r 余额不足,需要 " - "§e{consume}§r,当前 §f{balance}" - ), + "创建公会二次余额不足提示词": "§c❀ §r当前 §b{scoreboard}§r 余额不足,需要 §e{consume}§r,当前 §f{balance}", "创建公会成功提示词": "§a❀ §r已创建公会 §e{guild}", "创建公会全服公告提示词": "§a❀ §r§e{player}§r 创建了公会 §e{guild}§r!", "创建公会名称已存在提示词": "§c❀ §r该公会名已存在", diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" index f9747fff..2121e4e8 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" @@ -1,429 +1,428 @@ -"""Guild data persistence and cache management.""" - -import os -import shutil -import time -from typing import Dict, List, Optional, Tuple, Any, Set - - -from tooldelta import fmts -from tooldelta.utils import tempjson -from guild_cloud_interop.config import Config -from guild_cloud_interop.models import GuildData, GuildMember, GuildRank -from guild_cloud_interop.service import DataTransaction - -# FIRE 公会管理器 FIRE - - -class GuildManager: - """公会管理器,负责公会数据的增删改查""" - - def __init__(self, file_path: str): - self.file_path = file_path - self._cache: Optional[Dict[str, GuildData]] = None - self._last_load_time = 0 - self.cache_duration = Config.CACHE_DURATION - self._player_guild_cache: Dict[str, str] = {} - self._dirty_guilds: Set[str] = set() # 标记需要保存的公会 - self._last_save_time = 0 - self._batch_operations: List[callable] = [] # 批量操作队列 - - def validate_guild_data( - self, guild_data: GuildData) -> Tuple[bool, List[str]]: - """验证公会数据的完整性""" - _ = self - errors = [] - - # 检查基本字段 - if not guild_data.name: - errors.append("公会名称为空 Err:event.check.data.guild_name") - - if not guild_data.guild_id: - errors.append("公会ID为空 Err:event.check.data.guild_id") - - if not guild_data.members: - errors.append("公会没有成员 Msg:event.check.data.member_length_LMIN") - - # 检查会长 - owners = [m for m in guild_data.members if m.rank == GuildRank.OWNER] - if len(owners) != 1: - errors.append( - f"公会应该有且仅有一个会长,当前有{ - len(owners)}个 Err:event.check.data.owner_length_LMAX") - - # 检查成员数量 - if len(guild_data.members) > Config.MAX_GUILD_MEMBERS: - member_count = len(guild_data.members) - errors.append( - f"成员数量超过限制:{member_count}/{Config.MAX_GUILD_MEMBERS} " - "Err:event.check.data.member_length_LMAX" - ) - - # 检查仓库物品 - if len(guild_data.vault_items) > Config.VAULT_INITIAL_SLOTS: - vault_count = len(guild_data.vault_items) - errors.append( - f"仓库物品超过容量:{vault_count}/{Config.VAULT_INITIAL_SLOTS} " - "Err:event.check.data.vault_items_LMAX" - ) - - # 检查数据类型 - if not isinstance(guild_data.exp, (int, float)) or guild_data.exp < 0: - errors.append("公会经验值无效 Err:event.check.data.exp_type_or_negative") - - if not isinstance(guild_data.level, int) or guild_data.level < 1: - errors.append("公会等级无效 Err:event.check.data.level_type_or_range") - - return len(errors) == 0, errors - - def create_transaction(self): - """创建数据事务""" - return DataTransaction(self) - - def _load_guilds(self, force_reload: bool = False) -> Dict[str, GuildData]: - """加载公会数据,带缓存""" - current_time = time.time() - if (not force_reload and self._cache is not None and - current_time - self._last_load_time < self.cache_duration): - return self._cache - - raw_data = tempjson.load_and_read( - self.file_path, need_file_exists=False, default={}) - self._cache = {} - self._player_guild_cache.clear() - - load_errors = [] - for guild_id, guild_dict in raw_data.items(): - try: - guild = GuildData.from_dict(guild_dict, outer_key=guild_id) - - # 验证数据完整性 - is_valid, errors = self.validate_guild_data(guild) - if not is_valid: - load_errors.extend( - [f"公会{guild_id}: {error}" for error in errors]) - continue - - self._cache[guild_id] = guild - # 更新玩家-公会缓存 - for member in guild.members: - self._player_guild_cache[member.name] = guild_id - except Exception as e: - load_errors.append(f"加载公会 {guild_id} 时出错: {e}") - continue - - # 记录加载错误 - if load_errors: - fmts.print_err(f"加载公会数据时发现 {len(load_errors)} 个错误") - for error in load_errors[:3]: # 只显示前3个错误 - fmts.print_err(f" - {error}") - if len(load_errors) > 3: - fmts.print_err(f" ... 还有 {len(load_errors) - 3} 个错误") - - self._last_load_time = current_time - return self._cache - - def load_guilds(self, force_reload: bool = False) -> Dict[str, GuildData]: - """Return guild data through the manager cache boundary.""" - return self._load_guilds(force_reload=force_reload) - - def rebuild_player_cache(self, guilds: Dict[str, GuildData]) -> None: - """Rebuild the player-to-guild lookup from the provided guild map.""" - self._player_guild_cache.clear() - for guild_id, guild in guilds.items(): - for member in guild.members: - self._player_guild_cache[member.name] = guild_id - - def cache_player_guild(self, player_name: str, guild_id: str) -> None: - """Record one player's guild id in the runtime cache.""" - self._player_guild_cache[player_name] = guild_id - - def get_cached_guild_id(self, player_name: str) -> Optional[str]: - """Return the cached guild id for a player, if present.""" - return self._player_guild_cache.get(player_name) - - def reset_runtime_state(self) -> None: - """Clear in-memory guild caches and pending save state.""" - self._cache = None - self._player_guild_cache.clear() - self._dirty_guilds.clear() - self._last_load_time = 0 - self._last_save_time = 0 - - def save_guilds(self, - guilds: Dict[str, - GuildData], - force: bool = False) -> bool: - """保存公会数据,支持批量保存优化""" - try: - current_time = time.time() - - # 如果不是强制保存且距离上次保存时间不足,则延迟保存 - if ( - not force - and current_time - self._last_save_time < Config.BATCH_SAVE_INTERVAL - ): - self._dirty_guilds.update(guilds.keys()) - return True - - # 确保数据目录存在 - data_dir = os.path.dirname(self.file_path) - if not os.path.exists(data_dir): - try: - os.makedirs(data_dir) - fmts.print_inf(f"创建公会系统数据目录: {data_dir}") - except Exception as e: - fmts.print_err(f"创建公会系统数据目录失败: {e}") - return False - - self._backup_before_save(force=force) - - raw_data = {} - for gid, guild in guilds.items(): - try: - guild_dict = guild.to_dict() - raw_data[gid] = guild_dict - except Exception as e: - fmts.print_err(f"转换公会 {gid} 数据时出错: {e}") - continue - - # 写入文件 - tempjson.write(self.file_path, raw_data) - if force: - tempjson.flush(self.file_path) - - # 更新缓存和状态 - self._cache = guilds.copy() - self._last_load_time = current_time - self._last_save_time = current_time - self._dirty_guilds.clear() - - return True - - except Exception as e: - fmts.print_err(f"保存公会数据时出错: {e}") - import traceback - fmts.print_err(traceback.format_exc()) - return False - - def _backup_before_save(self, force: bool = False) -> None: - """按配置在写入前备份当前数据文件。""" - safety_config = getattr(Config, "GUILD_DATA_SAFETY_CONFIG", {}) - if not safety_config.get("启用保存前备份", True): - return - if force and not safety_config.get("强制保存时也备份", True): - return - if not self.file_path or not os.path.exists(self.file_path): - return - - data_dir = os.path.dirname(self.file_path) - backup_dir = os.path.join( - data_dir, safety_config.get( - "备份目录名", "公会数据备份")) - os.makedirs(backup_dir, exist_ok=True) - - timestamp = time.strftime("%Y%m%d-%H%M%S") - backup_path = os.path.join(backup_dir, f"公会数据文件-{timestamp}.json") - shutil.copy2(self.file_path, backup_path) - - max_backups = int(safety_config.get("最大备份数量", 10)) - if max_backups <= 0: - return - - backups = [ - os.path.join(backup_dir, name) - for name in os.listdir(backup_dir) - if name.startswith("公会数据文件-") and name.endswith(".json") - ] - backups.sort(key=os.path.getmtime, reverse=True) - for old_path in backups[max_backups:]: - try: - os.remove(old_path) - except OSError as err: - fmts.print_err(f"删除旧公会备份失败: {err}") - - def mark_guild_dirty(self, guild_id: str) -> None: - """标记公会数据已修改,需要保存""" - self._dirty_guilds.add(guild_id) - - def flush_dirty_guilds(self) -> bool: - """强制保存所有标记为脏的公会数据""" - if not self._dirty_guilds: - return True - - if self._cache is None: - self._load_guilds(force_reload=True) - if self._cache is None: - return False - return self.save_guilds(self._cache, force=True) - - def get_guild_by_player( - self, - player_name: str, - force_reload: bool = False) -> Optional[GuildData]: - """根据玩家名获取其所在公会""" - guilds = self._load_guilds(force_reload=force_reload) - guild_id = self._player_guild_cache.get(player_name) - return guilds.get(guild_id) if guild_id else None - - def get_guild_by_name(self, guild_name: str) -> Optional[GuildData]: - """根据公会名获取公会""" - guilds = self._load_guilds() - for guild in guilds.values(): - if guild.name == guild_name: - return guild - return None - - def create_guild( - self, - owner_xuid: str, - owner_name: str, - guild_name: str) -> bool: - """创建公会""" - guilds = self._load_guilds(force_reload=True) - - # 检查是否已有同名公会 - if any(g.name == guild_name for g in guilds.values()): - return False - - owner_member = GuildMember( - name=owner_name, - rank=GuildRank.OWNER, - join_time=time.time() - ) - - new_guild = GuildData( - guild_id=owner_xuid, - name=guild_name, - owner=owner_name, - members=[owner_member] - ) - new_guild.add_log(f"公会 {guild_name} 成立") - - guilds[owner_xuid] = new_guild - self.save_guilds(guilds) - return True - - def add_member( - self, - guild_name: str, - player_name: str, - inviter: str = None) -> bool: - """添加成员到公会""" - guilds = self._load_guilds(force_reload=True) - guild = self.get_guild_by_name(guild_name) - - if not guild or len(guild.members) >= Config.MAX_GUILD_MEMBERS: - return False - - new_member = GuildMember( - name=player_name, - rank=GuildRank.MEMBER, - join_time=time.time() - ) - - guild.members.append(new_member) - guild.add_log(f"{player_name} 加入公会" + - (f" (邀请人: {inviter})" if inviter else "")) - - # 更新缓存 - self._player_guild_cache[player_name] = guild.guild_id - self.save_guilds(guilds) - return True - - def remove_member(self, player_name: str) -> Optional[str]: - """从公会移除成员,返回公会名""" - guilds = self._load_guilds(force_reload=True) - guild = self.get_guild_by_player(player_name) - - if not guild: - return None - - # 检查是否是会长 - member = guild.get_member(player_name) - if member and member.rank == GuildRank.OWNER: - return None # 会长不能退出公会,只能解散 - - guild.members = [m for m in guild.members if m.name != player_name] - guild.add_log(f"{player_name} 退出公会") - - # 更新缓存 - if player_name in self._player_guild_cache: - del self._player_guild_cache[player_name] - self.save_guilds(guilds) - return guild.name - - def set_member_rank( - self, - guild: GuildData, - player_name: str, - new_rank: GuildRank) -> bool: - """设置成员职位""" - guilds = self._load_guilds(force_reload=True) - current_guild = guilds.get(guild.guild_id) - if not current_guild: - return False - - member = current_guild.get_member(player_name) - - if not member or member.rank == GuildRank.OWNER: - return False - - old_rank = member.rank - member.rank = new_rank - current_guild.add_log( - f"{player_name} 职位变更: {old_rank.display_name} -> {new_rank.display_name}") - - self.save_guilds(guilds, force=True) - return True - - def update_contribution(self, player_name: str, amount: int) -> bool: - """更新成员贡献度""" - guild = self.get_guild_by_player(player_name) - - if not guild: - return False - - member = guild.get_member(player_name) - if member: - member.contribution += amount - guild.stats.total_contribution += amount - self.mark_guild_dirty(guild.guild_id) - return True - return False - - def batch_update_members(self, updates: List[Tuple[str, str, Any]]) -> int: - """批量更新成员信息""" - success_count = 0 - - for player_name, field_name, value in updates: - guild = self.get_guild_by_player(player_name) - if not guild: - continue - - member = guild.get_member(player_name) - if not member: - continue - - if hasattr(member, field_name): - setattr(member, field_name, value) - self.mark_guild_dirty(guild.guild_id) - success_count += 1 - - # 批量保存 - if success_count > 0: - self.flush_dirty_guilds() - - return success_count - - def update_online_status(self, online_players: List[str]) -> None: - """更新在线状态""" - guilds = self._load_guilds(force_reload=True) - current_time = time.time() - - for guild in guilds.values(): - for member in guild.members: - if member.name in online_players: - member.last_online = current_time - - self.save_guilds(guilds) +"""Guild data persistence and cache management.""" + +import os +import shutil +import time +from typing import Dict, List, Optional, Tuple, Any, Set + + +from tooldelta import fmts +from tooldelta.utils import tempjson +from guild_cloud_interop.config import Config +from guild_cloud_interop.models import GuildData, GuildMember, GuildRank +from guild_cloud_interop.service import DataTransaction + +# FIRE 公会管理器 FIRE + + +class GuildManager: + """公会管理器,负责公会数据的增删改查""" + + def __init__(self, file_path: str): + self.file_path = file_path + self._cache: Optional[Dict[str, GuildData]] = None + self._last_load_time = 0 + self.cache_duration = Config.CACHE_DURATION + self._player_guild_cache: Dict[str, str] = {} + self._dirty_guilds: Set[str] = set() # 标记需要保存的公会 + self._last_save_time = 0 + self._batch_operations: List[callable] = [] # 批量操作队列 + + def validate_guild_data( + self, guild_data: GuildData) -> Tuple[bool, List[str]]: + """验证公会数据的完整性""" + _ = self + errors = [] + + # 检查基本字段 + if not guild_data.name: + errors.append("公会名称为空 Err:event.check.data.guild_name") + + if not guild_data.guild_id: + errors.append("公会ID为空 Err:event.check.data.guild_id") + + if not guild_data.members: + errors.append("公会没有成员 Msg:event.check.data.member_length_LMIN") + + # 检查会长 + owners = [m for m in guild_data.members if m.rank == GuildRank.OWNER] + if len(owners) != 1: + errors.append( + f"公会应该有且仅有一个会长,当前有{len(owners)}个 Err:event.check.data.owner_length_LMAX") + + # 检查成员数量 + if len(guild_data.members) > Config.MAX_GUILD_MEMBERS: + member_count = len(guild_data.members) + errors.append( + f"成员数量超过限制:{member_count}/{Config.MAX_GUILD_MEMBERS} " + "Err:event.check.data.member_length_LMAX" + ) + + # 检查仓库物品 + if len(guild_data.vault_items) > Config.VAULT_INITIAL_SLOTS: + vault_count = len(guild_data.vault_items) + errors.append( + f"仓库物品超过容量:{vault_count}/{Config.VAULT_INITIAL_SLOTS} " + "Err:event.check.data.vault_items_LMAX" + ) + + # 检查数据类型 + if not isinstance(guild_data.exp, (int, float)) or guild_data.exp < 0: + errors.append("公会经验值无效 Err:event.check.data.exp_type_or_negative") + + if not isinstance(guild_data.level, int) or guild_data.level < 1: + errors.append("公会等级无效 Err:event.check.data.level_type_or_range") + + return len(errors) == 0, errors + + def create_transaction(self): + """创建数据事务""" + return DataTransaction(self) + + def _load_guilds(self, force_reload: bool = False) -> Dict[str, GuildData]: + """加载公会数据,带缓存""" + current_time = time.time() + if (not force_reload and self._cache is not None and + current_time - self._last_load_time < self.cache_duration): + return self._cache + + raw_data = tempjson.load_and_read( + self.file_path, need_file_exists=False, default={}) + self._cache = {} + self._player_guild_cache.clear() + + load_errors = [] + for guild_id, guild_dict in raw_data.items(): + try: + guild = GuildData.from_dict(guild_dict, outer_key=guild_id) + + # 验证数据完整性 + is_valid, errors = self.validate_guild_data(guild) + if not is_valid: + load_errors.extend( + [f"公会{guild_id}: {error}" for error in errors]) + continue + + self._cache[guild_id] = guild + # 更新玩家-公会缓存 + for member in guild.members: + self._player_guild_cache[member.name] = guild_id + except Exception as e: + load_errors.append(f"加载公会 {guild_id} 时出错: {e}") + continue + + # 记录加载错误 + if load_errors: + fmts.print_err(f"加载公会数据时发现 {len(load_errors)} 个错误") + for error in load_errors[:3]: # 只显示前3个错误 + fmts.print_err(f" - {error}") + if len(load_errors) > 3: + fmts.print_err(f" ... 还有 {len(load_errors) - 3} 个错误") + + self._last_load_time = current_time + return self._cache + + def load_guilds(self, force_reload: bool = False) -> Dict[str, GuildData]: + """Return guild data through the manager cache boundary.""" + return self._load_guilds(force_reload=force_reload) + + def rebuild_player_cache(self, guilds: Dict[str, GuildData]) -> None: + """Rebuild the player-to-guild lookup from the provided guild map.""" + self._player_guild_cache.clear() + for guild_id, guild in guilds.items(): + for member in guild.members: + self._player_guild_cache[member.name] = guild_id + + def cache_player_guild(self, player_name: str, guild_id: str) -> None: + """Record one player's guild id in the runtime cache.""" + self._player_guild_cache[player_name] = guild_id + + def get_cached_guild_id(self, player_name: str) -> Optional[str]: + """Return the cached guild id for a player, if present.""" + return self._player_guild_cache.get(player_name) + + def reset_runtime_state(self) -> None: + """Clear in-memory guild caches and pending save state.""" + self._cache = None + self._player_guild_cache.clear() + self._dirty_guilds.clear() + self._last_load_time = 0 + self._last_save_time = 0 + + def save_guilds(self, + guilds: Dict[str, + GuildData], + force: bool = False) -> bool: + """保存公会数据,支持批量保存优化""" + try: + current_time = time.time() + + # 如果不是强制保存且距离上次保存时间不足,则延迟保存 + if ( + not force + and current_time - self._last_save_time < Config.BATCH_SAVE_INTERVAL + ): + self._dirty_guilds.update(guilds.keys()) + return True + + # 确保数据目录存在 + data_dir = os.path.dirname(self.file_path) + if not os.path.exists(data_dir): + try: + os.makedirs(data_dir) + fmts.print_inf(f"创建公会系统数据目录: {data_dir}") + except Exception as e: + fmts.print_err(f"创建公会系统数据目录失败: {e}") + return False + + self._backup_before_save(force=force) + + raw_data = {} + for gid, guild in guilds.items(): + try: + guild_dict = guild.to_dict() + raw_data[gid] = guild_dict + except Exception as e: + fmts.print_err(f"转换公会 {gid} 数据时出错: {e}") + continue + + # 写入文件 + tempjson.write(self.file_path, raw_data) + if force: + tempjson.flush(self.file_path) + + # 更新缓存和状态 + self._cache = guilds.copy() + self._last_load_time = current_time + self._last_save_time = current_time + self._dirty_guilds.clear() + + return True + + except Exception as e: + fmts.print_err(f"保存公会数据时出错: {e}") + import traceback + fmts.print_err(traceback.format_exc()) + return False + + def _backup_before_save(self, force: bool = False) -> None: + """按配置在写入前备份当前数据文件。""" + safety_config = getattr(Config, "GUILD_DATA_SAFETY_CONFIG", {}) + if not safety_config.get("启用保存前备份", True): + return + if force and not safety_config.get("强制保存时也备份", True): + return + if not self.file_path or not os.path.exists(self.file_path): + return + + data_dir = os.path.dirname(self.file_path) + backup_dir = os.path.join( + data_dir, safety_config.get( + "备份目录名", "公会数据备份")) + os.makedirs(backup_dir, exist_ok=True) + + timestamp = time.strftime("%Y%m%d-%H%M%S") + backup_path = os.path.join(backup_dir, f"公会数据文件-{timestamp}.json") + shutil.copy2(self.file_path, backup_path) + + max_backups = int(safety_config.get("最大备份数量", 10)) + if max_backups <= 0: + return + + backups = [ + os.path.join(backup_dir, name) + for name in os.listdir(backup_dir) + if name.startswith("公会数据文件-") and name.endswith(".json") + ] + backups.sort(key=os.path.getmtime, reverse=True) + for old_path in backups[max_backups:]: + try: + os.remove(old_path) + except OSError as err: + fmts.print_err(f"删除旧公会备份失败: {err}") + + def mark_guild_dirty(self, guild_id: str) -> None: + """标记公会数据已修改,需要保存""" + self._dirty_guilds.add(guild_id) + + def flush_dirty_guilds(self) -> bool: + """强制保存所有标记为脏的公会数据""" + if not self._dirty_guilds: + return True + + if self._cache is None: + self._load_guilds(force_reload=True) + if self._cache is None: + return False + return self.save_guilds(self._cache, force=True) + + def get_guild_by_player( + self, + player_name: str, + force_reload: bool = False) -> Optional[GuildData]: + """根据玩家名获取其所在公会""" + guilds = self._load_guilds(force_reload=force_reload) + guild_id = self._player_guild_cache.get(player_name) + return guilds.get(guild_id) if guild_id else None + + def get_guild_by_name(self, guild_name: str) -> Optional[GuildData]: + """根据公会名获取公会""" + guilds = self._load_guilds() + for guild in guilds.values(): + if guild.name == guild_name: + return guild + return None + + def create_guild( + self, + owner_xuid: str, + owner_name: str, + guild_name: str) -> bool: + """创建公会""" + guilds = self._load_guilds(force_reload=True) + + # 检查是否已有同名公会 + if any(g.name == guild_name for g in guilds.values()): + return False + + owner_member = GuildMember( + name=owner_name, + rank=GuildRank.OWNER, + join_time=time.time() + ) + + new_guild = GuildData( + guild_id=owner_xuid, + name=guild_name, + owner=owner_name, + members=[owner_member] + ) + new_guild.add_log(f"公会 {guild_name} 成立") + + guilds[owner_xuid] = new_guild + self.save_guilds(guilds) + return True + + def add_member( + self, + guild_name: str, + player_name: str, + inviter: str = None) -> bool: + """添加成员到公会""" + guilds = self._load_guilds(force_reload=True) + guild = self.get_guild_by_name(guild_name) + + if not guild or len(guild.members) >= Config.MAX_GUILD_MEMBERS: + return False + + new_member = GuildMember( + name=player_name, + rank=GuildRank.MEMBER, + join_time=time.time() + ) + + guild.members.append(new_member) + guild.add_log(f"{player_name} 加入公会" + + (f" (邀请人: {inviter})" if inviter else "")) + + # 更新缓存 + self._player_guild_cache[player_name] = guild.guild_id + self.save_guilds(guilds) + return True + + def remove_member(self, player_name: str) -> Optional[str]: + """从公会移除成员,返回公会名""" + guilds = self._load_guilds(force_reload=True) + guild = self.get_guild_by_player(player_name) + + if not guild: + return None + + # 检查是否是会长 + member = guild.get_member(player_name) + if member and member.rank == GuildRank.OWNER: + return None # 会长不能退出公会,只能解散 + + guild.members = [m for m in guild.members if m.name != player_name] + guild.add_log(f"{player_name} 退出公会") + + # 更新缓存 + if player_name in self._player_guild_cache: + del self._player_guild_cache[player_name] + self.save_guilds(guilds) + return guild.name + + def set_member_rank( + self, + guild: GuildData, + player_name: str, + new_rank: GuildRank) -> bool: + """设置成员职位""" + guilds = self._load_guilds(force_reload=True) + current_guild = guilds.get(guild.guild_id) + if not current_guild: + return False + + member = current_guild.get_member(player_name) + + if not member or member.rank == GuildRank.OWNER: + return False + + old_rank = member.rank + member.rank = new_rank + current_guild.add_log( + f"{player_name} 职位变更: {old_rank.display_name} -> {new_rank.display_name}") + + self.save_guilds(guilds, force=True) + return True + + def update_contribution(self, player_name: str, amount: int) -> bool: + """更新成员贡献度""" + guild = self.get_guild_by_player(player_name) + + if not guild: + return False + + member = guild.get_member(player_name) + if member: + member.contribution += amount + guild.stats.total_contribution += amount + self.mark_guild_dirty(guild.guild_id) + return True + return False + + def batch_update_members(self, updates: List[Tuple[str, str, Any]]) -> int: + """批量更新成员信息""" + success_count = 0 + + for player_name, field_name, value in updates: + guild = self.get_guild_by_player(player_name) + if not guild: + continue + + member = guild.get_member(player_name) + if not member: + continue + + if hasattr(member, field_name): + setattr(member, field_name, value) + self.mark_guild_dirty(guild.guild_id) + success_count += 1 + + # 批量保存 + if success_count > 0: + self.flush_dirty_guilds() + + return success_count + + def update_online_status(self, online_players: List[str]) -> None: + """更新在线状态""" + guilds = self._load_guilds(force_reload=True) + current_time = time.time() + + for guild in guilds.values(): + for member in guild.members: + if member.name in online_players: + member.last_online = current_time + + self.save_guilds(guilds) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" index 3e7c8cff..764ab6b3 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" @@ -1,2474 +1,2425 @@ -"""Interactive guild menu handlers.""" - -# pylint: disable=protected-access - -import json -import time -import uuid -from datetime import datetime - -from tooldelta import Player, game_utils, fmts -from guild_cloud_interop.models import ( - GuildBase, - GuildData, - GuildMember, - GuildRank, - GuildTask, - VaultItem, -) -from guild_cloud_interop.config import Config -from guild_cloud_interop.prompts import render_config_prompt, render_create_guild_prompt -from guild_cloud_interop.ui import ORION_BORDER, TITLE_PREFIX, format_page_footer -from guild_cloud_interop.validators import InputValidator - - -def _handle_effect(self, player: Player) -> bool: # skipcq: PY-R1000 - """Handle guild effect purchases.""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - if not guild.has_permission(player.name, "effect_buy"): - player.show("§l§a公会 §d>> §r你没有购买公会效果权限") - return True - - # 显示可选效果 - player.show("§l§a公会 §d>> §r可用效果列表:") - - for key, val in Config.EFFECTS_CONFIG.items(): - try: - # 检查配置完整性 - if "name" not in val: - fmts.print_err(f"效果配置错误: {key} 缺少 name 字段") - continue - - if "costs" not in val: - fmts.print_err(f"效果配置错误: {key} 缺少 costs 字段") - continue - - purchased_lv = guild.purchased_effects.get(key) - costs_str_list = [] - - for lv, cost in val["costs"].items(): - if purchased_lv == lv: - color = "§b" - else: - color = "§7" - costs_str_list.append(f"{color}Lv{lv}:{cost}钻§r") - - costs_str = " ".join(costs_str_list) - player.show(f"§r§f>> {val['name']} §r({costs_str})") - - except Exception as e: - fmts.print_err(f"处理效果 {key} 时出错: {e}") - continue - - player.show("§r§7>> 输入效果选择升级") - choice = game_utils.waitMsg(player.name) - - effect_key = None - for k, v in Config.EFFECTS_CONFIG.items(): - if v['name'] == choice: - effect_key = k - break - - if not effect_key: - player.show("§c无效效果") - return True - - selected = Config.EFFECTS_CONFIG[effect_key] - - # 判断钻石数量 - diamond_count = player.getItemCount("minecraft:diamond") - player.show(f"§7当前钻石数量: {diamond_count}") - - player.show("§7请输入等级 (1/2/3)") - lv_choice = game_utils.waitMsg(player.name) - if not lv_choice.isdigit() or int(lv_choice) not in selected["costs"]: - player.show("§c无效等级") - return True - - lv_choice = int(lv_choice) - cost = selected["costs"][lv_choice] - - if diamond_count < cost: - player.show(f"§c钻石不足,{cost}钻石才能购买") - return True - - # 扣除钻石 - self.game_ctrl.sendwocmd( - f"clear {player.safe_name} minecraft:diamond 0 {cost}") - - # 保存公会已购买效果 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild_id = guild.guild_id - if guild_id in guilds: - guild = guilds[guild_id] - guild.purchased_effects[effect_key] = lv_choice - guild.add_log( - f"{player.name} 为公会购买了 {selected['name']} 等级{lv_choice} 效果") - self.guild_manager.save_guilds(guilds) - else: - player.show("§c保存失败,公会不存在") - return True - - # 购买后立即给在线成员补发效果,后续由低频兜底刷新,避免周期性全量刷命令。 - for member in guild.members: - if member.name in self.game_ctrl.allplayers: - self._apply_guild_effects_to_player( - member.name, - guild=guild, - force=True, - effect_names={effect_key}, - ) - - player.show(f"§a已激活效果: {selected['name']} 等级{lv_choice}") - - return True - - -def _handle_rankings(self, player: Player) -> bool: - """处理公会排行榜""" - player.show("§r========== §a公会排行榜§r ==========") - player.show("§e1. §f等级排行") - player.show("§e2. §f成员数量排行") - player.show("§e3. §f贡献度排行") - player.show("§e4. §f活跃度排行") - player.show("§7输入选项序号:") - - choice = game_utils.waitMsg(player.name, timeout=30) - - if choice == "1": - rankings = self.get_guild_rankings("level") - title = "公会等级排行榜" - - def formatter(i, data): - """Format one menu item for display.""" - return ( - f"§e{i}. §r{data[0].name} §7Lv.{data[1]}\n" - f" §7会长: §f{data[0].owner} §7| 成员: §a{len(data[0].members)}\n" - ) - elif choice == "2": - rankings = self.get_guild_rankings("members") - title = "公会成员数排行榜" - - def formatter(i, data): - """Format one menu item for display.""" - return ( - f"§e{i}. §r{data[0].name} §7成员: §a{data[1]}\n" - f" §7会长: §f{data[0].owner} §7| 等级: §e{data[0].level}\n" - ) - elif choice == "3": - rankings = self.get_guild_rankings("contribution") - title = "公会贡献度排行榜" - - def formatter(i, data): - """Format one menu item for display.""" - return ( - f"§e{i}. §r{data[0].name} §7贡献: §b{data[1]}\n" - f" §7会长: §f{data[0].owner} §7| 等级: §e{data[0].level}\n" - ) - elif choice == "4": - rankings = self.get_guild_rankings("activity") - title = "公会活跃度排行榜" - - def formatter(i, data): - """Format one menu item for display.""" - return ( - f"§e{i}. §r{data[0].name}\n" - f" §7会长: §f{data[0].owner} " - f"§7| 最近活跃: §a{self._format_time_ago(data[1])}\n" - ) - else: - player.show("§c无效选项") - return True - - if not rankings: - player.show("§l§a公会 §d>> §r暂无公会数据") - return True - - self._paginate_display(player, rankings, title, formatter) - return True - - -def _format_time_ago(self, timestamp: float) -> str: - """格式化时间差显示""" - if self is None: - return "从未" - if timestamp == 0: - return "从未" - - current_time = time.time() - diff = current_time - timestamp - - if diff < 60: - return "刚刚" - if diff < 3600: - return f"{int(diff // 60)}分钟前" - if diff < 86400: - return f"{int(diff // 3600)}小时前" - return f"{int(diff // 86400)}天前" - - -def _handle_view_guild(self, player: Player) -> bool: - """查看公会详细信息""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - - level = guild.level - exp = guild.exp - required_exp = Config.GUILD_LEVEL_EXP.get(level + 1, "MAX") - - msg = f"§l§a{guild.name}§r §7(ID: {guild.guild_id[:8]}...)\n" - msg += f"§7创建时间: §f{ - datetime.fromtimestamp( - guild.create_time).strftime('%Y-%m-%d')}\n" - msg += f"§7会长: §e{guild.owner}\n" - msg += f"§7等级: §e{level} §7经验: §b{exp}/{required_exp}\n" - msg += f"§7成员: §a{len(guild.members)}/{Config.MAX_GUILD_MEMBERS}\n" - msg += f"§7仓库容量: §a{Config.VAULT_INITIAL_SLOTS} 格\n" - - if guild.announcement: - msg += f"\n§e公告: §f{guild.announcement}\n" - - if guild.base: - base = guild.base - dim_name = Config.DIMENSION_NAMES.get( - base.dimension, f"维度{base.dimension}") - msg += f"\n§7据点: §f{dim_name} ({ - base.x:.1f}, { - base.y:.1f}, { - base.z:.1f})" - - player.show(msg) - return True - - -def _handle_view_members(self, player: Player) -> bool: - """查看成员列表""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - - # 按职位和贡献度排序 - sorted_members = sorted( - guild.members, - key=lambda m: ( - ["owner", "deputy", "elder", "member"].index(m.rank.value), - -m.contribution - ) - ) - - def formatter(i, member: GuildMember): - """Format one menu item for display.""" - online = member.name in self.game_ctrl.allplayers - online_status = "§a在线" if online else "§7离线" - days_since_join = (time.time() - member.join_time) / 86400 - - return (f"§e{i}. {member.rank.display_name} §f{member.name} " - f"[{online_status}§f]\n" - f" §7贡献: §b{member.contribution} §7| " - f"加入: §f{int(days_since_join)}天前\n") - - self._paginate_display( - player, sorted_members, f"{ - guild.name} 成员列表", formatter) - return True - - -def _handle_view_logs(self, player: Player) -> bool: - """查看公会日志""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - - msg = f"§r========== §a{guild.name} 日志§r ==========\n" - if guild.logs: - for log in guild.logs[-20:]: - msg += f"§7{log}\n" - else: - msg += "§7暂无普通日志记录\n" - - if guild.has_permission(player.name, "audit_log"): - msg += f"\n§r========== §a{guild.name} 审计日志§r ==========\n" - if guild.audit_logs: - for log in guild.audit_logs[-20:]: - time_str = datetime.fromtimestamp( - log.timestamp).strftime("%m-%d %H:%M") - target = f" §7| 目标: §f{log.target}" if log.target else "" - detail = f" §7| 详情: §f{log.detail}" if log.detail else "" - msg += ( - f"§7[{time_str}] §f{log.action} §7| 操作者: §e{log.actor}" - f"{target}{detail} §7| 结果: §f{log.result}\n" - ) - else: - msg += "§7暂无审计日志记录\n" - - player.show(msg) - return True - - -def _handle_announcement(self, player: Player) -> bool: - """处理公会公告""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - - # 显示当前公告 - if guild.announcement: - player.show(f"§l§a公会公告§r\n§f{guild.announcement}") - else: - player.show("§l§a公会 §d>> §r当前没有公告") - - # 检查权限 - if not guild.has_permission(player.name, "announce"): - return True - - player.show("§7输入 '设置' 来修改公告,其他任意键返回") - choice = game_utils.waitMsg(player.name, timeout=20) - - if choice == "设置": - player.show("§l§a公会 §d>> §r请输入新的公告内容:") - player.show("§7要求: 不超过200个字符,不能为空") - new_announcement = game_utils.waitMsg(player.name, timeout=60) - - # 使用新的输入验证 - is_valid, error_msg = InputValidator.validate_announcement( - new_announcement) - if not is_valid: - player.show(f"§l§a公会 §d>> §r{error_msg}") - return True - - try: - guilds = self.guild_manager.load_guilds(force_reload=True) - guild_data = guilds.get(guild.guild_id) - if not guild_data: - player.show("§l§a公会 §d>> §r公会数据异常") - return True - - guild_data.announcement = new_announcement - guild_data.add_log(f"{player.name} 更新了公告") - self.guild_manager.save_guilds(guilds) - player.show("§l§a公会 §d>> §r公告已更新") - - # 通知在线成员 - message = "§l§a公会 §d>> §r公告已更新,输入 .公会 公告 查看" - for member in guild_data.members: - if member.name in self.game_ctrl.allplayers: - self.game_ctrl.sendcmd( - f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' - ) - except Exception as e: - player.show("§l§a公会 §d>> §r更新公告失败") - fmts.print_err(f"更新公告时出错:{e}") - - return True - - -def _handle_tasks(self, player: Player) -> bool: # skipcq: PY-R1000 - """处理公会任务系统""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - while True: - latest_guild = self.guild_manager.get_guild_by_player( - player.name, force_reload=True) - if latest_guild: - guild = latest_guild - - player.show("§r========== §a公会任务§r ==========") - - # 显示活跃任务统计 - active_tasks = [t for t in guild.tasks if not t.completed] - completed_tasks = [t for t in guild.tasks if t.completed] - - player.show( - f"§7活跃任务: §e{ - len(active_tasks)} §7| 已完成: §a{ - len(completed_tasks)}") - can_manage_legacy = guild.has_permission(player.name, "task_manage") - can_create = guild.has_permission( - player.name, "task_create") or can_manage_legacy - can_delete = guild.has_permission( - player.name, "task_delete") or can_manage_legacy - can_complete = guild.has_permission( - player.name, "task_complete") or can_manage_legacy - can_manage = can_delete or can_complete - can_auto = ( - can_create - and getattr(Config, "GUILD_TASK_CONFIG", {}).get("启用自动任务模板", True) - ) - - menu_options = [ - ("查看", "查看所有任务", True), - ("参与", "参与任务", len(active_tasks) > 0), - ("创建", "创建新任务", can_create), - ("自动", "生成自动任务模板", can_auto), - ("管理", "管理任务", can_manage), - ("退出", "退出任务系统", True) - ] - - available_options = [(cmd, desc) - for cmd, desc, cond in menu_options if cond] - - for cmd, desc in available_options: - player.show(f"§e● {cmd} §7- {desc}") - - player.show("§7输入选项:") - choice = game_utils.waitMsg(player.name, timeout=30) - - if choice == "查看": - self._handle_view_tasks(player, guild) - elif choice == "参与" and len(active_tasks) > 0: - self._handle_join_task(player, guild) - elif choice == "创建" and can_create: - self._handle_create_task(player, guild) - elif choice == "自动" and can_auto: - self._handle_generate_auto_tasks(player, guild) - elif choice == "管理" and can_manage: - self._handle_manage_tasks(player, guild) - elif choice == "退出" or choice is None: - break - else: - player.show("§c无效选项") - - return True - - -def _handle_view_tasks(self, player: Player, guild: GuildData) -> bool: - """查看任务列表""" - if not guild.tasks: - player.show("§l§a公会任务 §d>> §r暂无任务") - return True - - def formatter(i, task: GuildTask): - """Format one menu item for display.""" - status = "§a已完成" if task.completed else f"§e进行中 ({ - task.current_count}/{ - task.target_count})" - progress_bar = self._create_progress_bar( - task.current_count, task.target_count) - - deadline_str = "" - if task.deadline > 0: - remaining = task.deadline - time.time() - if remaining > 0: - deadline_str = f" §7| 剩余: §f{ - self._format_time_duration(remaining)}" - else: - deadline_str = " §7| §c已过期" - - return ( - f"§e{i}. §f{task.name} [{status}§f]\n" - f" §7{task.description}\n" - f" {progress_bar}{deadline_str}\n" - f" §7奖励: §b{task.reward_contribution}贡献点 " - f"§7+ §e{task.reward_exp}经验\n" - ) - - self._paginate_display(player, guild.tasks, "公会任务列表", formatter) - return True - - -def _handle_join_task(self, player: Player, guild: GuildData) -> bool: - """参与任务""" - active_tasks = [t for t in guild.tasks if not t.completed] - - if not active_tasks: - player.show("§l§a公会任务 §d>> §r暂无可参与的任务") - return True - - def formatter(i, task: GuildTask): - """Format one menu item for display.""" - status = f"§e进行中 ({task.current_count}/{task.target_count})" - is_participant = player.name in task.participants - participant_status = " §a[已参与]" if is_participant else " §7[未参与]" - - return ( - f"§e{i}. §f{ - task.name} [{status}§f]{participant_status}\n" f" §7{ - task.description}\n" f" §7奖励: §b{ - task.reward_contribution}贡献点 §7+ §e{ - task.reward_exp}经验\n") - - idx = self._paginate_display( - player, active_tasks, "选择参与任务", formatter, True) - if idx is None: - return True - - task = active_tasks[idx] - - if player.name in task.participants: - player.show("§l§a公会任务 §d>> §r你已经参与了这个任务") - return True - - # 加入任务 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if guild: - for t in guild.tasks: - if t.task_id == task.task_id: - t.participants.append(player.name) - guild.add_log(f"{player.name} 参与了任务: {t.name}") - break - - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a公会任务 §d>> §r已参与任务: {task.name}") - - return True - - -def _handle_create_task( # skipcq: PY-R1000 - self, - player: Player, - guild: GuildData, -) -> bool: - """创建新任务""" - if not ( - guild.has_permission( - player.name, - "task_create") or guild.has_permission( - player.name, - "task_manage")): - player.show("§l§a公会任务 §d>> §r你没有创建任务权限") - return True - - task_config = getattr(Config, "GUILD_TASK_CONFIG", {}) - max_name_len = int(task_config.get("创建任务名称最大长度", 20)) - max_desc_len = int(task_config.get("创建任务描述最大长度", 100)) - max_target_count = int(task_config.get("创建任务目标数量上限", 10000)) - max_contribution_reward = int(task_config.get("创建任务贡献奖励上限", 1000)) - max_exp_reward = int(task_config.get("创建任务经验奖励上限", 1000)) - - player.show("§r========== §a创建任务§r ==========") - player.show("§7任务类型:") - player.show("§e1. §f收集任务 (收集指定物品)") - player.show("§e2. §f建造任务 (放置指定方块)") - player.show("§e3. §f贸易任务 (进行仓库交易)") - player.show("§7输入任务类型序号:") - - task_type_choice = game_utils.waitMsg(player.name, timeout=30) - - if task_type_choice == "1": - task_type = "collect" - type_name = "收集任务" - elif task_type_choice == "2": - task_type = "build" - type_name = "建造任务" - elif task_type_choice == "3": - task_type = "trade" - type_name = "贸易任务" - else: - player.show("§c无效的任务类型") - return True - - # 获取任务名称 - player.show(f"§l§a创建{type_name} §d>> §r请输入任务名称:") - task_name = game_utils.waitMsg(player.name, timeout=30) - if not task_name or len(task_name) > max_name_len: - player.show("§c任务名称无效或过长") - return True - - # 获取任务描述 - player.show("§l§a创建任务 §d>> §r请输入任务描述:") - description = game_utils.waitMsg(player.name, timeout=60) - if not description or len(description) > max_desc_len: - player.show("§c任务描述无效或过长") - return True - - # 获取目标 - if task_type == "collect": - player.show("§l§a创建任务 §d>> §r请输入目标物品名称:") - player.show("§7支持中文名称,如: 钻石、铁锭、金块等") - player.show("§7也支持英文ID,如: minecraft:diamond") - - user_input = game_utils.waitMsg(player.name, timeout=30) - if not user_input: - player.show("§c输入为空") - return True - - # 使用智能匹配查找物品ID - item_id, suggestions = self.item_matcher.validate_and_suggest( - user_input) - - if not item_id: - player.show("§c未找到匹配的物品") - if suggestions: - player.show("§7您是否想要:") - for i, suggestion in enumerate(suggestions[:3], 1): - player.show(f"§e{i}. §f{suggestion}") - return True - - target = item_id - chinese_name = self.item_matcher.get_chinese_name(item_id) - player.show(f"§l§a创建任务 §d>> §r目标物品: §f{chinese_name}") - - elif task_type == "build": - player.show("§l§a创建任务 §d>> §r请输入目标方块名称:") - player.show("§7支持中文名称,如: 石头、圆石、橡木等") - player.show("§7也支持英文ID,如: minecraft:stone") - - user_input = game_utils.waitMsg(player.name, timeout=30) - if not user_input: - player.show("§c输入为空") - return True - - # 使用智能匹配查找方块ID - block_id, suggestions = self.item_matcher.validate_and_suggest( - user_input) - - if not block_id: - player.show("§c未找到匹配的方块") - if suggestions: - player.show("§7您是否想要:") - for i, suggestion in enumerate(suggestions[:3], 1): - player.show(f"§e{i}. §f{suggestion}") - return True - - target = block_id - chinese_name = self.item_matcher.get_chinese_name(block_id) - player.show(f"§l§a创建任务 §d>> §r目标方块: §f{chinese_name}") - - else: # trade - target = "trade_count" - - # 获取目标数量 - player.show("§l§a创建任务 §d>> §r请输入目标数量:") - count_str = game_utils.waitMsg(player.name, timeout=30) - if not count_str or not count_str.isdigit(): - player.show("§c无效的数量") - return True - - target_count = int(count_str) - if target_count <= 0 or target_count > max_target_count: - player.show(f"§c数量必须在1-{max_target_count}之间") - return True - - # 获取奖励 - player.show("§l§a创建任务 §d>> §r请输入贡献点奖励:") - contrib_str = game_utils.waitMsg(player.name, timeout=30) - if not contrib_str or not contrib_str.isdigit(): - player.show("§c无效的贡献点数量") - return True - - reward_contribution = int(contrib_str) - if reward_contribution < 0 or reward_contribution > max_contribution_reward: - player.show(f"§c贡献点奖励必须在0-{max_contribution_reward}之间") - return True - - player.show("§l§a创建任务 §d>> §r请输入经验奖励:") - exp_str = game_utils.waitMsg(player.name, timeout=30) - if not exp_str or not exp_str.isdigit(): - player.show("§c无效的经验数量") - return True - - reward_exp = int(exp_str) - if reward_exp < 0 or reward_exp > max_exp_reward: - player.show(f"§c经验奖励必须在0-{max_exp_reward}之间") - return True - - task_id = uuid.uuid4().hex[:8] - new_task = GuildTask( - task_id=task_id, - name=task_name, - description=description, - task_type=task_type, - target=target, - target_count=target_count, - reward_contribution=reward_contribution, - reward_exp=reward_exp - ) - - # 保存任务 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if guild: - guild.tasks.append(new_task) - guild.add_log(f"{player.name} 创建了任务: {task_name}") - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a公会任务 §d>> §r任务 '{task_name}' 创建成功!") - - return True - - -def _handle_generate_auto_tasks( # skipcq: PY-R1000 - self, - player: Player, - guild: GuildData) -> bool: - """按配置模板生成自动任务""" - if not ( - guild.has_permission( - player.name, - "task_create") or guild.has_permission( - player.name, - "task_manage")): - player.show("§l§a公会任务 §d>> §r你没有生成任务权限") - return True - - task_config = getattr(Config, "GUILD_TASK_CONFIG", {}) - if not task_config.get("启用自动任务模板", True): - player.show("§l§a公会任务 §d>> §r自动任务模板未启用") - return True - - templates = task_config.get("自动任务模板列表", []) - if not templates: - player.show("§l§a公会任务 §d>> §r暂无自动任务模板") - return True - - guilds = self.guild_manager.load_guilds(force_reload=True) - latest_guild = guilds.get(guild.guild_id) - if not latest_guild: - player.show("§l§a公会任务 §d>> §r公会数据异常") - return True - - active_auto_tasks = [ - task for task in latest_guild.tasks - if not task.completed and task.task_id.startswith("auto-") - ] - max_active = int(task_config.get("自动任务最大同时存在数量", 6)) - if len(active_auto_tasks) >= max_active > 0: - player.show(f"§l§a公会任务 §d>> §r自动任务数量已达上限 {max_active}") - return True - - generate_count = int(task_config.get("每次生成自动任务数量", 3)) - if max_active > 0: - generate_count = min( - generate_count, - max_active - - len(active_auto_tasks)) - if generate_count <= 0: - player.show("§l§a公会任务 §d>> §r无需生成新的自动任务") - return True - - active_keys = { - (task.name, task.task_type, task.target) - for task in latest_guild.tasks - if not task.completed - } - candidates = [ - template for template in templates - if ( - template.get("name"), - template.get("task_type"), - template.get("target"), - ) not in active_keys - ] - if not candidates: - player.show("§l§a公会任务 §d>> §r所有自动任务模板都已存在") - return True - - now = time.time() - deadline_seconds = int(task_config.get("自动任务默认有效期秒", 172800)) - deadline = now + deadline_seconds if deadline_seconds > 0 else 0 - created_tasks = [] - for template in candidates[:generate_count]: - task = GuildTask( - task_id=f"auto-{uuid.uuid4().hex[: 8]} ", - name=str(template.get("name", "自动任务"))[: 20], - description=str(template.get("description", ""))[: 100], - task_type=str(template.get("task_type", "trade")), - target=str(template.get("target", "trade_count")), - target_count=max(1, int(template.get("target_count", 1))), - reward_exp=max(0, int(template.get("reward_exp", 0))), - reward_contribution=max( - 0, int(template.get("reward_contribution", 0))), - create_time=now, deadline=deadline,) - latest_guild.tasks.append(task) - created_tasks.append(task) - - latest_guild.add_log(f"{player.name} 生成了 {len(created_tasks)} 个自动任务") - latest_guild.add_audit_log( - "task_auto_generate", - player.name, - detail=",".join(task.name for task in created_tasks), - ) - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a公会任务 §d>> §r已生成 {len(created_tasks)} 个自动任务") - return True - - -def _handle_manage_tasks( # skipcq: PY-R1000 - self, - player: Player, - guild: GuildData, -) -> bool: - """管理任务""" - can_manage_legacy = guild.has_permission(player.name, "task_manage") - can_delete = guild.has_permission( - player.name, "task_delete") or can_manage_legacy - can_complete = guild.has_permission( - player.name, "task_complete") or can_manage_legacy - - if not can_delete and not can_complete: - player.show("§l§a公会任务 §d>> §r你没有管理任务权限") - return True - - player.show("§r========== §a任务管理§r ==========") - if can_delete: - player.show("§e1. §f删除任务") - if can_complete: - player.show("§e2. §f完成任务") - player.show("§7输入选项序号:") - - choice = game_utils.waitMsg(player.name, timeout=30) - - if choice == "1" and can_delete: - if not guild.tasks: - player.show("§l§a公会任务 §d>> §r暂无任务") - return True - # 删除任务 - - def formatter(i, task: GuildTask): - """Format one menu item for display.""" - status = "§a已完成" if task.completed else "§e进行中" - return f"§e{i}. §f{ - task.name} [{status}§f]\n §7{ - task.description}\n" - - idx = self._paginate_display( - player, guild.tasks, "选择删除任务", formatter, True) - if idx is not None: - task = guild.tasks[idx] - player.show(f"§l§a任务管理 §d>> §r确认删除任务 '{task.name}'?输入 '确认' 继续") - confirm = game_utils.waitMsg(player.name, timeout=20) - - if confirm == "确认": - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if guild and idx < len(guild.tasks): - removed_task = guild.tasks.pop(idx) - guild.add_log(f"{player.name} 删除了任务: {removed_task.name}") - guild.add_audit_log("task_delete", player.name, - detail=removed_task.name) - self.guild_manager.save_guilds(guilds) - player.show( - f"§l§a任务管理 §d>> §r任务 '{ - removed_task.name}' 已删除") - - elif choice == "2" and can_complete: - # 强制完成任务 - active_tasks = [t for t in guild.tasks if not t.completed] - if not active_tasks: - player.show("§l§a任务管理 §d>> §r暂无进行中的任务") - return True - - def formatter(i, task: GuildTask): - """Format one menu item for display.""" - return f"§e{i}. §f{ - task.name} §7({ - task.current_count}/{ - task.target_count})\n §7{ - task.description}\n" - - idx = self._paginate_display( - player, active_tasks, "选择完成任务", formatter, True) - if idx is not None: - task = active_tasks[idx] - player.show(f"§l§a任务管理 §d>> §r确认强制完成任务 '{task.name}'?输入 '确认' 继续") - confirm = game_utils.waitMsg(player.name, timeout=20) - - if confirm == "确认": - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if guild: - for t in guild.tasks: - if t.task_id == task.task_id: - t.completed = True - t.current_count = t.target_count - guild.add_log(f"{player.name} 强制完成了任务: {t.name}") - guild.add_audit_log("task_force_complete", - player.name, detail=t.name) - break - - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a任务管理 §d>> §r任务 '{task.name}' 已完成") - else: - player.show("§c无效选项") - - return True - - -def _handle_manage_members(self, player: Player) -> bool: # skipcq: PY-R1000 - """管理公会成员""" - guild = self.guild_manager.get_guild_by_player(player.name) - member = guild.get_member(player.name) if guild else None - - if not guild or not member or not any( - guild.has_permission(player.name, permission) - for permission in ("kick", "set_rank", "transfer_owner", "join_queue") - ): - player.show("§l§a公会 §d>> §r你没有管理权限") - return True - - player.show("§r========== §a成员管理§r ==========") - options = [ - ("1", "踢出成员", guild.has_permission(player.name, "kick")), - ("2", "设置职位", guild.has_permission(player.name, "set_rank")), - ("3", "转让会长", guild.has_permission(player.name, "transfer_owner")), - ("4", "申请队列", guild.has_permission(player.name, "join_queue")), - ] - for key, label, enabled in options: - if enabled: - player.show(f"§e{key}. §f{label}") - player.show("§7输入选项序号,q 返回") - - choice = game_utils.waitMsg(player.name, timeout=30) - - if choice == "1" and guild.has_permission(player.name, "kick"): - return self._handle_kick_member(player) - if choice == "2" and guild.has_permission(player.name, "set_rank"): - return self._handle_set_rank(player) - if choice == "3" and guild.has_permission(player.name, "transfer_owner"): - return self._handle_transfer_ownership(player) - if choice == "4" and guild.has_permission(player.name, "join_queue"): - return self._handle_join_request_queue(player, guild) - - return True - - -def _notify_join_request_admins( - self, - guild: GuildData, - applicant_name: str) -> None: - """通知在线的申请队列处理人。""" - if not getattr( - Config, - "GUILD_JOIN_REQUEST_CONFIG", - {}).get( - "申请提交后通知在线管理员", - True): - return - - for member in guild.members: - if ( - member.name in self.game_ctrl.allplayers - and guild.has_permission(member.name, "join_queue") - ): - message = ( - f"§l§a公会 §d>> §r§e{applicant_name} " - f"§f申请加入公会 §e{guild.name}\\n" - "§f请在成员管理的 §a申请队列 §f中处理" - ) - self.game_ctrl.sendcmd( - f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' - ) - - -def _handle_join_request_queue( # skipcq: PY-R1000 - self, - player: Player, - guild: GuildData, -) -> bool: - """处理加入申请队列""" - if not guild.has_permission(player.name, "join_queue"): - player.show("§l§a公会 §d>> §r你没有处理申请队列权限") - return True - - pending_requests = guild.pending_join_requests() - if not pending_requests: - player.show("§l§a公会 §d>> §r暂无待处理加入申请") - return True - - def formatter(i, request): - """Format one menu item for display.""" - age = self._format_time_duration(time.time() - request.create_time) - reason = request.reason or "无" - return ( - f"§e{i}. §f{request.player_name}\n" - f" §7理由: §f{reason} §7| 提交: §f{age}前\n" - ) - - idx = self._paginate_display( - player, - pending_requests, - "加入申请队列", - formatter, - True) - if idx is None: - return True - - selected = pending_requests[idx] - player.show(f"§l§a公会 §d>> §r处理 §e{selected.player_name} §r的加入申请") - player.show("§e1. §f同意") - player.show("§e2. §f拒绝") - player.show("§7输入选项序号:") - choice = game_utils.waitMsg(player.name, timeout=30) - approved = choice in ("1", "同意", "批准") - rejected = choice in ("2", "拒绝") - if not approved and not rejected: - player.show("§l§a公会 §d>> §r操作已取消") - return True - - guilds = self.guild_manager.load_guilds(force_reload=True) - latest_guild = guilds.get(guild.guild_id) - if not latest_guild: - player.show("§l§a公会 §d>> §r公会数据异常") - return True - - if approved and len(latest_guild.members) >= Config.MAX_GUILD_MEMBERS: - latest_guild.resolve_join_request( - selected.player_name, - player.name, - False, - result_reason="公会已满员", - ) - self.guild_manager.save_guilds(guilds) - player.show("§l§a公会 §d>> §r公会已满员,已拒绝该申请") - return True - - if not latest_guild.resolve_join_request( - selected.player_name, - player.name, - approved, - result_reason="管理员处理", - ): - player.show("§l§a公会 §d>> §r申请已不存在或已过期") - return True - - if approved: - new_member = GuildMember( - name=selected.player_name, - rank=GuildRank.MEMBER, - join_time=time.time(), - ) - latest_guild.members.append(new_member) - latest_guild.add_log( - f"{selected.player_name} 加入公会 (审核人: {player.name})") - self.guild_manager.cache_player_guild( - selected.player_name, latest_guild.guild_id) - - self.guild_manager.save_guilds(guilds) - result_text = "已同意" if approved else "已拒绝" - player.show(f"§l§a公会 §d>> §r{result_text} {selected.player_name} 的加入申请") - - if selected.player_name in self.game_ctrl.allplayers: - message = ( - f"§l§a公会 §d>> §r你的公会申请已通过,已加入 §e{latest_guild.name}" - if approved - else f"§l§a公会 §d>> §r你加入 §e{latest_guild.name} §r的申请被拒绝" - ) - self.game_ctrl.sendcmd( - f'/tellraw {selected.player_name} {{"rawtext":[{{"text":"{message}"}}]}}' - ) - - if approved and getattr( - Config, - "GUILD_JOIN_REQUEST_CONFIG", - {}).get( - "批准后通知全体在线成员", - True): - for member in latest_guild.members: - if ( - member.name in self.game_ctrl.allplayers - and member.name != selected.player_name - ): - message = f"§l§a公会 §d>> §r§e{selected.player_name}§r 加入了公会" - self.game_ctrl.sendcmd( - f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' - ) - - return True - - -def _handle_set_rank(self, player: Player) -> bool: - """设置成员职位""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - return True - if not guild.has_permission(player.name, "set_rank"): - player.show("§l§a公会 §d>> §r你没有设置成员职位权限") - return True - - # 过滤可管理的成员 - manageable_members = [ - m for m in guild.members - if guild.can_manage_member(player.name, m.name) - ] - - if not manageable_members: - player.show("§l§a公会 §d>> §r没有可管理的成员") - return True - - def formatter(i, m): - """Format one menu item for display.""" - return f"§e{i}. {m.rank.display_name} §f{m.name}\n" - idx = self._paginate_display( - player, - manageable_members, - "选择成员", - formatter, - True) - - if idx is None: - return True - - target_member = manageable_members[idx] - - # 选择新职位 - player.show("§r========== §a设置职位§r ==========") - player.show("§e1. §6副会长") - player.show("§e2. §e长老") - player.show("§e3. §a成员") - player.show("§7输入选项序号") - - rank_choice = game_utils.waitMsg(player.name, timeout=30) - rank_map = { - "1": GuildRank.DEPUTY, - "2": GuildRank.ELDER, - "3": GuildRank.MEMBER} - - new_rank = rank_map.get(rank_choice) - if new_rank and self.guild_manager.set_member_rank( - guild, target_member.name, new_rank): - player.show( - f"§l§a公会 §d>> §r已将 { - target_member.name} 的职位设置为 { - new_rank.display_name}") - - return True - - -def _handle_donation(self, player: Player) -> bool: - """处理物品捐献""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - player.show("§l§a公会捐献§r") - player.show("§7支持捐献的物品类型:") - player.show("§e钻石 §7- 50贡献/个") - player.show("§e绿宝石 §7- 25贡献/个") - player.show("§e金锭 §7- 10贡献/个") - player.show("§e铁锭 §7- 5贡献/个") - player.show("§e下界合金锭 §7- 200贡献/个") - player.show("§e远古残骸 §7- 150贡献/个") - player.show("§7以及其他有价值的物品...") - player.show("§7请输入要捐献的物品名称 (支持中文名称):") - - user_input = game_utils.waitMsg(player.name, timeout=30) - - if not user_input or user_input.lower() == 'q': - return True - - # 使用智能匹配查找物品ID - item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) - - if not item_id: - player.show("§l§a公会捐献 §d>> §r未找到匹配的物品") - if suggestions: - player.show("§7您是否想要:") - for i, suggestion in enumerate(suggestions[:3], 1): - player.show(f"§e{i}. §f{suggestion}") - return True - - # 获取物品的贡献点价值 - contrib_per_item = guild.get_item_value(item_id) - exp_per_item = contrib_per_item * 0.5 # 经验为贡献点的一半 - - chinese_name = self.item_matcher.get_chinese_name(item_id) - player.show(f"§l§a公会捐献 §d>> §r选择物品: §f{chinese_name}") - player.show(f"§7价值: §e{contrib_per_item}贡献点/个 §7+ §b{exp_per_item}经验/个") - - player.show(f"§7你有 {player.getItemCount(item_id)} 个{chinese_name}") - player.show("§7输入要捐献的数量:") - - amount_str = game_utils.waitMsg(player.name, timeout=30) - if not amount_str or not amount_str.isdigit(): - return True - - amount = int(amount_str) - if amount <= 0 or amount > player.getItemCount(item_id): - player.show("§l§a公会 §d>> §r数量无效") - return True - - self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {amount}") - exp, contribution = self.guild_apply_reward_multipliers( - int(amount * exp_per_item), - amount * contrib_per_item, - ) - - # 重新加载公会数据并更新经验值 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild_id = guild.guild_id - - if guild_id in guilds: - # 更新贡献与公会经验 - latest_guild = guilds[guild_id] - member = latest_guild.get_member(player.name) - if member: - member.contribution += contribution - latest_guild.stats.total_contribution += contribution - latest_guild.exp += exp - latest_guild.add_log(f"{player.name} 捐献了 {amount} 个{chinese_name}") - - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a公会 §d>> §r捐献成功!获得 {contribution} 贡献度,公会获得 {exp} 经验") - else: - player.show("§l§a公会 §d>> §r捐献失败,公会数据异常") - fmts.print_err(f"捐献失败: 公会ID {guild_id} 不存在于加载的数据中") - - return True - - -def _handle_vault(self, player: Player) -> bool: # skipcq: PY-R1000 - """处理公会仓库""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - if not guild.has_permission(player.name, "vault"): - player.show("§l§a公会仓库 §d>> §r你没有使用仓库权限") - return True - - while True: - latest_guild = self.guild_manager.get_guild_by_player( - player.name, force_reload=True) - if latest_guild: - guild = latest_guild - - player.show("§r========== §a公会仓库§r ==========") - max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 - player.show(f"§7容量: §a{len(guild.vault_items)}/{max_slots}") - - member = guild.get_member(player.name) - if member: - player.show(f"§7你的贡献点: §e{member.contribution}") - - can_buy = guild.has_permission(player.name, "vault_buy") - can_sell = guild.has_permission(player.name, "vault_sell") - can_cancel = ( - getattr(Config, "GUILD_VAULT_CONFIG", {}).get("启用撤回出售", True) - and ( - guild.has_permission(player.name, "vault_cancel_own") - or guild.has_permission(player.name, "vault_cancel_any") - ) - ) - can_view_logs = getattr( - Config, "GUILD_VAULT_CONFIG", {}).get( - "启用交易日志", True) - can_settings = guild.has_permission(player.name, "vault_settings") - - menu_options = [ - ("查看", "查看仓库物品", True), - ("购买", "购买仓库物品", can_buy), - ("出售", "出售物品到仓库", can_sell), - ("撤回", "撤回已上架物品", can_cancel), - ("日志", "查看仓库交易日志", can_view_logs), - ("设置", "设置仓库物品价值", can_settings), - ("退出", "退出仓库", True) - ] - - available_options = [(cmd, desc) - for cmd, desc, cond in menu_options if cond] - - for cmd, desc in available_options: - player.show(f"§e● {cmd} §7- {desc}") - - player.show("§7输入选项:") - choice = game_utils.waitMsg(player.name, timeout=30) - - if choice == "查看": - self._handle_vault_view(player, guild) - elif choice == "购买" and can_buy: - self._handle_vault_buy(player, guild) - elif choice == "出售" and can_sell: - self._handle_vault_sell(player, guild) - elif choice == "撤回" and can_cancel: - self._handle_vault_cancel(player, guild) - elif choice == "日志" and can_view_logs: - self._handle_vault_logs(player, guild) - elif choice == "设置" and can_settings: - self._handle_vault_settings(player, guild) - elif choice == "退出" or choice is None: - break - else: - player.show("§c无效选项") - - return True - - -def _handle_vault_view(self, player: Player, guild: GuildData) -> bool: - """查看仓库物品""" - if not guild.vault_items: - player.show("§l§a公会仓库 §d>> §r仓库为空") - return True - - def formatter(i, item: VaultItem): - # 获取物品显示名称 - """Format one menu item for display.""" - item_name = self._get_item_display_name(item.item_id) - time_str = datetime.fromtimestamp( - item.timestamp).strftime("%m-%d %H:%M") - return (f"§e{i}. §f{item_name} §7x{item.count}\n" - f" §7价格: §e{item.price}贡献点 §7| 卖家: §a{item.seller}\n" - f" §7时间: §f{time_str}\n") - - self._paginate_display(player, guild.vault_items, "仓库物品", formatter) - return True - - -def _handle_vault_buy( # skipcq: PY-R1000 - self, - player: Player, - guild: GuildData, -) -> bool: - """购买仓库物品""" - if not guild.has_permission(player.name, "vault_buy"): - player.show("§l§a公会仓库 §d>> §r你没有购买仓库物品权限") - return True - - if not guild.vault_items: - player.show("§l§a公会仓库 §d>> §r仓库为空") - return True - - member = guild.get_member(player.name) - if not member: - return True - - def formatter(i, item: VaultItem): - """Format one menu item for display.""" - item_name = self._get_item_display_name(item.item_id) - time_str = datetime.fromtimestamp( - item.timestamp).strftime("%m-%d %H:%M") - affordable = "§a可购买" if member.contribution >= item.price else "§c贡献点不足" - return (f"§e{i}. §f{item_name} §7x{item.count}\n" - f" §7价格: §e{item.price}贡献点 §7| {affordable}\n" - f" §7卖家: §a{item.seller} §7| 时间: §f{time_str}\n") - - idx = self._paginate_display( - player, - guild.vault_items, - "选择购买物品", - formatter, - True) - if idx is None: - return True - - item = guild.vault_items[idx] - if (item.seller == player.name and not getattr( - Config, "GUILD_VAULT_CONFIG", {}).get("允许购买自己出售的物品", False)): - player.show("§l§a公会仓库 §d>> §r不能购买自己出售的物品") - return True - - # 检查贡献点 - if member.contribution < item.price: - player.show("§l§a公会仓库 §d>> §r贡献点不足") - return True - - # 检查背包空间 - if not self._has_inventory_space(player, item.item_id, item.count): - player.show("§l§a公会仓库 §d>> §r背包空间不足") - return True - - # 确认购买 - item_name = self._get_item_display_name(item.item_id) - player.show( - f"§l§a公会仓库 §d>> §r确认购买 §f{item_name} §7x{ - item.count} §r花费 §e{ - item.price}贡献点§r?") - player.show("§7输入 '确认' 继续购买") - - confirm = game_utils.waitMsg(player.name, timeout=20) - if confirm != "确认": - player.show("§l§a公会仓库 §d>> §r购买已取消") - return True - - # 执行购买 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if not guild: - player.show("§l§a公会仓库 §d>> §r公会数据异常") - return True - - # 重新检查物品是否还存在 - if idx >= len( - guild.vault_items) or guild.vault_items[idx].item_id != item.item_id: - player.show("§l§a公会仓库 §d>> §r物品已被其他人购买") - return True - item = guild.vault_items[idx] - if (item.seller == player.name and not getattr( - Config, "GUILD_VAULT_CONFIG", {}).get("允许购买自己出售的物品", False)): - player.show("§l§a公会仓库 §d>> §r不能购买自己出售的物品") - return True - - # 扣除贡献点 - buyer_member = guild.get_member(player.name) - if not buyer_member or buyer_member.contribution < item.price: - player.show("§l§a公会仓库 §d>> §r贡献点不足") - return True - - buyer_member.contribution -= item.price - - # 给卖家贡献点(扣除税费) - tax = int(item.price * Config.VAULT_TRADE_TAX) - seller_income = item.price - tax - seller_member = guild.get_member(item.seller) - if seller_member: - seller_member.contribution += seller_income - - # 给玩家物品 - self.game_ctrl.sendwocmd(f"give {player.name} {item.item_id} {item.count}") - - guild.vault_items.pop(idx) - - guild.add_log( - f"{player.name} 购买了 {item_name} x{item.count} (花费{item.price}贡献点)") - if getattr(Config, "GUILD_VAULT_CONFIG", {}).get("启用交易日志", True): - guild.add_vault_trade_log( - "buy", - item, - player.name, - buyer=player.name, - detail=f"税费{tax},卖家收入{seller_income}", - ) - suggested_value = guild.get_item_value(item.item_id) * item.count - audit_ratio = float( - getattr( - Config, - "GUILD_VAULT_CONFIG", - {}).get( - "高价交易审计倍率", - 3.0)) - if suggested_value > 0 and item.price >= suggested_value * audit_ratio: - guild.add_audit_log( - "vault_high_price_buy", - player.name, - target=item.seller, - detail=f"{item.item_id} x{item.count} price={item.price}", - ) - - self.guild_manager.save_guilds(guilds) - - player.show(f"§l§a公会仓库 §d>> §r购买成功!花费 §e{item.price}贡献点") - - # 更新贸易任务进度 - self.check_and_complete_trade_tasks(player.name) - - # 通知卖家 - if seller_member and item.seller in self.game_ctrl.allplayers: - message = ( - f"§l§a公会仓库 §d>> §r你的 {item_name} x{item.count} " - f"被 {player.name} 购买了,获 {seller_income} 贡献点" - ) - self.game_ctrl.sendcmd( - f'/tellraw {item.seller} {{"rawtext":[{{"text":"{message}"}}]}}' - ) - - return True - - -def _handle_vault_sell( # skipcq: PY-R1000 - self, - player: Player, - guild: GuildData, -) -> bool: - """出售物品到仓库""" - if not guild.has_permission(player.name, "vault_sell"): - player.show("§l§a公会仓库 §d>> §r你没有出售仓库物品权限") - return True - - # 检查仓库容量 - max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 - if len(guild.vault_items) >= max_slots: - player.show("§l§a公会仓库 §d>> §r仓库已满") - return True - - player.show("§l§a公会仓库 §d>> §r请输入要出售的物品名称") - player.show("§7支持中文名称,如: 钻石、铁锭、金块等") - player.show("§7也支持英文ID,如: minecraft:diamond") - - user_input = game_utils.waitMsg(player.name, timeout=30) - - if not user_input: - player.show("§l§a公会仓库 §d>> §r输入为空") - return True - - # 使用智能匹配查找物品ID - item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) - - if not item_id: - player.show("§l§a公会仓库 §d>> §r未找到匹配的物品") - if suggestions: - player.show("§7您是否想要:") - for i, suggestion in enumerate(suggestions[:3], 1): - player.show(f"§e{i}. §f{suggestion}") - player.show("§7请重新输入准确的物品名称") - return True - - # 显示找到的物品 - chinese_name = self.item_matcher.get_chinese_name(item_id) - player.show(f"§l§a公会仓库 §d>> §r找到物品: §f{chinese_name} §7({item_id})") - - # 检查玩家是否有该物品 - item_count = player.getItemCount(item_id) - if item_count <= 0: - player.show("§l§a公会仓库 §d>> §r你没有这个物品") - return True - - player.show(f"§l§a公会仓库 §d>> §r你有 {item_count} 个该物品") - player.show("§7请输入要出售的数量:") - - count_str = game_utils.waitMsg(player.name, timeout=30) - if not count_str or not count_str.isdigit(): - player.show("§l§a公会仓库 §d>> §r无效的数量") - return True - - count = int(count_str) - if count <= 0 or count > item_count: - player.show("§l§a公会仓库 §d>> §r数量无效") - return True - max_sell_count = int( - getattr( - Config, - "GUILD_VAULT_CONFIG", - {}).get( - "单次出售最大数量", - 64)) - if count > max_sell_count > 0: - player.show(f"§l§a公会仓库 §d>> §r单次最多出售 {max_sell_count} 个") - return True - - # 获取建议价格 - suggested_price = guild.get_item_value(item_id) * count - player.show(f"§l§a公会仓库 §d>> §r建议价格: §e{suggested_price}贡献点") - player.show("§7请输入出售价格 (贡献点,你可以自定义任意价格):") - - price_str = game_utils.waitMsg(player.name, timeout=30) - if not price_str or not price_str.isdigit(): - player.show("§l§a公会仓库 §d>> §r无效的价格") - return True - - price = int(price_str) - if price <= 0: - player.show("§l§a公会仓库 §d>> §r价格必须大于0") - return True - vault_config = getattr(Config, "GUILD_VAULT_CONFIG", {}) - min_price = int(vault_config.get("单笔价格下限", 1)) - max_price = int(vault_config.get("单笔价格上限", 100000)) - if price < min_price or price > max_price: - player.show(f"§l§a公会仓库 §d>> §r价格必须在 {min_price}-{max_price} 之间") - return True - - # 确认出售 - item_name = self._get_item_display_name(item_id) - player.show( - f"§l§a公会仓库 §d>> §r确认出售 §f{item_name} §7x{count} §r价格 §e{price}贡献点§r?") - player.show("§7输入 '确认' 继续出售") - - confirm = game_utils.waitMsg(player.name, timeout=20) - if confirm != "确认": - player.show("§l§a公会仓库 §d>> §r出售已取消") - return True - - # 执行出售 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if not guild: - player.show("§l§a公会仓库 §d>> §r公会数据异常") - return True - - # 再次检查仓库容量 - if len(guild.vault_items) >= max_slots: - player.show("§l§a公会仓库 §d>> §r仓库已满") - return True - max_listing = int(vault_config.get("单个成员最大上架数量", 18)) - if max_listing > 0: - own_listing_count = sum( - 1 for item in guild.vault_items if item.seller == player.name) - if own_listing_count >= max_listing: - player.show(f"§l§a公会仓库 §d>> §r你最多同时上架 {max_listing} 件物品") - return True - - # 扣除物品 - self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {count}") - - # 添加到仓库 - vault_item = VaultItem( - item_id=item_id, - count=count, - price=price, - seller=player.name - ) - - guild.vault_items.append(vault_item) - guild.add_log(f"{player.name} 出售了 {item_name} x{count} (价格{price}贡献点)") - if vault_config.get("启用交易日志", True): - guild.add_vault_trade_log( - "sell", vault_item, player.name, detail="上架出售") - - audit_ratio = float(vault_config.get("高价交易审计倍率", 3.0)) - if suggested_price > 0 and price >= suggested_price * audit_ratio: - guild.add_audit_log( - "vault_high_price_sell", - player.name, - detail=f"{item_id} x{count} price={price}", - ) - - # 保存数据 - self.guild_manager.save_guilds(guilds) - - player.show(f"§l§a公会仓库 §d>> §r出售成功!{item_name} x{count} 已上架") - - # 更新贸易任务进度 - self.check_and_complete_trade_tasks(player.name) - - return True - - -def _handle_vault_logs(self, player: Player, guild: GuildData) -> bool: - """查看仓库交易日志""" - if not getattr(Config, "GUILD_VAULT_CONFIG", {}).get("启用交易日志", True): - player.show("§l§a公会仓库 §d>> §r交易日志未启用") - return True - - if not guild.vault_trade_logs: - player.show("§l§a公会仓库 §d>> §r暂无仓库交易日志") - return True - - action_names = { - "sell": "上架", - "buy": "购买", - "cancel": "撤回", - } - - def formatter(i, log): - """Format one menu item for display.""" - item_name = self._get_item_display_name(log.item_id) - time_str = datetime.fromtimestamp( - log.timestamp).strftime("%m-%d %H:%M") - action_name = action_names.get(log.action, log.action) - participants = [] - if log.seller: - participants.append(f"卖家:{log.seller}") - if log.buyer: - participants.append(f"买家:{log.buyer}") - participant_text = " §7| ".join( - participants) if participants else f"操作者:{log.actor}" - detail = f"\n §7说明: §f{log.detail}" if log.detail else "" - return ( - f"§e{i}. §f{action_name} §f{item_name} §7x{log.count}\n" - f" §7价格: §e{log.price}贡献点 §7| {participant_text} §7| 时间: §f{time_str}" - f"{detail}\n" - ) - - logs = list(reversed(guild.vault_trade_logs[-50:])) - self._paginate_display(player, logs, "仓库交易日志", formatter) - return True - - -def _handle_vault_cancel( # skipcq: PY-R1000 - self, - player: Player, - guild: GuildData, -) -> bool: - """撤回仓库上架物品""" - vault_config = getattr(Config, "GUILD_VAULT_CONFIG", {}) - if not vault_config.get("启用撤回出售", True): - player.show("§l§a公会仓库 §d>> §r撤回出售未启用") - return True - - can_cancel_own = guild.has_permission(player.name, "vault_cancel_own") - can_cancel_any = guild.has_permission(player.name, "vault_cancel_any") - only_own = vault_config.get("只允许撤回自己的物品", False) - - cancelable_items = [] - for index, item in enumerate(guild.vault_items): - is_own = item.seller == player.name - if is_own and can_cancel_own: - cancelable_items.append((index, item)) - elif not only_own and can_cancel_any: - cancelable_items.append((index, item)) - - if not cancelable_items: - player.show("§l§a公会仓库 §d>> §r没有可撤回的上架物品") - return True - - def formatter(i, data): - """Format one menu item for display.""" - _index, item = data - item_name = self._get_item_display_name(item.item_id) - time_str = datetime.fromtimestamp( - item.timestamp).strftime("%m-%d %H:%M") - return ( - f"§e{i}. §f{item_name} §7x{ - item.count}\n" f" §7价格: §e{ - item.price}贡献点 §7| 卖家: §a{ - item.seller} §7| 时间: §f{time_str}\n") - - idx = self._paginate_display( - player, - cancelable_items, - "选择撤回物品", - formatter, - True) - if idx is None: - return True - - original_index, selected_item = cancelable_items[idx] - selected_name = self._get_item_display_name(selected_item.item_id) - player.show( - f"§l§a公会仓库 §d>> §r确认撤回 §f{selected_name} §7x{ - selected_item.count}§r?") - player.show("§7输入 '确认' 继续撤回") - - confirm = game_utils.waitMsg(player.name, timeout=20) - if confirm != "确认": - player.show("§l§a公会仓库 §d>> §r撤回已取消") - return True - - guilds = self.guild_manager.load_guilds(force_reload=True) - latest_guild = guilds.get(guild.guild_id) - if not latest_guild: - player.show("§l§a公会仓库 §d>> §r公会数据异常") - return True - - if original_index >= len(latest_guild.vault_items): - player.show("§l§a公会仓库 §d>> §r物品已不存在") - return True - - latest_item = latest_guild.vault_items[original_index] - if ( - latest_item.item_id != selected_item.item_id - or latest_item.count != selected_item.count - or latest_item.price != selected_item.price - or latest_item.seller != selected_item.seller - ): - player.show("§l§a公会仓库 §d>> §r物品状态已变化,请重新打开仓库") - return True - - is_own = latest_item.seller == player.name - if not ((is_own and can_cancel_own) or (not only_own and can_cancel_any)): - player.show("§l§a公会仓库 §d>> §r你没有撤回该物品的权限") - return True - - removed_item = latest_guild.cancel_vault_item(player.name, original_index) - if not removed_item: - player.show("§l§a公会仓库 §d>> §r撤回失败") - return True - - if vault_config.get("撤回后返还物品", True): - self.game_ctrl.sendwocmd( - f"give { - removed_item.seller} { - removed_item.item_id} { - removed_item.count}") - - self.guild_manager.save_guilds(guilds) - player.show( - f"§l§a公会仓库 §d>> §r已撤回 §f{selected_name} §7x{ - removed_item.count}") - if ( - removed_item.seller != player.name - and removed_item.seller in self.game_ctrl.allplayers - ): - message = ( - f"§l§a公会仓库 §d>> §r你上架的 {selected_name} " - f"x{removed_item.count} 已被 {player.name} 撤回" - ) - self.game_ctrl.sendcmd( - f'/tellraw {removed_item.seller} {{"rawtext":[{{"text":"{message}"}}]}}' - ) - - return True - - -def _handle_vault_settings( # skipcq: PY-R1000 - self, - player: Player, - guild: GuildData, -) -> bool: - """设置物品价值""" - if not guild.has_permission(player.name, "vault_settings"): - player.show("§l§a公会仓库 §d>> §r你没有设置仓库物品价值权限") - return True - - player.show("§r========== §a物品价值设置§r ==========") - player.show("§e1. §f查看当前设置") - player.show("§e2. §f设置物品价值") - player.show("§e3. §f删除自定义价值") - player.show("§7输入选项序号:") - - choice = game_utils.waitMsg(player.name, timeout=30) - - if choice == "1": - # 显示当前设置 - if guild.custom_item_values: - player.show("§l§a自定义物品价值§r") - for item_id, value in guild.custom_item_values.items(): - item_name = self._get_item_display_name(item_id) - player.show(f"§f{item_name}: §e{value}贡献点") - else: - player.show("§l§a公会仓库 §d>> §r暂无自定义物品价值") - - player.show("\n§l§a默认物品价值§r") - for item_id, value in Config.DEFAULT_ITEM_VALUES.items(): - item_name = self._get_item_display_name(item_id) - player.show(f"§f{item_name}: §e{value}贡献点") - - elif choice == "2": - # 设置物品价值 - player.show("§l§a公会仓库 §d>> §r请输入物品ID (如: minecraft:diamond):") - item_id = game_utils.waitMsg(player.name, timeout=30) - - if not item_id or not item_id.startswith("minecraft:"): - player.show("§l§a公会仓库 §d>> §r无效的物品ID") - return True - - player.show("§l§a公会仓库 §d>> §r请输入贡献点价值:") - value_str = game_utils.waitMsg(player.name, timeout=30) - - if not value_str or not value_str.isdigit(): - player.show("§l§a公会仓库 §d>> §r无效的价值") - return True - - value = int(value_str) - if value <= 0: - player.show("§l§a公会仓库 §d>> §r价值必须大于0") - return True - - # 保存设置 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if guild: - guild.custom_item_values[item_id] = value - item_name = self._get_item_display_name(item_id) - guild.add_log(f"{player.name} 设置了 {item_name} 的价值为 {value} 贡献点") - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a公会仓库 §d>> §r已设置 {item_name} 的价值为 {value} 贡献点") - - elif choice == "3": - # 删除自定义价值 - if not guild.custom_item_values: - player.show("§l§a公会仓库 §d>> §r暂无自定义物品价值") - return True - - player.show("§l§a当前自定义价值§r") - items = list(guild.custom_item_values.items()) - for i, (item_id, value) in enumerate(items, 1): - item_name = self._get_item_display_name(item_id) - player.show(f"§e{i}. §f{item_name}: §e{value}贡献点") - - player.show("§7输入序号删除:") - idx_str = game_utils.waitMsg(player.name, timeout=30) - - if idx_str and idx_str.isdigit(): - idx = int(idx_str) - 1 - if 0 <= idx < len(items): - item_id, _ = items[idx] - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if guild and item_id in guild.custom_item_values: - del guild.custom_item_values[item_id] - item_name = self._get_item_display_name(item_id) - guild.add_log(f"{player.name} 删除了 {item_name} 的自定义价值") - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a公会仓库 §d>> §r已删除 {item_name} 的自定义价值") - - return True - - -def _handle_create_guild(self, player: Player, player_xuid: str) -> bool: - """处理创建公会""" - guild = self.guild_manager.get_guild_by_player(player.name) - if guild: - player.show(render_create_guild_prompt("已有公会提示词")) - return True - - score = player.getScore(Config.GUILD_SCOREBOARD) - if score < Config.GUILD_CREATION_COST: - player.show(render_create_guild_prompt("创建公会余额不足提示词", balance=score)) - return True - - player.show(render_create_guild_prompt("创建公会提示词", balance=score)) - confirm = game_utils.waitMsg(player.name, timeout=30) - if confirm is None: - player.show(render_create_guild_prompt("创建公会回复超时提示词")) - return True - if confirm.strip().lower() not in ("确认", "继续", "是", "y", "yes"): - player.show(render_create_guild_prompt("创建公会取消提示词")) - return True - - player.show(render_create_guild_prompt("创建公会输入名称提示词")) - guild_name = game_utils.waitMsg(player.name, timeout=30) - - # 使用新的输入验证 - is_valid, error_msg = InputValidator.validate_guild_name(guild_name) - if not is_valid: - player.show(render_create_guild_prompt("创建公会名称无效提示词", error=error_msg)) - return True - - score = player.getScore(Config.GUILD_SCOREBOARD) - if score < Config.GUILD_CREATION_COST: - player.show(render_create_guild_prompt("创建公会二次余额不足提示词", balance=score)) - return True - - # 扣除配置的计分板积分并创建公会 - self.game_ctrl.sendwocmd( - f"scoreboard players remove { - player.name} { - Config.GUILD_SCOREBOARD} { - Config.GUILD_CREATION_COST}") - - if self.guild_manager.create_guild(player_xuid, player.name, guild_name): - player.show(render_create_guild_prompt( - "创建公会成功提示词", guild=guild_name, player=player.name)) - # 通知所有在线玩家 - announcement = render_create_guild_prompt( - "创建公会全服公告提示词", - guild=guild_name, - player=player.name, - ) - payload = json.dumps( - {"rawtext": [{"text": announcement}]}, ensure_ascii=False) - self.game_ctrl.sendcmd(f"/tellraw @a {payload}") - else: - player.show(render_create_guild_prompt( - "创建公会名称已存在提示词", guild=guild_name, player=player.name)) - self.game_ctrl.sendwocmd( - f"scoreboard players add { - player.name} { - Config.GUILD_SCOREBOARD} { - Config.GUILD_CREATION_COST}") - - return True - - -def _handle_list_guilds(self, player: Player) -> bool: - """处理查看公会列表""" - guilds = list(self.guild_manager.load_guilds().values()) - if not guilds: - player.show(render_config_prompt("公会列表为空提示词")) - return True - - # 按等级和成员数排序 - guilds.sort(key=lambda g: (g.level, len(g.members)), reverse=True) - - def formatter(i, g): - """Format one menu item for display.""" - return ( - f"§e{i}. §r{g.name} §7Lv.{g.level}\n" - f" §7会长: §f{g.owner} " - f"§7| 成员: §a{len(g.members)}/{Config.MAX_GUILD_MEMBERS}\n" - ) - - page = 1 - max_page = (len(guilds) + Config.ITEMS_PER_PAGE - - 1) // Config.ITEMS_PER_PAGE - while True: - page = max(1, min(page, max_page)) - start = (page - 1) * Config.ITEMS_PER_PAGE - end = start + Config.ITEMS_PER_PAGE - - msg = ( - f"{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b公会列表§d" - f"\n{ORION_BORDER}" - ) - for i, guild in enumerate(guilds[start:end], start=1): - msg += "\n" + formatter(start + i, guild).rstrip() - msg += "\n" + format_page_footer(page, max_page, start + 1, end, False) - - player.show(msg) - - choice = game_utils.waitMsg(player.name, timeout=20) - if choice is None: - player.show(render_config_prompt("公会列表分页超时提示词")) - return True - if choice == "+": - page = min(page + 1, max_page) - elif choice == "-": - page = max(page - 1, 1) - elif choice == "q": - player.show(render_config_prompt("公会列表分页退出提示词")) - return True - - return True - - -def _handle_join_guild(self, player: Player) -> bool: - """处理加入公会申请""" - if self.guild_manager.get_guild_by_player(player.name): - player.show("§l§a公会 §d>> §r你已经加入了一个公会") - return True - - player.show("§l§a公会 §d>> §r请输入公会名字(支持模糊搜索)") - search_name = game_utils.waitMsg(player.name, timeout=30) - - if not search_name: - player.show("§l§a公会 §d>> §r公会名字不能为空") - return True - - # 搜索匹配的公会 - guilds = self.guild_manager.load_guilds() - matched_guilds = [g for g in guilds.values() if search_name.lower() - in g.name.lower()] - - if not matched_guilds: - player.show("§l§a公会 §d>> §r未找到匹配的公会") - return True - - # 选择公会 - if len(matched_guilds) == 1: - target_guild = matched_guilds[0] - else: - def formatter(i, g): - """Format one menu item for display.""" - return f"{i}. {g.name} (会长:{g.owner})\n" - idx = self._paginate_display( - player, matched_guilds, "选择公会", formatter, True) - if idx is None: - return True - target_guild = matched_guilds[idx] - - # 检查人数上限 - if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: - player.show("§l§a公会 §d>> §r该公会已满员") - return True - - reason = "" - join_config = getattr(Config, "GUILD_JOIN_REQUEST_CONFIG", {}) - if join_config.get("启用离线申请队列", True): - player.show("§l§a公会 §d>> §r请输入申请理由,可直接发送空白跳过") - reason_input = game_utils.waitMsg(player.name, timeout=60) - reason = reason_input or "" - - guilds = self.guild_manager.load_guilds(force_reload=True) - latest_guild = guilds.get(target_guild.guild_id) - if not latest_guild: - player.show("§l§a公会 §d>> §r公会数据异常") - return True - - if not latest_guild.add_join_request(player.name, reason): - player.show("§l§a公会 §d>> §r申请提交失败,可能已有待处理申请或队列已满") - return True - - self.guild_manager.save_guilds(guilds) - self._notify_join_request_admins(latest_guild, player.name) - player.show(f"§l§a公会 §d>> §r申请已提交至 §e{latest_guild.name} §r的申请队列") - - return True - - -def _handle_leave_guild(self, player: Player) -> bool: - """处理退出公会""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你未加入任何公会") - return True - - member = guild.get_member(player.name) - if member and member.rank == GuildRank.OWNER: - player.show("§l§a公会 §d>> §r会长不能退出公会,只能解散公会") - player.show("§7请使用 '解散' 选项来解散公会") - return True - - guild_name = self.guild_manager.remove_member(player.name) - if guild_name: - player.show(f"§l§a公会 §d>> §r已退出公会 {guild_name}") - else: - player.show("§l§a公会 §d>> §r退出失败") - return True - - -def _handle_dissolve_guild(self, player: Player, player_xuid: str) -> bool: - """处理解散公会""" - _ = player_xuid - guild = self.guild_manager.get_guild_by_player(player.name) - member = guild.get_member(player.name) if guild else None - - if not guild or not member or member.rank != GuildRank.OWNER: - player.show("§l§a公会 §d>> §r你不是任何公会的会长") - return True - - player.show(f"§l§a公会 §d>> §r确定要解散公会 §e{guild.name} §r吗?") - player.show("§c此操作不可撤销!输入'确认解散'继续") - confirm = game_utils.waitMsg(player.name, timeout=30) - - if confirm != "确认解散": - player.show("§l§a公会 §d>> §r操作已取消") - return True - - # 通知所有在线成员 - message = f"§l§a公会 §d>> §r公会 §e{guild.name}§r 已被解散" - for member in guild.members: - if member.name in self.game_ctrl.allplayers: - self.game_ctrl.sendcmd( - f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' - ) - - guilds = self.guild_manager.load_guilds(force_reload=True) - if guild.guild_id in guilds: - del guilds[guild.guild_id] - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a公会 §d>> §r已解散公会 §e{guild.name}") - - return True - - -def _handle_kick_member(self, player: Player) -> bool: - """处理踢出成员""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild or not guild.has_permission(player.name, "kick"): - player.show("§l§a公会 §d>> §r你没有踢人权限") - return True - - # 使用新的权限检查逻辑过滤可踢出的成员 - kickable_members = [] - for member in guild.members: - if guild.can_manage_member(player.name, member.name): - kickable_members.append(member) - - if not kickable_members: - player.show("§l§a公会 §d>> §r没有可踢出的成员") - return True - - def formatter(i, m): - """Format one menu item for display.""" - return f"§e{i}. {m.rank.display_name} §f{m.name}\n" - idx = self._paginate_display( - player, - kickable_members, - "踢出成员", - formatter, - True) - - if idx is not None: - target = kickable_members[idx] - self.guild_manager.remove_member(target.name) - player.show(f"§l§a公会 §d>> §r已将 {target.name} 踢出公会") - - # 通知被踢玩家 - if target.name in self.game_ctrl.allplayers: - self.game_ctrl.sendcmd( - f'/tellraw {target.name} ' - '{"rawtext":[{"text":"§l§a公会 §d>> §r你已被踢出公会"}]}' - ) - - return True - - -def _handle_set_base(self, player: Player) -> bool: - """处理设置据点 - 增强版本""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - if not guild.has_permission(player.name, "setbase"): - player.show("§l§a公会 §d>> §r你没有设置公会据点权限") - return True - - try: - fmts.print_inf(f"开始为公会 {guild.name} 设置据点") - pos = game_utils.getPos(player.name) - - if not pos: - player.show("§l§a公会 §d>> §r获取位置失败,请稍后重试") - fmts.print_err("获取位置失败: pos为空") - return True - - dimension = pos.get("dimension", -1) - if dimension != 0: - player.show("§l§a公会 §d>> §r据点只能设置在主世界") - fmts.print_err(f"设置据点失败: 维度不是主世界 (维度={dimension})") - return True - - # 确保position字段存在 - if "position" not in pos: - player.show("§l§a公会 §d>> §r获取位置信息不完整,请稍后重试") - fmts.print_err(f"设置据点错误: 位置信息不完整 {pos}") - return True - - position = pos["position"] - x = position.get("x", 0) - y = position.get("y", 0) - z = position.get("z", 0) - - base = GuildBase( - dimension=dimension, - x=x, - y=y, - z=z - ) - - # 打印调试信息 - fmts.print_inf(f"正在设置据点: 维度={dimension}, 坐标=({x}, {y}, {z})") - - # 直接保存到当前公会对象 - guild.base = base - guild.add_log(f"{player.name} 设置了据点") - - # 重新加载公会数据以确保使用最新数据 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild_id = guild.guild_id - - # 确保公会ID存在于加载的数据中 - if guild_id not in guilds: - player.show("§l§a公会 §d>> §r公会数据加载失败,请稍后重试") - fmts.print_err(f"设置据点错误: 公会ID {guild_id} 不存在于加载的数据中") - return True - - # 更新公会数据 - guilds[guild_id].base = base - guilds[guild_id].add_log(f"{player.name} 设置了据点") - - # 保存数据 - self.guild_manager.save_guilds(guilds) - - # 再次检查是否保存成功 - check_guilds = self.guild_manager.load_guilds(force_reload=True) - if guild_id in check_guilds and check_guilds[guild_id].base: - check_base = check_guilds[guild_id].base - fmts.print_inf( - f"据点设置成功: 公会={ - guild.name}, 维度={ - check_base.dimension}, 坐标=({ - check_base.x}, { - check_base.y}, { - check_base.z})") - player.show(f"§l§a公会 §d>> §r据点已设置为 ({x:.1f}, {y:.1f}, {z:.1f})") - else: - player.show("§l§a公会 §d>> §r据点设置可能失败,请重试") - fmts.print_err(f"据点设置后验证失败: 公会={guild.name}") - - except Exception as e: - player.show("§l§a公会 §d>> §r获取位置失败,请稍后重试") - fmts.print_err(f"设置据点错误: {e}") - import traceback - fmts.print_err(traceback.format_exc()) - - return True - - -def _handle_return_base(self, player: Player) -> bool: - """处理返回据点 - 增强版本""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你没有加入任何公会") - return True - if not guild.has_permission(player.name, "return_base"): - player.show("§l§a公会 §d>> §r你没有返回公会据点权限") - return True - - # 重新加载公会数据以确保使用最新数据 - try: - guilds = self.guild_manager.load_guilds(force_reload=True) - guild_id = guild.guild_id - - # 确保公会ID存在于加载的数据中 - if guild_id not in guilds: - player.show("§l§a公会 §d>> §r公会数据加载失败,请稍后重试") - fmts.print_err(f"传送失败: 公会ID {guild_id} 不存在于加载的数据中") - return True - - # 使用最新的公会数据 - guild = guilds[guild_id] - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - if isinstance( - getattr( - guild, - "settings", - None), - dict) and guild.settings.get( - "base_locked", - False): - player.show("§l§a公会 §d>> §r公会据点已被锁定") - return True - - base = guild.base - if not base: - player.show("§l§a公会 §d>> §r公会尚未设置据点") - fmts.print_err(f"传送失败: 公会 {guild.name} 没有设置据点") - return True - - # 验证据点数据完整性 - if not all(hasattr(base, attr) - for attr in ['dimension', 'x', 'y', 'z']): - player.show("§l§a公会 §d>> §r据点数据损坏,请重新设置") - fmts.print_err(f"传送失败: 据点数据不完整 {base}") - return True - - # 输出详细的据点信息用于调试 - fmts.print_inf(f"开始传送: 玩家={player.name}, 公会={guild.name}") - fmts.print_inf( - f"据点信息: 维度={ - base.dimension}, 坐标=({ - base.x}, { - base.y}, { - base.z})") - - # 检查维度是否有效 - if base.dimension not in [0, -1, 1]: # 主世界、下界、末地 - player.show("§l§a公会 §d>> §r据点维度无效,请重新设置") - fmts.print_err(f"传送失败: 无效维度 {base.dimension}") - return True - - # 准备传送 - player.show("§l§a公会 §d>> §r准备传送到据点...") - player.show("§7请保持静止3秒...") - - # 延迟传送 - time.sleep(3) - - # 获取坐标并确保为数值类型 - x = float(base.x) - y = float(base.y) - z = float(base.z) - fmts.print_inf(f"传送玩家 {player.name} 到据点: ({x}, {y}, {z})") - - # 尝试多种传送方法,按成功率排序 - success = False - - try: - cmd = f"tp {player.name} {x} {y} {z}" - fmts.print_inf(f"方法1 - 使用sendwocmd执行: {cmd}") - self.game_ctrl.sendwocmd(cmd) - success = True - fmts.print_inf("方法1 - sendwocmd传送成功") - except Exception as e: - fmts.print_err(f"方法1 - sendwocmd失败: {e}") - - if success: - player.show(f"§l§a公会 §d>> §r已传送到公会据点 ({x:.1f}, {y:.1f}, {z:.1f})") - fmts.print_inf(f"传送成功: {player.name} -> ({x}, {y}, {z})") - else: - player.show("§l§a公会 §d>> §r传送失败,所有传送方式都无效") - fmts.print_err("所有传送指令都失败了") - - except Exception as e: - player.show("§l§a公会 §d>> §r传送过程中发生错误,请稍后重试") - fmts.print_err(f"传送到据点时发生异常: {e}") - import traceback - fmts.print_err(traceback.format_exc()) - - return True - - -def _handle_transfer_ownership(self, player: Player) -> bool: - """转让会长""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - return True - if not guild.has_permission(player.name, "transfer_owner"): - player.show("§l§a公会 §d>> §r你没有转让会长权限") - return True - - # 选择新会长 - other_members = [m for m in guild.members if m.name != player.name] - if not other_members: - player.show("§l§a公会 §d>> §r没有其他成员") - return True - - def formatter(i, m): - """Format one menu item for display.""" - return f"§e{i}. {m.rank.display_name} §f{m.name}\n" - idx = self._paginate_display( - player, other_members, "选择新会长", formatter, True) - - if idx is None: - return True - - new_owner = other_members[idx] - - player.show(f"§l§a公会 §d>> §r确定要将会长转让给 §e{new_owner.name}§r 吗?") - player.show("§c此操作不可撤销!输入'确认'继续") - - confirm = game_utils.waitMsg(player.name, timeout=30) - if confirm != "确认": - player.show("§l§a公会 §d>> §r操作已取消") - return True - - guilds = self.guild_manager.load_guilds(force_reload=True) - latest_guild = guilds.get(guild.guild_id) - if not latest_guild: - player.show("§l§a公会 §d>> §r公会数据异常") - return True - - old_owner = latest_guild.get_member(player.name) - latest_new_owner = latest_guild.get_member(new_owner.name) - if not old_owner or not latest_new_owner: - player.show("§l§a公会 §d>> §r成员数据异常") - return True - - latest_guild.owner = latest_new_owner.name - latest_new_owner.rank = GuildRank.OWNER - old_owner.rank = GuildRank.DEPUTY - - latest_guild.add_log(f"{player.name} 将会长转让给了 {latest_new_owner.name}") - latest_guild.add_audit_log("transfer_owner", player.name, - target=latest_new_owner.name) - self.guild_manager.save_guilds(guilds) - - player.show(f"§l§a公会 §d>> §r已将会长转让给 {latest_new_owner.name}") - - # 通知新会长 - if latest_new_owner.name in self.game_ctrl.allplayers: - self.game_ctrl.sendcmd( - f'/tellraw {latest_new_owner.name} ' - '{"rawtext":[{"text":"§l§a公会 §d>> §r你已成为公会会长!"}]}' - ) - - return True - - -handlers = { - '_handle_effect': _handle_effect, - '_handle_rankings': _handle_rankings, - '_format_time_ago': _format_time_ago, - '_handle_view_guild': _handle_view_guild, - '_handle_view_members': _handle_view_members, - '_handle_view_logs': _handle_view_logs, - '_handle_announcement': _handle_announcement, - '_handle_tasks': _handle_tasks, - '_handle_view_tasks': _handle_view_tasks, - '_handle_join_task': _handle_join_task, - '_handle_create_task': _handle_create_task, - '_handle_generate_auto_tasks': _handle_generate_auto_tasks, - '_handle_manage_tasks': _handle_manage_tasks, - '_handle_manage_members': _handle_manage_members, - '_notify_join_request_admins': _notify_join_request_admins, - '_handle_join_request_queue': _handle_join_request_queue, - '_handle_set_rank': _handle_set_rank, - '_handle_donation': _handle_donation, - '_handle_vault': _handle_vault, - '_handle_vault_view': _handle_vault_view, - '_handle_vault_buy': _handle_vault_buy, - '_handle_vault_sell': _handle_vault_sell, - '_handle_vault_logs': _handle_vault_logs, - '_handle_vault_cancel': _handle_vault_cancel, - '_handle_vault_settings': _handle_vault_settings, - '_handle_create_guild': _handle_create_guild, - '_handle_list_guilds': _handle_list_guilds, - '_handle_join_guild': _handle_join_guild, - '_handle_leave_guild': _handle_leave_guild, - '_handle_dissolve_guild': _handle_dissolve_guild, - '_handle_kick_member': _handle_kick_member, - '_handle_set_base': _handle_set_base, - '_handle_return_base': _handle_return_base, - '_handle_transfer_ownership': _handle_transfer_ownership, -} +"""Interactive guild menu handlers.""" + +# pylint: disable=protected-access + +import json +import time +import uuid +from datetime import datetime + +from tooldelta import Player, game_utils, fmts +from guild_cloud_interop.models import ( + GuildBase, + GuildData, + GuildMember, + GuildRank, + GuildTask, + VaultItem, +) +from guild_cloud_interop.config import Config +from guild_cloud_interop.prompts import render_config_prompt, render_create_guild_prompt +from guild_cloud_interop.ui import ORION_BORDER, TITLE_PREFIX, format_page_footer +from guild_cloud_interop.validators import InputValidator + + +def _handle_effect(self, player: Player) -> bool: # skipcq: PY-R1000 + """Handle guild effect purchases.""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + if not guild.has_permission(player.name, "effect_buy"): + player.show("§l§a公会 §d>> §r你没有购买公会效果权限") + return True + + # 显示可选效果 + player.show("§l§a公会 §d>> §r可用效果列表:") + + for key, val in Config.EFFECTS_CONFIG.items(): + try: + # 检查配置完整性 + if "name" not in val: + fmts.print_err(f"效果配置错误: {key} 缺少 name 字段") + continue + + if "costs" not in val: + fmts.print_err(f"效果配置错误: {key} 缺少 costs 字段") + continue + + purchased_lv = guild.purchased_effects.get(key) + costs_str_list = [] + + for lv, cost in val["costs"].items(): + if purchased_lv == lv: + color = "§b" + else: + color = "§7" + costs_str_list.append(f"{color}Lv{lv}:{cost}钻§r") + + costs_str = " ".join(costs_str_list) + player.show(f"§r§f>> {val['name']} §r({costs_str})") + + except Exception as e: + fmts.print_err(f"处理效果 {key} 时出错: {e}") + continue + + player.show("§r§7>> 输入效果选择升级") + choice = game_utils.waitMsg(player.name) + + effect_key = None + for k, v in Config.EFFECTS_CONFIG.items(): + if v['name'] == choice: + effect_key = k + break + + if not effect_key: + player.show("§c无效效果") + return True + + selected = Config.EFFECTS_CONFIG[effect_key] + + # 判断钻石数量 + diamond_count = player.getItemCount("minecraft:diamond") + player.show(f"§7当前钻石数量: {diamond_count}") + + player.show("§7请输入等级 (1/2/3)") + lv_choice = game_utils.waitMsg(player.name) + if not lv_choice.isdigit() or int(lv_choice) not in selected["costs"]: + player.show("§c无效等级") + return True + + lv_choice = int(lv_choice) + cost = selected["costs"][lv_choice] + + if diamond_count < cost: + player.show(f"§c钻石不足,{cost}钻石才能购买") + return True + + # 扣除钻石 + self.game_ctrl.sendwocmd( + f"clear {player.safe_name} minecraft:diamond 0 {cost}") + + # 保存公会已购买效果 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_id = guild.guild_id + if guild_id in guilds: + guild = guilds[guild_id] + guild.purchased_effects[effect_key] = lv_choice + guild.add_log( + f"{player.name} 为公会购买了 {selected['name']} 等级{lv_choice} 效果") + self.guild_manager.save_guilds(guilds) + else: + player.show("§c保存失败,公会不存在") + return True + + # 购买后立即给在线成员补发效果,后续由低频兜底刷新,避免周期性全量刷命令。 + for member in guild.members: + if member.name in self.game_ctrl.allplayers: + self._apply_guild_effects_to_player( + member.name, + guild=guild, + force=True, + effect_names={effect_key}, + ) + + player.show(f"§a已激活效果: {selected['name']} 等级{lv_choice}") + + return True + + +def _handle_rankings(self, player: Player) -> bool: + """处理公会排行榜""" + player.show("§r========== §a公会排行榜§r ==========") + player.show("§e1. §f等级排行") + player.show("§e2. §f成员数量排行") + player.show("§e3. §f贡献度排行") + player.show("§e4. §f活跃度排行") + player.show("§7输入选项序号:") + + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "1": + rankings = self.get_guild_rankings("level") + title = "公会等级排行榜" + + def formatter(i, data): + """Format one menu item for display.""" + return ( + f"§e{i}. §r{data[0].name} §7Lv.{data[1]}\n" + f" §7会长: §f{data[0].owner} §7| 成员: §a{len(data[0].members)}\n" + ) + elif choice == "2": + rankings = self.get_guild_rankings("members") + title = "公会成员数排行榜" + + def formatter(i, data): + """Format one menu item for display.""" + return ( + f"§e{i}. §r{data[0].name} §7成员: §a{data[1]}\n" + f" §7会长: §f{data[0].owner} §7| 等级: §e{data[0].level}\n" + ) + elif choice == "3": + rankings = self.get_guild_rankings("contribution") + title = "公会贡献度排行榜" + + def formatter(i, data): + """Format one menu item for display.""" + return ( + f"§e{i}. §r{data[0].name} §7贡献: §b{data[1]}\n" + f" §7会长: §f{data[0].owner} §7| 等级: §e{data[0].level}\n" + ) + elif choice == "4": + rankings = self.get_guild_rankings("activity") + title = "公会活跃度排行榜" + + def formatter(i, data): + """Format one menu item for display.""" + return ( + f"§e{i}. §r{data[0].name}\n" + f" §7会长: §f{data[0].owner} " + f"§7| 最近活跃: §a{self._format_time_ago(data[1])}\n" + ) + else: + player.show("§c无效选项") + return True + + if not rankings: + player.show("§l§a公会 §d>> §r暂无公会数据") + return True + + self._paginate_display(player, rankings, title, formatter) + return True + + +def _format_time_ago(self, timestamp: float) -> str: + """格式化时间差显示""" + if self is None: + return "从未" + if timestamp == 0: + return "从未" + + current_time = time.time() + diff = current_time - timestamp + + if diff < 60: + return "刚刚" + if diff < 3600: + return f"{int(diff // 60)}分钟前" + if diff < 86400: + return f"{int(diff // 3600)}小时前" + return f"{int(diff // 86400)}天前" + + +def _handle_view_guild(self, player: Player) -> bool: + """查看公会详细信息""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + + level = guild.level + exp = guild.exp + required_exp = Config.GUILD_LEVEL_EXP.get(level + 1, "MAX") + + msg = f"§l§a{guild.name}§r §7(ID: {guild.guild_id[:8]}...)\n" + msg += f"§7创建时间: §f{datetime.fromtimestamp(guild.create_time).strftime('%Y-%m-%d')}\n" + msg += f"§7会长: §e{guild.owner}\n" + msg += f"§7等级: §e{level} §7经验: §b{exp}/{required_exp}\n" + msg += f"§7成员: §a{len(guild.members)}/{Config.MAX_GUILD_MEMBERS}\n" + msg += f"§7仓库容量: §a{Config.VAULT_INITIAL_SLOTS} 格\n" + + if guild.announcement: + msg += f"\n§e公告: §f{guild.announcement}\n" + + if guild.base: + base = guild.base + dim_name = Config.DIMENSION_NAMES.get( + base.dimension, f"维度{base.dimension}") + msg += f"\n§7据点: §f{dim_name} ({base.x:.1f}, {base.y:.1f}, {base.z:.1f})" + + player.show(msg) + return True + + +def _handle_view_members(self, player: Player) -> bool: + """查看成员列表""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + + # 按职位和贡献度排序 + sorted_members = sorted( + guild.members, + key=lambda m: ( + ["owner", "deputy", "elder", "member"].index(m.rank.value), + -m.contribution + ) + ) + + def formatter(i, member: GuildMember): + """Format one menu item for display.""" + online = member.name in self.game_ctrl.allplayers + online_status = "§a在线" if online else "§7离线" + days_since_join = (time.time() - member.join_time) / 86400 + + return (f"§e{i}. {member.rank.display_name} §f{member.name} " + f"[{online_status}§f]\n" + f" §7贡献: §b{member.contribution} §7| " + f"加入: §f{int(days_since_join)}天前\n") + + self._paginate_display( + player, sorted_members, f"{guild.name} 成员列表", formatter) + return True + + +def _handle_view_logs(self, player: Player) -> bool: + """查看公会日志""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + + msg = f"§r========== §a{guild.name} 日志§r ==========\n" + if guild.logs: + for log in guild.logs[-20:]: + msg += f"§7{log}\n" + else: + msg += "§7暂无普通日志记录\n" + + if guild.has_permission(player.name, "audit_log"): + msg += f"\n§r========== §a{guild.name} 审计日志§r ==========\n" + if guild.audit_logs: + for log in guild.audit_logs[-20:]: + time_str = datetime.fromtimestamp( + log.timestamp).strftime("%m-%d %H:%M") + target = f" §7| 目标: §f{log.target}" if log.target else "" + detail = f" §7| 详情: §f{log.detail}" if log.detail else "" + msg += ( + f"§7[{time_str}] §f{log.action} §7| 操作者: §e{log.actor}" + f"{target}{detail} §7| 结果: §f{log.result}\n" + ) + else: + msg += "§7暂无审计日志记录\n" + + player.show(msg) + return True + + +def _handle_announcement(self, player: Player) -> bool: + """处理公会公告""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + + # 显示当前公告 + if guild.announcement: + player.show(f"§l§a公会公告§r\n§f{guild.announcement}") + else: + player.show("§l§a公会 §d>> §r当前没有公告") + + # 检查权限 + if not guild.has_permission(player.name, "announce"): + return True + + player.show("§7输入 '设置' 来修改公告,其他任意键返回") + choice = game_utils.waitMsg(player.name, timeout=20) + + if choice == "设置": + player.show("§l§a公会 §d>> §r请输入新的公告内容:") + player.show("§7要求: 不超过200个字符,不能为空") + new_announcement = game_utils.waitMsg(player.name, timeout=60) + + # 使用新的输入验证 + is_valid, error_msg = InputValidator.validate_announcement( + new_announcement) + if not is_valid: + player.show(f"§l§a公会 §d>> §r{error_msg}") + return True + + try: + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_data = guilds.get(guild.guild_id) + if not guild_data: + player.show("§l§a公会 §d>> §r公会数据异常") + return True + + guild_data.announcement = new_announcement + guild_data.add_log(f"{player.name} 更新了公告") + self.guild_manager.save_guilds(guilds) + player.show("§l§a公会 §d>> §r公告已更新") + + # 通知在线成员 + message = "§l§a公会 §d>> §r公告已更新,输入 .公会 公告 查看" + for member in guild_data.members: + if member.name in self.game_ctrl.allplayers: + self.game_ctrl.sendcmd( + f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + except Exception as e: + player.show("§l§a公会 §d>> §r更新公告失败") + fmts.print_err(f"更新公告时出错:{e}") + + return True + + +def _handle_tasks(self, player: Player) -> bool: # skipcq: PY-R1000 + """处理公会任务系统""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + while True: + latest_guild = self.guild_manager.get_guild_by_player( + player.name, force_reload=True) + if latest_guild: + guild = latest_guild + + player.show("§r========== §a公会任务§r ==========") + + # 显示活跃任务统计 + active_tasks = [t for t in guild.tasks if not t.completed] + completed_tasks = [t for t in guild.tasks if t.completed] + + player.show( + f"§7活跃任务: §e{len(active_tasks)} §7| 已完成: §a{len(completed_tasks)}") + can_manage_legacy = guild.has_permission(player.name, "task_manage") + can_create = guild.has_permission( + player.name, "task_create") or can_manage_legacy + can_delete = guild.has_permission( + player.name, "task_delete") or can_manage_legacy + can_complete = guild.has_permission( + player.name, "task_complete") or can_manage_legacy + can_manage = can_delete or can_complete + can_auto = ( + can_create + and getattr(Config, "GUILD_TASK_CONFIG", {}).get("启用自动任务模板", True) + ) + + menu_options = [ + ("查看", "查看所有任务", True), + ("参与", "参与任务", len(active_tasks) > 0), + ("创建", "创建新任务", can_create), + ("自动", "生成自动任务模板", can_auto), + ("管理", "管理任务", can_manage), + ("退出", "退出任务系统", True) + ] + + available_options = [(cmd, desc) + for cmd, desc, cond in menu_options if cond] + + for cmd, desc in available_options: + player.show(f"§e● {cmd} §7- {desc}") + + player.show("§7输入选项:") + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "查看": + self._handle_view_tasks(player, guild) + elif choice == "参与" and len(active_tasks) > 0: + self._handle_join_task(player, guild) + elif choice == "创建" and can_create: + self._handle_create_task(player, guild) + elif choice == "自动" and can_auto: + self._handle_generate_auto_tasks(player, guild) + elif choice == "管理" and can_manage: + self._handle_manage_tasks(player, guild) + elif choice == "退出" or choice is None: + break + else: + player.show("§c无效选项") + + return True + + +def _handle_view_tasks(self, player: Player, guild: GuildData) -> bool: + """查看任务列表""" + if not guild.tasks: + player.show("§l§a公会任务 §d>> §r暂无任务") + return True + + def formatter(i, task: GuildTask): + """Format one menu item for display.""" + status = "§a已完成" if task.completed else f"§e进行中 ({task.current_count}/{task.target_count})" + progress_bar = self._create_progress_bar( + task.current_count, task.target_count) + + deadline_str = "" + if task.deadline > 0: + remaining = task.deadline - time.time() + if remaining > 0: + deadline_str = f" §7| 剩余: §f{self._format_time_duration(remaining)}" + else: + deadline_str = " §7| §c已过期" + + return ( + f"§e{i}. §f{task.name} [{status}§f]\n" + f" §7{task.description}\n" + f" {progress_bar}{deadline_str}\n" + f" §7奖励: §b{task.reward_contribution}贡献点 " + f"§7+ §e{task.reward_exp}经验\n" + ) + + self._paginate_display(player, guild.tasks, "公会任务列表", formatter) + return True + + +def _handle_join_task(self, player: Player, guild: GuildData) -> bool: + """参与任务""" + active_tasks = [t for t in guild.tasks if not t.completed] + + if not active_tasks: + player.show("§l§a公会任务 §d>> §r暂无可参与的任务") + return True + + def formatter(i, task: GuildTask): + """Format one menu item for display.""" + status = f"§e进行中 ({task.current_count}/{task.target_count})" + is_participant = player.name in task.participants + participant_status = " §a[已参与]" if is_participant else " §7[未参与]" + + return ( + f"§e{i}. §f{task.name} [{status}§f]{participant_status}\n" f" §7{task.description}\n" f" §7奖励: §b{task.reward_contribution}贡献点 §7+ §e{task.reward_exp}经验\n") + + idx = self._paginate_display( + player, active_tasks, "选择参与任务", formatter, True) + if idx is None: + return True + + task = active_tasks[idx] + + if player.name in task.participants: + player.show("§l§a公会任务 §d>> §r你已经参与了这个任务") + return True + + # 加入任务 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild: + for t in guild.tasks: + if t.task_id == task.task_id: + t.participants.append(player.name) + guild.add_log(f"{player.name} 参与了任务: {t.name}") + break + + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会任务 §d>> §r已参与任务: {task.name}") + + return True + + +def _handle_create_task( # skipcq: PY-R1000 + self, + player: Player, + guild: GuildData, +) -> bool: + """创建新任务""" + if not ( + guild.has_permission( + player.name, + "task_create") or guild.has_permission( + player.name, + "task_manage")): + player.show("§l§a公会任务 §d>> §r你没有创建任务权限") + return True + + task_config = getattr(Config, "GUILD_TASK_CONFIG", {}) + max_name_len = int(task_config.get("创建任务名称最大长度", 20)) + max_desc_len = int(task_config.get("创建任务描述最大长度", 100)) + max_target_count = int(task_config.get("创建任务目标数量上限", 10000)) + max_contribution_reward = int(task_config.get("创建任务贡献奖励上限", 1000)) + max_exp_reward = int(task_config.get("创建任务经验奖励上限", 1000)) + + player.show("§r========== §a创建任务§r ==========") + player.show("§7任务类型:") + player.show("§e1. §f收集任务 (收集指定物品)") + player.show("§e2. §f建造任务 (放置指定方块)") + player.show("§e3. §f贸易任务 (进行仓库交易)") + player.show("§7输入任务类型序号:") + + task_type_choice = game_utils.waitMsg(player.name, timeout=30) + + if task_type_choice == "1": + task_type = "collect" + type_name = "收集任务" + elif task_type_choice == "2": + task_type = "build" + type_name = "建造任务" + elif task_type_choice == "3": + task_type = "trade" + type_name = "贸易任务" + else: + player.show("§c无效的任务类型") + return True + + # 获取任务名称 + player.show(f"§l§a创建{type_name} §d>> §r请输入任务名称:") + task_name = game_utils.waitMsg(player.name, timeout=30) + if not task_name or len(task_name) > max_name_len: + player.show("§c任务名称无效或过长") + return True + + # 获取任务描述 + player.show("§l§a创建任务 §d>> §r请输入任务描述:") + description = game_utils.waitMsg(player.name, timeout=60) + if not description or len(description) > max_desc_len: + player.show("§c任务描述无效或过长") + return True + + # 获取目标 + if task_type == "collect": + player.show("§l§a创建任务 §d>> §r请输入目标物品名称:") + player.show("§7支持中文名称,如: 钻石、铁锭、金块等") + player.show("§7也支持英文ID,如: minecraft:diamond") + + user_input = game_utils.waitMsg(player.name, timeout=30) + if not user_input: + player.show("§c输入为空") + return True + + # 使用智能匹配查找物品ID + item_id, suggestions = self.item_matcher.validate_and_suggest( + user_input) + + if not item_id: + player.show("§c未找到匹配的物品") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + return True + + target = item_id + chinese_name = self.item_matcher.get_chinese_name(item_id) + player.show(f"§l§a创建任务 §d>> §r目标物品: §f{chinese_name}") + + elif task_type == "build": + player.show("§l§a创建任务 §d>> §r请输入目标方块名称:") + player.show("§7支持中文名称,如: 石头、圆石、橡木等") + player.show("§7也支持英文ID,如: minecraft:stone") + + user_input = game_utils.waitMsg(player.name, timeout=30) + if not user_input: + player.show("§c输入为空") + return True + + # 使用智能匹配查找方块ID + block_id, suggestions = self.item_matcher.validate_and_suggest( + user_input) + + if not block_id: + player.show("§c未找到匹配的方块") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + return True + + target = block_id + chinese_name = self.item_matcher.get_chinese_name(block_id) + player.show(f"§l§a创建任务 §d>> §r目标方块: §f{chinese_name}") + + else: # trade + target = "trade_count" + + # 获取目标数量 + player.show("§l§a创建任务 §d>> §r请输入目标数量:") + count_str = game_utils.waitMsg(player.name, timeout=30) + if not count_str or not count_str.isdigit(): + player.show("§c无效的数量") + return True + + target_count = int(count_str) + if target_count <= 0 or target_count > max_target_count: + player.show(f"§c数量必须在1-{max_target_count}之间") + return True + + # 获取奖励 + player.show("§l§a创建任务 §d>> §r请输入贡献点奖励:") + contrib_str = game_utils.waitMsg(player.name, timeout=30) + if not contrib_str or not contrib_str.isdigit(): + player.show("§c无效的贡献点数量") + return True + + reward_contribution = int(contrib_str) + if reward_contribution < 0 or reward_contribution > max_contribution_reward: + player.show(f"§c贡献点奖励必须在0-{max_contribution_reward}之间") + return True + + player.show("§l§a创建任务 §d>> §r请输入经验奖励:") + exp_str = game_utils.waitMsg(player.name, timeout=30) + if not exp_str or not exp_str.isdigit(): + player.show("§c无效的经验数量") + return True + + reward_exp = int(exp_str) + if reward_exp < 0 or reward_exp > max_exp_reward: + player.show(f"§c经验奖励必须在0-{max_exp_reward}之间") + return True + + task_id = uuid.uuid4().hex[:8] + new_task = GuildTask( + task_id=task_id, + name=task_name, + description=description, + task_type=task_type, + target=target, + target_count=target_count, + reward_contribution=reward_contribution, + reward_exp=reward_exp + ) + + # 保存任务 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild: + guild.tasks.append(new_task) + guild.add_log(f"{player.name} 创建了任务: {task_name}") + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会任务 §d>> §r任务 '{task_name}' 创建成功!") + + return True + + +def _handle_generate_auto_tasks( # skipcq: PY-R1000 + self, + player: Player, + guild: GuildData) -> bool: + """按配置模板生成自动任务""" + if not ( + guild.has_permission( + player.name, + "task_create") or guild.has_permission( + player.name, + "task_manage")): + player.show("§l§a公会任务 §d>> §r你没有生成任务权限") + return True + + task_config = getattr(Config, "GUILD_TASK_CONFIG", {}) + if not task_config.get("启用自动任务模板", True): + player.show("§l§a公会任务 §d>> §r自动任务模板未启用") + return True + + templates = task_config.get("自动任务模板列表", []) + if not templates: + player.show("§l§a公会任务 §d>> §r暂无自动任务模板") + return True + + guilds = self.guild_manager.load_guilds(force_reload=True) + latest_guild = guilds.get(guild.guild_id) + if not latest_guild: + player.show("§l§a公会任务 §d>> §r公会数据异常") + return True + + active_auto_tasks = [ + task for task in latest_guild.tasks + if not task.completed and task.task_id.startswith("auto-") + ] + max_active = int(task_config.get("自动任务最大同时存在数量", 6)) + if len(active_auto_tasks) >= max_active > 0: + player.show(f"§l§a公会任务 §d>> §r自动任务数量已达上限 {max_active}") + return True + + generate_count = int(task_config.get("每次生成自动任务数量", 3)) + if max_active > 0: + generate_count = min( + generate_count, + max_active - + len(active_auto_tasks)) + if generate_count <= 0: + player.show("§l§a公会任务 §d>> §r无需生成新的自动任务") + return True + + active_keys = { + (task.name, task.task_type, task.target) + for task in latest_guild.tasks + if not task.completed + } + candidates = [ + template for template in templates + if ( + template.get("name"), + template.get("task_type"), + template.get("target"), + ) not in active_keys + ] + if not candidates: + player.show("§l§a公会任务 §d>> §r所有自动任务模板都已存在") + return True + + now = time.time() + deadline_seconds = int(task_config.get("自动任务默认有效期秒", 172800)) + deadline = now + deadline_seconds if deadline_seconds > 0 else 0 + created_tasks = [] + for template in candidates[:generate_count]: + task = GuildTask( + task_id=f"auto-{uuid.uuid4().hex[: 8]} ", + name=str(template.get("name", "自动任务"))[: 20], + description=str(template.get("description", ""))[: 100], + task_type=str(template.get("task_type", "trade")), + target=str(template.get("target", "trade_count")), + target_count=max(1, int(template.get("target_count", 1))), + reward_exp=max(0, int(template.get("reward_exp", 0))), + reward_contribution=max( + 0, int(template.get("reward_contribution", 0))), + create_time=now, deadline=deadline,) + latest_guild.tasks.append(task) + created_tasks.append(task) + + latest_guild.add_log(f"{player.name} 生成了 {len(created_tasks)} 个自动任务") + latest_guild.add_audit_log( + "task_auto_generate", + player.name, + detail=",".join(task.name for task in created_tasks), + ) + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会任务 §d>> §r已生成 {len(created_tasks)} 个自动任务") + return True + + +def _handle_manage_tasks( # skipcq: PY-R1000 + self, + player: Player, + guild: GuildData, +) -> bool: + """管理任务""" + can_manage_legacy = guild.has_permission(player.name, "task_manage") + can_delete = guild.has_permission( + player.name, "task_delete") or can_manage_legacy + can_complete = guild.has_permission( + player.name, "task_complete") or can_manage_legacy + + if not can_delete and not can_complete: + player.show("§l§a公会任务 §d>> §r你没有管理任务权限") + return True + + player.show("§r========== §a任务管理§r ==========") + if can_delete: + player.show("§e1. §f删除任务") + if can_complete: + player.show("§e2. §f完成任务") + player.show("§7输入选项序号:") + + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "1" and can_delete: + if not guild.tasks: + player.show("§l§a公会任务 §d>> §r暂无任务") + return True + # 删除任务 + + def formatter(i, task: GuildTask): + """Format one menu item for display.""" + status = "§a已完成" if task.completed else "§e进行中" + return f"§e{i}. §f{task.name} [{status}§f]\n §7{task.description}\n" + + idx = self._paginate_display( + player, guild.tasks, "选择删除任务", formatter, True) + if idx is not None: + task = guild.tasks[idx] + player.show(f"§l§a任务管理 §d>> §r确认删除任务 '{task.name}'?输入 '确认' 继续") + confirm = game_utils.waitMsg(player.name, timeout=20) + + if confirm == "确认": + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild and idx < len(guild.tasks): + removed_task = guild.tasks.pop(idx) + guild.add_log(f"{player.name} 删除了任务: {removed_task.name}") + guild.add_audit_log("task_delete", player.name, + detail=removed_task.name) + self.guild_manager.save_guilds(guilds) + player.show( + f"§l§a任务管理 §d>> §r任务 '{removed_task.name}' 已删除") + + elif choice == "2" and can_complete: + # 强制完成任务 + active_tasks = [t for t in guild.tasks if not t.completed] + if not active_tasks: + player.show("§l§a任务管理 §d>> §r暂无进行中的任务") + return True + + def formatter(i, task: GuildTask): + """Format one menu item for display.""" + return f"§e{i}. §f{task.name} §7({task.current_count}/{task.target_count})\n §7{task.description}\n" + + idx = self._paginate_display( + player, active_tasks, "选择完成任务", formatter, True) + if idx is not None: + task = active_tasks[idx] + player.show(f"§l§a任务管理 §d>> §r确认强制完成任务 '{task.name}'?输入 '确认' 继续") + confirm = game_utils.waitMsg(player.name, timeout=20) + + if confirm == "确认": + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild: + for t in guild.tasks: + if t.task_id == task.task_id: + t.completed = True + t.current_count = t.target_count + guild.add_log(f"{player.name} 强制完成了任务: {t.name}") + guild.add_audit_log("task_force_complete", + player.name, detail=t.name) + break + + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a任务管理 §d>> §r任务 '{task.name}' 已完成") + else: + player.show("§c无效选项") + + return True + + +def _handle_manage_members(self, player: Player) -> bool: # skipcq: PY-R1000 + """管理公会成员""" + guild = self.guild_manager.get_guild_by_player(player.name) + member = guild.get_member(player.name) if guild else None + + if not guild or not member or not any( + guild.has_permission(player.name, permission) + for permission in ("kick", "set_rank", "transfer_owner", "join_queue") + ): + player.show("§l§a公会 §d>> §r你没有管理权限") + return True + + player.show("§r========== §a成员管理§r ==========") + options = [ + ("1", "踢出成员", guild.has_permission(player.name, "kick")), + ("2", "设置职位", guild.has_permission(player.name, "set_rank")), + ("3", "转让会长", guild.has_permission(player.name, "transfer_owner")), + ("4", "申请队列", guild.has_permission(player.name, "join_queue")), + ] + for key, label, enabled in options: + if enabled: + player.show(f"§e{key}. §f{label}") + player.show("§7输入选项序号,q 返回") + + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "1" and guild.has_permission(player.name, "kick"): + return self._handle_kick_member(player) + if choice == "2" and guild.has_permission(player.name, "set_rank"): + return self._handle_set_rank(player) + if choice == "3" and guild.has_permission(player.name, "transfer_owner"): + return self._handle_transfer_ownership(player) + if choice == "4" and guild.has_permission(player.name, "join_queue"): + return self._handle_join_request_queue(player, guild) + + return True + + +def _notify_join_request_admins( + self, + guild: GuildData, + applicant_name: str) -> None: + """通知在线的申请队列处理人。""" + if not getattr( + Config, + "GUILD_JOIN_REQUEST_CONFIG", + {}).get( + "申请提交后通知在线管理员", + True): + return + + for member in guild.members: + if ( + member.name in self.game_ctrl.allplayers + and guild.has_permission(member.name, "join_queue") + ): + message = ( + f"§l§a公会 §d>> §r§e{applicant_name} " + f"§f申请加入公会 §e{guild.name}\\n" + "§f请在成员管理的 §a申请队列 §f中处理" + ) + self.game_ctrl.sendcmd( + f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + + +def _handle_join_request_queue( # skipcq: PY-R1000 + self, + player: Player, + guild: GuildData, +) -> bool: + """处理加入申请队列""" + if not guild.has_permission(player.name, "join_queue"): + player.show("§l§a公会 §d>> §r你没有处理申请队列权限") + return True + + pending_requests = guild.pending_join_requests() + if not pending_requests: + player.show("§l§a公会 §d>> §r暂无待处理加入申请") + return True + + def formatter(i, request): + """Format one menu item for display.""" + age = self._format_time_duration(time.time() - request.create_time) + reason = request.reason or "无" + return ( + f"§e{i}. §f{request.player_name}\n" + f" §7理由: §f{reason} §7| 提交: §f{age}前\n" + ) + + idx = self._paginate_display( + player, + pending_requests, + "加入申请队列", + formatter, + True) + if idx is None: + return True + + selected = pending_requests[idx] + player.show(f"§l§a公会 §d>> §r处理 §e{selected.player_name} §r的加入申请") + player.show("§e1. §f同意") + player.show("§e2. §f拒绝") + player.show("§7输入选项序号:") + choice = game_utils.waitMsg(player.name, timeout=30) + approved = choice in ("1", "同意", "批准") + rejected = choice in ("2", "拒绝") + if not approved and not rejected: + player.show("§l§a公会 §d>> §r操作已取消") + return True + + guilds = self.guild_manager.load_guilds(force_reload=True) + latest_guild = guilds.get(guild.guild_id) + if not latest_guild: + player.show("§l§a公会 §d>> §r公会数据异常") + return True + + if approved and len(latest_guild.members) >= Config.MAX_GUILD_MEMBERS: + latest_guild.resolve_join_request( + selected.player_name, + player.name, + False, + result_reason="公会已满员", + ) + self.guild_manager.save_guilds(guilds) + player.show("§l§a公会 §d>> §r公会已满员,已拒绝该申请") + return True + + if not latest_guild.resolve_join_request( + selected.player_name, + player.name, + approved, + result_reason="管理员处理", + ): + player.show("§l§a公会 §d>> §r申请已不存在或已过期") + return True + + if approved: + new_member = GuildMember( + name=selected.player_name, + rank=GuildRank.MEMBER, + join_time=time.time(), + ) + latest_guild.members.append(new_member) + latest_guild.add_log( + f"{selected.player_name} 加入公会 (审核人: {player.name})") + self.guild_manager.cache_player_guild( + selected.player_name, latest_guild.guild_id) + + self.guild_manager.save_guilds(guilds) + result_text = "已同意" if approved else "已拒绝" + player.show(f"§l§a公会 §d>> §r{result_text} {selected.player_name} 的加入申请") + + if selected.player_name in self.game_ctrl.allplayers: + message = ( + f"§l§a公会 §d>> §r你的公会申请已通过,已加入 §e{latest_guild.name}" + if approved + else f"§l§a公会 §d>> §r你加入 §e{latest_guild.name} §r的申请被拒绝" + ) + self.game_ctrl.sendcmd( + f'/tellraw {selected.player_name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + + if approved and getattr( + Config, + "GUILD_JOIN_REQUEST_CONFIG", + {}).get( + "批准后通知全体在线成员", + True): + for member in latest_guild.members: + if ( + member.name in self.game_ctrl.allplayers + and member.name != selected.player_name + ): + message = f"§l§a公会 §d>> §r§e{selected.player_name}§r 加入了公会" + self.game_ctrl.sendcmd( + f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + + return True + + +def _handle_set_rank(self, player: Player) -> bool: + """设置成员职位""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + return True + if not guild.has_permission(player.name, "set_rank"): + player.show("§l§a公会 §d>> §r你没有设置成员职位权限") + return True + + # 过滤可管理的成员 + manageable_members = [ + m for m in guild.members + if guild.can_manage_member(player.name, m.name) + ] + + if not manageable_members: + player.show("§l§a公会 §d>> §r没有可管理的成员") + return True + + def formatter(i, m): + """Format one menu item for display.""" + return f"§e{i}. {m.rank.display_name} §f{m.name}\n" + idx = self._paginate_display( + player, + manageable_members, + "选择成员", + formatter, + True) + + if idx is None: + return True + + target_member = manageable_members[idx] + + # 选择新职位 + player.show("§r========== §a设置职位§r ==========") + player.show("§e1. §6副会长") + player.show("§e2. §e长老") + player.show("§e3. §a成员") + player.show("§7输入选项序号") + + rank_choice = game_utils.waitMsg(player.name, timeout=30) + rank_map = { + "1": GuildRank.DEPUTY, + "2": GuildRank.ELDER, + "3": GuildRank.MEMBER} + + new_rank = rank_map.get(rank_choice) + if new_rank and self.guild_manager.set_member_rank( + guild, target_member.name, new_rank): + player.show( + f"§l§a公会 §d>> §r已将 {target_member.name} 的职位设置为 {new_rank.display_name}") + + return True + + +def _handle_donation(self, player: Player) -> bool: + """处理物品捐献""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + player.show("§l§a公会捐献§r") + player.show("§7支持捐献的物品类型:") + player.show("§e钻石 §7- 50贡献/个") + player.show("§e绿宝石 §7- 25贡献/个") + player.show("§e金锭 §7- 10贡献/个") + player.show("§e铁锭 §7- 5贡献/个") + player.show("§e下界合金锭 §7- 200贡献/个") + player.show("§e远古残骸 §7- 150贡献/个") + player.show("§7以及其他有价值的物品...") + player.show("§7请输入要捐献的物品名称 (支持中文名称):") + + user_input = game_utils.waitMsg(player.name, timeout=30) + + if not user_input or user_input.lower() == 'q': + return True + + # 使用智能匹配查找物品ID + item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) + + if not item_id: + player.show("§l§a公会捐献 §d>> §r未找到匹配的物品") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + return True + + # 获取物品的贡献点价值 + contrib_per_item = guild.get_item_value(item_id) + exp_per_item = contrib_per_item * 0.5 # 经验为贡献点的一半 + + chinese_name = self.item_matcher.get_chinese_name(item_id) + player.show(f"§l§a公会捐献 §d>> §r选择物品: §f{chinese_name}") + player.show(f"§7价值: §e{contrib_per_item}贡献点/个 §7+ §b{exp_per_item}经验/个") + + player.show(f"§7你有 {player.getItemCount(item_id)} 个{chinese_name}") + player.show("§7输入要捐献的数量:") + + amount_str = game_utils.waitMsg(player.name, timeout=30) + if not amount_str or not amount_str.isdigit(): + return True + + amount = int(amount_str) + if amount <= 0 or amount > player.getItemCount(item_id): + player.show("§l§a公会 §d>> §r数量无效") + return True + + self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {amount}") + exp, contribution = self.guild_apply_reward_multipliers( + int(amount * exp_per_item), + amount * contrib_per_item, + ) + + # 重新加载公会数据并更新经验值 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_id = guild.guild_id + + if guild_id in guilds: + # 更新贡献与公会经验 + latest_guild = guilds[guild_id] + member = latest_guild.get_member(player.name) + if member: + member.contribution += contribution + latest_guild.stats.total_contribution += contribution + latest_guild.exp += exp + latest_guild.add_log(f"{player.name} 捐献了 {amount} 个{chinese_name}") + + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会 §d>> §r捐献成功!获得 {contribution} 贡献度,公会获得 {exp} 经验") + else: + player.show("§l§a公会 §d>> §r捐献失败,公会数据异常") + fmts.print_err(f"捐献失败: 公会ID {guild_id} 不存在于加载的数据中") + + return True + + +def _handle_vault(self, player: Player) -> bool: # skipcq: PY-R1000 + """处理公会仓库""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + if not guild.has_permission(player.name, "vault"): + player.show("§l§a公会仓库 §d>> §r你没有使用仓库权限") + return True + + while True: + latest_guild = self.guild_manager.get_guild_by_player( + player.name, force_reload=True) + if latest_guild: + guild = latest_guild + + player.show("§r========== §a公会仓库§r ==========") + max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 + player.show(f"§7容量: §a{len(guild.vault_items)}/{max_slots}") + + member = guild.get_member(player.name) + if member: + player.show(f"§7你的贡献点: §e{member.contribution}") + + can_buy = guild.has_permission(player.name, "vault_buy") + can_sell = guild.has_permission(player.name, "vault_sell") + can_cancel = ( + getattr(Config, "GUILD_VAULT_CONFIG", {}).get("启用撤回出售", True) + and ( + guild.has_permission(player.name, "vault_cancel_own") + or guild.has_permission(player.name, "vault_cancel_any") + ) + ) + can_view_logs = getattr( + Config, "GUILD_VAULT_CONFIG", {}).get( + "启用交易日志", True) + can_settings = guild.has_permission(player.name, "vault_settings") + + menu_options = [ + ("查看", "查看仓库物品", True), + ("购买", "购买仓库物品", can_buy), + ("出售", "出售物品到仓库", can_sell), + ("撤回", "撤回已上架物品", can_cancel), + ("日志", "查看仓库交易日志", can_view_logs), + ("设置", "设置仓库物品价值", can_settings), + ("退出", "退出仓库", True) + ] + + available_options = [(cmd, desc) + for cmd, desc, cond in menu_options if cond] + + for cmd, desc in available_options: + player.show(f"§e● {cmd} §7- {desc}") + + player.show("§7输入选项:") + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "查看": + self._handle_vault_view(player, guild) + elif choice == "购买" and can_buy: + self._handle_vault_buy(player, guild) + elif choice == "出售" and can_sell: + self._handle_vault_sell(player, guild) + elif choice == "撤回" and can_cancel: + self._handle_vault_cancel(player, guild) + elif choice == "日志" and can_view_logs: + self._handle_vault_logs(player, guild) + elif choice == "设置" and can_settings: + self._handle_vault_settings(player, guild) + elif choice == "退出" or choice is None: + break + else: + player.show("§c无效选项") + + return True + + +def _handle_vault_view(self, player: Player, guild: GuildData) -> bool: + """查看仓库物品""" + if not guild.vault_items: + player.show("§l§a公会仓库 §d>> §r仓库为空") + return True + + def formatter(i, item: VaultItem): + # 获取物品显示名称 + """Format one menu item for display.""" + item_name = self._get_item_display_name(item.item_id) + time_str = datetime.fromtimestamp( + item.timestamp).strftime("%m-%d %H:%M") + return (f"§e{i}. §f{item_name} §7x{item.count}\n" + f" §7价格: §e{item.price}贡献点 §7| 卖家: §a{item.seller}\n" + f" §7时间: §f{time_str}\n") + + self._paginate_display(player, guild.vault_items, "仓库物品", formatter) + return True + + +def _handle_vault_buy( # skipcq: PY-R1000 + self, + player: Player, + guild: GuildData, +) -> bool: + """购买仓库物品""" + if not guild.has_permission(player.name, "vault_buy"): + player.show("§l§a公会仓库 §d>> §r你没有购买仓库物品权限") + return True + + if not guild.vault_items: + player.show("§l§a公会仓库 §d>> §r仓库为空") + return True + + member = guild.get_member(player.name) + if not member: + return True + + def formatter(i, item: VaultItem): + """Format one menu item for display.""" + item_name = self._get_item_display_name(item.item_id) + time_str = datetime.fromtimestamp( + item.timestamp).strftime("%m-%d %H:%M") + affordable = "§a可购买" if member.contribution >= item.price else "§c贡献点不足" + return (f"§e{i}. §f{item_name} §7x{item.count}\n" + f" §7价格: §e{item.price}贡献点 §7| {affordable}\n" + f" §7卖家: §a{item.seller} §7| 时间: §f{time_str}\n") + + idx = self._paginate_display( + player, + guild.vault_items, + "选择购买物品", + formatter, + True) + if idx is None: + return True + + item = guild.vault_items[idx] + if (item.seller == player.name and not getattr( + Config, "GUILD_VAULT_CONFIG", {}).get("允许购买自己出售的物品", False)): + player.show("§l§a公会仓库 §d>> §r不能购买自己出售的物品") + return True + + # 检查贡献点 + if member.contribution < item.price: + player.show("§l§a公会仓库 §d>> §r贡献点不足") + return True + + # 检查背包空间 + if not self._has_inventory_space(player, item.item_id, item.count): + player.show("§l§a公会仓库 §d>> §r背包空间不足") + return True + + # 确认购买 + item_name = self._get_item_display_name(item.item_id) + player.show( + f"§l§a公会仓库 §d>> §r确认购买 §f{item_name} §7x{item.count} §r花费 §e{item.price}贡献点§r?") + player.show("§7输入 '确认' 继续购买") + + confirm = game_utils.waitMsg(player.name, timeout=20) + if confirm != "确认": + player.show("§l§a公会仓库 §d>> §r购买已取消") + return True + + # 执行购买 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if not guild: + player.show("§l§a公会仓库 §d>> §r公会数据异常") + return True + + # 重新检查物品是否还存在 + if idx >= len( + guild.vault_items) or guild.vault_items[idx].item_id != item.item_id: + player.show("§l§a公会仓库 §d>> §r物品已被其他人购买") + return True + item = guild.vault_items[idx] + if (item.seller == player.name and not getattr( + Config, "GUILD_VAULT_CONFIG", {}).get("允许购买自己出售的物品", False)): + player.show("§l§a公会仓库 §d>> §r不能购买自己出售的物品") + return True + + # 扣除贡献点 + buyer_member = guild.get_member(player.name) + if not buyer_member or buyer_member.contribution < item.price: + player.show("§l§a公会仓库 §d>> §r贡献点不足") + return True + + buyer_member.contribution -= item.price + + # 给卖家贡献点(扣除税费) + tax = int(item.price * Config.VAULT_TRADE_TAX) + seller_income = item.price - tax + seller_member = guild.get_member(item.seller) + if seller_member: + seller_member.contribution += seller_income + + # 给玩家物品 + self.game_ctrl.sendwocmd(f"give {player.name} {item.item_id} {item.count}") + + guild.vault_items.pop(idx) + + guild.add_log( + f"{player.name} 购买了 {item_name} x{item.count} (花费{item.price}贡献点)") + if getattr(Config, "GUILD_VAULT_CONFIG", {}).get("启用交易日志", True): + guild.add_vault_trade_log( + "buy", + item, + player.name, + buyer=player.name, + detail=f"税费{tax},卖家收入{seller_income}", + ) + suggested_value = guild.get_item_value(item.item_id) * item.count + audit_ratio = float( + getattr( + Config, + "GUILD_VAULT_CONFIG", + {}).get( + "高价交易审计倍率", + 3.0)) + if suggested_value > 0 and item.price >= suggested_value * audit_ratio: + guild.add_audit_log( + "vault_high_price_buy", + player.name, + target=item.seller, + detail=f"{item.item_id} x{item.count} price={item.price}", + ) + + self.guild_manager.save_guilds(guilds) + + player.show(f"§l§a公会仓库 §d>> §r购买成功!花费 §e{item.price}贡献点") + + # 更新贸易任务进度 + self.check_and_complete_trade_tasks(player.name) + + # 通知卖家 + if seller_member and item.seller in self.game_ctrl.allplayers: + message = ( + f"§l§a公会仓库 §d>> §r你的 {item_name} x{item.count} " + f"被 {player.name} 购买了,获 {seller_income} 贡献点" + ) + self.game_ctrl.sendcmd( + f'/tellraw {item.seller} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + + return True + + +def _handle_vault_sell( # skipcq: PY-R1000 + self, + player: Player, + guild: GuildData, +) -> bool: + """出售物品到仓库""" + if not guild.has_permission(player.name, "vault_sell"): + player.show("§l§a公会仓库 §d>> §r你没有出售仓库物品权限") + return True + + # 检查仓库容量 + max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + + player.show("§l§a公会仓库 §d>> §r请输入要出售的物品名称") + player.show("§7支持中文名称,如: 钻石、铁锭、金块等") + player.show("§7也支持英文ID,如: minecraft:diamond") + + user_input = game_utils.waitMsg(player.name, timeout=30) + + if not user_input: + player.show("§l§a公会仓库 §d>> §r输入为空") + return True + + # 使用智能匹配查找物品ID + item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) + + if not item_id: + player.show("§l§a公会仓库 §d>> §r未找到匹配的物品") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + player.show("§7请重新输入准确的物品名称") + return True + + # 显示找到的物品 + chinese_name = self.item_matcher.get_chinese_name(item_id) + player.show(f"§l§a公会仓库 §d>> §r找到物品: §f{chinese_name} §7({item_id})") + + # 检查玩家是否有该物品 + item_count = player.getItemCount(item_id) + if item_count <= 0: + player.show("§l§a公会仓库 §d>> §r你没有这个物品") + return True + + player.show(f"§l§a公会仓库 §d>> §r你有 {item_count} 个该物品") + player.show("§7请输入要出售的数量:") + + count_str = game_utils.waitMsg(player.name, timeout=30) + if not count_str or not count_str.isdigit(): + player.show("§l§a公会仓库 §d>> §r无效的数量") + return True + + count = int(count_str) + if count <= 0 or count > item_count: + player.show("§l§a公会仓库 §d>> §r数量无效") + return True + max_sell_count = int( + getattr( + Config, + "GUILD_VAULT_CONFIG", + {}).get( + "单次出售最大数量", + 64)) + if count > max_sell_count > 0: + player.show(f"§l§a公会仓库 §d>> §r单次最多出售 {max_sell_count} 个") + return True + + # 获取建议价格 + suggested_price = guild.get_item_value(item_id) * count + player.show(f"§l§a公会仓库 §d>> §r建议价格: §e{suggested_price}贡献点") + player.show("§7请输入出售价格 (贡献点,你可以自定义任意价格):") + + price_str = game_utils.waitMsg(player.name, timeout=30) + if not price_str or not price_str.isdigit(): + player.show("§l§a公会仓库 §d>> §r无效的价格") + return True + + price = int(price_str) + if price <= 0: + player.show("§l§a公会仓库 §d>> §r价格必须大于0") + return True + vault_config = getattr(Config, "GUILD_VAULT_CONFIG", {}) + min_price = int(vault_config.get("单笔价格下限", 1)) + max_price = int(vault_config.get("单笔价格上限", 100000)) + if price < min_price or price > max_price: + player.show(f"§l§a公会仓库 §d>> §r价格必须在 {min_price}-{max_price} 之间") + return True + + # 确认出售 + item_name = self._get_item_display_name(item_id) + player.show( + f"§l§a公会仓库 §d>> §r确认出售 §f{item_name} §7x{count} §r价格 §e{price}贡献点§r?") + player.show("§7输入 '确认' 继续出售") + + confirm = game_utils.waitMsg(player.name, timeout=20) + if confirm != "确认": + player.show("§l§a公会仓库 §d>> §r出售已取消") + return True + + # 执行出售 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if not guild: + player.show("§l§a公会仓库 §d>> §r公会数据异常") + return True + + # 再次检查仓库容量 + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + max_listing = int(vault_config.get("单个成员最大上架数量", 18)) + if max_listing > 0: + own_listing_count = sum( + 1 for item in guild.vault_items if item.seller == player.name) + if own_listing_count >= max_listing: + player.show(f"§l§a公会仓库 §d>> §r你最多同时上架 {max_listing} 件物品") + return True + + # 扣除物品 + self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {count}") + + # 添加到仓库 + vault_item = VaultItem( + item_id=item_id, + count=count, + price=price, + seller=player.name + ) + + guild.vault_items.append(vault_item) + guild.add_log(f"{player.name} 出售了 {item_name} x{count} (价格{price}贡献点)") + if vault_config.get("启用交易日志", True): + guild.add_vault_trade_log( + "sell", vault_item, player.name, detail="上架出售") + + audit_ratio = float(vault_config.get("高价交易审计倍率", 3.0)) + if suggested_price > 0 and price >= suggested_price * audit_ratio: + guild.add_audit_log( + "vault_high_price_sell", + player.name, + detail=f"{item_id} x{count} price={price}", + ) + + # 保存数据 + self.guild_manager.save_guilds(guilds) + + player.show(f"§l§a公会仓库 §d>> §r出售成功!{item_name} x{count} 已上架") + + # 更新贸易任务进度 + self.check_and_complete_trade_tasks(player.name) + + return True + + +def _handle_vault_logs(self, player: Player, guild: GuildData) -> bool: + """查看仓库交易日志""" + if not getattr(Config, "GUILD_VAULT_CONFIG", {}).get("启用交易日志", True): + player.show("§l§a公会仓库 §d>> §r交易日志未启用") + return True + + if not guild.vault_trade_logs: + player.show("§l§a公会仓库 §d>> §r暂无仓库交易日志") + return True + + action_names = { + "sell": "上架", + "buy": "购买", + "cancel": "撤回", + } + + def formatter(i, log): + """Format one menu item for display.""" + item_name = self._get_item_display_name(log.item_id) + time_str = datetime.fromtimestamp( + log.timestamp).strftime("%m-%d %H:%M") + action_name = action_names.get(log.action, log.action) + participants = [] + if log.seller: + participants.append(f"卖家:{log.seller}") + if log.buyer: + participants.append(f"买家:{log.buyer}") + participant_text = " §7| ".join( + participants) if participants else f"操作者:{log.actor}" + detail = f"\n §7说明: §f{log.detail}" if log.detail else "" + return ( + f"§e{i}. §f{action_name} §f{item_name} §7x{log.count}\n" + f" §7价格: §e{log.price}贡献点 §7| {participant_text} §7| 时间: §f{time_str}" + f"{detail}\n" + ) + + logs = list(reversed(guild.vault_trade_logs[-50:])) + self._paginate_display(player, logs, "仓库交易日志", formatter) + return True + + +def _handle_vault_cancel( # skipcq: PY-R1000 + self, + player: Player, + guild: GuildData, +) -> bool: + """撤回仓库上架物品""" + vault_config = getattr(Config, "GUILD_VAULT_CONFIG", {}) + if not vault_config.get("启用撤回出售", True): + player.show("§l§a公会仓库 §d>> §r撤回出售未启用") + return True + + can_cancel_own = guild.has_permission(player.name, "vault_cancel_own") + can_cancel_any = guild.has_permission(player.name, "vault_cancel_any") + only_own = vault_config.get("只允许撤回自己的物品", False) + + cancelable_items = [] + for index, item in enumerate(guild.vault_items): + is_own = item.seller == player.name + if is_own and can_cancel_own: + cancelable_items.append((index, item)) + elif not only_own and can_cancel_any: + cancelable_items.append((index, item)) + + if not cancelable_items: + player.show("§l§a公会仓库 §d>> §r没有可撤回的上架物品") + return True + + def formatter(i, data): + """Format one menu item for display.""" + _index, item = data + item_name = self._get_item_display_name(item.item_id) + time_str = datetime.fromtimestamp( + item.timestamp).strftime("%m-%d %H:%M") + return ( + f"§e{i}. §f{item_name} §7x{item.count}\n" f" §7价格: §e{item.price}贡献点 §7| 卖家: §a{item.seller} §7| 时间: §f{time_str}\n") + + idx = self._paginate_display( + player, + cancelable_items, + "选择撤回物品", + formatter, + True) + if idx is None: + return True + + original_index, selected_item = cancelable_items[idx] + selected_name = self._get_item_display_name(selected_item.item_id) + player.show( + f"§l§a公会仓库 §d>> §r确认撤回 §f{selected_name} §7x{selected_item.count}§r?") + player.show("§7输入 '确认' 继续撤回") + + confirm = game_utils.waitMsg(player.name, timeout=20) + if confirm != "确认": + player.show("§l§a公会仓库 §d>> §r撤回已取消") + return True + + guilds = self.guild_manager.load_guilds(force_reload=True) + latest_guild = guilds.get(guild.guild_id) + if not latest_guild: + player.show("§l§a公会仓库 §d>> §r公会数据异常") + return True + + if original_index >= len(latest_guild.vault_items): + player.show("§l§a公会仓库 §d>> §r物品已不存在") + return True + + latest_item = latest_guild.vault_items[original_index] + if ( + latest_item.item_id != selected_item.item_id + or latest_item.count != selected_item.count + or latest_item.price != selected_item.price + or latest_item.seller != selected_item.seller + ): + player.show("§l§a公会仓库 §d>> §r物品状态已变化,请重新打开仓库") + return True + + is_own = latest_item.seller == player.name + if not ((is_own and can_cancel_own) or (not only_own and can_cancel_any)): + player.show("§l§a公会仓库 §d>> §r你没有撤回该物品的权限") + return True + + removed_item = latest_guild.cancel_vault_item(player.name, original_index) + if not removed_item: + player.show("§l§a公会仓库 §d>> §r撤回失败") + return True + + if vault_config.get("撤回后返还物品", True): + self.game_ctrl.sendwocmd( + f"give {removed_item.seller} {removed_item.item_id} {removed_item.count}") + + self.guild_manager.save_guilds(guilds) + player.show( + f"§l§a公会仓库 §d>> §r已撤回 §f{selected_name} §7x{removed_item.count}") + if ( + removed_item.seller != player.name + and removed_item.seller in self.game_ctrl.allplayers + ): + message = ( + f"§l§a公会仓库 §d>> §r你上架的 {selected_name} " + f"x{removed_item.count} 已被 {player.name} 撤回" + ) + self.game_ctrl.sendcmd( + f'/tellraw {removed_item.seller} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + + return True + + +def _handle_vault_settings( # skipcq: PY-R1000 + self, + player: Player, + guild: GuildData, +) -> bool: + """设置物品价值""" + if not guild.has_permission(player.name, "vault_settings"): + player.show("§l§a公会仓库 §d>> §r你没有设置仓库物品价值权限") + return True + + player.show("§r========== §a物品价值设置§r ==========") + player.show("§e1. §f查看当前设置") + player.show("§e2. §f设置物品价值") + player.show("§e3. §f删除自定义价值") + player.show("§7输入选项序号:") + + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "1": + # 显示当前设置 + if guild.custom_item_values: + player.show("§l§a自定义物品价值§r") + for item_id, value in guild.custom_item_values.items(): + item_name = self._get_item_display_name(item_id) + player.show(f"§f{item_name}: §e{value}贡献点") + else: + player.show("§l§a公会仓库 §d>> §r暂无自定义物品价值") + + player.show("\n§l§a默认物品价值§r") + for item_id, value in Config.DEFAULT_ITEM_VALUES.items(): + item_name = self._get_item_display_name(item_id) + player.show(f"§f{item_name}: §e{value}贡献点") + + elif choice == "2": + # 设置物品价值 + player.show("§l§a公会仓库 §d>> §r请输入物品ID (如: minecraft:diamond):") + item_id = game_utils.waitMsg(player.name, timeout=30) + + if not item_id or not item_id.startswith("minecraft:"): + player.show("§l§a公会仓库 §d>> §r无效的物品ID") + return True + + player.show("§l§a公会仓库 §d>> §r请输入贡献点价值:") + value_str = game_utils.waitMsg(player.name, timeout=30) + + if not value_str or not value_str.isdigit(): + player.show("§l§a公会仓库 §d>> §r无效的价值") + return True + + value = int(value_str) + if value <= 0: + player.show("§l§a公会仓库 §d>> §r价值必须大于0") + return True + + # 保存设置 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild: + guild.custom_item_values[item_id] = value + item_name = self._get_item_display_name(item_id) + guild.add_log(f"{player.name} 设置了 {item_name} 的价值为 {value} 贡献点") + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会仓库 §d>> §r已设置 {item_name} 的价值为 {value} 贡献点") + + elif choice == "3": + # 删除自定义价值 + if not guild.custom_item_values: + player.show("§l§a公会仓库 §d>> §r暂无自定义物品价值") + return True + + player.show("§l§a当前自定义价值§r") + items = list(guild.custom_item_values.items()) + for i, (item_id, value) in enumerate(items, 1): + item_name = self._get_item_display_name(item_id) + player.show(f"§e{i}. §f{item_name}: §e{value}贡献点") + + player.show("§7输入序号删除:") + idx_str = game_utils.waitMsg(player.name, timeout=30) + + if idx_str and idx_str.isdigit(): + idx = int(idx_str) - 1 + if 0 <= idx < len(items): + item_id, _ = items[idx] + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild and item_id in guild.custom_item_values: + del guild.custom_item_values[item_id] + item_name = self._get_item_display_name(item_id) + guild.add_log(f"{player.name} 删除了 {item_name} 的自定义价值") + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会仓库 §d>> §r已删除 {item_name} 的自定义价值") + + return True + + +def _handle_create_guild(self, player: Player, player_xuid: str) -> bool: + """处理创建公会""" + guild = self.guild_manager.get_guild_by_player(player.name) + if guild: + player.show(render_create_guild_prompt("已有公会提示词")) + return True + + score = player.getScore(Config.GUILD_SCOREBOARD) + if score < Config.GUILD_CREATION_COST: + player.show(render_create_guild_prompt("创建公会余额不足提示词", balance=score)) + return True + + player.show(render_create_guild_prompt("创建公会提示词", balance=score)) + confirm = game_utils.waitMsg(player.name, timeout=30) + if confirm is None: + player.show(render_create_guild_prompt("创建公会回复超时提示词")) + return True + if confirm.strip().lower() not in ("确认", "继续", "是", "y", "yes"): + player.show(render_create_guild_prompt("创建公会取消提示词")) + return True + + player.show(render_create_guild_prompt("创建公会输入名称提示词")) + guild_name = game_utils.waitMsg(player.name, timeout=30) + + # 使用新的输入验证 + is_valid, error_msg = InputValidator.validate_guild_name(guild_name) + if not is_valid: + player.show(render_create_guild_prompt("创建公会名称无效提示词", error=error_msg)) + return True + + score = player.getScore(Config.GUILD_SCOREBOARD) + if score < Config.GUILD_CREATION_COST: + player.show(render_create_guild_prompt("创建公会二次余额不足提示词", balance=score)) + return True + + # 扣除配置的计分板积分并创建公会 + self.game_ctrl.sendwocmd( + f"scoreboard players remove {player.name} {Config.GUILD_SCOREBOARD} {Config.GUILD_CREATION_COST}") + + if self.guild_manager.create_guild(player_xuid, player.name, guild_name): + player.show(render_create_guild_prompt( + "创建公会成功提示词", guild=guild_name, player=player.name)) + # 通知所有在线玩家 + announcement = render_create_guild_prompt( + "创建公会全服公告提示词", + guild=guild_name, + player=player.name, + ) + payload = json.dumps( + {"rawtext": [{"text": announcement}]}, ensure_ascii=False) + self.game_ctrl.sendcmd(f"/tellraw @a {payload}") + else: + player.show(render_create_guild_prompt( + "创建公会名称已存在提示词", guild=guild_name, player=player.name)) + self.game_ctrl.sendwocmd( + f"scoreboard players add {player.name} {Config.GUILD_SCOREBOARD} {Config.GUILD_CREATION_COST}") + + return True + + +def _handle_list_guilds(self, player: Player) -> bool: + """处理查看公会列表""" + guilds = list(self.guild_manager.load_guilds().values()) + if not guilds: + player.show(render_config_prompt("公会列表为空提示词")) + return True + + # 按等级和成员数排序 + guilds.sort(key=lambda g: (g.level, len(g.members)), reverse=True) + + def formatter(i, g): + """Format one menu item for display.""" + return ( + f"§e{i}. §r{g.name} §7Lv.{g.level}\n" + f" §7会长: §f{g.owner} " + f"§7| 成员: §a{len(g.members)}/{Config.MAX_GUILD_MEMBERS}\n" + ) + + page = 1 + max_page = (len(guilds) + Config.ITEMS_PER_PAGE - + 1) // Config.ITEMS_PER_PAGE + while True: + page = max(1, min(page, max_page)) + start = (page - 1) * Config.ITEMS_PER_PAGE + end = start + Config.ITEMS_PER_PAGE + + msg = ( + f"{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b公会列表§d" + f"\n{ORION_BORDER}" + ) + for i, guild in enumerate(guilds[start:end], start=1): + msg += "\n" + formatter(start + i, guild).rstrip() + msg += "\n" + format_page_footer(page, max_page, start + 1, end, False) + + player.show(msg) + + choice = game_utils.waitMsg(player.name, timeout=20) + if choice is None: + player.show(render_config_prompt("公会列表分页超时提示词")) + return True + if choice == "+": + page = min(page + 1, max_page) + elif choice == "-": + page = max(page - 1, 1) + elif choice == "q": + player.show(render_config_prompt("公会列表分页退出提示词")) + return True + + return True + + +def _handle_join_guild(self, player: Player) -> bool: + """处理加入公会申请""" + if self.guild_manager.get_guild_by_player(player.name): + player.show("§l§a公会 §d>> §r你已经加入了一个公会") + return True + + player.show("§l§a公会 §d>> §r请输入公会名字(支持模糊搜索)") + search_name = game_utils.waitMsg(player.name, timeout=30) + + if not search_name: + player.show("§l§a公会 §d>> §r公会名字不能为空") + return True + + # 搜索匹配的公会 + guilds = self.guild_manager.load_guilds() + matched_guilds = [g for g in guilds.values() if search_name.lower() + in g.name.lower()] + + if not matched_guilds: + player.show("§l§a公会 §d>> §r未找到匹配的公会") + return True + + # 选择公会 + if len(matched_guilds) == 1: + target_guild = matched_guilds[0] + else: + def formatter(i, g): + """Format one menu item for display.""" + return f"{i}. {g.name} (会长:{g.owner})\n" + idx = self._paginate_display( + player, matched_guilds, "选择公会", formatter, True) + if idx is None: + return True + target_guild = matched_guilds[idx] + + # 检查人数上限 + if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: + player.show("§l§a公会 §d>> §r该公会已满员") + return True + + reason = "" + join_config = getattr(Config, "GUILD_JOIN_REQUEST_CONFIG", {}) + if join_config.get("启用离线申请队列", True): + player.show("§l§a公会 §d>> §r请输入申请理由,可直接发送空白跳过") + reason_input = game_utils.waitMsg(player.name, timeout=60) + reason = reason_input or "" + + guilds = self.guild_manager.load_guilds(force_reload=True) + latest_guild = guilds.get(target_guild.guild_id) + if not latest_guild: + player.show("§l§a公会 §d>> §r公会数据异常") + return True + + if not latest_guild.add_join_request(player.name, reason): + player.show("§l§a公会 §d>> §r申请提交失败,可能已有待处理申请或队列已满") + return True + + self.guild_manager.save_guilds(guilds) + self._notify_join_request_admins(latest_guild, player.name) + player.show(f"§l§a公会 §d>> §r申请已提交至 §e{latest_guild.name} §r的申请队列") + + return True + + +def _handle_leave_guild(self, player: Player) -> bool: + """处理退出公会""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你未加入任何公会") + return True + + member = guild.get_member(player.name) + if member and member.rank == GuildRank.OWNER: + player.show("§l§a公会 §d>> §r会长不能退出公会,只能解散公会") + player.show("§7请使用 '解散' 选项来解散公会") + return True + + guild_name = self.guild_manager.remove_member(player.name) + if guild_name: + player.show(f"§l§a公会 §d>> §r已退出公会 {guild_name}") + else: + player.show("§l§a公会 §d>> §r退出失败") + return True + + +def _handle_dissolve_guild(self, player: Player, player_xuid: str) -> bool: + """处理解散公会""" + _ = player_xuid + guild = self.guild_manager.get_guild_by_player(player.name) + member = guild.get_member(player.name) if guild else None + + if not guild or not member or member.rank != GuildRank.OWNER: + player.show("§l§a公会 §d>> §r你不是任何公会的会长") + return True + + player.show(f"§l§a公会 §d>> §r确定要解散公会 §e{guild.name} §r吗?") + player.show("§c此操作不可撤销!输入'确认解散'继续") + confirm = game_utils.waitMsg(player.name, timeout=30) + + if confirm != "确认解散": + player.show("§l§a公会 §d>> §r操作已取消") + return True + + # 通知所有在线成员 + message = f"§l§a公会 §d>> §r公会 §e{guild.name}§r 已被解散" + for member in guild.members: + if member.name in self.game_ctrl.allplayers: + self.game_ctrl.sendcmd( + f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + + guilds = self.guild_manager.load_guilds(force_reload=True) + if guild.guild_id in guilds: + del guilds[guild.guild_id] + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会 §d>> §r已解散公会 §e{guild.name}") + + return True + + +def _handle_kick_member(self, player: Player) -> bool: + """处理踢出成员""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild or not guild.has_permission(player.name, "kick"): + player.show("§l§a公会 §d>> §r你没有踢人权限") + return True + + # 使用新的权限检查逻辑过滤可踢出的成员 + kickable_members = [] + for member in guild.members: + if guild.can_manage_member(player.name, member.name): + kickable_members.append(member) + + if not kickable_members: + player.show("§l§a公会 §d>> §r没有可踢出的成员") + return True + + def formatter(i, m): + """Format one menu item for display.""" + return f"§e{i}. {m.rank.display_name} §f{m.name}\n" + idx = self._paginate_display( + player, + kickable_members, + "踢出成员", + formatter, + True) + + if idx is not None: + target = kickable_members[idx] + self.guild_manager.remove_member(target.name) + player.show(f"§l§a公会 §d>> §r已将 {target.name} 踢出公会") + + # 通知被踢玩家 + if target.name in self.game_ctrl.allplayers: + self.game_ctrl.sendcmd( + f'/tellraw {target.name} ' + '{"rawtext":[{"text":"§l§a公会 §d>> §r你已被踢出公会"}]}' + ) + + return True + + +def _handle_set_base(self, player: Player) -> bool: + """处理设置据点 - 增强版本""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + if not guild.has_permission(player.name, "setbase"): + player.show("§l§a公会 §d>> §r你没有设置公会据点权限") + return True + + try: + fmts.print_inf(f"开始为公会 {guild.name} 设置据点") + pos = game_utils.getPos(player.name) + + if not pos: + player.show("§l§a公会 §d>> §r获取位置失败,请稍后重试") + fmts.print_err("获取位置失败: pos为空") + return True + + dimension = pos.get("dimension", -1) + if dimension != 0: + player.show("§l§a公会 §d>> §r据点只能设置在主世界") + fmts.print_err(f"设置据点失败: 维度不是主世界 (维度={dimension})") + return True + + # 确保position字段存在 + if "position" not in pos: + player.show("§l§a公会 §d>> §r获取位置信息不完整,请稍后重试") + fmts.print_err(f"设置据点错误: 位置信息不完整 {pos}") + return True + + position = pos["position"] + x = position.get("x", 0) + y = position.get("y", 0) + z = position.get("z", 0) + + base = GuildBase( + dimension=dimension, + x=x, + y=y, + z=z + ) + + # 打印调试信息 + fmts.print_inf(f"正在设置据点: 维度={dimension}, 坐标=({x}, {y}, {z})") + + # 直接保存到当前公会对象 + guild.base = base + guild.add_log(f"{player.name} 设置了据点") + + # 重新加载公会数据以确保使用最新数据 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_id = guild.guild_id + + # 确保公会ID存在于加载的数据中 + if guild_id not in guilds: + player.show("§l§a公会 §d>> §r公会数据加载失败,请稍后重试") + fmts.print_err(f"设置据点错误: 公会ID {guild_id} 不存在于加载的数据中") + return True + + # 更新公会数据 + guilds[guild_id].base = base + guilds[guild_id].add_log(f"{player.name} 设置了据点") + + # 保存数据 + self.guild_manager.save_guilds(guilds) + + # 再次检查是否保存成功 + check_guilds = self.guild_manager.load_guilds(force_reload=True) + if guild_id in check_guilds and check_guilds[guild_id].base: + check_base = check_guilds[guild_id].base + fmts.print_inf( + f"据点设置成功: 公会={guild.name}, 维度={check_base.dimension}, 坐标=({check_base.x}, {check_base.y}, {check_base.z})") + player.show(f"§l§a公会 §d>> §r据点已设置为 ({x:.1f}, {y:.1f}, {z:.1f})") + else: + player.show("§l§a公会 §d>> §r据点设置可能失败,请重试") + fmts.print_err(f"据点设置后验证失败: 公会={guild.name}") + + except Exception as e: + player.show("§l§a公会 §d>> §r获取位置失败,请稍后重试") + fmts.print_err(f"设置据点错误: {e}") + import traceback + fmts.print_err(traceback.format_exc()) + + return True + + +def _handle_return_base(self, player: Player) -> bool: + """处理返回据点 - 增强版本""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你没有加入任何公会") + return True + if not guild.has_permission(player.name, "return_base"): + player.show("§l§a公会 §d>> §r你没有返回公会据点权限") + return True + + # 重新加载公会数据以确保使用最新数据 + try: + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_id = guild.guild_id + + # 确保公会ID存在于加载的数据中 + if guild_id not in guilds: + player.show("§l§a公会 §d>> §r公会数据加载失败,请稍后重试") + fmts.print_err(f"传送失败: 公会ID {guild_id} 不存在于加载的数据中") + return True + + # 使用最新的公会数据 + guild = guilds[guild_id] + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + if isinstance( + getattr( + guild, + "settings", + None), + dict) and guild.settings.get( + "base_locked", + False): + player.show("§l§a公会 §d>> §r公会据点已被锁定") + return True + + base = guild.base + if not base: + player.show("§l§a公会 §d>> §r公会尚未设置据点") + fmts.print_err(f"传送失败: 公会 {guild.name} 没有设置据点") + return True + + # 验证据点数据完整性 + if not all(hasattr(base, attr) + for attr in ['dimension', 'x', 'y', 'z']): + player.show("§l§a公会 §d>> §r据点数据损坏,请重新设置") + fmts.print_err(f"传送失败: 据点数据不完整 {base}") + return True + + # 输出详细的据点信息用于调试 + fmts.print_inf(f"开始传送: 玩家={player.name}, 公会={guild.name}") + fmts.print_inf( + f"据点信息: 维度={base.dimension}, 坐标=({base.x}, {base.y}, {base.z})") + + # 检查维度是否有效 + if base.dimension not in [0, -1, 1]: # 主世界、下界、末地 + player.show("§l§a公会 §d>> §r据点维度无效,请重新设置") + fmts.print_err(f"传送失败: 无效维度 {base.dimension}") + return True + + # 准备传送 + player.show("§l§a公会 §d>> §r准备传送到据点...") + player.show("§7请保持静止3秒...") + + # 延迟传送 + time.sleep(3) + + # 获取坐标并确保为数值类型 + x = float(base.x) + y = float(base.y) + z = float(base.z) + fmts.print_inf(f"传送玩家 {player.name} 到据点: ({x}, {y}, {z})") + + # 尝试多种传送方法,按成功率排序 + success = False + + try: + cmd = f"tp {player.name} {x} {y} {z}" + fmts.print_inf(f"方法1 - 使用sendwocmd执行: {cmd}") + self.game_ctrl.sendwocmd(cmd) + success = True + fmts.print_inf("方法1 - sendwocmd传送成功") + except Exception as e: + fmts.print_err(f"方法1 - sendwocmd失败: {e}") + + if success: + player.show(f"§l§a公会 §d>> §r已传送到公会据点 ({x:.1f}, {y:.1f}, {z:.1f})") + fmts.print_inf(f"传送成功: {player.name} -> ({x}, {y}, {z})") + else: + player.show("§l§a公会 §d>> §r传送失败,所有传送方式都无效") + fmts.print_err("所有传送指令都失败了") + + except Exception as e: + player.show("§l§a公会 §d>> §r传送过程中发生错误,请稍后重试") + fmts.print_err(f"传送到据点时发生异常: {e}") + import traceback + fmts.print_err(traceback.format_exc()) + + return True + + +def _handle_transfer_ownership(self, player: Player) -> bool: + """转让会长""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + return True + if not guild.has_permission(player.name, "transfer_owner"): + player.show("§l§a公会 §d>> §r你没有转让会长权限") + return True + + # 选择新会长 + other_members = [m for m in guild.members if m.name != player.name] + if not other_members: + player.show("§l§a公会 §d>> §r没有其他成员") + return True + + def formatter(i, m): + """Format one menu item for display.""" + return f"§e{i}. {m.rank.display_name} §f{m.name}\n" + idx = self._paginate_display( + player, other_members, "选择新会长", formatter, True) + + if idx is None: + return True + + new_owner = other_members[idx] + + player.show(f"§l§a公会 §d>> §r确定要将会长转让给 §e{new_owner.name}§r 吗?") + player.show("§c此操作不可撤销!输入'确认'继续") + + confirm = game_utils.waitMsg(player.name, timeout=30) + if confirm != "确认": + player.show("§l§a公会 §d>> §r操作已取消") + return True + + guilds = self.guild_manager.load_guilds(force_reload=True) + latest_guild = guilds.get(guild.guild_id) + if not latest_guild: + player.show("§l§a公会 §d>> §r公会数据异常") + return True + + old_owner = latest_guild.get_member(player.name) + latest_new_owner = latest_guild.get_member(new_owner.name) + if not old_owner or not latest_new_owner: + player.show("§l§a公会 §d>> §r成员数据异常") + return True + + latest_guild.owner = latest_new_owner.name + latest_new_owner.rank = GuildRank.OWNER + old_owner.rank = GuildRank.DEPUTY + + latest_guild.add_log(f"{player.name} 将会长转让给了 {latest_new_owner.name}") + latest_guild.add_audit_log("transfer_owner", player.name, + target=latest_new_owner.name) + self.guild_manager.save_guilds(guilds) + + player.show(f"§l§a公会 §d>> §r已将会长转让给 {latest_new_owner.name}") + + # 通知新会长 + if latest_new_owner.name in self.game_ctrl.allplayers: + self.game_ctrl.sendcmd( + f'/tellraw {latest_new_owner.name} ' + '{"rawtext":[{"text":"§l§a公会 §d>> §r你已成为公会会长!"}]}' + ) + + return True + + +handlers = { + '_handle_effect': _handle_effect, + '_handle_rankings': _handle_rankings, + '_format_time_ago': _format_time_ago, + '_handle_view_guild': _handle_view_guild, + '_handle_view_members': _handle_view_members, + '_handle_view_logs': _handle_view_logs, + '_handle_announcement': _handle_announcement, + '_handle_tasks': _handle_tasks, + '_handle_view_tasks': _handle_view_tasks, + '_handle_join_task': _handle_join_task, + '_handle_create_task': _handle_create_task, + '_handle_generate_auto_tasks': _handle_generate_auto_tasks, + '_handle_manage_tasks': _handle_manage_tasks, + '_handle_manage_members': _handle_manage_members, + '_notify_join_request_admins': _notify_join_request_admins, + '_handle_join_request_queue': _handle_join_request_queue, + '_handle_set_rank': _handle_set_rank, + '_handle_donation': _handle_donation, + '_handle_vault': _handle_vault, + '_handle_vault_view': _handle_vault_view, + '_handle_vault_buy': _handle_vault_buy, + '_handle_vault_sell': _handle_vault_sell, + '_handle_vault_logs': _handle_vault_logs, + '_handle_vault_cancel': _handle_vault_cancel, + '_handle_vault_settings': _handle_vault_settings, + '_handle_create_guild': _handle_create_guild, + '_handle_list_guilds': _handle_list_guilds, + '_handle_join_guild': _handle_join_guild, + '_handle_leave_guild': _handle_leave_guild, + '_handle_dissolve_guild': _handle_dissolve_guild, + '_handle_kick_member': _handle_kick_member, + '_handle_set_base': _handle_set_base, + '_handle_return_base': _handle_return_base, + '_handle_transfer_ownership': _handle_transfer_ownership, +} diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" index 9e9a3206..af681764 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" @@ -1,495 +1,485 @@ -"""Quick command handlers for common guild operations.""" - -# pylint: disable=protected-access - -import json - -from tooldelta import Player, game_utils, fmts -from guild_cloud_interop.models import GuildRank, VaultItem -from guild_cloud_interop.config import Config -from guild_cloud_interop.prompts import render_create_guild_prompt - - -def quick_create_guild(self, player: Player, args: tuple): - """快捷创建公会""" - player_xuid = self.xuidm.get_xuid_by_name(player.name, allow_offline=True) - - # 检查玩家是否已有公会 - guild = self.guild_manager.get_guild_by_player(player.name) - if guild: - player.show(render_create_guild_prompt("已有公会提示词")) - return True - - # 获取公会名称 - guild_name = args[0] if args and args[0] else None - - if not guild_name: - player.show(render_create_guild_prompt("快捷创建缺少名称提示词")) - return True - - if len(guild_name) < 2 or len(guild_name) > 16: - player.show(render_create_guild_prompt("快捷创建名称长度无效提示词")) - return True - - # 检查条件 - score = player.getScore(Config.GUILD_SCOREBOARD) - if score < Config.GUILD_CREATION_COST: - player.show(render_create_guild_prompt("创建公会余额不足提示词", balance=score)) - return True - - # 扣除钻石并创建公会 - self.game_ctrl.sendwocmd( - f"scoreboard players remove { - player.name} { - Config.GUILD_SCOREBOARD} { - Config.GUILD_CREATION_COST}") - - if self.guild_manager.create_guild(player_xuid, player.name, guild_name): - player.show(render_create_guild_prompt( - "创建公会成功提示词", guild=guild_name, player=player.name)) - # 通知所有在线玩家 - announcement = render_create_guild_prompt( - "创建公会全服公告提示词", - guild=guild_name, - player=player.name, - ) - payload = json.dumps( - {"rawtext": [{"text": announcement}]}, ensure_ascii=False) - self.game_ctrl.sendcmd(f"/tellraw @a {payload}") - else: - player.show(render_create_guild_prompt( - "创建公会名称已存在提示词", guild=guild_name, player=player.name)) - self.game_ctrl.sendwocmd( - f"scoreboard players add { - player.name} { - Config.GUILD_SCOREBOARD} { - Config.GUILD_CREATION_COST}") - - return True - - -def quick_join_guild(self, player: Player, args: tuple): # skipcq: PY-R1000 - """快捷加入公会""" - if self.guild_manager.get_guild_by_player(player.name): - player.show("§l§a公会 §d>> §r你已经加入了一个公会") - return True - - search_name = args[0] if args and args[0] else None - - if not search_name: - player.show("§l§a公会 §d>> §r请输入公会名字,例如: 公会加入 某某公会") - return True - - # 搜索匹配的公会 - guilds = self.guild_manager.load_guilds() - matched_guilds = [g for g in guilds.values() if search_name.lower() - in g.name.lower()] - - if not matched_guilds: - player.show("§l§a公会 §d>> §r未找到匹配的公会") - return True - - # 选择公会 - if len(matched_guilds) == 1: - target_guild = matched_guilds[0] - else: - player.show("§l§a公会 §d>> §r找到多个匹配的公会,请选择:") - for i, guild in enumerate(matched_guilds, 1): - player.show(f"§e{i}. §f{guild.name} §7(会长: {guild.owner})") - - player.show("§7请输入序号选择公会:") - choice = game_utils.waitMsg(player.name, timeout=30) - - if not choice or not choice.isdigit() or int( - choice) < 1 or int(choice) > len(matched_guilds): - player.show("§l§a公会 §d>> §r无效的选择") - return True - - target_guild = matched_guilds[int(choice) - 1] - - if self.guild_is_frozen(target_guild): - self.show_guild_frozen(player, target_guild) - return True - - # 检查人数上限 - if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: - player.show("§l§a公会 §d>> §r该公会已满员") - return True - - # 获取有邀请权限的在线成员 - online_inviters = [] - for member in target_guild.members: - if member.name in self.game_ctrl.allplayers and target_guild.has_permission( - member.name, "invite"): - online_inviters.append(member.name) - - if not online_inviters: - player.show("§l§a公会 §d>> §r没有可以处理申请的成员在线") - return True - - # 选择一个在线的管理员发送申请 - inviter = online_inviters[0] # 可以改进为选择职位最高的 - - # 发送申请 - message = ( - f"§l§a公会 §d>> §r§e{player.name} " - f"§f申请加入公会 §e{target_guild.name}\\n" - "§f输入 §a同意 §f或 §c拒绝" - ) - self.game_ctrl.sendcmd( - f'/tellraw {inviter} {{"rawtext":[{{"text":"{message}"}}]}}' - ) - player.show(f"已向 {inviter} 发送加入申请") - - reply = game_utils.waitMsg(inviter, timeout=60) - if reply == "同意": - if self.guild_manager.add_member( - target_guild.name, player.name, inviter): - player.show(f"§l§a公会 §d>> §r你已加入公会 {target_guild.name}") - self.game_ctrl.sendcmd( - f'/tellraw {inviter} {{"rawtext":[{{"text":"§l§a公会 §d>> §r已同意申请"}}]}}' - ) - # 通知其他在线成员 - for member in target_guild.members: - if ( - member.name in self.game_ctrl.allplayers - and member.name != player.name - ): - message = f"§l§a公会 §d>> §r§e{player.name}§r 加入了公会" - self.game_ctrl.sendcmd( - f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' - ) - elif reply == "拒绝": - player.show("§l§a公会 §d>> §r你的申请被拒绝了") - else: - player.show("§l§a公会 §d>> §r申请超时") - - return True - - -def quick_view_guild(self, player: Player, args: tuple): - """快捷查看公会信息""" - _ = args - self._handle_view_guild(player) - return True - - -def quick_view_members(self, player: Player, args: tuple): - """快捷查看成员列表""" - _ = args - self._handle_view_members(player) - return True - - -def quick_base_action(self, player: Player, args: tuple): - """快捷公会据点操作""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - action = args[0] if args and args[0] else None - - if action == "tp": - if not guild.base: - player.show("§l§a公会 §d>> §r公会尚未设置据点,请先设置") - return True - return self._handle_return_base(player) - if action == "set": - # 检查权限 - 只有会长可以设置据点 - member = guild.get_member(player.name) - if not member or member.rank != GuildRank.OWNER: - player.show("§l§a公会 §d>> §r只有会长才能设置公会据点") - player.show("§7当前权限: §f" + - (member.rank.display_name if member else "无")) - return True - return self._handle_set_base(player) - - # 显示据点信息 - member = guild.get_member(player.name) - is_owner = member and member.rank == GuildRank.OWNER - - if guild.base: - base = guild.base - dim_name = Config.DIMENSION_NAMES.get( - base.dimension, f"维度{base.dimension}") - player.show( - f"§l§a公会据点§r\n§7位置: §f{dim_name} ({ - base.x:.1f}, { - base.y:.1f}, { - base.z:.1f})") - player.show("§7使用 §f.公会据点 tp §7传送到据点") - if is_owner: - player.show("§7使用 §f.公会据点 set §7重新设置据点") - else: - player.show("§l§a公会 §d>> §r公会尚未设置据点") - if is_owner: - player.show("§7使用 §f公会据点 set §7设置据点") - else: - player.show("§7只有会长才能设置据点") - - return True - - -def quick_donate(self, player: Player, args: tuple): # skipcq: PY-R1000 - """快捷捐献物品""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - item_name = args[0] if args and args[0] else None - amount = args[1] if args and args[1] else 1 - - if not item_name: - player.show("§l§a公会 §d>> §r请输入物品名称 (如: 钻石, 绿宝石, 金锭, 铁锭)") - return True - - if amount <= 0: - player.show("§l§a公会 §d>> §r数量必须大于0") - return True - - item_map = { - "钻石": ("minecraft:diamond", 10, 5), - "绿宝石": ("minecraft:emerald", 5, 3), - "金锭": ("minecraft:gold_ingot", 2, 1), - "铁锭": ("minecraft:iron_ingot", 1, 0.5) - } - - item_info = item_map.get(item_name.lower()) - if not item_info: - player.show("§l§a公会 §d>> §r不支持的捐献物品") - return True - - item_id, contrib_per_item, exp_per_item = item_info - - if player.getItemCount(item_id) < amount: - player.show( - f"§l§a公会 §d>> §r你只有 { - player.getItemCount(item_id)} 个{item_name}") - return True - - self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {amount}") - - exp, contribution = self.guild_apply_reward_multipliers( - int(amount * exp_per_item), - amount * contrib_per_item, - ) - - # 重新加载公会数据并更新经验值 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild_id = guild.guild_id - - if guild_id in guilds: - # 更新贡献与公会经验 - guild = guilds[guild_id] - member = guild.get_member(player.name) - if member: - member.contribution += contribution - guild.stats.total_contribution += contribution - guild.exp += exp - - # 等级提升逻辑 - while True: - next_level = guild.level + 1 - next_exp_needed = Config.GUILD_LEVEL_EXP.get(next_level) - if not next_exp_needed: - break # 已到最大等级 - if guild.exp >= next_exp_needed: - guild.level += 1 - guild.exp -= next_exp_needed - guild.add_log(f"{guild.name} 公会升级到 Lv{guild.level}!") - else: - break - - # 保存公会数据 - self.guild_manager.save_guilds(guilds) - - player.show(f"§l§a公会 §d>> §r捐献成功!获得 {contribution} 贡献度,公会获得 {exp} 经验") - else: - player.show("§l§a公会 §d>> §r捐献失败,公会数据异常") - fmts.print_err(f"捐献失败: 公会ID {guild_id} 不存在于加载的数据中") - - return True - - -def quick_announcement(self, player: Player, args: tuple): - """快捷查看/设置公告""" - _ = args - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - - # 显示当前公告 - if guild.announcement: - player.show(f"§l§a公会公告§r\n§f{guild.announcement}") - else: - player.show("§l§a公会 §d>> §r当前没有公告") - - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - # 检查权限 - if not guild.has_permission(player.name, "announce"): - player.show("§l§a公会 §d>> §r你没有设置公告的权限") - return True - - player.show("§7输入 '设置' 来修改公告,其他任意键返回") - choice = game_utils.waitMsg(player.name, timeout=20) - - if choice == "设置": - player.show("§l§a公会 §d>> §r请输入新的公告内容 (最多100字):") - new_announcement = game_utils.waitMsg(player.name, timeout=60) - - if new_announcement and len(new_announcement) <= 100: - guilds = self.guild_manager.load_guilds(force_reload=True) - guild.announcement = new_announcement - guild.add_log(f"{player.name} 更新了公告") - self.guild_manager.save_guilds(guilds) - player.show("§l§a公会 §d>> §r公告已更新") - - # 通知在线成员 - message = "§l§a公会 §d>> §r公告已更新,输入 公会 公告 查看" - for member in guild.members: - if member.name in self.game_ctrl.allplayers: - self.game_ctrl.sendcmd( - f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' - ) - else: - player.show("§l§a公会 §d>> §r公告内容无效或过长") - - return True - - -def quick_vault_menu(self, player: Player, args: tuple): - """快捷仓库菜单""" - _ = args - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - return self._handle_vault(player) - - -def quick_vault_sell(self, player: Player, args: tuple): # skipcq: PY-R1000 - """快速出售物品到仓库""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - # 所有成员都可以使用仓库 - - user_input = args[0] if args and args[0] else None - count = args[1] if len(args) > 1 and args[1] else 1 - price = args[2] if len(args) > 2 and args[2] else 0 - - if not user_input: - player.show("§l§a公会仓库 §d>> §r请输入物品名称,例如: 出售 钻石 10 500") - player.show("§7支持中文名称: 出售 钻石 10 500") - player.show("§7也支持英文ID: 出售 minecraft:diamond 10 500") - return True - - # 使用智能匹配查找物品ID - item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) - - if not item_id: - player.show("§l§a公会仓库 §d>> §r未找到匹配的物品") - if suggestions: - player.show("§7您是否想要:") - for i, suggestion in enumerate(suggestions[:3], 1): - player.show(f"§e{i}. §f{suggestion}") - return True - - # 显示找到的物品 - chinese_name = self.item_matcher.get_chinese_name(item_id) - player.show(f"§l§a公会仓库 §d>> §r找到物品: §f{chinese_name}") - - # 检查仓库容量 - max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 - if len(guild.vault_items) >= max_slots: - player.show("§l§a公会仓库 §d>> §r仓库已满") - return True - - # 检查玩家是否有该物品 - item_count = player.getItemCount(item_id) - if item_count < count: - player.show(f"§l§a公会仓库 §d>> §r你只有 {item_count} 个该物品,无法出售 {count} 个") - return True - - # 如果没有指定价格,使用建议价格 - if price <= 0: - price = guild.get_item_value(item_id) * count - player.show(f"§l§a公会仓库 §d>> §r使用建议价格: {price} 贡献点") - - # 确认出售 - item_name = self._get_item_display_name(item_id) - player.show( - f"§l§a公会仓库 §d>> §r确认出售 §f{item_name} §7x{count} §r价格 §e{price}贡献点§r?") - player.show("§7输入 '确认' 继续出售,其他任意键取消") - - confirm = game_utils.waitMsg(player.name, timeout=20) - if confirm != "确认": - player.show("§l§a公会仓库 §d>> §r出售已取消") - return True - - # 执行出售 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if not guild: - player.show("§l§a公会仓库 §d>> §r公会数据异常") - return True - - # 再次检查仓库容量 - if len(guild.vault_items) >= max_slots: - player.show("§l§a公会仓库 §d>> §r仓库已满") - return True - - # 扣除物品 - self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {count}") - - # 添加到仓库 - vault_item = VaultItem( - item_id=item_id, - count=count, - price=price, - seller=player.name - ) - - guild.vault_items.append(vault_item) - guild.add_log(f"{player.name} 出售了 {item_name} x{count} (价格{price}贡献点)") - - # 保存数据 - self.guild_manager.save_guilds(guilds) - - player.show( - f"§l§a公会仓库 §d>> §r出售成功!{item_name} x{count} 已上架,价格 {price} 贡献点") - return True - - -handlers_quick = { - "quick_create_guild": quick_create_guild, - "quick_join_guild": quick_join_guild, - "quick_view_guild": quick_view_guild, - "quick_view_members": quick_view_members, - "quick_base_action": quick_base_action, - "quick_donate": quick_donate, - "quick_announcement": quick_announcement, - "quick_vault_menu": quick_vault_menu, - "quick_vault_sell": quick_vault_sell, -} +"""Quick command handlers for common guild operations.""" + +# pylint: disable=protected-access + +import json + +from tooldelta import Player, game_utils, fmts +from guild_cloud_interop.models import GuildRank, VaultItem +from guild_cloud_interop.config import Config +from guild_cloud_interop.prompts import render_create_guild_prompt + + +def quick_create_guild(self, player: Player, args: tuple): + """快捷创建公会""" + player_xuid = self.xuidm.get_xuid_by_name(player.name, allow_offline=True) + + # 检查玩家是否已有公会 + guild = self.guild_manager.get_guild_by_player(player.name) + if guild: + player.show(render_create_guild_prompt("已有公会提示词")) + return True + + # 获取公会名称 + guild_name = args[0] if args and args[0] else None + + if not guild_name: + player.show(render_create_guild_prompt("快捷创建缺少名称提示词")) + return True + + if len(guild_name) < 2 or len(guild_name) > 16: + player.show(render_create_guild_prompt("快捷创建名称长度无效提示词")) + return True + + # 检查条件 + score = player.getScore(Config.GUILD_SCOREBOARD) + if score < Config.GUILD_CREATION_COST: + player.show(render_create_guild_prompt("创建公会余额不足提示词", balance=score)) + return True + + # 扣除钻石并创建公会 + self.game_ctrl.sendwocmd( + f"scoreboard players remove {player.name} {Config.GUILD_SCOREBOARD} {Config.GUILD_CREATION_COST}") + + if self.guild_manager.create_guild(player_xuid, player.name, guild_name): + player.show(render_create_guild_prompt( + "创建公会成功提示词", guild=guild_name, player=player.name)) + # 通知所有在线玩家 + announcement = render_create_guild_prompt( + "创建公会全服公告提示词", + guild=guild_name, + player=player.name, + ) + payload = json.dumps( + {"rawtext": [{"text": announcement}]}, ensure_ascii=False) + self.game_ctrl.sendcmd(f"/tellraw @a {payload}") + else: + player.show(render_create_guild_prompt( + "创建公会名称已存在提示词", guild=guild_name, player=player.name)) + self.game_ctrl.sendwocmd( + f"scoreboard players add {player.name} {Config.GUILD_SCOREBOARD} {Config.GUILD_CREATION_COST}") + + return True + + +def quick_join_guild(self, player: Player, args: tuple): # skipcq: PY-R1000 + """快捷加入公会""" + if self.guild_manager.get_guild_by_player(player.name): + player.show("§l§a公会 §d>> §r你已经加入了一个公会") + return True + + search_name = args[0] if args and args[0] else None + + if not search_name: + player.show("§l§a公会 §d>> §r请输入公会名字,例如: 公会加入 某某公会") + return True + + # 搜索匹配的公会 + guilds = self.guild_manager.load_guilds() + matched_guilds = [g for g in guilds.values() if search_name.lower() + in g.name.lower()] + + if not matched_guilds: + player.show("§l§a公会 §d>> §r未找到匹配的公会") + return True + + # 选择公会 + if len(matched_guilds) == 1: + target_guild = matched_guilds[0] + else: + player.show("§l§a公会 §d>> §r找到多个匹配的公会,请选择:") + for i, guild in enumerate(matched_guilds, 1): + player.show(f"§e{i}. §f{guild.name} §7(会长: {guild.owner})") + + player.show("§7请输入序号选择公会:") + choice = game_utils.waitMsg(player.name, timeout=30) + + if not choice or not choice.isdigit() or int( + choice) < 1 or int(choice) > len(matched_guilds): + player.show("§l§a公会 §d>> §r无效的选择") + return True + + target_guild = matched_guilds[int(choice) - 1] + + if self.guild_is_frozen(target_guild): + self.show_guild_frozen(player, target_guild) + return True + + # 检查人数上限 + if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: + player.show("§l§a公会 §d>> §r该公会已满员") + return True + + # 获取有邀请权限的在线成员 + online_inviters = [] + for member in target_guild.members: + if member.name in self.game_ctrl.allplayers and target_guild.has_permission( + member.name, "invite"): + online_inviters.append(member.name) + + if not online_inviters: + player.show("§l§a公会 §d>> §r没有可以处理申请的成员在线") + return True + + # 选择一个在线的管理员发送申请 + inviter = online_inviters[0] # 可以改进为选择职位最高的 + + # 发送申请 + message = ( + f"§l§a公会 §d>> §r§e{player.name} " + f"§f申请加入公会 §e{target_guild.name}\\n" + "§f输入 §a同意 §f或 §c拒绝" + ) + self.game_ctrl.sendcmd( + f'/tellraw {inviter} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + player.show(f"已向 {inviter} 发送加入申请") + + reply = game_utils.waitMsg(inviter, timeout=60) + if reply == "同意": + if self.guild_manager.add_member( + target_guild.name, player.name, inviter): + player.show(f"§l§a公会 §d>> §r你已加入公会 {target_guild.name}") + self.game_ctrl.sendcmd( + f'/tellraw {inviter} {{"rawtext":[{{"text":"§l§a公会 §d>> §r已同意申请"}}]}}' + ) + # 通知其他在线成员 + for member in target_guild.members: + if ( + member.name in self.game_ctrl.allplayers + and member.name != player.name + ): + message = f"§l§a公会 §d>> §r§e{player.name}§r 加入了公会" + self.game_ctrl.sendcmd( + f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + elif reply == "拒绝": + player.show("§l§a公会 §d>> §r你的申请被拒绝了") + else: + player.show("§l§a公会 §d>> §r申请超时") + + return True + + +def quick_view_guild(self, player: Player, args: tuple): + """快捷查看公会信息""" + _ = args + self._handle_view_guild(player) + return True + + +def quick_view_members(self, player: Player, args: tuple): + """快捷查看成员列表""" + _ = args + self._handle_view_members(player) + return True + + +def quick_base_action(self, player: Player, args: tuple): + """快捷公会据点操作""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + action = args[0] if args and args[0] else None + + if action == "tp": + if not guild.base: + player.show("§l§a公会 §d>> §r公会尚未设置据点,请先设置") + return True + return self._handle_return_base(player) + if action == "set": + # 检查权限 - 只有会长可以设置据点 + member = guild.get_member(player.name) + if not member or member.rank != GuildRank.OWNER: + player.show("§l§a公会 §d>> §r只有会长才能设置公会据点") + player.show("§7当前权限: §f" + + (member.rank.display_name if member else "无")) + return True + return self._handle_set_base(player) + + # 显示据点信息 + member = guild.get_member(player.name) + is_owner = member and member.rank == GuildRank.OWNER + + if guild.base: + base = guild.base + dim_name = Config.DIMENSION_NAMES.get( + base.dimension, f"维度{base.dimension}") + player.show( + f"§l§a公会据点§r\n§7位置: §f{dim_name} ({base.x:.1f}, {base.y:.1f}, {base.z:.1f})") + player.show("§7使用 §f.公会据点 tp §7传送到据点") + if is_owner: + player.show("§7使用 §f.公会据点 set §7重新设置据点") + else: + player.show("§l§a公会 §d>> §r公会尚未设置据点") + if is_owner: + player.show("§7使用 §f公会据点 set §7设置据点") + else: + player.show("§7只有会长才能设置据点") + + return True + + +def quick_donate(self, player: Player, args: tuple): # skipcq: PY-R1000 + """快捷捐献物品""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + item_name = args[0] if args and args[0] else None + amount = args[1] if args and args[1] else 1 + + if not item_name: + player.show("§l§a公会 §d>> §r请输入物品名称 (如: 钻石, 绿宝石, 金锭, 铁锭)") + return True + + if amount <= 0: + player.show("§l§a公会 §d>> §r数量必须大于0") + return True + + item_map = { + "钻石": ("minecraft:diamond", 10, 5), + "绿宝石": ("minecraft:emerald", 5, 3), + "金锭": ("minecraft:gold_ingot", 2, 1), + "铁锭": ("minecraft:iron_ingot", 1, 0.5) + } + + item_info = item_map.get(item_name.lower()) + if not item_info: + player.show("§l§a公会 §d>> §r不支持的捐献物品") + return True + + item_id, contrib_per_item, exp_per_item = item_info + + if player.getItemCount(item_id) < amount: + player.show( + f"§l§a公会 §d>> §r你只有 {player.getItemCount(item_id)} 个{item_name}") + return True + + self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {amount}") + + exp, contribution = self.guild_apply_reward_multipliers( + int(amount * exp_per_item), + amount * contrib_per_item, + ) + + # 重新加载公会数据并更新经验值 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_id = guild.guild_id + + if guild_id in guilds: + # 更新贡献与公会经验 + guild = guilds[guild_id] + member = guild.get_member(player.name) + if member: + member.contribution += contribution + guild.stats.total_contribution += contribution + guild.exp += exp + + # 等级提升逻辑 + while True: + next_level = guild.level + 1 + next_exp_needed = Config.GUILD_LEVEL_EXP.get(next_level) + if not next_exp_needed: + break # 已到最大等级 + if guild.exp >= next_exp_needed: + guild.level += 1 + guild.exp -= next_exp_needed + guild.add_log(f"{guild.name} 公会升级到 Lv{guild.level}!") + else: + break + + # 保存公会数据 + self.guild_manager.save_guilds(guilds) + + player.show(f"§l§a公会 §d>> §r捐献成功!获得 {contribution} 贡献度,公会获得 {exp} 经验") + else: + player.show("§l§a公会 §d>> §r捐献失败,公会数据异常") + fmts.print_err(f"捐献失败: 公会ID {guild_id} 不存在于加载的数据中") + + return True + + +def quick_announcement(self, player: Player, args: tuple): + """快捷查看/设置公告""" + _ = args + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + + # 显示当前公告 + if guild.announcement: + player.show(f"§l§a公会公告§r\n§f{guild.announcement}") + else: + player.show("§l§a公会 §d>> §r当前没有公告") + + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + # 检查权限 + if not guild.has_permission(player.name, "announce"): + player.show("§l§a公会 §d>> §r你没有设置公告的权限") + return True + + player.show("§7输入 '设置' 来修改公告,其他任意键返回") + choice = game_utils.waitMsg(player.name, timeout=20) + + if choice == "设置": + player.show("§l§a公会 §d>> §r请输入新的公告内容 (最多100字):") + new_announcement = game_utils.waitMsg(player.name, timeout=60) + + if new_announcement and len(new_announcement) <= 100: + guilds = self.guild_manager.load_guilds(force_reload=True) + guild.announcement = new_announcement + guild.add_log(f"{player.name} 更新了公告") + self.guild_manager.save_guilds(guilds) + player.show("§l§a公会 §d>> §r公告已更新") + + # 通知在线成员 + message = "§l§a公会 §d>> §r公告已更新,输入 公会 公告 查看" + for member in guild.members: + if member.name in self.game_ctrl.allplayers: + self.game_ctrl.sendcmd( + f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + else: + player.show("§l§a公会 §d>> §r公告内容无效或过长") + + return True + + +def quick_vault_menu(self, player: Player, args: tuple): + """快捷仓库菜单""" + _ = args + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + return self._handle_vault(player) + + +def quick_vault_sell(self, player: Player, args: tuple): # skipcq: PY-R1000 + """快速出售物品到仓库""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + # 所有成员都可以使用仓库 + + user_input = args[0] if args and args[0] else None + count = args[1] if len(args) > 1 and args[1] else 1 + price = args[2] if len(args) > 2 and args[2] else 0 + + if not user_input: + player.show("§l§a公会仓库 §d>> §r请输入物品名称,例如: 出售 钻石 10 500") + player.show("§7支持中文名称: 出售 钻石 10 500") + player.show("§7也支持英文ID: 出售 minecraft:diamond 10 500") + return True + + # 使用智能匹配查找物品ID + item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) + + if not item_id: + player.show("§l§a公会仓库 §d>> §r未找到匹配的物品") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + return True + + # 显示找到的物品 + chinese_name = self.item_matcher.get_chinese_name(item_id) + player.show(f"§l§a公会仓库 §d>> §r找到物品: §f{chinese_name}") + + # 检查仓库容量 + max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + + # 检查玩家是否有该物品 + item_count = player.getItemCount(item_id) + if item_count < count: + player.show(f"§l§a公会仓库 §d>> §r你只有 {item_count} 个该物品,无法出售 {count} 个") + return True + + # 如果没有指定价格,使用建议价格 + if price <= 0: + price = guild.get_item_value(item_id) * count + player.show(f"§l§a公会仓库 §d>> §r使用建议价格: {price} 贡献点") + + # 确认出售 + item_name = self._get_item_display_name(item_id) + player.show( + f"§l§a公会仓库 §d>> §r确认出售 §f{item_name} §7x{count} §r价格 §e{price}贡献点§r?") + player.show("§7输入 '确认' 继续出售,其他任意键取消") + + confirm = game_utils.waitMsg(player.name, timeout=20) + if confirm != "确认": + player.show("§l§a公会仓库 §d>> §r出售已取消") + return True + + # 执行出售 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if not guild: + player.show("§l§a公会仓库 §d>> §r公会数据异常") + return True + + # 再次检查仓库容量 + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + + # 扣除物品 + self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {count}") + + # 添加到仓库 + vault_item = VaultItem( + item_id=item_id, + count=count, + price=price, + seller=player.name + ) + + guild.vault_items.append(vault_item) + guild.add_log(f"{player.name} 出售了 {item_name} x{count} (价格{price}贡献点)") + + # 保存数据 + self.guild_manager.save_guilds(guilds) + + player.show( + f"§l§a公会仓库 §d>> §r出售成功!{item_name} x{count} 已上架,价格 {price} 贡献点") + return True + + +handlers_quick = { + "quick_create_guild": quick_create_guild, + "quick_join_guild": quick_join_guild, + "quick_view_guild": quick_view_guild, + "quick_view_members": quick_view_members, + "quick_base_action": quick_base_action, + "quick_donate": quick_donate, + "quick_announcement": quick_announcement, + "quick_vault_menu": quick_vault_menu, + "quick_vault_sell": quick_vault_sell, +} diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" index ccbd77b1..6efa1252 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" @@ -1,1175 +1,1161 @@ -"""Shared guild runtime and gameplay logic.""" - -# pylint: disable=protected-access - -import os -import shutil -import time -from typing import List, Optional, Tuple, Any - - -from tooldelta import Player, game_utils, fmts -from tooldelta.utils import tempjson - -from guild_cloud_interop.config import Config -from guild_cloud_interop.models import GuildData, GuildMember, GuildRank, VaultItem -from guild_cloud_interop.prompts import render_config_prompt -from guild_cloud_interop.ui import format_page_footer, format_panel - - -def _format_template(template: str, **values: Any) -> str: - """Implement the format template operation.""" - result = str(template) - for key, value in values.items(): - result = result.replace("{" + key + "}", str(value)) - return result - - -def _menu_config() -> dict[str, Any]: - """Implement the menu config operation.""" - config = getattr(Config, "MENU_CONFIG", {}) - return config if isinstance(config, dict) else {} - - -def _menu_item(group: str, key: str, fallback_name: str, - fallback_desc: str) -> tuple[str, str]: - """Implement the menu item operation.""" - menu_config = _menu_config() - group_config = menu_config.get(group, {}) - item_config = group_config.get( - key, {}) if isinstance( - group_config, dict) else {} - if not isinstance(item_config, dict): - item_config = {} - name = item_config.get("名称") - desc = item_config.get("描述") - return ( - str(name) if isinstance(name, str) and name else fallback_name, - str(desc) if isinstance(desc, str) else fallback_desc, - ) - - -def _menu_item_name(group: str, key: str, fallback_name: str) -> str: - """Implement the menu item name operation.""" - return _menu_item(group, key, fallback_name, "")[0] - - -def _show_menu( # skipcq: PY-R1000 - self, - player: Player, - guild: Optional[GuildData], - member: Optional[GuildMember]) -> Optional[str]: - """显示公会菜单并返回用户选择 - 增强版本""" - # 数据完整性检查 - if guild and not member: - # 公会存在但成员不存在,尝试重新获取 - fmts.print_err(f"数据不一致:玩家 {player.name} 在公会 {guild.name} 中但成员信息缺失") - member = guild.get_member(player.name) - if not member: - player.show("§c错误:公会数据不一致,请尝试重新加入公会或联系管理员") - fmts.print_err(f"严重错误:无法找到玩家 {player.name} 的成员信息") - return None - - is_member = guild is not None - is_owner = member and member.rank == GuildRank.OWNER - can_manage_members = bool( - guild and member and any( - guild.has_permission( - player.name, - permission) for permission in ( - "kick", - "set_rank", - "transfer_owner", - "join_queue"))) - - # 调试日志 - if guild: - fmts.print_inf( - f"菜单显示 - 玩家: { - player.name}, 公会: { - guild.name}, 成员职位: { - member.rank.value if member else 'None'}, 是否会长: {is_owner}") - - menu_config = getattr(self, "_guild_menu_config_override", None) or _menu_config() - base_items = [ - ("创建", "创建自己的公会", not is_member), - ("列表", "查看所有公会", True), - ("查看", "查看公会详情", is_member), - ("成员", "查看成员列表", is_member), - ("日志", "查看公会日志", is_member), - ("公告", "查看/设置公告", is_member), - ("加入", "加入一个公会", not is_member), - ("退出", "退出当前公会", is_member and not is_owner), - ("管理", "管理公会成员", can_manage_members), - ("解散", "解散公会", is_owner), - ] - menu_items = [ - (*_menu_item("基础功能", key, default_name, default_desc), condition) - for key, default_desc, condition in base_items - for default_name in (key,) - ] - - optional_menu_config = [ - (Config.GUILD_FUNCTION_VAULT, "仓库", "公会仓库", is_member), - (Config.GUILD_FUNCTION_BASE, "据点", "据点相关操作", is_member), - (Config.GUILD_FUNCTION_DONATION, "捐献", "捐献物品到公会", is_member), - (Config.GUILD_FUNCTION_TASKS, "任务", "公会任务系统", is_member), - (Config.GUILD_FUNCTION_EFFECT, "效果", "通过钻石获得效果增益", is_member), - (Config.GUILD_FUNCTION_RANKINGS, "排行", "查看公会排行榜", True), - ] - - for enabled, cmd, desc, condition in optional_menu_config: - if enabled: - menu_items.append((*_menu_item("可选功能", cmd, cmd, desc), condition)) - - available_items = [(cmd, desc) for cmd, desc, cond in menu_items if cond] - - if member and guild: - subtitle_template = menu_config.get("成员身份显示模板", "[{rank}§f] §e{guild}") - subtitle = _format_template( - subtitle_template, rank=member.rank.display_name, guild=guild.name) - else: - subtitle = str(menu_config.get("游客身份显示", "[§7游客§f]")) - - number_prompt = _format_template( - menu_config.get("输入数字提示模板", "§a❀ §r输入 §e[1-{count}]§r 之间的数字选择功能"), - count=len(available_items), - ) - name_prompt = str(menu_config.get("输入名称提示词", "§a❀ §r也可输入功能名称,输入 §cq §r退出")) - player.show(format_panel( - str(menu_config.get("菜单标题", "公会管理系统")), - available_items, - subtitle=subtitle, - footer=f"{number_prompt}\n{name_prompt}", - )) - choice_map = {str(index): cmd for index, (cmd, _desc) - in enumerate(available_items, start=1)} - choice_map.update({cmd: cmd for cmd, _desc in available_items}) - user_input = game_utils.waitMsg(player.name, timeout=30) - if user_input is None: - player.show(render_config_prompt("菜单回复超时提示词")) - return None - return choice_map.get(user_input, user_input) - - -def guild_update_data(self, args: list[str]): - """更新过去的数据,确保所有公会都有 guild_id,且与外层 id 一致""" - _ = args - updated = False - - # 使用统一接口加载所有公会数据 - guilds = self.guild_manager.load_guilds(force_reload=True) - - for outer_id, guild in guilds.items(): - inner_id = guild.guild_id - if inner_id != outer_id: - guild.guild_id = outer_id - fmts.print_inf(f"已更新 guild_id: {outer_id}(原: {inner_id})") - updated = True - - if updated: - try: - self.guild_manager.save_guilds(guilds) - fmts.print_inf("公会数据文件已更新完成") - except Exception as e: - fmts.print_err(f"保存公会数据时出错: {e}") - else: - fmts.print_inf("无需更新,所有 数据 已正确") - - -def guild_menu_cb(self, player: Player, args: tuple): - """公会菜单回调函数 - 增强版本""" - _ = args - player_xuid = self.xuidm.get_xuid_by_name(player.name, allow_offline=True) - - # 强制刷新缓存以确保数据最新 - guild = self.guild_manager.get_guild_by_player( - player.name, force_reload=True) - member = guild.get_member(player.name) if guild else None - - # 额外的数据验证 - if guild and not member: - fmts.print_err(f"数据不一致警告:玩家 {player.name} 在公会 {guild.name} 中但找不到成员记录") - # 尝试重新加载数据 - guild = self.guild_manager.get_guild_by_player( - player.name, force_reload=True) - member = guild.get_member(player.name) if guild else None - - subcommand = self._show_menu(player, guild, member) - - if subcommand is None or subcommand in ("q", "Q", ".", "。"): - return True - - if guild and member and self.guild_is_frozen(guild): - frozen_readonly_items = { - _menu_item_name("基础功能", "列表", "列表"), - _menu_item_name("基础功能", "查看", "查看"), - _menu_item_name("基础功能", "成员", "成员"), - _menu_item_name("基础功能", "日志", "日志"), - _menu_item_name("可选功能", "排行", "排行"), - } - if subcommand not in frozen_readonly_items: - self.show_guild_frozen(player, guild) - return True - - # 路由到对应的处理函数 - # 功能项配置:功能是否启用、菜单标题、处理函数 - optional_handlers_config = [ - (Config.GUILD_FUNCTION_VAULT, "仓库", self._handle_vault), - (Config.GUILD_FUNCTION_BASE, "据点", self._handle_base_menu), - (Config.GUILD_FUNCTION_DONATION, "捐献", self._handle_donation), - (Config.GUILD_FUNCTION_TASKS, "任务", self._handle_tasks), - (Config.GUILD_FUNCTION_EFFECT, "效果", self._handle_effect), - (Config.GUILD_FUNCTION_RANKINGS, "排行", self._handle_rankings), - ] - - # 基础菜单项(总是存在) - handlers = { - _menu_item_name("基础功能", "创建", "创建"): ( - lambda: self._handle_create_guild(player, player_xuid) - ), - _menu_item_name("基础功能", "列表", "列表"): lambda: self._handle_list_guilds(player), - _menu_item_name("基础功能", "查看", "查看"): lambda: self._handle_view_guild(player), - _menu_item_name("基础功能", "成员", "成员"): lambda: self._handle_view_members(player), - _menu_item_name("基础功能", "日志", "日志"): lambda: self._handle_view_logs(player), - _menu_item_name("基础功能", "公告", "公告"): lambda: self._handle_announcement(player), - _menu_item_name("基础功能", "加入", "加入"): lambda: self._handle_join_guild(player), - _menu_item_name("基础功能", "退出", "退出"): lambda: self._handle_leave_guild(player), - _menu_item_name("基础功能", "管理", "管理"): ( - lambda: self._handle_manage_members(player) - ), - _menu_item_name("基础功能", "解散", "解散"): ( - lambda: self._handle_dissolve_guild(player, player_xuid) - ), - } - - # 追加可选功能项 - for enabled, title, func in optional_handlers_config: - if enabled: - handlers[_menu_item_name("可选功能", title, title) - ] = lambda f=func: f(player) - - handler = handlers.get(subcommand) - if handler: - return handler() - player.show(render_config_prompt("无效指令提示词")) - - return True - - -def _create_progress_bar( - self, - current: int, - total: int, - length: int = 10) -> str: - """创建进度条""" - if self is None: - return "" - if total == 0: - return "§7[§c无效§7]" - - progress = min(current / total, 1.0) - filled = int(progress * length) - empty = length - filled - - bar = "§a" + "█" * filled + "§7" + "░" * empty - percentage = int(progress * 100) - - return f"§7[{bar}§7] §f{percentage}%" - - -def _format_time_duration(self, seconds: float) -> str: - """格式化时间长度""" - if self is None: - return "" - if seconds < 60: - return f"{int(seconds)}秒" - if seconds < 3600: - return f"{int(seconds // 60)}分钟" - if seconds < 86400: - return f"{int(seconds // 3600)}小时" - return f"{int(seconds // 86400)}天" - - -def _get_item_display_name(self, item_id: str) -> str: - """获取物品显示名称""" - return self.item_matcher.get_chinese_name(item_id) - - -def _has_inventory_space( - self, - player: Player, - item_id: str, - count: int) -> bool: - """检查玩家背包是否有足够空间""" - if self is None or player is None or not item_id or count <= 0: - return False - return True - - -def _handle_base_menu(self, player: Player) -> bool: - """据点菜单""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - can_return = guild.has_permission(player.name, "return_base") - can_set = guild.has_permission(player.name, "setbase") - - player.show("§r========== §a据点菜单§r ==========") - - if guild.base: - base = guild.base - dim_name = Config.DIMENSION_NAMES.get( - base.dimension, f"维度{base.dimension}") - player.show( - f"§7当前据点: §f{dim_name} ({ - base.x:.1f}, { - base.y:.1f}, { - base.z:.1f})") - if can_return: - player.show("§e1. §f传送到据点") - else: - player.show("§7当前据点: §c未设置") - - if can_set: - player.show("§e2. §f设置据点") - - player.show("§7输入选项序号,q 返回") - - choice = game_utils.waitMsg(player.name, timeout=30) - - if choice == "1" and guild.base and can_return: - return self._handle_return_base(player) - if choice == "2" and can_set: - return self._handle_set_base(player) - - return True - - -def guild_chat_cb(self, player: Player, args: tuple): - """公会聊天回调""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - message = args[0] if args and args[0] else None - - if not message: - # 切换聊天模式 - current_mode = self.guild_chat_mode.get(player.name, False) - self.guild_chat_mode[player.name] = not current_mode - - if self.guild_chat_mode[player.name]: - player.show("§l§a公会 §d>> §r已切换到公会聊天模式") - else: - player.show("§l§a公会 §d>> §r已切换到公共聊天模式") - else: - # 发送公会消息 - self._send_guild_message(guild, player.name, message) - - return True - - -def _send_guild_message(self, guild: GuildData, sender: str, message: str): - """发送公会聊天消息""" - if self.guild_is_frozen(guild): - return - member = guild.get_member(sender) - if not member: - return - - # 构建消息 - chat_msg = f"§d✧§b[公会]§d✦ { - member.rank.display_name} §e{sender}§7: §f{message}" - - # 发送给所有在线的公会成员 - for member in guild.members: - if member.name in self.game_ctrl.allplayers: - self.game_ctrl.sendcmd( - f'/tellraw {member.name} {{"rawtext":[{{"text":"{chat_msg}"}}]}}' - ) - - -def on_chat_packet(self, packet): - """处理聊天数据包""" - try: - # 检查是否是玩家聊天 - if packet.get("type") != 1: # 1 表示玩家聊天 - return False - - sender = packet.get("source_name", "") - message = packet.get("message", "") - - # 检查是否在公会聊天模式 - if self.guild_chat_mode.get(sender, False): - guild = self.guild_manager.get_guild_by_player(sender) - if guild: - self._send_guild_message(guild, sender, message) - return True # 阻止原始消息 - except BaseException: - pass - - return False - - -def _should_stop(self) -> bool: - """Implement the should stop operation.""" - stop_event = getattr(self, "_stop_event", None) - return bool(stop_event and stop_event.is_set()) - - -def _wait_or_stopped(self, seconds: float) -> bool: - """Implement the wait or stopped operation.""" - stop_event = getattr(self, "_stop_event", None) - if stop_event is None: - time.sleep(seconds) - return False - return stop_event.wait(seconds) - - -def _apply_guild_effects_to_player( # skipcq: PY-R1000 - self, - player_name: str, - guild: Optional[GuildData] = None, - force: bool = False, - command_delay: float = 0.2, - effect_names: Optional[set] = None, -) -> int: - """按需给单个在线玩家补发公会增益,避免周期性全量刷命令压租赁服。""" - if not Config.GUILD_FUNCTION_EFFECT or player_name not in self.game_ctrl.allplayers: - return 0 - - if guild is None: - guild = self.guild_manager.get_guild_by_player(player_name) - if guild is None: - return 0 - - effects = getattr(guild, "purchased_effects", {}) or {} - if not effects: - return 0 - - now = time.time() - cache = getattr(self, "_effect_refresh_cache", None) - if cache is None: - cache = {} - self._effect_refresh_cache = cache - - refresh_interval = getattr(Config, "EFFECT_REFRESH_INTERVAL", 3600) - sent_count = 0 - - for effect_name, raw_level in effects.items(): - if _should_stop(self): - break - if effect_names is not None and effect_name not in effect_names: - continue - - try: - amplifier = max(int(raw_level) - 1, 0) - except (TypeError, ValueError): - fmts.print_err( - f"公会 { - guild.name} 的效果 {effect_name} 等级无效: {raw_level}") - continue - - cache_key = (player_name, effect_name) - cached = cache.get(cache_key) - if not force and cached: - cached_amplifier, cached_time = cached - if cached_amplifier == amplifier and now - cached_time < refresh_interval: - continue - - if sent_count and command_delay > 0 and _wait_or_stopped( - self, command_delay): - break - - try: - self.game_ctrl.sendwocmd( - f"effect {player_name} {effect_name} 100000 {amplifier} true" - ) - cache[cache_key] = (amplifier, time.time()) - sent_count += 1 - except Exception as e: - fmts.print_err(f"为玩家 {player_name} 添加效果 {effect_name} 时出错: {e}") - - return sent_count - - -def _refresh_online_effects( - self, - online_players: List[str], - guilds: Optional[dict] = None) -> int: - """Implement the refresh online effects operation.""" - if not Config.GUILD_FUNCTION_EFFECT: - return 0 - - online_set = set(online_players) - cache = getattr(self, "_effect_refresh_cache", None) - if cache: - for cache_key in list(cache): - if cache_key[0] not in online_set: - del cache[cache_key] - - sent_count = 0 - for player_name in online_players: - if _should_stop(self): - break - - guild = None - if guilds is not None: - guild_id = self.guild_manager.get_cached_guild_id(player_name) - if guild_id: - guild = guilds.get(guild_id) - - sent_count += _apply_guild_effects_to_player( - self, player_name, guild=guild) - - return sent_count - - -def guild_exp_task(self): - """公会经验更新任务""" - while not _should_stop(self): - if _wait_or_stopped(self, Config.EXP_UPDATE_INTERVAL): - break - if not self._plugin_enabled(): - continue - - try: - fmts.print_inf("正在更新公会经验...") - - online_players = list(self.game_ctrl.allplayers) - guilds = self.guild_manager.load_guilds(force_reload=True) - guild_online_count = {} - level_ups = [] - - # 统计每个公会在线人数 - for player_name in online_players: - guild = self.guild_manager.get_guild_by_player(player_name) - if guild: - guild_online_count[guild.guild_id] = guild_online_count.get( - guild.guild_id, 0) + 1 - - # 更新经验和等级 - for gid, count in guild_online_count.items(): - if count == 0: - continue - - guild = guilds[gid] - exp_add, _ = self.guild_apply_reward_multipliers( - count * Config.EXP_PER_ONLINE_MEMBER, - 0, - ) - guild.exp += exp_add - - # 处理升级 - level = guild.level - next_required_exp = Config.GUILD_LEVEL_EXP.get(level + 1) - while next_required_exp and guild.exp >= next_required_exp: - guild.exp -= next_required_exp - level += 1 - next_required_exp = Config.GUILD_LEVEL_EXP.get(level + 1) - level_ups.append((guild.name, level)) - guild.add_log(f"公会升级到 {level} 级") - - guild.level = level - - self.guild_manager.save_guilds(guilds) - - for guild_name, new_level in level_ups: - if _should_stop(self): - break - message = f"§l§a公会 §d>> §r公会 §e{guild_name}§r 升级到了 §e{new_level}§r 级!" - self.game_ctrl.sendcmd( - f'/tellraw @a {{"rawtext":[{{"text":"{message}"}}]}}' - ) - except Exception as e: - fmts.print_err(f"更新公会经验出错: {e}") - - -def update_online_task(self): - """更新在线状态任务""" - while not _should_stop(self): - try: - if _wait_or_stopped(self, 300): - break - if not self._plugin_enabled(): - continue - online_players = list(self.game_ctrl.allplayers) # 这里是 List[str] - - self.guild_manager.update_online_status(online_players) # 传名字列表 - guilds = self.guild_manager.load_guilds() - sent_count = _refresh_online_effects( - self, online_players, guilds=guilds) - - if sent_count: - fmts.print_inf(f"已低频补发 {sent_count} 条公会增益命令") - except Exception as e: - fmts.print_err(f"更新在线状态出错: {e}") - - -def on_player_action(self, packet): - """监听玩家行为,用于任务进度跟踪""" - if self is None or packet is None: - return - # 等待具体的数据包格式后再处理任务进度。 - return - - -def update_task_progress( - self, - player_name: str, - task_type: str, - target: str, - amount: int = 1): - """更新任务进度""" - try: - guild = self.guild_manager.get_guild_by_player(player_name) - if not guild: - return - if self.guild_is_frozen(guild): - return - - updated = False - for task in guild.tasks: - if (task.completed or - task.task_type != task_type or - task.target != target or - player_name not in task.participants): - continue - - task.current_count += amount - if task.current_count >= task.target_count: - # 任务完成 - task.completed = True - task.current_count = task.target_count - - # 发放奖励 - reward_exp, reward_contribution = self.guild_apply_reward_multipliers( - task.reward_exp, task.reward_contribution, ) - member = guild.get_member(player_name) - if member: - member.contribution += reward_contribution - guild.exp += reward_exp - guild.stats.total_contribution += reward_contribution - - # 通知玩家 - if player_name in self.game_ctrl.allplayers: - message = ( - f"§l§a公会任务 §d>> §r任务 '{task.name}' 已完成!" - f"获得 {reward_contribution} 贡献点和 {reward_exp} 公会经验" - ) - self.game_ctrl.sendcmd( - f'/tellraw {player_name} {{"rawtext":[{{"text":"{message}"}}]}}' - ) - - guild.add_log(f"{player_name} 完成了任务: {task.name}") - updated = True - else: - updated = True - - if updated: - self.guild_manager.mark_guild_dirty(guild.guild_id) - - except Exception as e: - fmts.print_err(f"更新任务进度出错: {e}") - - -def check_and_complete_trade_tasks(self, player_name: str): - """检查并完成贸易任务""" - self.update_task_progress(player_name, "trade", "trade_count", 1) - - -def get_guild_rankings( - self, sort_by: str = "level") -> List[Tuple[GuildData, Any]]: - """获取公会排行榜""" - guilds = self.guild_manager.load_guilds() - guild_list = list(guilds.values()) - - if sort_by == "level": - guild_list.sort(key=lambda g: (g.level, g.exp), reverse=True) - return [(g, g.level) for g in guild_list] - if sort_by == "members": - guild_list.sort(key=lambda g: len(g.members), reverse=True) - return [(g, len(g.members)) for g in guild_list] - if sort_by == "contribution": - guild_list.sort(key=lambda g: g.stats.total_contribution, reverse=True) - return [(g, g.stats.total_contribution) for g in guild_list] - if sort_by == "activity": - # 基于最近活跃度排序 - guild_list.sort(key=lambda g: max( - [m.last_online for m in g.members] + [0]), reverse=True) - return [(g, max([m.last_online for m in g.members] + [0])) - for g in guild_list] - return [(g, 0) for g in guild_list] - - -def get_member_rankings( - self, - guild_id: str, - sort_by: str = "contribution", -) -> List[Tuple[GuildMember, Any]]: - """获取公会成员排行榜""" - guilds = self.guild_manager.load_guilds() - guild = guilds.get(guild_id) - - if not guild: - return [] - - members = guild.members.copy() - - if sort_by == "contribution": - members.sort(key=lambda m: m.contribution, reverse=True) - return [(m, m.contribution) for m in members] - if sort_by == "online_time": - current_time = time.time() - members.sort(key=lambda m: current_time - m.last_online) - return [(m, current_time - m.last_online) for m in members] - if sort_by == "join_time": - members.sort(key=lambda m: m.join_time) - return [(m, m.join_time) for m in members] - return [(m, 0) for m in members] - - -def _paginate_display( - self, - player: Player, - items: List[Any], - title: str, - formatter, - allow_selection: bool = False) -> Optional[int]: - """分页显示通用函数""" - if self is None: - return None - if not items: - player.show(render_config_prompt("通用分页为空提示词", title=title)) - return None - - page = 1 - max_page = (len(items) + Config.ITEMS_PER_PAGE - - 1) // Config.ITEMS_PER_PAGE - - while True: - page = max(1, min(page, max_page)) - start = (page - 1) * Config.ITEMS_PER_PAGE - end = start + Config.ITEMS_PER_PAGE - - msg = format_panel(title, subtitle=f"第 {page}/{max_page} 页") - - for i, item in enumerate(items[start:end], start=1): - msg += "\n" + formatter(start + i, item).rstrip() - - msg += "\n" + format_page_footer(page, max_page, - start + 1, end, allow_selection) - - player.show(msg) - - choice = game_utils.waitMsg(player.name, timeout=20) - if choice is None: - player.show(render_config_prompt("通用分页超时提示词")) - return None - if choice == "+": - page = min(page + 1, max_page) - elif choice == "-": - page = max(page - 1, 1) - elif choice == "q": - player.show(render_config_prompt("通用分页退出提示词")) - return None - elif allow_selection and choice.isdigit(): - idx = int(choice) - if 1 <= idx <= len(items): - return idx - 1 - player.show(render_config_prompt("通用分页无效选择提示词")) - - -def custom_vault_sell(self, player: Player, args: tuple): # skipcq: PY-R1000 - """自定义价格出售物品到仓库""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - user_input = args[0] if args and args[0] else None - count = args[1] if len(args) > 1 and args[1] else 1 - custom_price = args[2] if len(args) > 2 and args[2] else 0 - - if not user_input: - player.show("§l§a公会仓库 §d>> §r请输入物品名称,例如: .自定义出售 钻石 10 800") - player.show("§7格式: 自定义出售 [物品名称] [数量] [自定义价格]") - player.show("§7支持中文: 自定义出售 钻石 10 800") - player.show("§7支持英文: 自定义出售 minecraft:diamond 10 800") - return True - - # 使用智能匹配查找物品ID - item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) - - if not item_id: - player.show("§l§a公会仓库 §d>> §r未找到匹配的物品") - if suggestions: - player.show("§7您是否想要:") - for i, suggestion in enumerate(suggestions[:3], 1): - player.show(f"§e{i}. §f{suggestion}") - return True - - # 检查仓库容量 - max_slots = Config.VAULT_INITIAL_SLOTS - if len(guild.vault_items) >= max_slots: - player.show("§l§a公会仓库 §d>> §r仓库已满") - return True - - # 检查玩家是否有该物品 - item_count = player.getItemCount(item_id) - if item_count < count: - player.show(f"§l§a公会仓库 §d>> §r你只有 {item_count} 个该物品,无法出售 {count} 个") - return True - - # 如果没有指定价格,询问自定义价格 - if custom_price <= 0: - suggested_price = guild.get_item_value(item_id) * count - player.show(f"§l§a公会仓库 §d>> §r建议价格: {suggested_price} 贡献点") - player.show("§7请输入你的自定义价格 (贡献点):") - - price_input = game_utils.waitMsg(player.name, timeout=30) - if not price_input or not price_input.isdigit(): - player.show("§l§a公会仓库 §d>> §r无效的价格") - return True - - custom_price = int(price_input) - if custom_price <= 0: - player.show("§l§a公会仓库 §d>> §r价格必须大于0") - return True - - # 确认出售 - item_name = self._get_item_display_name(item_id) - player.show("§l§a公会仓库 §d>> §r确认以自定义价格出售?") - player.show(f"§7物品: §f{item_name} x{count}") - player.show(f"§7自定义价格: §e{custom_price}贡献点") - player.show("§7输入 '确认' 继续出售,其他任意键取消") - - confirm = game_utils.waitMsg(player.name, timeout=20) - if confirm != "确认": - player.show("§l§a公会仓库 §d>> §r出售已取消") - return True - - # 执行出售 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if not guild: - player.show("§l§a公会仓库 §d>> §r公会数据异常") - return True - - # 再次检查仓库容量 - if len(guild.vault_items) >= max_slots: - player.show("§l§a公会仓库 §d>> §r仓库已满") - return True - - # 扣除物品 - self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {count}") - - # 添加到仓库 - vault_item = VaultItem( - item_id=item_id, - count=count, - price=custom_price, - seller=player.name - ) - - guild.vault_items.append(vault_item) - guild.add_log( - f"{player.name} 自定义价格出售了 {item_name} x{count} (价格{custom_price}贡献点)") - - # 保存数据 - self.guild_manager.save_guilds(guilds) - - player.show( - f"§l§a公会仓库 §d>> §r自定义价格出售成功!{item_name} x{count} 已上架,价格 {custom_price} 贡献点") - - # 更新贸易任务进度 - self.check_and_complete_trade_tasks(player.name) - - return True - - -def show_item_list(self, player: Player, args: tuple): - """显示支持的物品名称列表""" - if self is None: - return False - _ = args - player.show("§r========== §a支持的物品名称§r ==========") - player.show("§7以下是系统支持的物品名称,您可以在各种功能中使用:") - player.show("") - - # 按类别显示物品 - categories = { - "§e基础材料": ["钻石", "绿宝石", "金锭", "铁锭", "铜锭", "煤炭", "红石", "青金石", "石英"], - "§a稀有材料": ["下界合金锭", "远古残骸", "末影珍珠", "烈焰棒", "恶魂之泪"], - "§b方块材料": ["石头", "圆石", "泥土", "沙子", "砂砾", "黑曜石", "下界岩"], - "§6木材": ["橡木原木", "白桦原木", "云杉原木", "丛林原木", "金合欢原木"], - "§c食物": ["苹果", "金苹果", "面包", "牛肉", "熟牛肉", "胡萝卜", "土豆"], - "§d染料": ["墨囊", "玫瑰红", "橙色染料", "黄色染料", "绿色染料", "蓝色染料"] - } - - for category, items in categories.items(): - player.show(f"{category}:") - item_line = " " - for i, item in enumerate(items): - if i > 0 and i % 4 == 0: # 每行显示4个物品 - player.show(item_line) - item_line = " " - item_line += f"§f{item}§7, " - if item_line.strip() != "": - player.show(item_line.rstrip(", ")) - player.show("") - - player.show("§7提示:") - player.show("§7- 支持中文名称输入,如: §f钻石§7、§f铁锭§7、§f金块") - player.show("§7- 支持模糊匹配,如: 输入 §f铁§7 可匹配 §f铁锭§7、§f铁块") - player.show("§7- 支持别名,如: §f钻§7 可匹配 §f钻石") - player.show("§7- 也支持英文ID,如: §fminecraft:diamond") - player.show("§7- 使用 §e.物品列表§7 随时查看此列表") - - return True - - -def admin_clear_guild_data(self, player: Player, args: tuple): - """管理员清理公会数据功能""" - confirm = args[0] if args and args[0] else "" - - # 简单的管理员验证 (可以根据需要修改) - if player.name not in ["Admin", "管理员", "op"]: # 根据实际情况修改管理员名单 - player.show("§c权限不足:此功能仅限管理员使用") - return True - - if confirm != "确认清理": - player.show("§l§c公会数据清理工具§r") - player.show("§c警告:此操作将删除所有公会数据,包括:") - player.show("§7- 所有公会记录") - player.show("§7- 成员信息") - player.show("§7- 仓库物品") - player.show("§7- 任务记录") - player.show("§7- 公会日志") - player.show("§c此操作不可撤销!") - player.show("§e如果确认要清理,请使用:§f.清理公会数据 确认清理") - return True - - try: - # 备份当前数据 - backup_file = f"{self.guilds_file}.backup_{int(time.time())}" - if os.path.exists(self.guilds_file): - shutil.copy2(self.guilds_file, backup_file) - player.show(f"§a已创建数据备份:{backup_file}") - - # 清理内存中的数据 - self.guild_manager.reset_runtime_state() - - # 清理数据文件 - empty_data = {} - tempjson.load_and_write( - self.guilds_file, - empty_data, - need_file_exists=False) - tempjson.flush(self.guilds_file) - - player.show("§l§a公会数据清理完成§r") - player.show("§a✅ 已清空所有公会记录") - player.show("§a✅ 已重置缓存数据") - player.show("§a✅ 已清空数据文件") - player.show(f"§7备份文件:{backup_file}") - player.show("§7系统已回到初始状态,可以重新创建公会") - - # 记录操作日志 - fmts.print_inf(f"管理员 {player.name} 清理了所有公会数据") - - except Exception as e: - player.show(f"§c清理失败:{str(e)}") - fmts.print_err(f"清理公会数据时出错:{e}") - - return True - - -def debug_guild_menu(self, player: Player, args: tuple): - """调试公会菜单显示问题""" - _ = args - player.show("§l§c公会菜单调试信息§r") - player.show("=" * 40) - - # 获取公会和成员信息 - guild = self.guild_manager.get_guild_by_player(player.name) - member = guild.get_member(player.name) if guild else None - - player.show(f"§7玩家名称: §f{player.name}") - player.show(f"§7公会存在: §f{guild is not None}") - - if guild: - player.show(f"§7公会名称: §f{guild.name}") - player.show(f"§7公会ID: §f{guild.guild_id}") - player.show(f"§7成员数量: §f{len(guild.members)}") - - if member: - player.show(f"§7成员存在: §f{member is not None}") - player.show(f"§7成员名称: §f{member.name}") - player.show(f"§7成员职位: §f{member.rank}") - player.show(f"§7职位值: §f{member.rank.value}") - player.show(f"§7是否会长: §f{member.rank == GuildRank.OWNER}") - player.show(f"§7是否副会长: §f{member.rank == GuildRank.DEPUTY}") - - # 测试菜单条件 - is_member = guild is not None - is_owner = member and member.rank == GuildRank.OWNER - is_deputy_or_above = member and member.rank in [ - GuildRank.OWNER, GuildRank.DEPUTY] - - player.show("§7菜单条件测试:") - player.show(f" is_member: §f{is_member}") - player.show(f" is_owner: §f{is_owner}") - player.show(f" is_deputy_or_above: §f{is_deputy_or_above}") - - # 测试具体菜单项 - menu_tests = [ - ("查看", is_member), - ("成员", is_member), - ("日志", is_member), - ("公告", is_member), - ("仓库", Config.GUILD_FUNCTION_VAULT and is_member), - ("管理", is_deputy_or_above), - ("解散", is_owner), - ("据点", Config.GUILD_FUNCTION_BASE and is_member), - ("捐献", Config.GUILD_FUNCTION_DONATION and is_member), - ("任务", Config.GUILD_FUNCTION_TASKS and is_member), - ("效果", Config.GUILD_FUNCTION_EFFECT and is_member), - ] - - player.show("§7菜单项显示测试:") - for menu_name, condition in menu_tests: - status = "✅显示" if condition else "❌隐藏" - player.show(f" {menu_name}: §f{status}") - else: - player.show("§c成员信息不存在!") - else: - player.show("§c公会信息不存在!") - - player.show("=" * 40) - return True - - -def debug_base_function(self, player: Player, args: tuple): - """调试据点功能问题""" - _ = args - player.show("§l§c据点功能调试信息§r") - player.show("=" * 50) - - # 获取公会和成员信息 - guild = self.guild_manager.get_guild_by_player(player.name) - member = guild.get_member(player.name) if guild else None - - player.show(f"§7玩家名称: §f{player.name}") - player.show(f"§7公会存在: §f{guild is not None}") - - if guild: - player.show(f"§7公会名称: §f{guild.name}") - player.show(f"§7公会ID: §f{guild.guild_id}") - - if member: - player.show(f"§7成员职位: §f{member.rank.value}") - player.show(f"§7是否会长: §f{member.rank == GuildRank.OWNER}") - - # 权限检查测试 - has_setbase_old = guild.has_permission(player.name, "setbase") - is_owner_new = member.rank == GuildRank.OWNER - - player.show("§7权限检查结果:") - player.show(f" 旧方式(has_permission): §f{has_setbase_old}") - player.show(f" 新方式(直接检查会长): §f{is_owner_new}") - - if has_setbase_old != is_owner_new: - player.show("§c⚠️ 权限检查结果不一致!") - else: - player.show("§a✅ 权限检查一致") - - # 据点信息检查 - player.show("§7据点信息:") - if guild.base: - base = guild.base - player.show(" 据点存在: §a是") - player.show(f" 维度: §f{base.dimension}") - player.show(f" 坐标: §f({base.x}, {base.y}, {base.z})") - player.show( - f" 坐标类型: §f{ - type( - base.x).__name__}, { - type( - base.y).__name__}, { - type( - base.z).__name__}") - - # 验证坐标有效性 - try: - x, y, z = float(base.x), float(base.y), float(base.z) - player.show(f" 坐标转换: §a成功 ({x}, {y}, {z})") - except Exception as e: - player.show(f" 坐标转换: §c失败 - {e}") - - # 验证维度有效性 - valid_dimensions = [0, -1, 1] - if base.dimension in valid_dimensions: - player.show(" 维度有效性: §a有效") - else: - player.show(f" 维度有效性: §c无效 (应为 {valid_dimensions})") - - # 测试传送指令格式和方法 - player.show("§7传送方法测试:") - tp_methods = [ - ("sendwocmd", f"tp {player.name} {base.x} {base.y} {base.z}"), - ] - for i, (method, cmd) in enumerate(tp_methods): - player.show(f" 方法{i + 1}({method}): §f{cmd}") - - else: - player.show(" 据点存在: §c否") - player.show(" 原因: 公会未设置据点") - else: - player.show("§c公会信息不存在!") - - player.show("=" * 50) - return True - - -logic_functions = { - "_show_menu": _show_menu, - "guild_update_data": guild_update_data, - "guild_menu_cb": guild_menu_cb, - "_create_progress_bar": _create_progress_bar, - "_format_time_duration": _format_time_duration, - "_get_item_display_name": _get_item_display_name, - "_has_inventory_space": _has_inventory_space, - "_handle_base_menu": _handle_base_menu, - "_send_guild_message": _send_guild_message, - "on_chat_packet": on_chat_packet, - "_should_stop": _should_stop, - "_wait_or_stopped": _wait_or_stopped, - "_apply_guild_effects_to_player": _apply_guild_effects_to_player, - "_refresh_online_effects": _refresh_online_effects, - "guild_exp_task": guild_exp_task, - "update_online_task": update_online_task, - "on_player_action": on_player_action, - "update_task_progress": update_task_progress, - "check_and_complete_trade_tasks": check_and_complete_trade_tasks, - "get_member_rankings": get_member_rankings, - "_paginate_display": _paginate_display, - "custom_vault_sell": custom_vault_sell, - "show_item_list": show_item_list, - "admin_clear_guild_data": admin_clear_guild_data, - "guild_chat_cb": guild_chat_cb, - "debug_guild_menu": debug_guild_menu, - "debug_base_function": debug_base_function, - "get_guild_rankings": get_guild_rankings -} +"""Shared guild runtime and gameplay logic.""" + +# pylint: disable=protected-access + +import os +import shutil +import time +from typing import List, Optional, Tuple, Any + + +from tooldelta import Player, game_utils, fmts +from tooldelta.utils import tempjson + +from guild_cloud_interop.config import Config +from guild_cloud_interop.models import GuildData, GuildMember, GuildRank, VaultItem +from guild_cloud_interop.prompts import render_config_prompt +from guild_cloud_interop.ui import format_page_footer, format_panel + + +def _format_template(template: str, **values: Any) -> str: + """Implement the format template operation.""" + result = str(template) + for key, value in values.items(): + result = result.replace("{" + key + "}", str(value)) + return result + + +def _menu_config() -> dict[str, Any]: + """Implement the menu config operation.""" + config = getattr(Config, "MENU_CONFIG", {}) + return config if isinstance(config, dict) else {} + + +def _menu_item(group: str, key: str, fallback_name: str, + fallback_desc: str) -> tuple[str, str]: + """Implement the menu item operation.""" + menu_config = _menu_config() + group_config = menu_config.get(group, {}) + item_config = group_config.get( + key, {}) if isinstance( + group_config, dict) else {} + if not isinstance(item_config, dict): + item_config = {} + name = item_config.get("名称") + desc = item_config.get("描述") + return ( + str(name) if isinstance(name, str) and name else fallback_name, + str(desc) if isinstance(desc, str) else fallback_desc, + ) + + +def _menu_item_name(group: str, key: str, fallback_name: str) -> str: + """Implement the menu item name operation.""" + return _menu_item(group, key, fallback_name, "")[0] + + +def _show_menu( # skipcq: PY-R1000 + self, + player: Player, + guild: Optional[GuildData], + member: Optional[GuildMember]) -> Optional[str]: + """显示公会菜单并返回用户选择 - 增强版本""" + # 数据完整性检查 + if guild and not member: + # 公会存在但成员不存在,尝试重新获取 + fmts.print_err(f"数据不一致:玩家 {player.name} 在公会 {guild.name} 中但成员信息缺失") + member = guild.get_member(player.name) + if not member: + player.show("§c错误:公会数据不一致,请尝试重新加入公会或联系管理员") + fmts.print_err(f"严重错误:无法找到玩家 {player.name} 的成员信息") + return None + + is_member = guild is not None + is_owner = member and member.rank == GuildRank.OWNER + can_manage_members = bool( + guild and member and any( + guild.has_permission( + player.name, + permission) for permission in ( + "kick", + "set_rank", + "transfer_owner", + "join_queue"))) + + # 调试日志 + if guild: + fmts.print_inf( + f"菜单显示 - 玩家: {player.name}, 公会: {guild.name}, 成员职位: {member.rank.value if member else 'None'}, 是否会长: {is_owner}") + + menu_config = getattr(self, "_guild_menu_config_override", None) or _menu_config() + base_items = [ + ("创建", "创建自己的公会", not is_member), + ("列表", "查看所有公会", True), + ("查看", "查看公会详情", is_member), + ("成员", "查看成员列表", is_member), + ("日志", "查看公会日志", is_member), + ("公告", "查看/设置公告", is_member), + ("加入", "加入一个公会", not is_member), + ("退出", "退出当前公会", is_member and not is_owner), + ("管理", "管理公会成员", can_manage_members), + ("解散", "解散公会", is_owner), + ] + menu_items = [ + (*_menu_item("基础功能", key, default_name, default_desc), condition) + for key, default_desc, condition in base_items + for default_name in (key,) + ] + + optional_menu_config = [ + (Config.GUILD_FUNCTION_VAULT, "仓库", "公会仓库", is_member), + (Config.GUILD_FUNCTION_BASE, "据点", "据点相关操作", is_member), + (Config.GUILD_FUNCTION_DONATION, "捐献", "捐献物品到公会", is_member), + (Config.GUILD_FUNCTION_TASKS, "任务", "公会任务系统", is_member), + (Config.GUILD_FUNCTION_EFFECT, "效果", "通过钻石获得效果增益", is_member), + (Config.GUILD_FUNCTION_RANKINGS, "排行", "查看公会排行榜", True), + ] + + for enabled, cmd, desc, condition in optional_menu_config: + if enabled: + menu_items.append((*_menu_item("可选功能", cmd, cmd, desc), condition)) + + available_items = [(cmd, desc) for cmd, desc, cond in menu_items if cond] + + if member and guild: + subtitle_template = menu_config.get("成员身份显示模板", "[{rank}§f] §e{guild}") + subtitle = _format_template( + subtitle_template, rank=member.rank.display_name, guild=guild.name) + else: + subtitle = str(menu_config.get("游客身份显示", "[§7游客§f]")) + + number_prompt = _format_template( + menu_config.get("输入数字提示模板", "§a❀ §r输入 §e[1-{count}]§r 之间的数字选择功能"), + count=len(available_items), + ) + name_prompt = str(menu_config.get("输入名称提示词", "§a❀ §r也可输入功能名称,输入 §cq §r退出")) + player.show(format_panel( + str(menu_config.get("菜单标题", "公会管理系统")), + available_items, + subtitle=subtitle, + footer=f"{number_prompt}\n{name_prompt}", + )) + choice_map = {str(index): cmd for index, (cmd, _desc) + in enumerate(available_items, start=1)} + choice_map.update({cmd: cmd for cmd, _desc in available_items}) + user_input = game_utils.waitMsg(player.name, timeout=30) + if user_input is None: + player.show(render_config_prompt("菜单回复超时提示词")) + return None + return choice_map.get(user_input, user_input) + + +def guild_update_data(self, args: list[str]): + """更新过去的数据,确保所有公会都有 guild_id,且与外层 id 一致""" + _ = args + updated = False + + # 使用统一接口加载所有公会数据 + guilds = self.guild_manager.load_guilds(force_reload=True) + + for outer_id, guild in guilds.items(): + inner_id = guild.guild_id + if inner_id != outer_id: + guild.guild_id = outer_id + fmts.print_inf(f"已更新 guild_id: {outer_id}(原: {inner_id})") + updated = True + + if updated: + try: + self.guild_manager.save_guilds(guilds) + fmts.print_inf("公会数据文件已更新完成") + except Exception as e: + fmts.print_err(f"保存公会数据时出错: {e}") + else: + fmts.print_inf("无需更新,所有 数据 已正确") + + +def guild_menu_cb(self, player: Player, args: tuple): + """公会菜单回调函数 - 增强版本""" + _ = args + player_xuid = self.xuidm.get_xuid_by_name(player.name, allow_offline=True) + + # 强制刷新缓存以确保数据最新 + guild = self.guild_manager.get_guild_by_player( + player.name, force_reload=True) + member = guild.get_member(player.name) if guild else None + + # 额外的数据验证 + if guild and not member: + fmts.print_err(f"数据不一致警告:玩家 {player.name} 在公会 {guild.name} 中但找不到成员记录") + # 尝试重新加载数据 + guild = self.guild_manager.get_guild_by_player( + player.name, force_reload=True) + member = guild.get_member(player.name) if guild else None + + subcommand = self._show_menu(player, guild, member) + + if subcommand is None or subcommand in ("q", "Q", ".", "。"): + return True + + if guild and member and self.guild_is_frozen(guild): + frozen_readonly_items = { + _menu_item_name("基础功能", "列表", "列表"), + _menu_item_name("基础功能", "查看", "查看"), + _menu_item_name("基础功能", "成员", "成员"), + _menu_item_name("基础功能", "日志", "日志"), + _menu_item_name("可选功能", "排行", "排行"), + } + if subcommand not in frozen_readonly_items: + self.show_guild_frozen(player, guild) + return True + + # 路由到对应的处理函数 + # 功能项配置:功能是否启用、菜单标题、处理函数 + optional_handlers_config = [ + (Config.GUILD_FUNCTION_VAULT, "仓库", self._handle_vault), + (Config.GUILD_FUNCTION_BASE, "据点", self._handle_base_menu), + (Config.GUILD_FUNCTION_DONATION, "捐献", self._handle_donation), + (Config.GUILD_FUNCTION_TASKS, "任务", self._handle_tasks), + (Config.GUILD_FUNCTION_EFFECT, "效果", self._handle_effect), + (Config.GUILD_FUNCTION_RANKINGS, "排行", self._handle_rankings), + ] + + # 基础菜单项(总是存在) + handlers = { + _menu_item_name("基础功能", "创建", "创建"): ( + lambda: self._handle_create_guild(player, player_xuid) + ), + _menu_item_name("基础功能", "列表", "列表"): lambda: self._handle_list_guilds(player), + _menu_item_name("基础功能", "查看", "查看"): lambda: self._handle_view_guild(player), + _menu_item_name("基础功能", "成员", "成员"): lambda: self._handle_view_members(player), + _menu_item_name("基础功能", "日志", "日志"): lambda: self._handle_view_logs(player), + _menu_item_name("基础功能", "公告", "公告"): lambda: self._handle_announcement(player), + _menu_item_name("基础功能", "加入", "加入"): lambda: self._handle_join_guild(player), + _menu_item_name("基础功能", "退出", "退出"): lambda: self._handle_leave_guild(player), + _menu_item_name("基础功能", "管理", "管理"): ( + lambda: self._handle_manage_members(player) + ), + _menu_item_name("基础功能", "解散", "解散"): ( + lambda: self._handle_dissolve_guild(player, player_xuid) + ), + } + + # 追加可选功能项 + for enabled, title, func in optional_handlers_config: + if enabled: + handlers[_menu_item_name("可选功能", title, title) + ] = lambda f=func: f(player) + + handler = handlers.get(subcommand) + if handler: + return handler() + player.show(render_config_prompt("无效指令提示词")) + + return True + + +def _create_progress_bar( + self, + current: int, + total: int, + length: int = 10) -> str: + """创建进度条""" + if self is None: + return "" + if total == 0: + return "§7[§c无效§7]" + + progress = min(current / total, 1.0) + filled = int(progress * length) + empty = length - filled + + bar = "§a" + "█" * filled + "§7" + "░" * empty + percentage = int(progress * 100) + + return f"§7[{bar}§7] §f{percentage}%" + + +def _format_time_duration(self, seconds: float) -> str: + """格式化时间长度""" + if self is None: + return "" + if seconds < 60: + return f"{int(seconds)}秒" + if seconds < 3600: + return f"{int(seconds // 60)}分钟" + if seconds < 86400: + return f"{int(seconds // 3600)}小时" + return f"{int(seconds // 86400)}天" + + +def _get_item_display_name(self, item_id: str) -> str: + """获取物品显示名称""" + return self.item_matcher.get_chinese_name(item_id) + + +def _has_inventory_space( + self, + player: Player, + item_id: str, + count: int) -> bool: + """检查玩家背包是否有足够空间""" + if self is None or player is None or not item_id or count <= 0: + return False + return True + + +def _handle_base_menu(self, player: Player) -> bool: + """据点菜单""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + can_return = guild.has_permission(player.name, "return_base") + can_set = guild.has_permission(player.name, "setbase") + + player.show("§r========== §a据点菜单§r ==========") + + if guild.base: + base = guild.base + dim_name = Config.DIMENSION_NAMES.get( + base.dimension, f"维度{base.dimension}") + player.show( + f"§7当前据点: §f{dim_name} ({base.x:.1f}, {base.y:.1f}, {base.z:.1f})") + if can_return: + player.show("§e1. §f传送到据点") + else: + player.show("§7当前据点: §c未设置") + + if can_set: + player.show("§e2. §f设置据点") + + player.show("§7输入选项序号,q 返回") + + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "1" and guild.base and can_return: + return self._handle_return_base(player) + if choice == "2" and can_set: + return self._handle_set_base(player) + + return True + + +def guild_chat_cb(self, player: Player, args: tuple): + """公会聊天回调""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + message = args[0] if args and args[0] else None + + if not message: + # 切换聊天模式 + current_mode = self.guild_chat_mode.get(player.name, False) + self.guild_chat_mode[player.name] = not current_mode + + if self.guild_chat_mode[player.name]: + player.show("§l§a公会 §d>> §r已切换到公会聊天模式") + else: + player.show("§l§a公会 §d>> §r已切换到公共聊天模式") + else: + # 发送公会消息 + self._send_guild_message(guild, player.name, message) + + return True + + +def _send_guild_message(self, guild: GuildData, sender: str, message: str): + """发送公会聊天消息""" + if self.guild_is_frozen(guild): + return + member = guild.get_member(sender) + if not member: + return + + # 构建消息 + chat_msg = f"§d✧§b[公会]§d✦ {member.rank.display_name} §e{sender}§7: §f{message}" + + # 发送给所有在线的公会成员 + for member in guild.members: + if member.name in self.game_ctrl.allplayers: + self.game_ctrl.sendcmd( + f'/tellraw {member.name} {{"rawtext":[{{"text":"{chat_msg}"}}]}}' + ) + + +def on_chat_packet(self, packet): + """处理聊天数据包""" + try: + # 检查是否是玩家聊天 + if packet.get("type") != 1: # 1 表示玩家聊天 + return False + + sender = packet.get("source_name", "") + message = packet.get("message", "") + + # 检查是否在公会聊天模式 + if self.guild_chat_mode.get(sender, False): + guild = self.guild_manager.get_guild_by_player(sender) + if guild: + self._send_guild_message(guild, sender, message) + return True # 阻止原始消息 + except BaseException: + pass + + return False + + +def _should_stop(self) -> bool: + """Implement the should stop operation.""" + stop_event = getattr(self, "_stop_event", None) + return bool(stop_event and stop_event.is_set()) + + +def _wait_or_stopped(self, seconds: float) -> bool: + """Implement the wait or stopped operation.""" + stop_event = getattr(self, "_stop_event", None) + if stop_event is None: + time.sleep(seconds) + return False + return stop_event.wait(seconds) + + +def _apply_guild_effects_to_player( # skipcq: PY-R1000 + self, + player_name: str, + guild: Optional[GuildData] = None, + force: bool = False, + command_delay: float = 0.2, + effect_names: Optional[set] = None, +) -> int: + """按需给单个在线玩家补发公会增益,避免周期性全量刷命令压租赁服。""" + if not Config.GUILD_FUNCTION_EFFECT or player_name not in self.game_ctrl.allplayers: + return 0 + + if guild is None: + guild = self.guild_manager.get_guild_by_player(player_name) + if guild is None: + return 0 + + effects = getattr(guild, "purchased_effects", {}) or {} + if not effects: + return 0 + + now = time.time() + cache = getattr(self, "_effect_refresh_cache", None) + if cache is None: + cache = {} + self._effect_refresh_cache = cache + + refresh_interval = getattr(Config, "EFFECT_REFRESH_INTERVAL", 3600) + sent_count = 0 + + for effect_name, raw_level in effects.items(): + if _should_stop(self): + break + if effect_names is not None and effect_name not in effect_names: + continue + + try: + amplifier = max(int(raw_level) - 1, 0) + except (TypeError, ValueError): + fmts.print_err( + f"公会 {guild.name} 的效果 {effect_name} 等级无效: {raw_level}") + continue + + cache_key = (player_name, effect_name) + cached = cache.get(cache_key) + if not force and cached: + cached_amplifier, cached_time = cached + if cached_amplifier == amplifier and now - cached_time < refresh_interval: + continue + + if sent_count and command_delay > 0 and _wait_or_stopped( + self, command_delay): + break + + try: + self.game_ctrl.sendwocmd( + f"effect {player_name} {effect_name} 100000 {amplifier} true" + ) + cache[cache_key] = (amplifier, time.time()) + sent_count += 1 + except Exception as e: + fmts.print_err(f"为玩家 {player_name} 添加效果 {effect_name} 时出错: {e}") + + return sent_count + + +def _refresh_online_effects( + self, + online_players: List[str], + guilds: Optional[dict] = None) -> int: + """Implement the refresh online effects operation.""" + if not Config.GUILD_FUNCTION_EFFECT: + return 0 + + online_set = set(online_players) + cache = getattr(self, "_effect_refresh_cache", None) + if cache: + for cache_key in list(cache): + if cache_key[0] not in online_set: + del cache[cache_key] + + sent_count = 0 + for player_name in online_players: + if _should_stop(self): + break + + guild = None + if guilds is not None: + guild_id = self.guild_manager.get_cached_guild_id(player_name) + if guild_id: + guild = guilds.get(guild_id) + + sent_count += _apply_guild_effects_to_player( + self, player_name, guild=guild) + + return sent_count + + +def guild_exp_task(self): + """公会经验更新任务""" + while not _should_stop(self): + if _wait_or_stopped(self, Config.EXP_UPDATE_INTERVAL): + break + if not self._plugin_enabled(): + continue + + try: + fmts.print_inf("正在更新公会经验...") + + online_players = list(self.game_ctrl.allplayers) + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_online_count = {} + level_ups = [] + + # 统计每个公会在线人数 + for player_name in online_players: + guild = self.guild_manager.get_guild_by_player(player_name) + if guild: + guild_online_count[guild.guild_id] = guild_online_count.get( + guild.guild_id, 0) + 1 + + # 更新经验和等级 + for gid, count in guild_online_count.items(): + if count == 0: + continue + + guild = guilds[gid] + exp_add, _ = self.guild_apply_reward_multipliers( + count * Config.EXP_PER_ONLINE_MEMBER, + 0, + ) + guild.exp += exp_add + + # 处理升级 + level = guild.level + next_required_exp = Config.GUILD_LEVEL_EXP.get(level + 1) + while next_required_exp and guild.exp >= next_required_exp: + guild.exp -= next_required_exp + level += 1 + next_required_exp = Config.GUILD_LEVEL_EXP.get(level + 1) + level_ups.append((guild.name, level)) + guild.add_log(f"公会升级到 {level} 级") + + guild.level = level + + self.guild_manager.save_guilds(guilds) + + for guild_name, new_level in level_ups: + if _should_stop(self): + break + message = f"§l§a公会 §d>> §r公会 §e{guild_name}§r 升级到了 §e{new_level}§r 级!" + self.game_ctrl.sendcmd( + f'/tellraw @a {{"rawtext":[{{"text":"{message}"}}]}}' + ) + except Exception as e: + fmts.print_err(f"更新公会经验出错: {e}") + + +def update_online_task(self): + """更新在线状态任务""" + while not _should_stop(self): + try: + if _wait_or_stopped(self, 300): + break + if not self._plugin_enabled(): + continue + online_players = list(self.game_ctrl.allplayers) # 这里是 List[str] + + self.guild_manager.update_online_status(online_players) # 传名字列表 + guilds = self.guild_manager.load_guilds() + sent_count = _refresh_online_effects( + self, online_players, guilds=guilds) + + if sent_count: + fmts.print_inf(f"已低频补发 {sent_count} 条公会增益命令") + except Exception as e: + fmts.print_err(f"更新在线状态出错: {e}") + + +def on_player_action(self, packet): + """监听玩家行为,用于任务进度跟踪""" + if self is None or packet is None: + return + # 等待具体的数据包格式后再处理任务进度。 + return + + +def update_task_progress( + self, + player_name: str, + task_type: str, + target: str, + amount: int = 1): + """更新任务进度""" + try: + guild = self.guild_manager.get_guild_by_player(player_name) + if not guild: + return + if self.guild_is_frozen(guild): + return + + updated = False + for task in guild.tasks: + if (task.completed or + task.task_type != task_type or + task.target != target or + player_name not in task.participants): + continue + + task.current_count += amount + if task.current_count >= task.target_count: + # 任务完成 + task.completed = True + task.current_count = task.target_count + + # 发放奖励 + reward_exp, reward_contribution = self.guild_apply_reward_multipliers( + task.reward_exp, task.reward_contribution, ) + member = guild.get_member(player_name) + if member: + member.contribution += reward_contribution + guild.exp += reward_exp + guild.stats.total_contribution += reward_contribution + + # 通知玩家 + if player_name in self.game_ctrl.allplayers: + message = ( + f"§l§a公会任务 §d>> §r任务 '{task.name}' 已完成!" + f"获得 {reward_contribution} 贡献点和 {reward_exp} 公会经验" + ) + self.game_ctrl.sendcmd( + f'/tellraw {player_name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + + guild.add_log(f"{player_name} 完成了任务: {task.name}") + updated = True + else: + updated = True + + if updated: + self.guild_manager.mark_guild_dirty(guild.guild_id) + + except Exception as e: + fmts.print_err(f"更新任务进度出错: {e}") + + +def check_and_complete_trade_tasks(self, player_name: str): + """检查并完成贸易任务""" + self.update_task_progress(player_name, "trade", "trade_count", 1) + + +def get_guild_rankings( + self, sort_by: str = "level") -> List[Tuple[GuildData, Any]]: + """获取公会排行榜""" + guilds = self.guild_manager.load_guilds() + guild_list = list(guilds.values()) + + if sort_by == "level": + guild_list.sort(key=lambda g: (g.level, g.exp), reverse=True) + return [(g, g.level) for g in guild_list] + if sort_by == "members": + guild_list.sort(key=lambda g: len(g.members), reverse=True) + return [(g, len(g.members)) for g in guild_list] + if sort_by == "contribution": + guild_list.sort(key=lambda g: g.stats.total_contribution, reverse=True) + return [(g, g.stats.total_contribution) for g in guild_list] + if sort_by == "activity": + # 基于最近活跃度排序 + guild_list.sort(key=lambda g: max( + [m.last_online for m in g.members] + [0]), reverse=True) + return [(g, max([m.last_online for m in g.members] + [0])) + for g in guild_list] + return [(g, 0) for g in guild_list] + + +def get_member_rankings( + self, + guild_id: str, + sort_by: str = "contribution", +) -> List[Tuple[GuildMember, Any]]: + """获取公会成员排行榜""" + guilds = self.guild_manager.load_guilds() + guild = guilds.get(guild_id) + + if not guild: + return [] + + members = guild.members.copy() + + if sort_by == "contribution": + members.sort(key=lambda m: m.contribution, reverse=True) + return [(m, m.contribution) for m in members] + if sort_by == "online_time": + current_time = time.time() + members.sort(key=lambda m: current_time - m.last_online) + return [(m, current_time - m.last_online) for m in members] + if sort_by == "join_time": + members.sort(key=lambda m: m.join_time) + return [(m, m.join_time) for m in members] + return [(m, 0) for m in members] + + +def _paginate_display( + self, + player: Player, + items: List[Any], + title: str, + formatter, + allow_selection: bool = False) -> Optional[int]: + """分页显示通用函数""" + if self is None: + return None + if not items: + player.show(render_config_prompt("通用分页为空提示词", title=title)) + return None + + page = 1 + max_page = (len(items) + Config.ITEMS_PER_PAGE - + 1) // Config.ITEMS_PER_PAGE + + while True: + page = max(1, min(page, max_page)) + start = (page - 1) * Config.ITEMS_PER_PAGE + end = start + Config.ITEMS_PER_PAGE + + msg = format_panel(title, subtitle=f"第 {page}/{max_page} 页") + + for i, item in enumerate(items[start:end], start=1): + msg += "\n" + formatter(start + i, item).rstrip() + + msg += "\n" + format_page_footer(page, max_page, + start + 1, end, allow_selection) + + player.show(msg) + + choice = game_utils.waitMsg(player.name, timeout=20) + if choice is None: + player.show(render_config_prompt("通用分页超时提示词")) + return None + if choice == "+": + page = min(page + 1, max_page) + elif choice == "-": + page = max(page - 1, 1) + elif choice == "q": + player.show(render_config_prompt("通用分页退出提示词")) + return None + elif allow_selection and choice.isdigit(): + idx = int(choice) + if 1 <= idx <= len(items): + return idx - 1 + player.show(render_config_prompt("通用分页无效选择提示词")) + + +def custom_vault_sell(self, player: Player, args: tuple): # skipcq: PY-R1000 + """自定义价格出售物品到仓库""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + user_input = args[0] if args and args[0] else None + count = args[1] if len(args) > 1 and args[1] else 1 + custom_price = args[2] if len(args) > 2 and args[2] else 0 + + if not user_input: + player.show("§l§a公会仓库 §d>> §r请输入物品名称,例如: .自定义出售 钻石 10 800") + player.show("§7格式: 自定义出售 [物品名称] [数量] [自定义价格]") + player.show("§7支持中文: 自定义出售 钻石 10 800") + player.show("§7支持英文: 自定义出售 minecraft:diamond 10 800") + return True + + # 使用智能匹配查找物品ID + item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) + + if not item_id: + player.show("§l§a公会仓库 §d>> §r未找到匹配的物品") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + return True + + # 检查仓库容量 + max_slots = Config.VAULT_INITIAL_SLOTS + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + + # 检查玩家是否有该物品 + item_count = player.getItemCount(item_id) + if item_count < count: + player.show(f"§l§a公会仓库 §d>> §r你只有 {item_count} 个该物品,无法出售 {count} 个") + return True + + # 如果没有指定价格,询问自定义价格 + if custom_price <= 0: + suggested_price = guild.get_item_value(item_id) * count + player.show(f"§l§a公会仓库 §d>> §r建议价格: {suggested_price} 贡献点") + player.show("§7请输入你的自定义价格 (贡献点):") + + price_input = game_utils.waitMsg(player.name, timeout=30) + if not price_input or not price_input.isdigit(): + player.show("§l§a公会仓库 §d>> §r无效的价格") + return True + + custom_price = int(price_input) + if custom_price <= 0: + player.show("§l§a公会仓库 §d>> §r价格必须大于0") + return True + + # 确认出售 + item_name = self._get_item_display_name(item_id) + player.show("§l§a公会仓库 §d>> §r确认以自定义价格出售?") + player.show(f"§7物品: §f{item_name} x{count}") + player.show(f"§7自定义价格: §e{custom_price}贡献点") + player.show("§7输入 '确认' 继续出售,其他任意键取消") + + confirm = game_utils.waitMsg(player.name, timeout=20) + if confirm != "确认": + player.show("§l§a公会仓库 §d>> §r出售已取消") + return True + + # 执行出售 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if not guild: + player.show("§l§a公会仓库 §d>> §r公会数据异常") + return True + + # 再次检查仓库容量 + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + + # 扣除物品 + self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {count}") + + # 添加到仓库 + vault_item = VaultItem( + item_id=item_id, + count=count, + price=custom_price, + seller=player.name + ) + + guild.vault_items.append(vault_item) + guild.add_log( + f"{player.name} 自定义价格出售了 {item_name} x{count} (价格{custom_price}贡献点)") + + # 保存数据 + self.guild_manager.save_guilds(guilds) + + player.show( + f"§l§a公会仓库 §d>> §r自定义价格出售成功!{item_name} x{count} 已上架,价格 {custom_price} 贡献点") + + # 更新贸易任务进度 + self.check_and_complete_trade_tasks(player.name) + + return True + + +def show_item_list(self, player: Player, args: tuple): + """显示支持的物品名称列表""" + if self is None: + return False + _ = args + player.show("§r========== §a支持的物品名称§r ==========") + player.show("§7以下是系统支持的物品名称,您可以在各种功能中使用:") + player.show("") + + # 按类别显示物品 + categories = { + "§e基础材料": ["钻石", "绿宝石", "金锭", "铁锭", "铜锭", "煤炭", "红石", "青金石", "石英"], + "§a稀有材料": ["下界合金锭", "远古残骸", "末影珍珠", "烈焰棒", "恶魂之泪"], + "§b方块材料": ["石头", "圆石", "泥土", "沙子", "砂砾", "黑曜石", "下界岩"], + "§6木材": ["橡木原木", "白桦原木", "云杉原木", "丛林原木", "金合欢原木"], + "§c食物": ["苹果", "金苹果", "面包", "牛肉", "熟牛肉", "胡萝卜", "土豆"], + "§d染料": ["墨囊", "玫瑰红", "橙色染料", "黄色染料", "绿色染料", "蓝色染料"] + } + + for category, items in categories.items(): + player.show(f"{category}:") + item_line = " " + for i, item in enumerate(items): + if i > 0 and i % 4 == 0: # 每行显示4个物品 + player.show(item_line) + item_line = " " + item_line += f"§f{item}§7, " + if item_line.strip() != "": + player.show(item_line.rstrip(", ")) + player.show("") + + player.show("§7提示:") + player.show("§7- 支持中文名称输入,如: §f钻石§7、§f铁锭§7、§f金块") + player.show("§7- 支持模糊匹配,如: 输入 §f铁§7 可匹配 §f铁锭§7、§f铁块") + player.show("§7- 支持别名,如: §f钻§7 可匹配 §f钻石") + player.show("§7- 也支持英文ID,如: §fminecraft:diamond") + player.show("§7- 使用 §e.物品列表§7 随时查看此列表") + + return True + + +def admin_clear_guild_data(self, player: Player, args: tuple): + """管理员清理公会数据功能""" + confirm = args[0] if args and args[0] else "" + + # 简单的管理员验证 (可以根据需要修改) + if player.name not in ["Admin", "管理员", "op"]: # 根据实际情况修改管理员名单 + player.show("§c权限不足:此功能仅限管理员使用") + return True + + if confirm != "确认清理": + player.show("§l§c公会数据清理工具§r") + player.show("§c警告:此操作将删除所有公会数据,包括:") + player.show("§7- 所有公会记录") + player.show("§7- 成员信息") + player.show("§7- 仓库物品") + player.show("§7- 任务记录") + player.show("§7- 公会日志") + player.show("§c此操作不可撤销!") + player.show("§e如果确认要清理,请使用:§f.清理公会数据 确认清理") + return True + + try: + # 备份当前数据 + backup_file = f"{self.guilds_file}.backup_{int(time.time())}" + if os.path.exists(self.guilds_file): + shutil.copy2(self.guilds_file, backup_file) + player.show(f"§a已创建数据备份:{backup_file}") + + # 清理内存中的数据 + self.guild_manager.reset_runtime_state() + + # 清理数据文件 + empty_data = {} + tempjson.load_and_write( + self.guilds_file, + empty_data, + need_file_exists=False) + tempjson.flush(self.guilds_file) + + player.show("§l§a公会数据清理完成§r") + player.show("§a✅ 已清空所有公会记录") + player.show("§a✅ 已重置缓存数据") + player.show("§a✅ 已清空数据文件") + player.show(f"§7备份文件:{backup_file}") + player.show("§7系统已回到初始状态,可以重新创建公会") + + # 记录操作日志 + fmts.print_inf(f"管理员 {player.name} 清理了所有公会数据") + + except Exception as e: + player.show(f"§c清理失败:{str(e)}") + fmts.print_err(f"清理公会数据时出错:{e}") + + return True + + +def debug_guild_menu(self, player: Player, args: tuple): + """调试公会菜单显示问题""" + _ = args + player.show("§l§c公会菜单调试信息§r") + player.show("=" * 40) + + # 获取公会和成员信息 + guild = self.guild_manager.get_guild_by_player(player.name) + member = guild.get_member(player.name) if guild else None + + player.show(f"§7玩家名称: §f{player.name}") + player.show(f"§7公会存在: §f{guild is not None}") + + if guild: + player.show(f"§7公会名称: §f{guild.name}") + player.show(f"§7公会ID: §f{guild.guild_id}") + player.show(f"§7成员数量: §f{len(guild.members)}") + + if member: + player.show(f"§7成员存在: §f{member is not None}") + player.show(f"§7成员名称: §f{member.name}") + player.show(f"§7成员职位: §f{member.rank}") + player.show(f"§7职位值: §f{member.rank.value}") + player.show(f"§7是否会长: §f{member.rank == GuildRank.OWNER}") + player.show(f"§7是否副会长: §f{member.rank == GuildRank.DEPUTY}") + + # 测试菜单条件 + is_member = guild is not None + is_owner = member and member.rank == GuildRank.OWNER + is_deputy_or_above = member and member.rank in [ + GuildRank.OWNER, GuildRank.DEPUTY] + + player.show("§7菜单条件测试:") + player.show(f" is_member: §f{is_member}") + player.show(f" is_owner: §f{is_owner}") + player.show(f" is_deputy_or_above: §f{is_deputy_or_above}") + + # 测试具体菜单项 + menu_tests = [ + ("查看", is_member), + ("成员", is_member), + ("日志", is_member), + ("公告", is_member), + ("仓库", Config.GUILD_FUNCTION_VAULT and is_member), + ("管理", is_deputy_or_above), + ("解散", is_owner), + ("据点", Config.GUILD_FUNCTION_BASE and is_member), + ("捐献", Config.GUILD_FUNCTION_DONATION and is_member), + ("任务", Config.GUILD_FUNCTION_TASKS and is_member), + ("效果", Config.GUILD_FUNCTION_EFFECT and is_member), + ] + + player.show("§7菜单项显示测试:") + for menu_name, condition in menu_tests: + status = "✅显示" if condition else "❌隐藏" + player.show(f" {menu_name}: §f{status}") + else: + player.show("§c成员信息不存在!") + else: + player.show("§c公会信息不存在!") + + player.show("=" * 40) + return True + + +def debug_base_function(self, player: Player, args: tuple): + """调试据点功能问题""" + _ = args + player.show("§l§c据点功能调试信息§r") + player.show("=" * 50) + + # 获取公会和成员信息 + guild = self.guild_manager.get_guild_by_player(player.name) + member = guild.get_member(player.name) if guild else None + + player.show(f"§7玩家名称: §f{player.name}") + player.show(f"§7公会存在: §f{guild is not None}") + + if guild: + player.show(f"§7公会名称: §f{guild.name}") + player.show(f"§7公会ID: §f{guild.guild_id}") + + if member: + player.show(f"§7成员职位: §f{member.rank.value}") + player.show(f"§7是否会长: §f{member.rank == GuildRank.OWNER}") + + # 权限检查测试 + has_setbase_old = guild.has_permission(player.name, "setbase") + is_owner_new = member.rank == GuildRank.OWNER + + player.show("§7权限检查结果:") + player.show(f" 旧方式(has_permission): §f{has_setbase_old}") + player.show(f" 新方式(直接检查会长): §f{is_owner_new}") + + if has_setbase_old != is_owner_new: + player.show("§c⚠️ 权限检查结果不一致!") + else: + player.show("§a✅ 权限检查一致") + + # 据点信息检查 + player.show("§7据点信息:") + if guild.base: + base = guild.base + player.show(" 据点存在: §a是") + player.show(f" 维度: §f{base.dimension}") + player.show(f" 坐标: §f({base.x}, {base.y}, {base.z})") + player.show( + f" 坐标类型: §f{type(base.x).__name__}, {type(base.y).__name__}, {type(base.z).__name__}") + + # 验证坐标有效性 + try: + x, y, z = float(base.x), float(base.y), float(base.z) + player.show(f" 坐标转换: §a成功 ({x}, {y}, {z})") + except Exception as e: + player.show(f" 坐标转换: §c失败 - {e}") + + # 验证维度有效性 + valid_dimensions = [0, -1, 1] + if base.dimension in valid_dimensions: + player.show(" 维度有效性: §a有效") + else: + player.show(f" 维度有效性: §c无效 (应为 {valid_dimensions})") + + # 测试传送指令格式和方法 + player.show("§7传送方法测试:") + tp_methods = [ + ("sendwocmd", f"tp {player.name} {base.x} {base.y} {base.z}"), + ] + for i, (method, cmd) in enumerate(tp_methods): + player.show(f" 方法{i + 1}({method}): §f{cmd}") + + else: + player.show(" 据点存在: §c否") + player.show(" 原因: 公会未设置据点") + else: + player.show("§c公会信息不存在!") + + player.show("=" * 50) + return True + + +logic_functions = { + "_show_menu": _show_menu, + "guild_update_data": guild_update_data, + "guild_menu_cb": guild_menu_cb, + "_create_progress_bar": _create_progress_bar, + "_format_time_duration": _format_time_duration, + "_get_item_display_name": _get_item_display_name, + "_has_inventory_space": _has_inventory_space, + "_handle_base_menu": _handle_base_menu, + "_send_guild_message": _send_guild_message, + "on_chat_packet": on_chat_packet, + "_should_stop": _should_stop, + "_wait_or_stopped": _wait_or_stopped, + "_apply_guild_effects_to_player": _apply_guild_effects_to_player, + "_refresh_online_effects": _refresh_online_effects, + "guild_exp_task": guild_exp_task, + "update_online_task": update_online_task, + "on_player_action": on_player_action, + "update_task_progress": update_task_progress, + "check_and_complete_trade_tasks": check_and_complete_trade_tasks, + "get_member_rankings": get_member_rankings, + "_paginate_display": _paginate_display, + "custom_vault_sell": custom_vault_sell, + "show_item_list": show_item_list, + "admin_clear_guild_data": admin_clear_guild_data, + "guild_chat_cb": guild_chat_cb, + "debug_guild_menu": debug_guild_menu, + "debug_base_function": debug_base_function, + "get_guild_rankings": get_guild_rankings +} diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" index 02abd1cd..5d119573 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" @@ -1,767 +1,763 @@ -"""Data models for the guild cloud interop plugin.""" - -import uuid -import time - -from dataclasses import dataclass, field -from enum import Enum -from typing import Dict, List, Optional, Tuple, Any -from tooldelta import fmts -from datetime import datetime -from guild_cloud_interop.config import Config - -# FIRE 枚举类型 FIRE - - -class GuildRank(Enum): - """Guild membership roles.""" - - OWNER = "owner" - DEPUTY = "deputy" - ELDER = "elder" - MEMBER = "member" - - @property - def display_name(self): - """Return the display name.""" - return { - "owner": "§c会长", - "deputy": "§6副会长", - "elder": "§e长老", - "member": "§a成员" - }[self.value] - - @property - def config_key(self): - """Return the config key.""" - return { - "owner": "会长", - "deputy": "副会长", - "elder": "长老", - "member": "成员" - }[self.value] - - -PERMISSION_CONFIG_KEYS = { - "kick": "踢出成员权限", - "invite": "处理/同意加入公会申请权限", - "announce": "设置公会公告权限", - "task_manage": "管理公会任务权限", - "vault": "公会仓库使用权限", - "setbase": "设置公会据点权限", - "return_base": "返回公会据点权限", - "effect_buy": "购买公会效果权限", - "vault_settings": "设置仓库物品价值权限", - "vault_sell": "出售仓库物品权限", - "vault_buy": "购买仓库物品权限", - "vault_cancel_own": "撤回自己出售物品权限", - "vault_cancel_any": "撤回任意仓库物品权限", - "join_queue": "处理加入申请队列权限", - "audit_log": "查看审计日志权限", - "set_rank": "设置成员职位权限", - "transfer_owner": "转让会长权限", - "task_create": "创建公会任务权限", - "task_delete": "删除公会任务权限", - "task_complete": "强制完成公会任务权限", -} - - -# FIRE 数据类 FIRE -@dataclass -class GuildBase: - """公会据点信息""" - - dimension: int - x: float - y: float - z: float - - def to_dict(self): - """Implement the to dict operation.""" - return { - "dimension": self.dimension, - "x": self.x, - "y": self.y, - "z": self.z} - - -@dataclass -class VaultItem: - """仓库物品信息""" - - item_id: str - count: int - price: int # 贡献点价格 - seller: str - timestamp: float = field(default_factory=time.time) - - def to_dict(self): - """Implement the to dict operation.""" - return { - "item_id": self.item_id, - "count": self.count, - "price": self.price, - "seller": self.seller, - "timestamp": self.timestamp - } - - @classmethod - def from_dict(cls, data): - """Implement the from dict operation.""" - return cls( - item_id=data["item_id"], - count=data["count"], - price=data["price"], - seller=data["seller"], - timestamp=data.get("timestamp", time.time()) - ) - - -@dataclass -class GuildMember: - """公会成员信息""" - - name: str - rank: GuildRank - join_time: float - contribution: int = 0 - last_online: float = field(default_factory=time.time) - - def to_dict(self): - """Implement the to dict operation.""" - return { - "name": self.name, - "rank": self.rank.value, - "join_time": self.join_time, - "contribution": self.contribution, - "last_online": self.last_online - } - - @classmethod - def from_dict(cls, data): - """Implement the from dict operation.""" - return cls( - name=data["name"], - rank=GuildRank(data.get("rank", "member")), - join_time=data.get("join_time", time.time()), - contribution=data.get("contribution", 0), - last_online=data.get("last_online", time.time()) - ) - - -@dataclass -class GuildJoinRequest: - """公会加入申请记录""" - - player_name: str - reason: str = "" - create_time: float = field(default_factory=time.time) - status: str = "pending" - handler: str = "" - handle_time: float = 0 - result_reason: str = "" - - def to_dict(self): - """Implement the to dict operation.""" - return { - "player_name": self.player_name, - "reason": self.reason, - "create_time": self.create_time, - "status": self.status, - "handler": self.handler, - "handle_time": self.handle_time, - "result_reason": self.result_reason, - } - - @classmethod - def from_dict(cls, data): - """Implement the from dict operation.""" - return cls( - player_name=data["player_name"], - reason=data.get("reason", ""), - create_time=data.get("create_time", time.time()), - status=data.get("status", "pending"), - handler=data.get("handler", ""), - handle_time=data.get("handle_time", 0), - result_reason=data.get("result_reason", ""), - ) - - -@dataclass -class VaultTradeLog: - """公会仓库交易日志""" - - action: str - item_id: str - count: int - price: int - actor: str - seller: str = "" - buyer: str = "" - timestamp: float = field(default_factory=time.time) - detail: str = "" - - def to_dict(self): - """Implement the to dict operation.""" - return { - "action": self.action, - "item_id": self.item_id, - "count": self.count, - "price": self.price, - "actor": self.actor, - "seller": self.seller, - "buyer": self.buyer, - "timestamp": self.timestamp, - "detail": self.detail, - } - - @classmethod - def from_dict(cls, data): - """Implement the from dict operation.""" - return cls( - action=data["action"], - item_id=data["item_id"], - count=data.get("count", 0), - price=data.get("price", 0), - actor=data.get("actor", ""), - seller=data.get("seller", ""), - buyer=data.get("buyer", ""), - timestamp=data.get("timestamp", time.time()), - detail=data.get("detail", ""), - ) - - -@dataclass -class GuildAuditLog: - """公会审计日志""" - - action: str - actor: str - target: str = "" - detail: str = "" - result: str = "success" - timestamp: float = field(default_factory=time.time) - - def to_dict(self): - """Implement the to dict operation.""" - return { - "action": self.action, - "actor": self.actor, - "target": self.target, - "detail": self.detail, - "result": self.result, - "timestamp": self.timestamp, - } - - @classmethod - def from_dict(cls, data): - """Implement the from dict operation.""" - return cls( - action=data["action"], - actor=data.get("actor", ""), - target=data.get("target", ""), - detail=data.get("detail", ""), - result=data.get("result", "success"), - timestamp=data.get("timestamp", time.time()), - ) - - -@dataclass -class GuildTask: - """公会任务""" - - task_id: str - name: str - description: str - task_type: str # "collect", "kill", "build", "trade" - target: str # 目标物品/怪物等 - target_count: int - current_count: int = 0 - reward_exp: int = 0 - reward_contribution: int = 0 - create_time: float = field(default_factory=time.time) - deadline: float = 0 # 截止时间,0表示无限期 - completed: bool = False - participants: List[str] = field(default_factory=list) # 参与者列表 - - def to_dict(self): - """Implement the to dict operation.""" - return { - "task_id": self.task_id, - "name": self.name, - "description": self.description, - "task_type": self.task_type, - "target": self.target, - "target_count": self.target_count, - "current_count": self.current_count, - "reward_exp": self.reward_exp, - "reward_contribution": self.reward_contribution, - "create_time": self.create_time, - "deadline": self.deadline, - "completed": self.completed, - "participants": self.participants - } - - @classmethod - def from_dict(cls, data): - """Implement the from dict operation.""" - return cls( - task_id=data["task_id"], - name=data["name"], - description=data["description"], - task_type=data["task_type"], - target=data["target"], - target_count=data["target_count"], - current_count=data.get("current_count", 0), - reward_exp=data.get("reward_exp", 0), - reward_contribution=data.get("reward_contribution", 0), - create_time=data.get("create_time", time.time()), - deadline=data.get("deadline", 0), - completed=data.get("completed", False), - participants=data.get("participants", []) - ) - - -@dataclass -class GuildStats: - """公会统计数据""" - - total_contribution: int = 0 - total_trades: int = 0 - total_online_time: int = 0 - member_count_history: List[Tuple[float, int]] = field(default_factory=list) - level_up_history: List[Tuple[float, int]] = field(default_factory=list) - - def to_dict(self): - """Implement the to dict operation.""" - return { - "total_contribution": self.total_contribution, - "total_trades": self.total_trades, - "total_online_time": self.total_online_time, - "member_count_history": self.member_count_history, - "level_up_history": self.level_up_history - } - - @classmethod - def from_dict(cls, data): - """Implement the from dict operation.""" - return cls( - total_contribution=data.get("total_contribution", 0), - total_trades=data.get("total_trades", 0), - total_online_time=data.get("total_online_time", 0), - member_count_history=data.get("member_count_history", []), - level_up_history=data.get("level_up_history", []) - ) - - -@dataclass -class GuildData: - """公会完整数据""" - - guild_id: str - name: str - owner: str - level: int = 1 - exp: int = 0 - create_time: float = field(default_factory=time.time) - base: Optional[GuildBase] = None - vault: Dict[str, int] = field(default_factory=dict) # 保留原有仓库格式兼容性 - vault_items: List[VaultItem] = field(default_factory=list) # 新的仓库物品列表 - custom_item_values: Dict[str, int] = field(default_factory=dict) # 自定义物品价值 - announcement: str = "" - members: List[GuildMember] = field(default_factory=list) - logs: List[str] = field(default_factory=list) - purchased_effects: Dict[str, int] = field(default_factory=dict) - stats: GuildStats = field(default_factory=GuildStats) # 新增统计数据 - settings: Dict[str, Any] = field(default_factory=dict) # 公会设置 - tasks: List[GuildTask] = field(default_factory=list) # 公会任务列表 - join_requests: List[GuildJoinRequest] = field(default_factory=list) - vault_trade_logs: List[VaultTradeLog] = field(default_factory=list) - audit_logs: List[GuildAuditLog] = field(default_factory=list) - - def add_log(self, message: str): - """添加日志""" - timestamp = datetime.now().strftime("%m-%d %H:%M") - self.logs.append(f"[{timestamp}] {message}") - # 保留最近50条日志 - if len(self.logs) > 50: - self.logs = self.logs[-50:] - - def add_audit_log( - self, - action: str, - actor: str, - target: str = "", - detail: str = "", - result: str = "success", - now: Optional[float] = None, - ): - """添加审计日志""" - self.audit_logs.append(GuildAuditLog( - action=action, - actor=actor, - target=target, - detail=detail, - result=result, - timestamp=time.time() if now is None else now, - )) - max_logs = int( - getattr( - Config, - "GUILD_DATA_SAFETY_CONFIG", - {}).get( - "审计日志保留数量", - 200)) - if 0 < max_logs < len(self.audit_logs): - self.audit_logs = self.audit_logs[-max_logs:] - - def get_member(self, name: str) -> Optional[GuildMember]: - """获取成员信息""" - for member in self.members: - if member.name == name: - return member - return None - - def pending_join_requests( - self, - now: Optional[float] = None) -> List[GuildJoinRequest]: - """获取未过期的待处理加入申请""" - current_time = time.time() if now is None else now - expire_seconds = int( - getattr( - Config, - "GUILD_JOIN_REQUEST_CONFIG", - {}).get( - "申请有效期秒", - 86400)) - return [ - request - for request in self.join_requests - if request.status == "pending" - and ( - expire_seconds <= 0 - or current_time - request.create_time <= expire_seconds - ) - ] - - def add_join_request( - self, - player_name: str, - reason: str = "", - now: Optional[float] = None) -> bool: - """添加离线加入申请""" - if self.get_member(player_name): - return False - - current_time = time.time() if now is None else now - join_config = getattr(Config, "GUILD_JOIN_REQUEST_CONFIG", {}) - max_reason_len = int(join_config.get("申请理由最大长度", 60)) - reason = (reason or "")[:max_reason_len] - - for request in self.pending_join_requests(now=current_time): - if request.player_name == player_name: - return False - - max_pending = int(join_config.get("每个公会最多待处理申请数", 30)) - if 0 < max_pending <= len( - self.pending_join_requests( - now=current_time)): - return False - - self.join_requests.append(GuildJoinRequest( - player_name=player_name, - reason=reason, - create_time=current_time, - )) - self.add_log(f"{player_name} 提交了加入申请") - self.add_audit_log("join_request_create", player_name, - detail=reason, now=current_time) - return True - - def resolve_join_request( - self, - player_name: str, - handler: str, - approved: bool, - result_reason: str = "", - now: Optional[float] = None, - ) -> bool: - """处理加入申请""" - current_time = time.time() if now is None else now - for request in self.pending_join_requests(now=current_time): - if request.player_name != player_name: - continue - request.status = "approved" if approved else "rejected" - request.handler = handler - request.handle_time = current_time - request.result_reason = result_reason - self.add_log( - f"{handler} { - '批准' if approved else '拒绝'}了 {player_name} 的加入申请") - self.add_audit_log( - "join_request_approve" if approved else "join_request_reject", - handler, - target=player_name, - detail=result_reason, - now=current_time, - ) - return True - return False - - def has_permission(self, player_name: str, permission: str) -> bool: - """检查成员权限 - 优化版本""" - if not player_name or not permission: - return False - - member = self.get_member(player_name) - if not member: - return False - - rank_permissions = Config.PERMISSIONS.get(member.rank.config_key, {}) - permission_key = PERMISSION_CONFIG_KEYS.get(permission) - - has_perm = ( - isinstance(rank_permissions, dict) - and permission_key is not None - and bool(rank_permissions.get(permission_key, False)) - ) - - if not has_perm: - self.add_log(f"权限检查失败: {player_name} 尝试使用 {permission} 权限") - - return has_perm - - def get_member_permissions(self, player_name: str) -> List[str]: - """获取成员的所有权限列表""" - member = self.get_member(player_name) - if not member: - return [] - - rank_permissions = Config.PERMISSIONS.get(member.rank.config_key, {}) - if not isinstance(rank_permissions, dict): - return [] - - return [ - permission - for permission, config_key in PERMISSION_CONFIG_KEYS.items() - if bool(rank_permissions.get(config_key, False)) - ] - - def can_manage_member(self, manager_name: str, target_name: str) -> bool: - """检查是否可以管理指定成员""" - manager = self.get_member(manager_name) - target = self.get_member(target_name) - - if not manager or not target: - return False - - if manager_name == target_name: - return False - - if manager.rank == GuildRank.OWNER: - return True - - if manager.rank == GuildRank.DEPUTY: - return target.rank in [GuildRank.ELDER, GuildRank.MEMBER] - - return False - - def get_item_value(self, item_id: str) -> int: - """获取物品的贡献点价值""" - # 优先使用自定义价值,否则使用默认价值 - return self.custom_item_values.get( - item_id, Config.DEFAULT_ITEM_VALUES.get(item_id, 1)) - - def add_vault_item(self, item: VaultItem) -> bool: - """添加物品到仓库""" - max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 - if len(self.vault_items) >= max_slots: - return False - self.vault_items.append(item) - return True - - def remove_vault_item(self, index: int) -> Optional[VaultItem]: - """从仓库移除物品""" - if 0 <= index < len(self.vault_items): - return self.vault_items.pop(index) - return None - - def add_vault_trade_log( - self, - action: str, - item: VaultItem, - actor: str, - buyer: str = "", - detail: str = "", - now: Optional[float] = None, - ): - """添加仓库交易日志""" - self.vault_trade_logs.append(VaultTradeLog( - action=action, - item_id=item.item_id, - count=item.count, - price=item.price, - actor=actor, - seller=item.seller, - buyer=buyer, - timestamp=time.time() if now is None else now, - detail=detail, - )) - max_logs = int( - getattr( - Config, - "GUILD_VAULT_CONFIG", - {}).get( - "交易日志保留数量", - 120)) - if 0 < max_logs < len(self.vault_trade_logs): - self.vault_trade_logs = self.vault_trade_logs[-max_logs:] - - def cancel_vault_item( - self, - actor: str, - index: int, - now: Optional[float] = None) -> Optional[VaultItem]: - """撤回仓库上架物品""" - item = self.remove_vault_item(index) - if not item: - return None - - self.add_vault_trade_log( - "cancel", item, actor, detail="撤回上架物品", now=now) - self.add_audit_log("vault_cancel", actor, target=item.seller, - detail=item.item_id, now=now) - self.add_log( - f"{actor} 撤回了 { - item.seller} 上架的 { - item.item_id} x{ - item.count}") - return item - - def to_dict(self): - """Implement the to dict operation.""" - return { - "guild_id": self.guild_id, - "name": self.name, - "owner": self.owner, - "level": self.level, - "exp": self.exp, - "create_time": self.create_time, - "base": self.base.to_dict() if self.base else None, - "vault": self.vault, - "vault_items": [ - item.to_dict() for item in self.vault_items], - "custom_item_values": self.custom_item_values, - "announcement": self.announcement, - "purchased_effects": self.purchased_effects, - "members": [ - m.to_dict() for m in self.members], - "logs": self.logs, - "stats": self.stats.to_dict(), - "settings": self.settings, - "tasks": [ - task.to_dict() for task in self.tasks], - "join_requests": [ - request.to_dict() for request in self.join_requests], - "vault_trade_logs": [ - log.to_dict() for log in self.vault_trade_logs], - "audit_logs": [ - log.to_dict() for log in self.audit_logs], - } - - @classmethod - def from_dict(cls, data, outer_key=None): # skipcq: PY-R1000 - """Implement the from dict operation.""" - base = None - try: - if data.get("base") and isinstance(data["base"], dict): - base_data = data["base"] - if all( - key in base_data for key in [ - "dimension", - "x", - "y", - "z"]): - base = GuildBase( - dimension=base_data["dimension"], - x=float(base_data["x"]), - y=float(base_data["y"]), - z=float(base_data["z"]) - ) - elif data.get("base"): - fmts.print_err(f"据点数据格式不正确: {data['base']}") - except Exception as e: - fmts.print_err(f"解析据点数据出错: {e}") - import traceback - fmts.print_err(traceback.format_exc()) - - guild_id = data.get("guild_id") - if not guild_id: - guild_id = outer_key if outer_key else uuid.uuid4().hex[:8] - - # 兼容 members 为字符串列表 - members = [] - for m in data.get("members", []): - if isinstance(m, dict): - members.append(GuildMember.from_dict(m)) - elif isinstance(m, str): - members.append(GuildMember( - name=m, - rank=GuildRank.MEMBER, - join_time=time.time() - )) - - # 处理仓库物品 - vault_items = [] - for item_data in data.get("vault_items", []): - if isinstance(item_data, dict): - vault_items.append(VaultItem.from_dict(item_data)) - - # 处理统计数据 - stats_data = data.get("stats", {}) - stats = GuildStats.from_dict(stats_data) if isinstance( - stats_data, dict) else GuildStats() - - # 处理任务数据 - tasks = [] - for task_data in data.get("tasks", []): - if isinstance(task_data, dict): - tasks.append(GuildTask.from_dict(task_data)) - - join_requests = [] - for request_data in data.get("join_requests", []): - if isinstance(request_data, dict): - join_requests.append(GuildJoinRequest.from_dict(request_data)) - - vault_trade_logs = [] - for log_data in data.get("vault_trade_logs", []): - if isinstance(log_data, dict): - vault_trade_logs.append(VaultTradeLog.from_dict(log_data)) - - audit_logs = [] - for log_data in data.get("audit_logs", []): - if isinstance(log_data, dict): - audit_logs.append(GuildAuditLog.from_dict(log_data)) - - return cls( - guild_id=guild_id, - name=data.get("name", ""), - owner=data.get("owner", ""), - level=data.get("level", 1), - exp=data.get("exp", 0), - create_time=data.get("create_time", time.time()), - base=base, - vault=data.get("vault", {}), - vault_items=vault_items, - custom_item_values=data.get("custom_item_values", {}), - announcement=data.get("announcement", ""), - members=members, - purchased_effects=data.get("purchased_effects", {}), - logs=data.get("logs", []), - stats=stats, - settings=data.get("settings", {}), - tasks=tasks, - join_requests=join_requests, - vault_trade_logs=vault_trade_logs, - audit_logs=audit_logs, - ) +"""Data models for the guild cloud interop plugin.""" + +import uuid +import time + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional, Tuple, Any +from tooldelta import fmts +from datetime import datetime +from guild_cloud_interop.config import Config + +# FIRE 枚举类型 FIRE + + +class GuildRank(Enum): + """Guild membership roles.""" + + OWNER = "owner" + DEPUTY = "deputy" + ELDER = "elder" + MEMBER = "member" + + @property + def display_name(self): + """Return the display name.""" + return { + "owner": "§c会长", + "deputy": "§6副会长", + "elder": "§e长老", + "member": "§a成员" + }[self.value] + + @property + def config_key(self): + """Return the config key.""" + return { + "owner": "会长", + "deputy": "副会长", + "elder": "长老", + "member": "成员" + }[self.value] + + +PERMISSION_CONFIG_KEYS = { + "kick": "踢出成员权限", + "invite": "处理/同意加入公会申请权限", + "announce": "设置公会公告权限", + "task_manage": "管理公会任务权限", + "vault": "公会仓库使用权限", + "setbase": "设置公会据点权限", + "return_base": "返回公会据点权限", + "effect_buy": "购买公会效果权限", + "vault_settings": "设置仓库物品价值权限", + "vault_sell": "出售仓库物品权限", + "vault_buy": "购买仓库物品权限", + "vault_cancel_own": "撤回自己出售物品权限", + "vault_cancel_any": "撤回任意仓库物品权限", + "join_queue": "处理加入申请队列权限", + "audit_log": "查看审计日志权限", + "set_rank": "设置成员职位权限", + "transfer_owner": "转让会长权限", + "task_create": "创建公会任务权限", + "task_delete": "删除公会任务权限", + "task_complete": "强制完成公会任务权限", +} + + +# FIRE 数据类 FIRE +@dataclass +class GuildBase: + """公会据点信息""" + + dimension: int + x: float + y: float + z: float + + def to_dict(self): + """Implement the to dict operation.""" + return { + "dimension": self.dimension, + "x": self.x, + "y": self.y, + "z": self.z} + + +@dataclass +class VaultItem: + """仓库物品信息""" + + item_id: str + count: int + price: int # 贡献点价格 + seller: str + timestamp: float = field(default_factory=time.time) + + def to_dict(self): + """Implement the to dict operation.""" + return { + "item_id": self.item_id, + "count": self.count, + "price": self.price, + "seller": self.seller, + "timestamp": self.timestamp + } + + @classmethod + def from_dict(cls, data): + """Implement the from dict operation.""" + return cls( + item_id=data["item_id"], + count=data["count"], + price=data["price"], + seller=data["seller"], + timestamp=data.get("timestamp", time.time()) + ) + + +@dataclass +class GuildMember: + """公会成员信息""" + + name: str + rank: GuildRank + join_time: float + contribution: int = 0 + last_online: float = field(default_factory=time.time) + + def to_dict(self): + """Implement the to dict operation.""" + return { + "name": self.name, + "rank": self.rank.value, + "join_time": self.join_time, + "contribution": self.contribution, + "last_online": self.last_online + } + + @classmethod + def from_dict(cls, data): + """Implement the from dict operation.""" + return cls( + name=data["name"], + rank=GuildRank(data.get("rank", "member")), + join_time=data.get("join_time", time.time()), + contribution=data.get("contribution", 0), + last_online=data.get("last_online", time.time()) + ) + + +@dataclass +class GuildJoinRequest: + """公会加入申请记录""" + + player_name: str + reason: str = "" + create_time: float = field(default_factory=time.time) + status: str = "pending" + handler: str = "" + handle_time: float = 0 + result_reason: str = "" + + def to_dict(self): + """Implement the to dict operation.""" + return { + "player_name": self.player_name, + "reason": self.reason, + "create_time": self.create_time, + "status": self.status, + "handler": self.handler, + "handle_time": self.handle_time, + "result_reason": self.result_reason, + } + + @classmethod + def from_dict(cls, data): + """Implement the from dict operation.""" + return cls( + player_name=data["player_name"], + reason=data.get("reason", ""), + create_time=data.get("create_time", time.time()), + status=data.get("status", "pending"), + handler=data.get("handler", ""), + handle_time=data.get("handle_time", 0), + result_reason=data.get("result_reason", ""), + ) + + +@dataclass +class VaultTradeLog: + """公会仓库交易日志""" + + action: str + item_id: str + count: int + price: int + actor: str + seller: str = "" + buyer: str = "" + timestamp: float = field(default_factory=time.time) + detail: str = "" + + def to_dict(self): + """Implement the to dict operation.""" + return { + "action": self.action, + "item_id": self.item_id, + "count": self.count, + "price": self.price, + "actor": self.actor, + "seller": self.seller, + "buyer": self.buyer, + "timestamp": self.timestamp, + "detail": self.detail, + } + + @classmethod + def from_dict(cls, data): + """Implement the from dict operation.""" + return cls( + action=data["action"], + item_id=data["item_id"], + count=data.get("count", 0), + price=data.get("price", 0), + actor=data.get("actor", ""), + seller=data.get("seller", ""), + buyer=data.get("buyer", ""), + timestamp=data.get("timestamp", time.time()), + detail=data.get("detail", ""), + ) + + +@dataclass +class GuildAuditLog: + """公会审计日志""" + + action: str + actor: str + target: str = "" + detail: str = "" + result: str = "success" + timestamp: float = field(default_factory=time.time) + + def to_dict(self): + """Implement the to dict operation.""" + return { + "action": self.action, + "actor": self.actor, + "target": self.target, + "detail": self.detail, + "result": self.result, + "timestamp": self.timestamp, + } + + @classmethod + def from_dict(cls, data): + """Implement the from dict operation.""" + return cls( + action=data["action"], + actor=data.get("actor", ""), + target=data.get("target", ""), + detail=data.get("detail", ""), + result=data.get("result", "success"), + timestamp=data.get("timestamp", time.time()), + ) + + +@dataclass +class GuildTask: + """公会任务""" + + task_id: str + name: str + description: str + task_type: str # "collect", "kill", "build", "trade" + target: str # 目标物品/怪物等 + target_count: int + current_count: int = 0 + reward_exp: int = 0 + reward_contribution: int = 0 + create_time: float = field(default_factory=time.time) + deadline: float = 0 # 截止时间,0表示无限期 + completed: bool = False + participants: List[str] = field(default_factory=list) # 参与者列表 + + def to_dict(self): + """Implement the to dict operation.""" + return { + "task_id": self.task_id, + "name": self.name, + "description": self.description, + "task_type": self.task_type, + "target": self.target, + "target_count": self.target_count, + "current_count": self.current_count, + "reward_exp": self.reward_exp, + "reward_contribution": self.reward_contribution, + "create_time": self.create_time, + "deadline": self.deadline, + "completed": self.completed, + "participants": self.participants + } + + @classmethod + def from_dict(cls, data): + """Implement the from dict operation.""" + return cls( + task_id=data["task_id"], + name=data["name"], + description=data["description"], + task_type=data["task_type"], + target=data["target"], + target_count=data["target_count"], + current_count=data.get("current_count", 0), + reward_exp=data.get("reward_exp", 0), + reward_contribution=data.get("reward_contribution", 0), + create_time=data.get("create_time", time.time()), + deadline=data.get("deadline", 0), + completed=data.get("completed", False), + participants=data.get("participants", []) + ) + + +@dataclass +class GuildStats: + """公会统计数据""" + + total_contribution: int = 0 + total_trades: int = 0 + total_online_time: int = 0 + member_count_history: List[Tuple[float, int]] = field(default_factory=list) + level_up_history: List[Tuple[float, int]] = field(default_factory=list) + + def to_dict(self): + """Implement the to dict operation.""" + return { + "total_contribution": self.total_contribution, + "total_trades": self.total_trades, + "total_online_time": self.total_online_time, + "member_count_history": self.member_count_history, + "level_up_history": self.level_up_history + } + + @classmethod + def from_dict(cls, data): + """Implement the from dict operation.""" + return cls( + total_contribution=data.get("total_contribution", 0), + total_trades=data.get("total_trades", 0), + total_online_time=data.get("total_online_time", 0), + member_count_history=data.get("member_count_history", []), + level_up_history=data.get("level_up_history", []) + ) + + +@dataclass +class GuildData: + """公会完整数据""" + + guild_id: str + name: str + owner: str + level: int = 1 + exp: int = 0 + create_time: float = field(default_factory=time.time) + base: Optional[GuildBase] = None + vault: Dict[str, int] = field(default_factory=dict) # 保留原有仓库格式兼容性 + vault_items: List[VaultItem] = field(default_factory=list) # 新的仓库物品列表 + custom_item_values: Dict[str, int] = field(default_factory=dict) # 自定义物品价值 + announcement: str = "" + members: List[GuildMember] = field(default_factory=list) + logs: List[str] = field(default_factory=list) + purchased_effects: Dict[str, int] = field(default_factory=dict) + stats: GuildStats = field(default_factory=GuildStats) # 新增统计数据 + settings: Dict[str, Any] = field(default_factory=dict) # 公会设置 + tasks: List[GuildTask] = field(default_factory=list) # 公会任务列表 + join_requests: List[GuildJoinRequest] = field(default_factory=list) + vault_trade_logs: List[VaultTradeLog] = field(default_factory=list) + audit_logs: List[GuildAuditLog] = field(default_factory=list) + + def add_log(self, message: str): + """添加日志""" + timestamp = datetime.now().strftime("%m-%d %H:%M") + self.logs.append(f"[{timestamp}] {message}") + # 保留最近50条日志 + if len(self.logs) > 50: + self.logs = self.logs[-50:] + + def add_audit_log( + self, + action: str, + actor: str, + target: str = "", + detail: str = "", + result: str = "success", + now: Optional[float] = None, + ): + """添加审计日志""" + self.audit_logs.append(GuildAuditLog( + action=action, + actor=actor, + target=target, + detail=detail, + result=result, + timestamp=time.time() if now is None else now, + )) + max_logs = int( + getattr( + Config, + "GUILD_DATA_SAFETY_CONFIG", + {}).get( + "审计日志保留数量", + 200)) + if 0 < max_logs < len(self.audit_logs): + self.audit_logs = self.audit_logs[-max_logs:] + + def get_member(self, name: str) -> Optional[GuildMember]: + """获取成员信息""" + for member in self.members: + if member.name == name: + return member + return None + + def pending_join_requests( + self, + now: Optional[float] = None) -> List[GuildJoinRequest]: + """获取未过期的待处理加入申请""" + current_time = time.time() if now is None else now + expire_seconds = int( + getattr( + Config, + "GUILD_JOIN_REQUEST_CONFIG", + {}).get( + "申请有效期秒", + 86400)) + return [ + request + for request in self.join_requests + if request.status == "pending" + and ( + expire_seconds <= 0 + or current_time - request.create_time <= expire_seconds + ) + ] + + def add_join_request( + self, + player_name: str, + reason: str = "", + now: Optional[float] = None) -> bool: + """添加离线加入申请""" + if self.get_member(player_name): + return False + + current_time = time.time() if now is None else now + join_config = getattr(Config, "GUILD_JOIN_REQUEST_CONFIG", {}) + max_reason_len = int(join_config.get("申请理由最大长度", 60)) + reason = (reason or "")[:max_reason_len] + + for request in self.pending_join_requests(now=current_time): + if request.player_name == player_name: + return False + + max_pending = int(join_config.get("每个公会最多待处理申请数", 30)) + if 0 < max_pending <= len( + self.pending_join_requests( + now=current_time)): + return False + + self.join_requests.append(GuildJoinRequest( + player_name=player_name, + reason=reason, + create_time=current_time, + )) + self.add_log(f"{player_name} 提交了加入申请") + self.add_audit_log("join_request_create", player_name, + detail=reason, now=current_time) + return True + + def resolve_join_request( + self, + player_name: str, + handler: str, + approved: bool, + result_reason: str = "", + now: Optional[float] = None, + ) -> bool: + """处理加入申请""" + current_time = time.time() if now is None else now + for request in self.pending_join_requests(now=current_time): + if request.player_name != player_name: + continue + request.status = "approved" if approved else "rejected" + request.handler = handler + request.handle_time = current_time + request.result_reason = result_reason + self.add_log( + f"{handler} {'批准' if approved else '拒绝'}了 {player_name} 的加入申请") + self.add_audit_log( + "join_request_approve" if approved else "join_request_reject", + handler, + target=player_name, + detail=result_reason, + now=current_time, + ) + return True + return False + + def has_permission(self, player_name: str, permission: str) -> bool: + """检查成员权限 - 优化版本""" + if not player_name or not permission: + return False + + member = self.get_member(player_name) + if not member: + return False + + rank_permissions = Config.PERMISSIONS.get(member.rank.config_key, {}) + permission_key = PERMISSION_CONFIG_KEYS.get(permission) + + has_perm = ( + isinstance(rank_permissions, dict) + and permission_key is not None + and bool(rank_permissions.get(permission_key, False)) + ) + + if not has_perm: + self.add_log(f"权限检查失败: {player_name} 尝试使用 {permission} 权限") + + return has_perm + + def get_member_permissions(self, player_name: str) -> List[str]: + """获取成员的所有权限列表""" + member = self.get_member(player_name) + if not member: + return [] + + rank_permissions = Config.PERMISSIONS.get(member.rank.config_key, {}) + if not isinstance(rank_permissions, dict): + return [] + + return [ + permission + for permission, config_key in PERMISSION_CONFIG_KEYS.items() + if bool(rank_permissions.get(config_key, False)) + ] + + def can_manage_member(self, manager_name: str, target_name: str) -> bool: + """检查是否可以管理指定成员""" + manager = self.get_member(manager_name) + target = self.get_member(target_name) + + if not manager or not target: + return False + + if manager_name == target_name: + return False + + if manager.rank == GuildRank.OWNER: + return True + + if manager.rank == GuildRank.DEPUTY: + return target.rank in [GuildRank.ELDER, GuildRank.MEMBER] + + return False + + def get_item_value(self, item_id: str) -> int: + """获取物品的贡献点价值""" + # 优先使用自定义价值,否则使用默认价值 + return self.custom_item_values.get( + item_id, Config.DEFAULT_ITEM_VALUES.get(item_id, 1)) + + def add_vault_item(self, item: VaultItem) -> bool: + """添加物品到仓库""" + max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 + if len(self.vault_items) >= max_slots: + return False + self.vault_items.append(item) + return True + + def remove_vault_item(self, index: int) -> Optional[VaultItem]: + """从仓库移除物品""" + if 0 <= index < len(self.vault_items): + return self.vault_items.pop(index) + return None + + def add_vault_trade_log( + self, + action: str, + item: VaultItem, + actor: str, + buyer: str = "", + detail: str = "", + now: Optional[float] = None, + ): + """添加仓库交易日志""" + self.vault_trade_logs.append(VaultTradeLog( + action=action, + item_id=item.item_id, + count=item.count, + price=item.price, + actor=actor, + seller=item.seller, + buyer=buyer, + timestamp=time.time() if now is None else now, + detail=detail, + )) + max_logs = int( + getattr( + Config, + "GUILD_VAULT_CONFIG", + {}).get( + "交易日志保留数量", + 120)) + if 0 < max_logs < len(self.vault_trade_logs): + self.vault_trade_logs = self.vault_trade_logs[-max_logs:] + + def cancel_vault_item( + self, + actor: str, + index: int, + now: Optional[float] = None) -> Optional[VaultItem]: + """撤回仓库上架物品""" + item = self.remove_vault_item(index) + if not item: + return None + + self.add_vault_trade_log( + "cancel", item, actor, detail="撤回上架物品", now=now) + self.add_audit_log("vault_cancel", actor, target=item.seller, + detail=item.item_id, now=now) + self.add_log( + f"{actor} 撤回了 {item.seller} 上架的 {item.item_id} x{item.count}") + return item + + def to_dict(self): + """Implement the to dict operation.""" + return { + "guild_id": self.guild_id, + "name": self.name, + "owner": self.owner, + "level": self.level, + "exp": self.exp, + "create_time": self.create_time, + "base": self.base.to_dict() if self.base else None, + "vault": self.vault, + "vault_items": [ + item.to_dict() for item in self.vault_items], + "custom_item_values": self.custom_item_values, + "announcement": self.announcement, + "purchased_effects": self.purchased_effects, + "members": [ + m.to_dict() for m in self.members], + "logs": self.logs, + "stats": self.stats.to_dict(), + "settings": self.settings, + "tasks": [ + task.to_dict() for task in self.tasks], + "join_requests": [ + request.to_dict() for request in self.join_requests], + "vault_trade_logs": [ + log.to_dict() for log in self.vault_trade_logs], + "audit_logs": [ + log.to_dict() for log in self.audit_logs], + } + + @classmethod + def from_dict(cls, data, outer_key=None): # skipcq: PY-R1000 + """Implement the from dict operation.""" + base = None + try: + if data.get("base") and isinstance(data["base"], dict): + base_data = data["base"] + if all( + key in base_data for key in [ + "dimension", + "x", + "y", + "z"]): + base = GuildBase( + dimension=base_data["dimension"], + x=float(base_data["x"]), + y=float(base_data["y"]), + z=float(base_data["z"]) + ) + elif data.get("base"): + fmts.print_err(f"据点数据格式不正确: {data['base']}") + except Exception as e: + fmts.print_err(f"解析据点数据出错: {e}") + import traceback + fmts.print_err(traceback.format_exc()) + + guild_id = data.get("guild_id") + if not guild_id: + guild_id = outer_key if outer_key else uuid.uuid4().hex[:8] + + # 兼容 members 为字符串列表 + members = [] + for m in data.get("members", []): + if isinstance(m, dict): + members.append(GuildMember.from_dict(m)) + elif isinstance(m, str): + members.append(GuildMember( + name=m, + rank=GuildRank.MEMBER, + join_time=time.time() + )) + + # 处理仓库物品 + vault_items = [] + for item_data in data.get("vault_items", []): + if isinstance(item_data, dict): + vault_items.append(VaultItem.from_dict(item_data)) + + # 处理统计数据 + stats_data = data.get("stats", {}) + stats = GuildStats.from_dict(stats_data) if isinstance( + stats_data, dict) else GuildStats() + + # 处理任务数据 + tasks = [] + for task_data in data.get("tasks", []): + if isinstance(task_data, dict): + tasks.append(GuildTask.from_dict(task_data)) + + join_requests = [] + for request_data in data.get("join_requests", []): + if isinstance(request_data, dict): + join_requests.append(GuildJoinRequest.from_dict(request_data)) + + vault_trade_logs = [] + for log_data in data.get("vault_trade_logs", []): + if isinstance(log_data, dict): + vault_trade_logs.append(VaultTradeLog.from_dict(log_data)) + + audit_logs = [] + for log_data in data.get("audit_logs", []): + if isinstance(log_data, dict): + audit_logs.append(GuildAuditLog.from_dict(log_data)) + + return cls( + guild_id=guild_id, + name=data.get("name", ""), + owner=data.get("owner", ""), + level=data.get("level", 1), + exp=data.get("exp", 0), + create_time=data.get("create_time", time.time()), + base=base, + vault=data.get("vault", {}), + vault_items=vault_items, + custom_item_values=data.get("custom_item_values", {}), + announcement=data.get("announcement", ""), + members=members, + purchased_effects=data.get("purchased_effects", {}), + logs=data.get("logs", []), + stats=stats, + settings=data.get("settings", {}), + tasks=tasks, + join_requests=join_requests, + vault_trade_logs=vault_trade_logs, + audit_logs=audit_logs, + ) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" index 94d1c28b..af171522 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" @@ -1,239 +1,235 @@ -"""Orion-style UI helpers for the guild cloud interop plugin.""" - -from __future__ import annotations - -import re -from typing import Iterable, Sequence - - -ORION_BORDER = "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧" -TITLE_PREFIX = "§l§d❐§f" -OPTION_PREFIX = "§l§b[ §e{}§b ] §r§e{}" -INFO_PREFIX = "§a❀ §r" -WARN_PREFIX = "§6❀ " -ERROR_PREFIX = "§c❀ " -SUCCESS_PREFIX = "§a❀ " -MAX_CHAT_MESSAGE_CHARS = 240 - - -def split_chat_chunks( - text: str, - max_chars: int = MAX_CHAT_MESSAGE_CHARS) -> list[str]: - """Split rich-text chat output into line-preserving chunks for rental servers.""" - chunks: list[str] = [] - current: list[str] = [] - current_len = 0 - - for line in str(text).splitlines(): - line_len = len(line) - separator_len = 1 if current else 0 - if current and current_len + separator_len + line_len > max_chars: - chunks.append("\n".join(current)) - current = [] - current_len = 0 - - if line_len > max_chars: - if current: - chunks.append("\n".join(current)) - current = [] - current_len = 0 - for start in range(0, line_len, max_chars): - chunks.append(line[start:start + max_chars]) - continue - - current.append(line) - current_len += separator_len + line_len - - if current: - chunks.append("\n".join(current)) - - return chunks or [""] - - -class OrionPlayerView: - """Player proxy that renders plugin messages in the Orion panel style.""" - - def __init__(self, player): - self._player = player - - def __getattr__(self, name): - return getattr(self._player, name) - - def show(self, text): - """Implement the show operation.""" - for chunk in split_chat_chunks(format_message(str(text))): - self._player.show(chunk) - - -def wrap_player(player): - """Implement the wrap player operation.""" - if isinstance(player, OrionPlayerView): - return player - return OrionPlayerView(player) - - -def strip_reset(text: str) -> str: - """Implement the strip reset operation.""" - return text.replace("§r", "") - - -def normalize_inline(text: str) -> str: - """Implement the normalize inline operation.""" - text = strip_reset(text) - text = text.replace("§l§a公会仓库 §d>> ", "") - text = text.replace("§l§a公会任务 §d>> ", "") - text = text.replace("§l§a任务管理 §d>> ", "") - text = text.replace("§l§a创建任务 §d>> ", "") - text = text.replace("§l§a公会 §d>> ", "") - text = text.replace("§r§7>> ", "") - text = text.replace("§7>> ", "") - text = text.replace(">>>", "-") - return text.strip() - - -def classify_prefix(text: str) -> str: # skipcq: PY-R1000 - """Implement the classify prefix operation.""" - if "错误" in text or "失败" in text or "无效" in text or "不足" in text or "权限不足" in text: - return ERROR_PREFIX - if "警告" in text or "超时" in text or "已满" in text or "不存在" in text or "未找到" in text: - return WARN_PREFIX - if "成功" in text or "已创建" in text or "已加入" in text or "已退出" in text or "已更新" in text: - return SUCCESS_PREFIX - return INFO_PREFIX - - -def format_option(line: str) -> str | None: - """Implement the format option operation.""" - clean = normalize_inline(line) - - match = re.match(r"^(?:§[0-9a-frlomnk])*([0-9]+)[..、]\s*(.*)$", clean) - if match: - return OPTION_PREFIX.format(match.group(1), match.group(2).strip()) - - match = re.match( - r"^(?:§[0-9a-frlomnk])*●\s*(.+?)(?:\s*§7[-—]\s*|\s+-\s+)(.+)$", clean) - if match: - return f"§l§b[ §e-§b ] §r§e{ - match.group(1).strip()} §7- §f{ - match.group(2).strip()}" - - match = re.match(r"^([^\s]+)\s+§7[-—]\s*(.+)$", clean) - if match: - return f"§l§b[ §e-§b ] §r§e{ - match.group(1).strip()} §7- §f{ - match.group(2).strip()}" - - return None - - -def format_title(title: str) -> str: - """Implement the format title operation.""" - title = normalize_inline(title) - title = title.replace("§a", "§6").replace("§c", "§c") - return ( - f"{ORION_BORDER}\n" - f"{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b{title}§d\n" - f"{ORION_BORDER}" - ) - - -def format_message(text: str) -> str: # skipcq: PY-R1000 - """Implement the format message operation.""" - lines = str(text).splitlines() - rendered: list[str] = [] - - for raw_line in lines: - if raw_line == "": - rendered.append("") - continue - - line = raw_line.strip() - if ( - line == ORION_BORDER - or line.startswith("§d✧✦") - or line.startswith(TITLE_PREFIX) - or line.startswith("§l§b[") - or line.startswith("§e>§g>§6>") - or "❀" in line - ): - rendered.append(line) - continue - - title_match = re.match(r"^(?:§r)?=+\s*§a(.+?)§r?\s*=+", line) - if title_match: - rendered.append(format_title(title_match.group(1))) - continue - - if re.fullmatch(r"=+", strip_reset(line)): - rendered.append(ORION_BORDER) - continue - - option = format_option(line) - if option is not None: - rendered.append(option) - continue - - clean = normalize_inline(line) - if not clean: - rendered.append("") - continue - - if clean.startswith("§c"): - rendered.append(ERROR_PREFIX + clean[2:]) - elif clean.startswith("§6"): - rendered.append(WARN_PREFIX + clean[2:]) - elif clean.startswith("§a"): - rendered.append(SUCCESS_PREFIX + clean[2:]) - elif clean.startswith("§7"): - rendered.append(INFO_PREFIX + clean[2:]) - elif clean.startswith("§e"): - rendered.append(INFO_PREFIX + clean[2:]) - else: - rendered.append(classify_prefix(clean) + clean) - - return "\n".join(rendered) - - -def format_panel( - title: str, - options: Sequence[tuple[str, str]] = (), - *, - subtitle: str = "", - footer: str = "", - lines: Iterable[str] = (), -) -> str: - """Implement the format panel operation.""" - output = [ - ORION_BORDER, - f"{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b{title}§d", - ] - if subtitle: - output.append(f"§e>§g>§6>§f{subtitle}") - output.append(ORION_BORDER) - for index, (label, description) in enumerate(options, start=1): - output.append( - OPTION_PREFIX.format( - index, - f"{label} §7- §f{description}")) - for line in lines: - output.append(format_message(line)) - if footer: - output.append(format_message(footer)) - return "\n".join(output) - - -def format_page_footer( - page: int, - total_pages: int, - start: int, - end: int, - allow_selection: bool) -> str: - """Implement the format page footer operation.""" - lines = [ - ORION_BORDER, - f"§l§a[ §e-§a ] §b上页§r§f▶ §7{page}/{total_pages} §f◀§l§b下页 §a[ §e+ §a]", - ] - if allow_selection: - lines.append(f"{INFO_PREFIX}输入 §e[{start}-{end}]§r 之间的数字以选择") - lines.append(f"{INFO_PREFIX}输入 §d-§e 上一页 §r| §d+§e 下一页 §r| §cq§r 退出") - return "\n".join(lines) +"""Orion-style UI helpers for the guild cloud interop plugin.""" + +from __future__ import annotations + +import re +from typing import Iterable, Sequence + + +ORION_BORDER = "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧" +TITLE_PREFIX = "§l§d❐§f" +OPTION_PREFIX = "§l§b[ §e{}§b ] §r§e{}" +INFO_PREFIX = "§a❀ §r" +WARN_PREFIX = "§6❀ " +ERROR_PREFIX = "§c❀ " +SUCCESS_PREFIX = "§a❀ " +MAX_CHAT_MESSAGE_CHARS = 240 + + +def split_chat_chunks( + text: str, + max_chars: int = MAX_CHAT_MESSAGE_CHARS) -> list[str]: + """Split rich-text chat output into line-preserving chunks for rental servers.""" + chunks: list[str] = [] + current: list[str] = [] + current_len = 0 + + for line in str(text).splitlines(): + line_len = len(line) + separator_len = 1 if current else 0 + if current and current_len + separator_len + line_len > max_chars: + chunks.append("\n".join(current)) + current = [] + current_len = 0 + + if line_len > max_chars: + if current: + chunks.append("\n".join(current)) + current = [] + current_len = 0 + for start in range(0, line_len, max_chars): + chunks.append(line[start:start + max_chars]) + continue + + current.append(line) + current_len += separator_len + line_len + + if current: + chunks.append("\n".join(current)) + + return chunks or [""] + + +class OrionPlayerView: + """Player proxy that renders plugin messages in the Orion panel style.""" + + def __init__(self, player): + self._player = player + + def __getattr__(self, name): + return getattr(self._player, name) + + def show(self, text): + """Implement the show operation.""" + for chunk in split_chat_chunks(format_message(str(text))): + self._player.show(chunk) + + +def wrap_player(player): + """Implement the wrap player operation.""" + if isinstance(player, OrionPlayerView): + return player + return OrionPlayerView(player) + + +def strip_reset(text: str) -> str: + """Implement the strip reset operation.""" + return text.replace("§r", "") + + +def normalize_inline(text: str) -> str: + """Implement the normalize inline operation.""" + text = strip_reset(text) + text = text.replace("§l§a公会仓库 §d>> ", "") + text = text.replace("§l§a公会任务 §d>> ", "") + text = text.replace("§l§a任务管理 §d>> ", "") + text = text.replace("§l§a创建任务 §d>> ", "") + text = text.replace("§l§a公会 §d>> ", "") + text = text.replace("§r§7>> ", "") + text = text.replace("§7>> ", "") + text = text.replace(">>>", "-") + return text.strip() + + +def classify_prefix(text: str) -> str: # skipcq: PY-R1000 + """Implement the classify prefix operation.""" + if "错误" in text or "失败" in text or "无效" in text or "不足" in text or "权限不足" in text: + return ERROR_PREFIX + if "警告" in text or "超时" in text or "已满" in text or "不存在" in text or "未找到" in text: + return WARN_PREFIX + if "成功" in text or "已创建" in text or "已加入" in text or "已退出" in text or "已更新" in text: + return SUCCESS_PREFIX + return INFO_PREFIX + + +def format_option(line: str) -> str | None: + """Implement the format option operation.""" + clean = normalize_inline(line) + + match = re.match(r"^(?:§[0-9a-frlomnk])*([0-9]+)[..、]\s*(.*)$", clean) + if match: + return OPTION_PREFIX.format(match.group(1), match.group(2).strip()) + + match = re.match( + r"^(?:§[0-9a-frlomnk])*●\s*(.+?)(?:\s*§7[-—]\s*|\s+-\s+)(.+)$", clean) + if match: + return f"§l§b[ §e-§b ] §r§e{match.group(1).strip()} §7- §f{match.group(2).strip()}" + + match = re.match(r"^([^\s]+)\s+§7[-—]\s*(.+)$", clean) + if match: + return f"§l§b[ §e-§b ] §r§e{match.group(1).strip()} §7- §f{match.group(2).strip()}" + + return None + + +def format_title(title: str) -> str: + """Implement the format title operation.""" + title = normalize_inline(title) + title = title.replace("§a", "§6").replace("§c", "§c") + return ( + f"{ORION_BORDER}\n" + f"{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b{title}§d\n" + f"{ORION_BORDER}" + ) + + +def format_message(text: str) -> str: # skipcq: PY-R1000 + """Implement the format message operation.""" + lines = str(text).splitlines() + rendered: list[str] = [] + + for raw_line in lines: + if raw_line == "": + rendered.append("") + continue + + line = raw_line.strip() + if ( + line == ORION_BORDER + or line.startswith("§d✧✦") + or line.startswith(TITLE_PREFIX) + or line.startswith("§l§b[") + or line.startswith("§e>§g>§6>") + or "❀" in line + ): + rendered.append(line) + continue + + title_match = re.match(r"^(?:§r)?=+\s*§a(.+?)§r?\s*=+", line) + if title_match: + rendered.append(format_title(title_match.group(1))) + continue + + if re.fullmatch(r"=+", strip_reset(line)): + rendered.append(ORION_BORDER) + continue + + option = format_option(line) + if option is not None: + rendered.append(option) + continue + + clean = normalize_inline(line) + if not clean: + rendered.append("") + continue + + if clean.startswith("§c"): + rendered.append(ERROR_PREFIX + clean[2:]) + elif clean.startswith("§6"): + rendered.append(WARN_PREFIX + clean[2:]) + elif clean.startswith("§a"): + rendered.append(SUCCESS_PREFIX + clean[2:]) + elif clean.startswith("§7"): + rendered.append(INFO_PREFIX + clean[2:]) + elif clean.startswith("§e"): + rendered.append(INFO_PREFIX + clean[2:]) + else: + rendered.append(classify_prefix(clean) + clean) + + return "\n".join(rendered) + + +def format_panel( + title: str, + options: Sequence[tuple[str, str]] = (), + *, + subtitle: str = "", + footer: str = "", + lines: Iterable[str] = (), +) -> str: + """Implement the format panel operation.""" + output = [ + ORION_BORDER, + f"{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b{title}§d", + ] + if subtitle: + output.append(f"§e>§g>§6>§f{subtitle}") + output.append(ORION_BORDER) + for index, (label, description) in enumerate(options, start=1): + output.append( + OPTION_PREFIX.format( + index, + f"{label} §7- §f{description}")) + for line in lines: + output.append(format_message(line)) + if footer: + output.append(format_message(footer)) + return "\n".join(output) + + +def format_page_footer( + page: int, + total_pages: int, + start: int, + end: int, + allow_selection: bool) -> str: + """Implement the format page footer operation.""" + lines = [ + ORION_BORDER, + f"§l§a[ §e-§a ] §b上页§r§f▶ §7{page}/{total_pages} §f◀§l§b下页 §a[ §e+ §a]", + ] + if allow_selection: + lines.append(f"{INFO_PREFIX}输入 §e[{start}-{end}]§r 之间的数字以选择") + lines.append(f"{INFO_PREFIX}输入 §d-§e 上一页 §r| §d+§e 下一页 §r| §cq§r 退出") + return "\n".join(lines) diff --git "a/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index e8210245..aca9198a 100644 --- "a/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -19,7 +19,7 @@ class WhitelistAndOpCheck(Plugin): """负责白名单与 OP 状态校验,并对外暴露管理接口。""" name = "白名单&管理员检测云链联动版" - author = "猫七街" + author = "猫七街 & 小六神" version = (1, 1, 4) description = "白名单与管理员状态检测,并向其他插件暴露可复用的管理 API。" diff --git "a/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" "b/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" index 7dde4311..dd4fe265 100644 --- "a/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" +++ "b/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" @@ -1,5 +1,5 @@ { - "author": "猫七街", + "author": "猫七街 & 小六神", "version": "1.1.4", "plugin-type": "classic", "description": "白名单&管理员检测云链联动版,支持运行时配置文件热载入", diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" index cb2e30e3..5dedeb29 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" @@ -135,9 +135,9 @@ def _config_file_select_menu(self, ctx: dict[str, Any]): while True: files = self._discover_config_files() if not files: + config_dir = self.CONFIG_FILE_DIR self._config_error( - ctx, f"未找到 { - self.CONFIG_FILE_DIR}/*.json 配置文件") + ctx, f"未找到 {config_dir}/*.json 配置文件") return self.CONFIG_BACK per_page = self.get_group_config_file_items_per_page( self._config_group_id(ctx)) diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/orion_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/orion_mixin.py" index e9deb01a..c14e65d5 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/orion_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/orion_mixin.py" @@ -1,945 +1,943 @@ -"""Orion System integration helpers for Ultra.""" - -import os -import re - -from tooldelta import fmts, utils - - -# 和 Orion_System 的联动全收在这里,主入口不用再关心封禁细节。 -class QQLinkerOrionMixin: - """负责 Orion_System 菜单、封禁与解封逻辑。""" - - @staticmethod - def console_menu_header(title: str) -> str: - """生成控制台使用的 Orion 风格标题栏。""" - return ( - "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓" - "§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" - f"§l§d❐§f 『§6群服互通云链版Ultra版§f』 §b{title}" - ) - - @staticmethod - def console_menu_footer(page_label: str, body: str) -> str: - """生成控制台使用的 Orion 风格页脚。""" - return ( - "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓 " - f"§r§7[ §b{page_label} §7] " - "§l§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" - f"§r{body}" - ) - - def prompt_console_input( - self, - title: str, - page_label: str, - body_lines: list[str], - prompt: str, - ) -> str: - """按 Orion 卡片样式展示控制台交互步骤,并读取输入。""" - self.print_console_card(title, page_label, body_lines, level="info") - return input(fmts.fmt_info(f"§a❀ §b{prompt}")).strip() - - @staticmethod - def print_console_info(text: str): - """按统一 UI 风格输出控制台普通信息。""" - fmts.print_inf(f"§a❀ §b{text}") - - @staticmethod - def print_console_success(text: str): - """按统一 UI 风格输出控制台成功信息。""" - fmts.print_suc(f"§a❀ §b{text}") - - @staticmethod - def print_console_warn(text: str): - """按统一 UI 风格输出控制台警告信息。""" - fmts.print_war(f"§6❀ §e{text}") - - @staticmethod - def print_console_error(text: str): - """按统一 UI 风格输出控制台错误信息。""" - fmts.print_err(f"§c❀ §e{text}") - - def print_console_card( - self, - title: str, - page_label: str, - body_lines: list[str], - level: str = "info", - ): - """按 Orion 风格打印一张控制台信息卡片。""" - card = ( - self.console_menu_header(title) + - "\n" + - self.console_menu_footer( - page_label, - "\n".join( - i if i.startswith("§") else f"§a❀ §b{i}" for i in body_lines), - )) - { - "info": fmts.print_inf, - "success": fmts.print_suc, - "warn": fmts.print_war, - "error": fmts.print_err, - }.get(level, fmts.print_inf)(card) - - def require_orion(self): - """返回可用的 Orion 实例,不可用时抛出明确异常。""" - if self.orion is None: - raise RuntimeError("相关插件未安装:Orion_System") - enabled = self._extract_plugin_enabled_flag(self.orion) - if not enabled and enabled is not None: - raise RuntimeError("相关插件未启用") - return self.orion - - def reply_to_qq(self, group_id: int, qqid: int, text: str): - """向指定群成员回复一条文本消息。""" - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {text}", - do_remove_cq_code=False, - ) - - def reply_result(self, group_id: int, qqid: int, ok: bool, msg: str): - """把统一格式的成功/失败结果回发到群里。""" - prefix = "😄" if ok else "😭" - self.reply_to_qq(group_id, qqid, f"{prefix} {msg}") - - def on_qq_orion_ban(self, group_id: int, sender: int, args: list[str]): - """群聊侧的 Orion 封禁入口。 - - 支持两种调用方式: - - 直接给参数:适合熟悉命令的管理人员 - - 不给参数:转到交互式菜单 - """ - if not self._can_use_group_permission(group_id, sender, "封禁/解封玩家权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if self.orion is None: - self.reply_to_qq(group_id, sender, "相关插件未安装:Orion_System") - return - if not self.ensure_linked_plugin_enabled(self.orion, group_id, sender): - return - if args == []: - self.qq_orion_ban_menu(group_id, sender) - return - target = args[0] - ban_time_raw = args[1] - reason = " ".join(args[2:]).strip() or "群聊管理员封禁" - ok, msg = self.orion_ban_player(target, ban_time_raw, reason) - self.reply_result(group_id, sender, ok, msg) - - def on_qq_orion_unban(self, group_id: int, sender: int, args: list[str]): - """群聊侧的 Orion 解封入口。""" - if not self._can_use_group_permission(group_id, sender, "封禁/解封玩家权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if self.orion is None: - self.reply_to_qq(group_id, sender, "相关插件未安装:Orion_System") - return - if not self.ensure_linked_plugin_enabled(self.orion, group_id, sender): - return - if args == []: - self.qq_orion_unban_menu(group_id, sender) - return - ok, msg = self.orion_unban_player(args[0]) - self.reply_result(group_id, sender, ok, msg) - - def qq_prompt( - self, - group_id: int, - qqid: int, - text: str, - timeout: int = 60): - """发送一段提示文本,并等待同群同 QQ 的下一条回复。""" - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {text}", - do_remove_cq_code=False) - resp = self.waitMsg(qqid, timeout=timeout, group_id=group_id) - if isinstance(resp, str): - return resp.strip() - return None - - @staticmethod - def orion_ui_border(): - """返回 Orion 菜单统一使用的装饰边框。""" - return "✧✦〓〓〓〓〓〓〓〓〓〓〓✦✧" - - def orion_ui_menu( - self, - subtitle: str, - options: list[str], - hints: list[str], - group_id: int | None = None, - ): - """生成 Orion 风格的普通菜单文本。""" - hints = self.normalize_menu_control_hints(hints, group_id) - parts = [self.orion_ui_border(), f"❐ 『Orion System 猎户座』 {subtitle}"] - parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(options)]) - parts.append(self.orion_ui_border()) - parts.extend([f"❀ {hint}" for hint in hints]) - return "\n".join(parts) - - def plugin_ui_menu( - self, - system_name: str, - subtitle: str, - options: list[str], - hints: list[str], - group_id: int | None = None, - ): - """生成插件内部复用的菜单文本。""" - hints = self.normalize_menu_control_hints(hints, group_id) - parts = [self.orion_ui_border(), f"❐ 『{system_name}』 {subtitle}"] - parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(options)]) - parts.append(self.orion_ui_border()) - parts.extend([f"❀ {hint}" for hint in hints]) - return "\n".join(parts) - - def orion_ui_list( - self, - subtitle: str, - items: list[str], - page: int, - total_pages: int, - select_hint: str, - search_hint: str | None = None, - group_id: int | None = None, - ): - """生成带分页和搜索提示的列表菜单文本。""" - parts = [self.orion_ui_border(), f"❐ 『Orion System 猎户座』 {subtitle}"] - parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(items)]) - parts.append(self.orion_ui_border()) - parts.append(f"❀ 当前第 {page}/{total_pages} 页") - parts.append(f"❀ 输入 {select_hint}") - if search_hint: - parts.append(f"❀ 输入 {search_hint}") - parts.append("❀ 输入 - 转到上一页") - parts.append("❀ 输入 + 转到下一页") - parts.append("❀ 输入 正整数+页 转到对应页") - parts.append(f"❀ {self.menu_exit_hint(group_id)}") - return "\n".join(parts) - - def get_orion_items_per_page(self, group_id: int): - """读取 Orion 菜单每页条数配置。""" - group_cfg = self.group_cfgs.get(group_id) - if group_cfg is not None: - try: - return max( - 1, - int(group_cfg["指令设置"]["QQ群封禁/解封菜单每页显示个数"]), - ) - except (KeyError, TypeError, ValueError): - return 10 - return 10 - - def orion_xuid_status_text(self, xuid: str): - """读取某个 xuid 在 Orion 中的封禁状态摘要。""" - orion = self.require_orion() - path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" - if not os.path.exists(path): - return "未封禁" - try: - data = orion.utils.disk_read_need_exists(path) - except Exception: - return "状态异常" - return f"封禁至: {data.get('ban_end_real_time', '未知')}" - - def orion_device_status_text(self, device_id: str): - """读取某个设备号在 Orion 中的封禁状态摘要。""" - orion = self.require_orion() - path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" - if not os.path.exists(path): - return "未封禁" - try: - data = orion.utils.disk_read_need_exists(path) - except Exception: - return "状态异常" - return f"封禁至: {data.get('ban_end_real_time', '未知')}" - - @staticmethod - def format_device_history(player_data: dict[str, list[str]]): - """把设备号关联历史压缩成适合列表展示的单行文本。""" - outputs: list[str] = [] - for xuid, names in list(player_data.items())[:3]: - if isinstance(names, list) and names: - outputs.append(f"{xuid}:{'/'.join(names[-2:])}") - else: - outputs.append(f"{xuid}:[]") - if len(player_data) > 3: - outputs.append("...") - return "; ".join(outputs) - - def build_online_xuid_data(self): - """构建当前在线玩家的 xuid 映射。""" - orion = self.require_orion() - result: dict[str, str] = {} - for player_name in self.game_ctrl.allplayers.copy(): - try: - xuid = orion.xuid_getter.get_xuid_by_name(player_name) - except Exception: - continue - result[xuid] = player_name - return result - - def build_historical_xuid_data(self): - """读取历史玩家名称到 xuid 的映射数据。""" - path = os.path.join("插件数据文件", "前置-玩家XUID获取", "xuids.json") - try: - return self.orion.utils.disk_read_need_exists( - path) if self.orion else {} - except Exception: - return {} - - def build_device_history_data(self): - """读取 Orion 设备号与玩家历史的映射数据。""" - orion = self.require_orion() - path = f"{orion.data_path}/{orion.config_mgr.player_data_file}" - try: - data = orion.utils.disk_read_need_exists(path) - except Exception: - return {} - return data if isinstance(data, dict) else {} - - def _show_paginated_orion_menu( - self, - group_id: int, - qqid: int, - title: str, - matched_items: list[dict[str, object]], - page: int, - per_page: int, - select_hint: str, - search_hint: str | None, - ): - """渲染一页列表菜单,并返回用户输入与分页边界。""" - total_pages, start_index, end_index = self.orion.utils.paginate( - len(matched_items), - per_page, - page, - ) - output_lines = [ - item["display"] - for item in matched_items[start_index - 1: end_index] - ] - displayed_count = len(output_lines) - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] " - + self.orion_ui_list( - title, - output_lines, - page, - total_pages, - select_hint.format( - start_index=start_index, - end_index=end_index, - displayed_count=displayed_count, - ), - search_hint, - group_id, - ), - do_remove_cq_code=False, - ) - user_input = self.waitMsg(qqid, timeout=60, group_id=group_id) - return user_input, total_pages, start_index, end_index - - def _handle_paginated_orion_input( - self, - group_id: int, - qqid: int, - user_input: str | None, - page: int, - total_pages: int, - allow_search: bool, - search: str, - ): - """统一处理分页菜单中的翻页、退出、搜索和选择输入。""" - if user_input is None: - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 回复超时! 已退出菜单", - do_remove_cq_code=False, - ) - return "exit", None - - user_input = user_input.strip() - if self.is_menu_exit_input(user_input, group_id): - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 已退出菜单", - do_remove_cq_code=False, - ) - return "exit", None - - if user_input == "+": - if page < total_pages: - return "page", page + 1 - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 已经是最后一页啦~", - do_remove_cq_code=False, - ) - return "retry", None - - if user_input == "-": - if page > 1: - return "page", page - 1 - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 已经是第一页啦~", - do_remove_cq_code=False, - ) - return "retry", None - - if match := re.fullmatch(r"^([1-9]\d*)页$", user_input): - page_num = int(match.group(1)) - if 1 <= page_num <= total_pages: - return "page", page_num - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 不存在第 {page_num} 页!请重新输入!", - do_remove_cq_code=False, - ) - return "retry", None - - choice = utils.try_int(user_input) - if choice is not None: - return "choice", choice - - if allow_search: - return "search", user_input.replace("\\", "") - - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 您的输入有误", - do_remove_cq_code=False, - ) - return "retry", search - - def _select_paginated_orion_items( - self, - group_id: int, - qqid: int, - title: str, - build_matches, - empty_message: str, - select_hint: str, - search_hint: str | None, - allow_search: bool = True, - ): - """执行一套通用的分页选择流程。""" - search = "" - page = 1 - per_page = self.get_orion_items_per_page(group_id) - while True: - matched_items = build_matches(search) - if not matched_items: - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {empty_message}", - do_remove_cq_code=False, - ) - return None - - user_input, total_pages, start_index, end_index = ( - self._show_paginated_orion_menu( - group_id, - qqid, - title, - matched_items, - page, - per_page, - select_hint, - search_hint, - ) - ) - action, value = self._handle_paginated_orion_input( - group_id, - qqid, - user_input, - page, - total_pages, - allow_search, - search, - ) - if action == "exit": - return None - if action == "page": - page = value - continue - if action == "search": - search = value - page = 1 - continue - if action == "retry": - continue - displayed_count = end_index - start_index + 1 - if action == "choice" and value in range(1, displayed_count + 1): - return matched_items[start_index + value - 2] - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 您的输入有误", - do_remove_cq_code=False, - ) - - def qq_select_orion_xuid( - self, - group_id: int, - qqid: int, - title: str, - xuid_data: dict[str, str], - allow_search: bool = True, - ): - """从 xuid 列表里分页选择目标玩家。 - - 返回 `(xuid, 玩家名)`,失败或退出时返回 `(None, None)`。 - """ - selected = self._select_paginated_orion_items( - group_id, - qqid, - title, - lambda search: [ - { - "value": (xuid, name), - "display": ( - f"{xuid} - {name}" - f" - {self.orion_xuid_status_text(xuid)}" - ), - } - for xuid, name in xuid_data.items() - if search == "" or search in xuid or search in name - ], - "找不到您输入的 xuid 或玩家名称", - "[1-{displayed_count}] 之间的数字以选择 对应玩家", - "xuid、玩家名称或玩家部分名称 可尝试搜索", - allow_search=allow_search, - ) - if selected is None: - return None, None - return selected["value"] - - def qq_select_orion_device( - self, - group_id: int, - qqid: int, - title: str, - device_data: dict[str, dict[str, list[str]]], - ): - """从设备号列表里分页选择目标设备。""" - selected = self._select_paginated_orion_items( - group_id, - qqid, - title, - lambda search: [ - { - "value": (device_id, player_info), - "display": ( - f"{device_id} - {self.format_device_history(player_info)}" - f" - {self.orion_device_status_text(device_id)}" - ), - } - for device_id, player_info in device_data.items() - if search == "" - or search in device_id - or search in self.format_device_history(player_info) - ], - "找不到您输入的设备号或玩家名称", - "[1-{displayed_count}] 之间的数字以选择 对应设备号", - "设备号、玩家名称或玩家部分名称 可尝试搜索", - ) - if selected is None: - return None, None - return selected["value"] - - def qq_get_orion_ban_time(self, group_id: int, qqid: int): - """统一处理群里输入的封禁时长。""" - prompt = self.orion_ui_menu( - "封禁时间输入", - [], - [ - "输入 -1 表示永久封禁", - "输入 正整数 表示封禁秒数", - "输入 0年0月5日6时7分8秒 表示对应时长", - "输入 . 退出", - ], - group_id, - ) - user_input = self.qq_prompt(group_id, qqid, prompt, timeout=120) - if user_input is None: - self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - if self.is_menu_exit_input(user_input, group_id): - self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - ban_time = self.orion.utils.ban_time_format( - user_input) if self.orion else 0 - if ban_time == 0: - self.reply_to_qq(group_id, qqid, "❀ 您输入的封禁时间有误") - return None - return ban_time - - def qq_get_orion_reason(self, group_id: int, qqid: int): - """获取封禁原因,允许直接回车走默认文案。""" - user_input = self.qq_prompt( - group_id, - qqid, - self.orion_ui_menu( - "封禁原因输入", - [], - [ - "请输入封禁原因", - "直接回车使用默认原因“群聊管理员封禁”", - "输入 . 退出", - ], - group_id, - ), - timeout=120, - ) - if user_input is None: - self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - if self.is_menu_exit_input(user_input, group_id): - self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - return user_input or "群聊管理员封禁" - - def _select_orion_ban_target(self, group_id: int, qqid: int, choice: str): - """根据封禁模式选择具体目标对象。""" - if choice == "1": - xuid, player_name = self.qq_select_orion_xuid( - group_id, - qqid, - "在线玩家封禁", - self.build_online_xuid_data(), - ) - if not xuid or not player_name: - return None - return { - "kind": "xuid", - "payload": (xuid, player_name), - "online_only": True, - } - if choice == "2": - xuid, player_name = self.qq_select_orion_xuid( - group_id, - qqid, - "历史玩家封禁", - self.build_historical_xuid_data(), - ) - if not xuid or not player_name: - return None - return { - "kind": "xuid", - "payload": (xuid, player_name), - "online_only": False, - } - if choice == "3": - device_id, player_info = self.qq_select_orion_device( - group_id, - qqid, - "设备号封禁", - self.build_device_history_data(), - ) - if not device_id or not player_info: - return None - return { - "kind": "device", - "payload": (device_id, player_info), - } - self.reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return None - - def _apply_selected_orion_ban( - self, - group_id: int, - qqid: int, - ban_target: dict): - """把封禁时间和封禁原因的通用交互流程复用到所有封禁模式。""" - ban_time = self.qq_get_orion_ban_time(group_id, qqid) - if ban_time is None: - return - reason = self.qq_get_orion_reason(group_id, qqid) - if reason is None: - return - - if ban_target["kind"] == "xuid": - xuid, player_name = ban_target["payload"] - ok, msg = self.apply_orion_xuid_ban( - xuid, - player_name, - ban_time, - reason, - online_only=ban_target.get("online_only", False), - ) - else: - device_id, player_info = ban_target["payload"] - ok, msg = self.apply_orion_device_ban( - device_id, - player_info, - ban_time, - reason, - ) - self.reply_result(group_id, qqid, ok, msg) - - def qq_orion_ban_menu(self, group_id: int, qqid: int): - """交互式 Orion 封禁菜单。""" - if not self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - if self.orion is None: - self.reply_to_qq(group_id, qqid, "相关插件未安装:Orion_System") - return - if not self.ensure_linked_plugin_enabled(self.orion, group_id, qqid): - return - choice = self.qq_prompt( - group_id, - qqid, - self.orion_ui_menu( - "封禁管理系统", - ["根据在线玩家名称和xuid封禁", "根据历史玩家名称和xuid封禁", "根据设备号封禁"], - ["输入 [1-3] 之间的数字以选择 封禁模式", "输入 . 退出"], - group_id, - ), - timeout=120, - ) - if choice is None: - self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self.is_menu_exit_input(choice, group_id): - self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - ban_target = self._select_orion_ban_target(group_id, qqid, choice) - if ban_target is None: - return - self._apply_selected_orion_ban(group_id, qqid, ban_target) - - def qq_orion_unban_menu( # skipcq: PY-R1000 - self, - group_id: int, - qqid: int, - ): - """交互式 Orion 解封菜单。""" - if not self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - if self.orion is None: - self.reply_to_qq(group_id, qqid, "相关插件未安装:Orion_System") - return - if not self.ensure_linked_plugin_enabled(self.orion, group_id, qqid): - return - choice = self.qq_prompt( - group_id, - qqid, - self.orion_ui_menu( - "解封管理系统", - ["根据玩家名称和xuid解封", "根据设备号解封"], - ["输入 [1-2] 之间的数字以选择 解封模式", "输入 . 退出"], - group_id, - ), - timeout=120, - ) - if choice is None: - self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self.is_menu_exit_input(choice, group_id): - self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if choice == "1": - xuid_dir = f"{self.orion.data_path}/{self.orion.config_mgr.xuid_dir}" - xuid_data: dict[str, str] = {} - if os.path.isdir(xuid_dir): - for xuid_json in os.listdir(xuid_dir): - xuid = xuid_json.replace(".json", "") - try: - xuid_data[xuid] = self.orion.xuid_getter.get_name_by_xuid( - xuid, True, ) - except Exception: - xuid_data[xuid] = xuid - xuid, player_name = self.qq_select_orion_xuid( - group_id, - qqid, - "xuid 解封", - xuid_data, - allow_search=True, - ) - if not xuid or not player_name: - return - ok, msg = self.apply_orion_xuid_unban(xuid, player_name) - self.reply_result(group_id, qqid, ok, msg) - return - if choice == "2": - device_dir = f"{ - self.orion.data_path}/{ - self.orion.config_mgr.device_id_dir}" - player_data = self.build_device_history_data() - device_data: dict[str, dict[str, list[str]]] = {} - if os.path.isdir(device_dir): - for device_json in os.listdir(device_dir): - device_id = device_json.replace(".json", "") - device_data[device_id] = player_data.get(device_id, {}) - device_id, player_info = self.qq_select_orion_device( - group_id, - qqid, - "设备号解封", - device_data, - ) - if not device_id or player_info is None: - return - ok, msg = self.apply_orion_device_unban(device_id, player_info) - self.reply_result(group_id, qqid, ok, msg) - return - self.reply_to_qq(group_id, qqid, "❀ 您的输入有误") - - def resolve_orion_target(self, target: str): - """把群里的输入尽量解析成稳定的 `(xuid, 玩家名)` 组合。""" - if self.orion is None: - return None, None, "相关插件未安装:Orion_System" - if not hasattr(self.orion, "xuid_getter"): - return None, None, "Orion 插件尚未完成初始化" - - # 先按玩家名解析,失败再把输入当 xuid 处理,兼容群里两种用法。 - try: - xuid = self.orion.xuid_getter.get_xuid_by_name(target, True) - try: - player_name = self.orion.xuid_getter.get_name_by_xuid( - xuid, True) - except Exception: - player_name = target - except Exception: - xuid = target - try: - player_name = self.orion.xuid_getter.get_name_by_xuid( - xuid, True) - except Exception: - player_name = target - - if not isinstance(xuid, str) or not xuid: - return None, None, "无法解析玩家名称或 xuid" - return xuid, player_name, None - - def orion_ban_player(self, target: str, ban_time_raw: str, reason: str): - """非交互式封禁入口,给命令行式调用使用。""" - xuid, player_name, error = self.resolve_orion_target(target) - if error: - return False, error - orion = self.require_orion() - ban_time = orion.utils.ban_time_format(ban_time_raw) - return self.apply_orion_xuid_ban(xuid, player_name, ban_time, reason) - - def orion_unban_player(self, target: str): - """非交互式解封入口,供命令式调用复用。""" - xuid, player_name, error = self.resolve_orion_target(target) - if error: - return False, error - return self.apply_orion_xuid_unban(xuid, player_name) - - def apply_orion_xuid_ban( - self, - xuid: str, - player_name: str, - ban_time: int | str, - reason: str, - online_only: bool = False, - ): - """写入 xuid 封禁记录,并在需要时踢出对应在线玩家。""" - orion = self.require_orion() - # 实际封禁前会先过一遍在线状态和 Orion 自己的白名单检查。 - if online_only and player_name not in self.game_ctrl.allplayers: - return False, f"玩家 {player_name} 当前不在线" - if orion.utils.in_whitelist(player_name): - return False, f"玩家 {player_name} 位于 Orion 反制白名单内" - timestamp_now, date_now = orion.utils.now() - path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" - with orion.lock_ban_xuid: - ban_data = orion.utils.disk_read(path) - timestamp_end, date_end = orion.utils.calculate_ban_end_time( - ban_data, - ban_time, - timestamp_now, - ) - if not timestamp_end and timestamp_end is not None: - return False, f"玩家 {player_name} 已经是永久封禁" - orion.utils.disk_write( - path, - { - "xuid": xuid, - "name": player_name, - "ban_start_real_time": date_now, - "ban_start_timestamp": timestamp_now, - "ban_end_real_time": date_end, - "ban_end_timestamp": timestamp_end, - "ban_reason": reason, - }, - ) - if player_name in self.game_ctrl.allplayers: - orion.utils.kick(player_name, f"由于{reason},您被系统封禁至:{date_end}") - return True, f"已通过 Orion 封禁 {player_name} (xuid:{xuid}) 至 {date_end}" - - def apply_orion_device_ban( - self, - device_id: str, - player_info: dict[str, list[str]], - ban_time: int | str, - reason: str, - ): - """写入设备号封禁记录,并处理命中该设备号的在线玩家。""" - orion = self.require_orion() - # 设备号封禁会顺手踢掉当前在线、且命中过这个设备号的玩家。 - timestamp_now, date_now = orion.utils.now() - path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" - with orion.lock_ban_device_id: - ban_data = orion.utils.disk_read(path) - timestamp_end, date_end = orion.utils.calculate_ban_end_time( - ban_data, - ban_time, - timestamp_now, - ) - if not timestamp_end and timestamp_end is not None: - return False, f"设备号 {device_id} 已经是永久封禁" - orion.utils.disk_write( - path, - { - "device_id": device_id, - "xuid_and_player": player_info, - "ban_start_real_time": date_now, - "ban_start_timestamp": timestamp_now, - "ban_end_real_time": date_end, - "ban_end_timestamp": timestamp_end, - "ban_reason": reason, - }, - ) - for _xuid, names in player_info.items(): - kick_name = None - if isinstance(names, list): - for name in reversed(names): - if name in self.game_ctrl.allplayers: - kick_name = name - break - if kick_name: - orion.utils.kick(kick_name, f"由于{reason},您被系统封禁至:{date_end}") - return True, f"已通过 Orion 封禁设备号 {device_id} 至 {date_end}" - - def apply_orion_xuid_unban(self, xuid: str, player_name: str): - """删除 Orion 中某个 xuid 的封禁记录。""" - orion = self.require_orion() - path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" - if not os.path.exists(path): - return False, f"玩家 {player_name} 当前不在 Orion 的 xuid 封禁列表中" - os.remove(path) - return True, f"已通过 Orion 解封 {player_name} (xuid:{xuid})" - - def apply_orion_device_unban( - self, - device_id: str, - player_info: dict[str, list[str]], - ): - """删除 Orion 中某个设备号的封禁记录。""" - _ = player_info - orion = self.require_orion() - path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" - if not os.path.exists(path): - return False, f"设备号 {device_id} 当前不在 Orion 的设备号封禁列表中" - os.remove(path) - return True, f"已通过 Orion 解封设备号 {device_id}" +"""Orion System integration helpers for Ultra.""" + +import os +import re + +from tooldelta import fmts, utils + + +# 和 Orion_System 的联动全收在这里,主入口不用再关心封禁细节。 +class QQLinkerOrionMixin: + """负责 Orion_System 菜单、封禁与解封逻辑。""" + + @staticmethod + def console_menu_header(title: str) -> str: + """生成控制台使用的 Orion 风格标题栏。""" + return ( + "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓" + "§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" + f"§l§d❐§f 『§6群服互通云链版Ultra版§f』 §b{title}" + ) + + @staticmethod + def console_menu_footer(page_label: str, body: str) -> str: + """生成控制台使用的 Orion 风格页脚。""" + return ( + "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓 " + f"§r§7[ §b{page_label} §7] " + "§l§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" + f"§r{body}" + ) + + def prompt_console_input( + self, + title: str, + page_label: str, + body_lines: list[str], + prompt: str, + ) -> str: + """按 Orion 卡片样式展示控制台交互步骤,并读取输入。""" + self.print_console_card(title, page_label, body_lines, level="info") + return input(fmts.fmt_info(f"§a❀ §b{prompt}")).strip() + + @staticmethod + def print_console_info(text: str): + """按统一 UI 风格输出控制台普通信息。""" + fmts.print_inf(f"§a❀ §b{text}") + + @staticmethod + def print_console_success(text: str): + """按统一 UI 风格输出控制台成功信息。""" + fmts.print_suc(f"§a❀ §b{text}") + + @staticmethod + def print_console_warn(text: str): + """按统一 UI 风格输出控制台警告信息。""" + fmts.print_war(f"§6❀ §e{text}") + + @staticmethod + def print_console_error(text: str): + """按统一 UI 风格输出控制台错误信息。""" + fmts.print_err(f"§c❀ §e{text}") + + def print_console_card( + self, + title: str, + page_label: str, + body_lines: list[str], + level: str = "info", + ): + """按 Orion 风格打印一张控制台信息卡片。""" + card = ( + self.console_menu_header(title) + + "\n" + + self.console_menu_footer( + page_label, + "\n".join( + i if i.startswith("§") else f"§a❀ §b{i}" for i in body_lines), + )) + { + "info": fmts.print_inf, + "success": fmts.print_suc, + "warn": fmts.print_war, + "error": fmts.print_err, + }.get(level, fmts.print_inf)(card) + + def require_orion(self): + """返回可用的 Orion 实例,不可用时抛出明确异常。""" + if self.orion is None: + raise RuntimeError("相关插件未安装:Orion_System") + enabled = self._extract_plugin_enabled_flag(self.orion) + if not enabled and enabled is not None: + raise RuntimeError("相关插件未启用") + return self.orion + + def reply_to_qq(self, group_id: int, qqid: int, text: str): + """向指定群成员回复一条文本消息。""" + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False, + ) + + def reply_result(self, group_id: int, qqid: int, ok: bool, msg: str): + """把统一格式的成功/失败结果回发到群里。""" + prefix = "😄" if ok else "😭" + self.reply_to_qq(group_id, qqid, f"{prefix} {msg}") + + def on_qq_orion_ban(self, group_id: int, sender: int, args: list[str]): + """群聊侧的 Orion 封禁入口。 + + 支持两种调用方式: + - 直接给参数:适合熟悉命令的管理人员 + - 不给参数:转到交互式菜单 + """ + if not self._can_use_group_permission(group_id, sender, "封禁/解封玩家权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if self.orion is None: + self.reply_to_qq(group_id, sender, "相关插件未安装:Orion_System") + return + if not self.ensure_linked_plugin_enabled(self.orion, group_id, sender): + return + if args == []: + self.qq_orion_ban_menu(group_id, sender) + return + target = args[0] + ban_time_raw = args[1] + reason = " ".join(args[2:]).strip() or "群聊管理员封禁" + ok, msg = self.orion_ban_player(target, ban_time_raw, reason) + self.reply_result(group_id, sender, ok, msg) + + def on_qq_orion_unban(self, group_id: int, sender: int, args: list[str]): + """群聊侧的 Orion 解封入口。""" + if not self._can_use_group_permission(group_id, sender, "封禁/解封玩家权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if self.orion is None: + self.reply_to_qq(group_id, sender, "相关插件未安装:Orion_System") + return + if not self.ensure_linked_plugin_enabled(self.orion, group_id, sender): + return + if args == []: + self.qq_orion_unban_menu(group_id, sender) + return + ok, msg = self.orion_unban_player(args[0]) + self.reply_result(group_id, sender, ok, msg) + + def qq_prompt( + self, + group_id: int, + qqid: int, + text: str, + timeout: int = 60): + """发送一段提示文本,并等待同群同 QQ 的下一条回复。""" + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False) + resp = self.waitMsg(qqid, timeout=timeout, group_id=group_id) + if isinstance(resp, str): + return resp.strip() + return None + + @staticmethod + def orion_ui_border(): + """返回 Orion 菜单统一使用的装饰边框。""" + return "✧✦〓〓〓〓〓〓〓〓〓〓〓✦✧" + + def orion_ui_menu( + self, + subtitle: str, + options: list[str], + hints: list[str], + group_id: int | None = None, + ): + """生成 Orion 风格的普通菜单文本。""" + hints = self.normalize_menu_control_hints(hints, group_id) + parts = [self.orion_ui_border(), f"❐ 『Orion System 猎户座』 {subtitle}"] + parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(options)]) + parts.append(self.orion_ui_border()) + parts.extend([f"❀ {hint}" for hint in hints]) + return "\n".join(parts) + + def plugin_ui_menu( + self, + system_name: str, + subtitle: str, + options: list[str], + hints: list[str], + group_id: int | None = None, + ): + """生成插件内部复用的菜单文本。""" + hints = self.normalize_menu_control_hints(hints, group_id) + parts = [self.orion_ui_border(), f"❐ 『{system_name}』 {subtitle}"] + parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(options)]) + parts.append(self.orion_ui_border()) + parts.extend([f"❀ {hint}" for hint in hints]) + return "\n".join(parts) + + def orion_ui_list( + self, + subtitle: str, + items: list[str], + page: int, + total_pages: int, + select_hint: str, + search_hint: str | None = None, + group_id: int | None = None, + ): + """生成带分页和搜索提示的列表菜单文本。""" + parts = [self.orion_ui_border(), f"❐ 『Orion System 猎户座』 {subtitle}"] + parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(items)]) + parts.append(self.orion_ui_border()) + parts.append(f"❀ 当前第 {page}/{total_pages} 页") + parts.append(f"❀ 输入 {select_hint}") + if search_hint: + parts.append(f"❀ 输入 {search_hint}") + parts.append("❀ 输入 - 转到上一页") + parts.append("❀ 输入 + 转到下一页") + parts.append("❀ 输入 正整数+页 转到对应页") + parts.append(f"❀ {self.menu_exit_hint(group_id)}") + return "\n".join(parts) + + def get_orion_items_per_page(self, group_id: int): + """读取 Orion 菜单每页条数配置。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["QQ群封禁/解封菜单每页显示个数"]), + ) + except (KeyError, TypeError, ValueError): + return 10 + return 10 + + def orion_xuid_status_text(self, xuid: str): + """读取某个 xuid 在 Orion 中的封禁状态摘要。""" + orion = self.require_orion() + path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" + if not os.path.exists(path): + return "未封禁" + try: + data = orion.utils.disk_read_need_exists(path) + except Exception: + return "状态异常" + return f"封禁至: {data.get('ban_end_real_time', '未知')}" + + def orion_device_status_text(self, device_id: str): + """读取某个设备号在 Orion 中的封禁状态摘要。""" + orion = self.require_orion() + path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" + if not os.path.exists(path): + return "未封禁" + try: + data = orion.utils.disk_read_need_exists(path) + except Exception: + return "状态异常" + return f"封禁至: {data.get('ban_end_real_time', '未知')}" + + @staticmethod + def format_device_history(player_data: dict[str, list[str]]): + """把设备号关联历史压缩成适合列表展示的单行文本。""" + outputs: list[str] = [] + for xuid, names in list(player_data.items())[:3]: + if isinstance(names, list) and names: + outputs.append(f"{xuid}:{'/'.join(names[-2:])}") + else: + outputs.append(f"{xuid}:[]") + if len(player_data) > 3: + outputs.append("...") + return "; ".join(outputs) + + def build_online_xuid_data(self): + """构建当前在线玩家的 xuid 映射。""" + orion = self.require_orion() + result: dict[str, str] = {} + for player_name in self.game_ctrl.allplayers.copy(): + try: + xuid = orion.xuid_getter.get_xuid_by_name(player_name) + except Exception: + continue + result[xuid] = player_name + return result + + def build_historical_xuid_data(self): + """读取历史玩家名称到 xuid 的映射数据。""" + path = os.path.join("插件数据文件", "前置-玩家XUID获取", "xuids.json") + try: + return self.orion.utils.disk_read_need_exists( + path) if self.orion else {} + except Exception: + return {} + + def build_device_history_data(self): + """读取 Orion 设备号与玩家历史的映射数据。""" + orion = self.require_orion() + path = f"{orion.data_path}/{orion.config_mgr.player_data_file}" + try: + data = orion.utils.disk_read_need_exists(path) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + def _show_paginated_orion_menu( + self, + group_id: int, + qqid: int, + title: str, + matched_items: list[dict[str, object]], + page: int, + per_page: int, + select_hint: str, + search_hint: str | None, + ): + """渲染一页列表菜单,并返回用户输入与分页边界。""" + total_pages, start_index, end_index = self.orion.utils.paginate( + len(matched_items), + per_page, + page, + ) + output_lines = [ + item["display"] + for item in matched_items[start_index - 1: end_index] + ] + displayed_count = len(output_lines) + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] " + + self.orion_ui_list( + title, + output_lines, + page, + total_pages, + select_hint.format( + start_index=start_index, + end_index=end_index, + displayed_count=displayed_count, + ), + search_hint, + group_id, + ), + do_remove_cq_code=False, + ) + user_input = self.waitMsg(qqid, timeout=60, group_id=group_id) + return user_input, total_pages, start_index, end_index + + def _handle_paginated_orion_input( + self, + group_id: int, + qqid: int, + user_input: str | None, + page: int, + total_pages: int, + allow_search: bool, + search: str, + ): + """统一处理分页菜单中的翻页、退出、搜索和选择输入。""" + if user_input is None: + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 回复超时! 已退出菜单", + do_remove_cq_code=False, + ) + return "exit", None + + user_input = user_input.strip() + if self.is_menu_exit_input(user_input, group_id): + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 已退出菜单", + do_remove_cq_code=False, + ) + return "exit", None + + if user_input == "+": + if page < total_pages: + return "page", page + 1 + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 已经是最后一页啦~", + do_remove_cq_code=False, + ) + return "retry", None + + if user_input == "-": + if page > 1: + return "page", page - 1 + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 已经是第一页啦~", + do_remove_cq_code=False, + ) + return "retry", None + + if match := re.fullmatch(r"^([1-9]\d*)页$", user_input): + page_num = int(match.group(1)) + if 1 <= page_num <= total_pages: + return "page", page_num + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 不存在第 {page_num} 页!请重新输入!", + do_remove_cq_code=False, + ) + return "retry", None + + choice = utils.try_int(user_input) + if choice is not None: + return "choice", choice + + if allow_search: + return "search", user_input.replace("\\", "") + + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 您的输入有误", + do_remove_cq_code=False, + ) + return "retry", search + + def _select_paginated_orion_items( + self, + group_id: int, + qqid: int, + title: str, + build_matches, + empty_message: str, + select_hint: str, + search_hint: str | None, + allow_search: bool = True, + ): + """执行一套通用的分页选择流程。""" + search = "" + page = 1 + per_page = self.get_orion_items_per_page(group_id) + while True: + matched_items = build_matches(search) + if not matched_items: + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {empty_message}", + do_remove_cq_code=False, + ) + return None + + user_input, total_pages, start_index, end_index = ( + self._show_paginated_orion_menu( + group_id, + qqid, + title, + matched_items, + page, + per_page, + select_hint, + search_hint, + ) + ) + action, value = self._handle_paginated_orion_input( + group_id, + qqid, + user_input, + page, + total_pages, + allow_search, + search, + ) + if action == "exit": + return None + if action == "page": + page = value + continue + if action == "search": + search = value + page = 1 + continue + if action == "retry": + continue + displayed_count = end_index - start_index + 1 + if action == "choice" and value in range(1, displayed_count + 1): + return matched_items[start_index + value - 2] + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 您的输入有误", + do_remove_cq_code=False, + ) + + def qq_select_orion_xuid( + self, + group_id: int, + qqid: int, + title: str, + xuid_data: dict[str, str], + allow_search: bool = True, + ): + """从 xuid 列表里分页选择目标玩家。 + + 返回 `(xuid, 玩家名)`,失败或退出时返回 `(None, None)`。 + """ + selected = self._select_paginated_orion_items( + group_id, + qqid, + title, + lambda search: [ + { + "value": (xuid, name), + "display": ( + f"{xuid} - {name}" + f" - {self.orion_xuid_status_text(xuid)}" + ), + } + for xuid, name in xuid_data.items() + if search == "" or search in xuid or search in name + ], + "找不到您输入的 xuid 或玩家名称", + "[1-{displayed_count}] 之间的数字以选择 对应玩家", + "xuid、玩家名称或玩家部分名称 可尝试搜索", + allow_search=allow_search, + ) + if selected is None: + return None, None + return selected["value"] + + def qq_select_orion_device( + self, + group_id: int, + qqid: int, + title: str, + device_data: dict[str, dict[str, list[str]]], + ): + """从设备号列表里分页选择目标设备。""" + selected = self._select_paginated_orion_items( + group_id, + qqid, + title, + lambda search: [ + { + "value": (device_id, player_info), + "display": ( + f"{device_id} - {self.format_device_history(player_info)}" + f" - {self.orion_device_status_text(device_id)}" + ), + } + for device_id, player_info in device_data.items() + if search == "" + or search in device_id + or search in self.format_device_history(player_info) + ], + "找不到您输入的设备号或玩家名称", + "[1-{displayed_count}] 之间的数字以选择 对应设备号", + "设备号、玩家名称或玩家部分名称 可尝试搜索", + ) + if selected is None: + return None, None + return selected["value"] + + def qq_get_orion_ban_time(self, group_id: int, qqid: int): + """统一处理群里输入的封禁时长。""" + prompt = self.orion_ui_menu( + "封禁时间输入", + [], + [ + "输入 -1 表示永久封禁", + "输入 正整数 表示封禁秒数", + "输入 0年0月5日6时7分8秒 表示对应时长", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, prompt, timeout=120) + if user_input is None: + self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + if self.is_menu_exit_input(user_input, group_id): + self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + ban_time = self.orion.utils.ban_time_format( + user_input) if self.orion else 0 + if ban_time == 0: + self.reply_to_qq(group_id, qqid, "❀ 您输入的封禁时间有误") + return None + return ban_time + + def qq_get_orion_reason(self, group_id: int, qqid: int): + """获取封禁原因,允许直接回车走默认文案。""" + user_input = self.qq_prompt( + group_id, + qqid, + self.orion_ui_menu( + "封禁原因输入", + [], + [ + "请输入封禁原因", + "直接回车使用默认原因“群聊管理员封禁”", + "输入 . 退出", + ], + group_id, + ), + timeout=120, + ) + if user_input is None: + self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + if self.is_menu_exit_input(user_input, group_id): + self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + return user_input or "群聊管理员封禁" + + def _select_orion_ban_target(self, group_id: int, qqid: int, choice: str): + """根据封禁模式选择具体目标对象。""" + if choice == "1": + xuid, player_name = self.qq_select_orion_xuid( + group_id, + qqid, + "在线玩家封禁", + self.build_online_xuid_data(), + ) + if not xuid or not player_name: + return None + return { + "kind": "xuid", + "payload": (xuid, player_name), + "online_only": True, + } + if choice == "2": + xuid, player_name = self.qq_select_orion_xuid( + group_id, + qqid, + "历史玩家封禁", + self.build_historical_xuid_data(), + ) + if not xuid or not player_name: + return None + return { + "kind": "xuid", + "payload": (xuid, player_name), + "online_only": False, + } + if choice == "3": + device_id, player_info = self.qq_select_orion_device( + group_id, + qqid, + "设备号封禁", + self.build_device_history_data(), + ) + if not device_id or not player_info: + return None + return { + "kind": "device", + "payload": (device_id, player_info), + } + self.reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return None + + def _apply_selected_orion_ban( + self, + group_id: int, + qqid: int, + ban_target: dict): + """把封禁时间和封禁原因的通用交互流程复用到所有封禁模式。""" + ban_time = self.qq_get_orion_ban_time(group_id, qqid) + if ban_time is None: + return + reason = self.qq_get_orion_reason(group_id, qqid) + if reason is None: + return + + if ban_target["kind"] == "xuid": + xuid, player_name = ban_target["payload"] + ok, msg = self.apply_orion_xuid_ban( + xuid, + player_name, + ban_time, + reason, + online_only=ban_target.get("online_only", False), + ) + else: + device_id, player_info = ban_target["payload"] + ok, msg = self.apply_orion_device_ban( + device_id, + player_info, + ban_time, + reason, + ) + self.reply_result(group_id, qqid, ok, msg) + + def qq_orion_ban_menu(self, group_id: int, qqid: int): + """交互式 Orion 封禁菜单。""" + if not self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if self.orion is None: + self.reply_to_qq(group_id, qqid, "相关插件未安装:Orion_System") + return + if not self.ensure_linked_plugin_enabled(self.orion, group_id, qqid): + return + choice = self.qq_prompt( + group_id, + qqid, + self.orion_ui_menu( + "封禁管理系统", + ["根据在线玩家名称和xuid封禁", "根据历史玩家名称和xuid封禁", "根据设备号封禁"], + ["输入 [1-3] 之间的数字以选择 封禁模式", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if choice is None: + self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self.is_menu_exit_input(choice, group_id): + self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + ban_target = self._select_orion_ban_target(group_id, qqid, choice) + if ban_target is None: + return + self._apply_selected_orion_ban(group_id, qqid, ban_target) + + def qq_orion_unban_menu( # skipcq: PY-R1000 + self, + group_id: int, + qqid: int, + ): + """交互式 Orion 解封菜单。""" + if not self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if self.orion is None: + self.reply_to_qq(group_id, qqid, "相关插件未安装:Orion_System") + return + if not self.ensure_linked_plugin_enabled(self.orion, group_id, qqid): + return + choice = self.qq_prompt( + group_id, + qqid, + self.orion_ui_menu( + "解封管理系统", + ["根据玩家名称和xuid解封", "根据设备号解封"], + ["输入 [1-2] 之间的数字以选择 解封模式", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if choice is None: + self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self.is_menu_exit_input(choice, group_id): + self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if choice == "1": + xuid_dir = f"{self.orion.data_path}/{self.orion.config_mgr.xuid_dir}" + xuid_data: dict[str, str] = {} + if os.path.isdir(xuid_dir): + for xuid_json in os.listdir(xuid_dir): + xuid = xuid_json.replace(".json", "") + try: + xuid_data[xuid] = self.orion.xuid_getter.get_name_by_xuid( + xuid, True, ) + except Exception: + xuid_data[xuid] = xuid + xuid, player_name = self.qq_select_orion_xuid( + group_id, + qqid, + "xuid 解封", + xuid_data, + allow_search=True, + ) + if not xuid or not player_name: + return + ok, msg = self.apply_orion_xuid_unban(xuid, player_name) + self.reply_result(group_id, qqid, ok, msg) + return + if choice == "2": + device_dir = f"{self.orion.data_path}/{self.orion.config_mgr.device_id_dir}" + player_data = self.build_device_history_data() + device_data: dict[str, dict[str, list[str]]] = {} + if os.path.isdir(device_dir): + for device_json in os.listdir(device_dir): + device_id = device_json.replace(".json", "") + device_data[device_id] = player_data.get(device_id, {}) + device_id, player_info = self.qq_select_orion_device( + group_id, + qqid, + "设备号解封", + device_data, + ) + if not device_id or player_info is None: + return + ok, msg = self.apply_orion_device_unban(device_id, player_info) + self.reply_result(group_id, qqid, ok, msg) + return + self.reply_to_qq(group_id, qqid, "❀ 您的输入有误") + + def resolve_orion_target(self, target: str): + """把群里的输入尽量解析成稳定的 `(xuid, 玩家名)` 组合。""" + if self.orion is None: + return None, None, "相关插件未安装:Orion_System" + if not hasattr(self.orion, "xuid_getter"): + return None, None, "Orion 插件尚未完成初始化" + + # 先按玩家名解析,失败再把输入当 xuid 处理,兼容群里两种用法。 + try: + xuid = self.orion.xuid_getter.get_xuid_by_name(target, True) + try: + player_name = self.orion.xuid_getter.get_name_by_xuid( + xuid, True) + except Exception: + player_name = target + except Exception: + xuid = target + try: + player_name = self.orion.xuid_getter.get_name_by_xuid( + xuid, True) + except Exception: + player_name = target + + if not isinstance(xuid, str) or not xuid: + return None, None, "无法解析玩家名称或 xuid" + return xuid, player_name, None + + def orion_ban_player(self, target: str, ban_time_raw: str, reason: str): + """非交互式封禁入口,给命令行式调用使用。""" + xuid, player_name, error = self.resolve_orion_target(target) + if error: + return False, error + orion = self.require_orion() + ban_time = orion.utils.ban_time_format(ban_time_raw) + return self.apply_orion_xuid_ban(xuid, player_name, ban_time, reason) + + def orion_unban_player(self, target: str): + """非交互式解封入口,供命令式调用复用。""" + xuid, player_name, error = self.resolve_orion_target(target) + if error: + return False, error + return self.apply_orion_xuid_unban(xuid, player_name) + + def apply_orion_xuid_ban( + self, + xuid: str, + player_name: str, + ban_time: int | str, + reason: str, + online_only: bool = False, + ): + """写入 xuid 封禁记录,并在需要时踢出对应在线玩家。""" + orion = self.require_orion() + # 实际封禁前会先过一遍在线状态和 Orion 自己的白名单检查。 + if online_only and player_name not in self.game_ctrl.allplayers: + return False, f"玩家 {player_name} 当前不在线" + if orion.utils.in_whitelist(player_name): + return False, f"玩家 {player_name} 位于 Orion 反制白名单内" + timestamp_now, date_now = orion.utils.now() + path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" + with orion.lock_ban_xuid: + ban_data = orion.utils.disk_read(path) + timestamp_end, date_end = orion.utils.calculate_ban_end_time( + ban_data, + ban_time, + timestamp_now, + ) + if not timestamp_end and timestamp_end is not None: + return False, f"玩家 {player_name} 已经是永久封禁" + orion.utils.disk_write( + path, + { + "xuid": xuid, + "name": player_name, + "ban_start_real_time": date_now, + "ban_start_timestamp": timestamp_now, + "ban_end_real_time": date_end, + "ban_end_timestamp": timestamp_end, + "ban_reason": reason, + }, + ) + if player_name in self.game_ctrl.allplayers: + orion.utils.kick(player_name, f"由于{reason},您被系统封禁至:{date_end}") + return True, f"已通过 Orion 封禁 {player_name} (xuid:{xuid}) 至 {date_end}" + + def apply_orion_device_ban( + self, + device_id: str, + player_info: dict[str, list[str]], + ban_time: int | str, + reason: str, + ): + """写入设备号封禁记录,并处理命中该设备号的在线玩家。""" + orion = self.require_orion() + # 设备号封禁会顺手踢掉当前在线、且命中过这个设备号的玩家。 + timestamp_now, date_now = orion.utils.now() + path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" + with orion.lock_ban_device_id: + ban_data = orion.utils.disk_read(path) + timestamp_end, date_end = orion.utils.calculate_ban_end_time( + ban_data, + ban_time, + timestamp_now, + ) + if not timestamp_end and timestamp_end is not None: + return False, f"设备号 {device_id} 已经是永久封禁" + orion.utils.disk_write( + path, + { + "device_id": device_id, + "xuid_and_player": player_info, + "ban_start_real_time": date_now, + "ban_start_timestamp": timestamp_now, + "ban_end_real_time": date_end, + "ban_end_timestamp": timestamp_end, + "ban_reason": reason, + }, + ) + for _xuid, names in player_info.items(): + kick_name = None + if isinstance(names, list): + for name in reversed(names): + if name in self.game_ctrl.allplayers: + kick_name = name + break + if kick_name: + orion.utils.kick(kick_name, f"由于{reason},您被系统封禁至:{date_end}") + return True, f"已通过 Orion 封禁设备号 {device_id} 至 {date_end}" + + def apply_orion_xuid_unban(self, xuid: str, player_name: str): + """删除 Orion 中某个 xuid 的封禁记录。""" + orion = self.require_orion() + path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" + if not os.path.exists(path): + return False, f"玩家 {player_name} 当前不在 Orion 的 xuid 封禁列表中" + os.remove(path) + return True, f"已通过 Orion 解封 {player_name} (xuid:{xuid})" + + def apply_orion_device_unban( + self, + device_id: str, + player_info: dict[str, list[str]], + ): + """删除 Orion 中某个设备号的封禁记录。""" + _ = player_info + orion = self.require_orion() + path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" + if not os.path.exists(path): + return False, f"设备号 {device_id} 当前不在 Orion 的设备号封禁列表中" + os.remove(path) + return True, f"已通过 Orion 解封设备号 {device_id}" diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" index 0d0cee1e..dbfbae88 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" @@ -1,4077 +1,4022 @@ -"""QQ group menu and command handlers for Ultra.""" - -import time -from typing import Any - -from tooldelta import utils - -try: - from tooldelta.utils.mc_translator import translate -except ImportError: - translate = None - - -# 日常群聊交互放在这一层:帮助、背包查询、管理员菜单、联动检查菜单。 -class QQLinkerQQMixin: - """负责群聊菜单、查询类功能以及白名单联动命令。""" - - @utils.thread_func("群服执行指令并获取返回") - def on_qq_execute_cmd(self, group_id: int, qqid: int, cmd: list[str]): - """在群里执行 Minecraft 指令,并把执行结果回发到群里。""" - if not self._can_use_group_permission(group_id, qqid, "发送指令权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - res = self.execute_cmd_and_get_zhcn_cb(" ".join(cmd)) - self.sendmsg(group_id, res) - - def _reply_to_qq(self, group_id: int, qqid: int, text: str): - """向指定群成员回复一条文本消息。""" - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {text}", - do_remove_cq_code=False, - ) - - def _reply_result(self, group_id: int, qqid: int, ok: bool, msg: str): - """统一回发成功/失败结果。""" - self._reply_to_qq(group_id, qqid, f"{'😄' if ok else '😭'} {msg}") - - def _can_use_group_permission( - self, - group_id: int, - qqid: int, - permission_name: str, - ) -> bool: - """Implement the can use group permission operation.""" - if hasattr(self, "_has_group_permission"): - return self._has_group_permission(group_id, qqid, permission_name) - return self.has_group_permission(group_id, qqid, permission_name) - - def _can_use_any_group_permission( - self, - group_id: int, - qqid: int, - permission_names: tuple[str, ...], - ) -> bool: - """Implement the can use any group permission operation.""" - return any( - self._can_use_group_permission(group_id, qqid, permission_name) - for permission_name in permission_names - ) - - def _reply_menu_permission_denied(self, group_id: int, qqid: int): - """Implement the reply menu permission denied operation.""" - if hasattr(self, "_reply_permission_denied"): - self._reply_permission_denied(group_id, qqid) - return - self._reply_to_qq(group_id, qqid, "你没有权限执行此指令") - - def _ensure_group_permission( - self, - group_id: int, - qqid: int, - permission_name: str, - ) -> bool: - """Implement the ensure group permission operation.""" - if self._can_use_group_permission(group_id, qqid, permission_name): - return True - self._reply_menu_permission_denied(group_id, qqid) - return False - - def _append_permission_action( - self, - group_id: int, - qqid: int, - options: list[str], - actions: list[Any], - permission_name: str, - label: str, - action, - ): - """Implement the append permission action operation.""" - if self._can_use_group_permission(group_id, qqid, permission_name): - options.append(label) - actions.append(action) - - def _has_help_admin_actions(self, group_id: int, qqid: int) -> bool: - """Implement the has help admin actions operation.""" - permission_names = [ - "QQ普通管理员菜单权限", - "QQ超级管理员菜单权限", - "发送指令权限", - "配置配置文件权限", - "查询背包权限", - "封禁/解封玩家权限", - "白名单&管理员检测权限", - "领地系统权限", - "公会系统权限", - ] - if self.task_system is not None: - permission_names.append("任务系统权限") - return self._can_use_any_group_permission( - group_id, - qqid, - tuple(permission_names), - ) - - @staticmethod - def _extract_plugin_enabled_flag(plugin: Any): # skipcq: PY-R1000 - """读取联动插件明确暴露的整体启用状态;缺少该项时返回 None。""" - for method_name in ("_plugin_enabled", "is_plugin_enabled"): - method = getattr(plugin, method_name, None) - if callable(method): - try: - return bool(method()) - except Exception: - return None - - enabled = getattr(plugin, "enabled", None) - if isinstance(enabled, bool): - return enabled - - for method_name in ("api_get_runtime_status", "get_runtime_status"): - method = getattr(plugin, method_name, None) - if not callable(method): - continue - try: - status = method() - except Exception: - continue - if isinstance(status, tuple) and len(status) >= 3: - status = status[2] - if isinstance(status, dict): - for key in ("enabled", "是否启用", "是否启用插件"): - if isinstance(status.get(key), bool): - return status[key] - - for attr_name in ("config", "cfg", "_cfg"): - config = getattr(plugin, attr_name, None) - if not isinstance(config, dict): - continue - for key in ("是否启用", "是否启用插件"): - if isinstance(config.get(key), bool): - return config[key] - base_config = config.get("基础配置") - if isinstance(base_config, dict): - for key in ("是否启用", "是否启用插件"): - if isinstance(base_config.get(key), bool): - return base_config[key] - - return None - - def ensure_linked_plugin_enabled( - self, - plugin: Any, - group_id: int, - sender: int) -> bool: - """联动插件明确配置为禁用时,不进入群内管理菜单。""" - enabled = self._extract_plugin_enabled_flag(plugin) - if not enabled and enabled is not None: - self._reply_to_qq(group_id, sender, "相关插件未启用") - return False - return True - - def on_qq_help(self, group_id: int, sender: int, _): - """打开可交互的群帮助主菜单。""" - self.qq_help_main_menu(group_id, sender) - - def _is_menu_exit(self, user_input: str, group_id: int | None = None): - """Implement the is menu exit operation.""" - return self.is_menu_exit_input(user_input, group_id) - - def _is_menu_back(self, user_input: str, group_id: int | None = None): - """Implement the is menu back operation.""" - return self.is_menu_back_input(user_input, group_id) - - def _prompt_help_menu( - self, - group_id: int, - qqid: int, - subtitle: str, - options: list[str], - hints: list[str], - allow_back: bool = False, - ): - """Implement the prompt help menu operation.""" - choice = self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - subtitle, - options, - hints, - group_id), - timeout=120, - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - choice = choice.strip() - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if allow_back and self._is_menu_back(choice, group_id): - return "back" - return choice - - def _parse_help_choice( - self, - group_id: int, - qqid: int, - choice: str, - count: int): - """Implement the parse help choice operation.""" - selected = self.parse_displayed_menu_choice(choice, count) - if selected is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return selected - - def _prompt_paginated_help_actions( # skipcq: PY-R1000 - self, - group_id: int, - qqid: int, - subtitle: str, - options: list[str], - actions: list[Any], - per_page: int, - ): - """Implement the prompt paginated help actions operation.""" - if len(options) != len(actions): - self._reply_to_qq(group_id, qqid, "❀ 菜单配置错误") - return None - if not options: - self._reply_to_qq(group_id, qqid, "当前没有可用功能") - return None - page = 1 - while True: - total_pages, start_index, end_index = ( - utils.paginate(len(options), per_page, page) - if hasattr(utils, "paginate") - else self.simple_paginate(len(options), per_page, page) - ) - page_options = options[start_index - 1: end_index] - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - subtitle, - page_options, - [ - f"当前第 {page}/{total_pages} 页", - f"输入 [1-{len(page_options)}] 之间的数字以选择 对应功能", - "输入 - 转到上一页", - "输入 + 转到下一页", - "输入 正整数+页 转到对应页", - "输入 0 返回上级菜单", - "输入 . 退出", - ], - group_id, - ) - user_input = self.qq_prompt(group_id, qqid, text, timeout=120) - if user_input is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - user_input = user_input.strip() - if self._is_menu_exit(user_input, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if self._is_menu_back(user_input, group_id): - return "back" - if user_input == "+": - if page < total_pages: - page += 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") - continue - if user_input == "-": - if page > 1: - page -= 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") - continue - if page_num := self.parse_page_jump(user_input): - if 1 <= page_num <= total_pages: - page = page_num - else: - self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") - continue - selected = self._parse_help_choice( - group_id, - qqid, - user_input, - len(page_options), - ) - if selected is None: - continue - result = actions[start_index + selected - 2]() - return result if result == "back" else None - - def qq_help_main_menu(self, group_id: int, qqid: int): - """群帮助主菜单,负责路由到各个二级菜单。""" - options = ["非管理功能"] - handlers = [self.qq_help_basic_menu] - if self._has_help_admin_actions(group_id, qqid): - options.append("管理功能") - handlers.append(self.qq_help_admin_menu) - options.append("命令说明") - handlers.append(self.qq_help_show_all_reference) - while True: - choice = self._prompt_help_menu( - group_id, - qqid, - "帮助主菜单", - options, - [f"输入 [1-{len(options)}] 之间的数字以选择 对应菜单", "输入 . 退出"], - ) - if choice is None: - return - selected = self._parse_help_choice( - group_id, qqid, choice, len(options)) - if selected is None: - continue - if handlers[selected - 1](group_id, qqid) == "back": - continue - return - - def qq_help_basic_menu(self, group_id: int, qqid: int): - """非管理功能二级菜单。""" - options = [] - actions = [] - if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"] and ( - self._can_use_group_permission(group_id, qqid, "查看玩家人数权限") - ): - options.append("查看在线玩家列表") - actions.append(lambda: self.on_qq_player_list(group_id, qqid, [])) - options.append("公会系统菜单") - actions.append(lambda: self.qq_guild_player_menu(group_id, qqid)) - options.append("查看非管理功能触发词") - actions.append( - lambda: self.qq_help_show_basic_reference( - group_id, qqid)) - return self._prompt_paginated_help_actions( - group_id, - qqid, - "非管理功能", - options, - actions, - self.get_group_help_non_admin_items_per_page(group_id), - ) - - def qq_help_admin_menu(self, group_id: int, qqid: int): - """管理功能二级菜单。""" - options = [] - actions = [] - if self._can_use_any_group_permission( - group_id, - qqid, - ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), - ): - options.append("QQ群管理员管理菜单") - actions.append(lambda: self.qq_admin_menu(group_id, qqid)) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "发送指令权限", - "发送 Minecraft 指令", - lambda: self.qq_help_execute_command_prompt(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "配置配置文件权限", - "配置中心", - lambda: self.qq_config_center_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "查询背包权限", - "查询在线玩家背包", - lambda: self.qq_inventory_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "封禁/解封玩家权限", - "Orion QQ 封禁菜单", - lambda: self.qq_orion_ban_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "封禁/解封玩家权限", - "Orion QQ 解封菜单", - lambda: self.qq_orion_unban_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "白名单&管理员检测权限", - "白名单&管理员检测管理菜单", - lambda: self.qq_checker_menu(group_id, qqid), - ) - if self.task_system is not None: - self._append_permission_action( - group_id, - qqid, - options, - actions, - "任务系统权限", - "任务系统管理菜单", - lambda: self.qq_task_system_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "领地系统权限", - "领地系统云链联动版管理菜单", - lambda: self.qq_land_system_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "公会系统权限", - "公会系统管理菜单", - lambda: self.qq_guild_system_menu(group_id, qqid), - ) - options.append("查看管理功能触发词") - actions.append( - lambda: self.qq_help_show_admin_reference( - group_id, qqid)) - return self._prompt_paginated_help_actions( - group_id, - qqid, - "管理功能", - options, - actions, - self.get_group_help_admin_items_per_page(group_id), - ) - - def qq_help_integration_menu(self, group_id: int, qqid: int): - """联动系统二级菜单。""" - while True: - options = [] - actions = [] - self._append_permission_action( - group_id, - qqid, - options, - actions, - "封禁/解封玩家权限", - "Orion QQ 封禁菜单", - lambda: self.qq_orion_ban_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "封禁/解封玩家权限", - "Orion QQ 解封菜单", - lambda: self.qq_orion_unban_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "白名单&管理员检测权限", - "白名单&管理员检测管理菜单", - lambda: self.qq_checker_menu(group_id, qqid), - ) - if self.task_system is not None: - self._append_permission_action( - group_id, - qqid, - options, - actions, - "任务系统权限", - "任务系统管理菜单", - lambda: self.qq_task_system_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "领地系统权限", - "领地系统云链联动版管理菜单", - lambda: self.qq_land_system_menu(group_id, qqid), - ) - self._append_permission_action( - group_id, - qqid, - options, - actions, - "公会系统权限", - "公会系统管理菜单", - lambda: self.qq_guild_system_menu(group_id, qqid), - ) - if not options: - self._reply_to_qq(group_id, qqid, "当前没有可用功能") - return None - choice = self._prompt_help_menu( - group_id, - qqid, - "联动系统", - options, - [ - f"输入 [1-{len(options)}] 之间的数字以选择 对应功能", - "输入 0 返回上级菜单", - "输入 . 退出", - ], - allow_back=True, - ) - if choice is None: - return None - if choice == "back": - return "back" - selected = self._parse_help_choice( - group_id, qqid, choice, len(options)) - if selected is None: - continue - actions[selected - 1]() - return None - - def qq_help_show_all_reference(self, group_id: int, qqid: int): - """直接分页展示本群当前可用的全部命令触发词。""" - lines = self.qq_help_build_all_reference_lines(group_id, qqid) - if not lines: - self._reply_to_qq(group_id, qqid, "当前没有可展示的命令触发词") - return None - page = 1 - per_page = self.get_group_command_help_items_per_page(group_id) - while True: - total_pages, start_index, end_index = ( - utils.paginate(len(lines), per_page, page) - if hasattr(utils, "paginate") - else self.simple_paginate(len(lines), per_page, page) - ) - page_lines = lines[start_index - 1: end_index] - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - "命令说明", - page_lines, - [ - f"当前第 {page}/{total_pages} 页", - "输入 - 转到上一页", - "输入 + 转到下一页", - "输入 正整数+页 转到对应页", - "输入 0 返回上级菜单", - "输入 . 退出", - ], - group_id, - ) - user_input = self.qq_prompt(group_id, qqid, text, timeout=120) - if user_input is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - if self._is_menu_exit(user_input, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if self._is_menu_back(user_input, group_id): - return "back" - if user_input == "+": - if page < total_pages: - page += 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") - continue - if user_input == "-": - if page > 1: - page -= 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") - continue - if page_num := self.parse_page_jump(user_input): - if 1 <= page_num <= total_pages: - page = page_num - else: - self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") - continue - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - - def qq_help_build_all_reference_lines( # skipcq: PY-R1000 - self, - group_id: int, - qqid: int, - ): - """按运行时触发分发入口整理全部命令触发词说明。""" - cmd_prefix = self.get_group_cmd_prefix(group_id) - lines = [ - f"{' / '.join(self.get_group_help_triggers(group_id))} - 打开帮助菜单", - ] - if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"] and ( - self._can_use_group_permission(group_id, qqid, "查看玩家人数权限") - ): - lines.append( - f"{' / '.join(self.get_group_player_list_triggers(group_id))} - 查看玩家列表" - ) - lines.append( - ( - f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " - "公会系统菜单(普通成员需绑定游戏账号;管理员进入管理菜单)" - ) - ) - if self._can_use_group_permission(group_id, qqid, "查询背包权限"): - lines.append( - ( - f"{' / '.join(self.get_group_inventory_menu_triggers(group_id))} - " - "查询在线玩家背包" - ) - ) - if self._can_use_group_permission(group_id, qqid, "发送指令权限"): - lines.append(f"{cmd_prefix}[指令] - 向租赁服发送指令") - if self._can_use_any_group_permission( - group_id, - qqid, - ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), - ): - lines.append( - ( - f"{' / '.join(self.get_group_admin_menu_triggers(group_id))} - " - "QQ群管理员管理菜单" - ) - ) - if self._can_use_group_permission(group_id, qqid, "配置配置文件权限"): - lines.append( - f"{' / '.join(self.get_group_config_menu_triggers(group_id))} - 配置中心" - ) - if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): - lines.extend( - [ - ( - f"{' / '.join(self.get_group_orion_ban_triggers(group_id))} " - "[玩家名/xuid] [封禁时间] [原因可选] - Orion QQ 封禁" - ), - ( - f"{' / '.join(self.get_group_orion_unban_triggers(group_id))} " - "[玩家名/xuid] - Orion QQ 解封" - ), - ] - ) - if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): - lines.append( - ( - f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - " - "白名单&管理员检测管理菜单" - ) - ) - if self.task_system is not None and self._can_use_group_permission( - group_id, - qqid, - "任务系统权限", - ): - lines.append( - f"{' / '.join(self.get_group_task_menu_triggers(group_id))} - 任务系统管理菜单" - ) - if self._can_use_group_permission(group_id, qqid, "领地系统权限"): - lines.append( - ( - f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - " - "领地系统云链联动版管理菜单" - ) - ) - if self._can_use_group_permission(group_id, qqid, "公会系统权限"): - lines.append( - ( - f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " - "公会系统管理菜单(管理员)" - ) - ) - for trigger in self.triggers: - if trigger.op_only and not self.is_group_admin(group_id, qqid): - continue - trigger_text = " / ".join(trigger.triggers) - argument_hint = f" { - trigger.argument_hint}" if trigger.argument_hint else "" - permission_hint = "(管理员)" if trigger.op_only else "" - lines.append( - f"外部|{trigger_text}{argument_hint} - {trigger.usage}{permission_hint}" - ) - return lines - - def qq_help_reference_menu(self, group_id: int, qqid: int): - """命令说明二级菜单。""" - while True: - options = ["非管理功能触发词"] - actions = [ - lambda: self.qq_help_show_basic_reference( - group_id, qqid)] - if self._has_help_admin_actions(group_id, qqid): - options.append("管理功能触发词") - actions.append( - lambda: self.qq_help_show_admin_reference(group_id, qqid)) - choice = self._prompt_help_menu( - group_id, - qqid, - "命令说明", - options, - [ - f"输入 [1-{len(options)}] 之间的数字以查看 对应说明", - "输入 0 返回上级菜单", - "输入 . 退出", - ], - allow_back=True, - ) - if choice is None: - return None - if choice == "back": - return "back" - selected = self._parse_help_choice( - group_id, qqid, choice, len(options)) - if selected is None: - continue - actions[selected - 1]() - return None - - def qq_help_execute_command_prompt(self, group_id: int, qqid: int): - """通过帮助菜单交互式发送一条 Minecraft 指令。""" - if not self._can_use_group_permission(group_id, qqid, "发送指令权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - cmd_prefix = self.get_group_cmd_prefix(group_id) - command = self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - "发送 Minecraft 指令", - ["在下一条消息中输入要发送到租赁服的指令"], - [f"可以省略或保留前缀 {cmd_prefix}", "输入 . 退出"], - group_id, - ), - timeout=120, - ) - if command is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - command = command.strip() - if self._is_menu_exit(command, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if command.startswith(cmd_prefix): - command = command.removeprefix(cmd_prefix).strip() - if not command: - self._reply_to_qq(group_id, qqid, f"参数错误,格式:{cmd_prefix}[指令]") - return - self.on_qq_execute_cmd(group_id, qqid, command.split()) - - def qq_help_show_basic_reference(self, group_id: int, qqid: int): - """Handle the qq help show basic reference QQ menu operation.""" - options = [ - f"{' / '.join(self.get_group_help_triggers(group_id))} - 打开帮助菜单", - ] - if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"] and ( - self._can_use_group_permission(group_id, qqid, "查看玩家人数权限") - ): - options.append( - f"{' / '.join(self.get_group_player_list_triggers(group_id))} - 查看玩家列表" - ) - options.append( - ( - f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " - "公会系统菜单(需绑定游戏账号)" - ) - ) - self._reply_to_qq( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - "非管理功能触发词", - options, - ["输入帮助菜单唤醒词可重新打开菜单"], - group_id, - ), - ) - - def qq_help_show_admin_reference(self, group_id: int, qqid: int): - """Handle the qq help show admin reference QQ menu operation.""" - cmd_prefix = self.get_group_cmd_prefix(group_id) - options = [] - if self._can_use_group_permission(group_id, qqid, "发送指令权限"): - options.append(f"{cmd_prefix}[指令] - 向租赁服发送指令") - if self._can_use_any_group_permission( - group_id, - qqid, - ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), - ): - options.append( - ( - f"{' / '.join(self.get_group_admin_menu_triggers(group_id))} - " - "QQ群管理员管理菜单" - ) - ) - if self._can_use_group_permission(group_id, qqid, "配置配置文件权限"): - options.append( - f"{' / '.join(self.get_group_config_menu_triggers(group_id))} - 配置中心" - ) - if self._can_use_group_permission(group_id, qqid, "查询背包权限"): - options.append( - ( - f"{' / '.join(self.get_group_inventory_menu_triggers(group_id))} - " - "查询在线玩家背包" - ) - ) - if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): - orion_ban_triggers = " / ".join( - self.get_group_orion_ban_triggers(group_id) - ) - orion_unban_triggers = " / ".join( - self.get_group_orion_unban_triggers(group_id) - ) - options.extend( - [ - f"{orion_ban_triggers} - Orion QQ 封禁菜单", - f"{orion_unban_triggers} - Orion QQ 解封菜单", - ] - ) - if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): - options.append( - ( - f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - " - "白名单&管理员检测管理菜单" - ) - ) - if self.task_system is not None and self._can_use_group_permission( - group_id, - qqid, - "任务系统权限", - ): - options.append( - f"{' / '.join(self.get_group_task_menu_triggers(group_id))} - 任务系统管理菜单" - ) - if self._can_use_group_permission(group_id, qqid, "领地系统权限"): - options.append( - ( - f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - " - "领地系统云链联动版管理菜单" - ) - ) - if self._can_use_group_permission(group_id, qqid, "公会系统权限"): - options.append( - ( - f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " - "公会系统管理菜单(管理员)" - ) - ) - if not options: - self._reply_to_qq(group_id, qqid, "当前没有可展示的管理功能触发词") - return - self._reply_to_qq( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - "管理功能触发词", - options, - ["这些功能需要本群所有者、超级管理员或普通管理员权限"], - group_id, - ), - ) - - def qq_help_show_integration_reference(self, group_id: int, qqid: int): - """Handle the qq help show integration reference QQ menu operation.""" - options = [] - if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): - orion_ban_triggers = " / ".join( - self.get_group_orion_ban_triggers(group_id) - ) - orion_unban_triggers = " / ".join( - self.get_group_orion_unban_triggers(group_id) - ) - options.extend( - [ - f"{orion_ban_triggers} - Orion QQ 封禁菜单", - f"{orion_unban_triggers} - Orion QQ 解封菜单", - ] - ) - if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): - options.append( - ( - f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - " - "白名单&管理员检测管理菜单" - ) - ) - if self.task_system is not None and self._can_use_group_permission( - group_id, - qqid, - "任务系统权限", - ): - options.append( - f"{' / '.join(self.get_group_task_menu_triggers(group_id))} - 任务系统管理菜单" - ) - if self._can_use_group_permission(group_id, qqid, "领地系统权限"): - options.append( - ( - f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - " - "领地系统云链联动版管理菜单" - ) - ) - if self._can_use_group_permission(group_id, qqid, "公会系统权限"): - options.append( - ( - f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " - "公会系统菜单(普通成员需绑定;管理员进入管理菜单)" - ) - ) - if not options: - self._reply_to_qq(group_id, qqid, "当前没有可展示的联动系统触发词") - return - self._reply_to_qq( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - "联动系统触发词", - options, - ["公会系统普通菜单需要 QQ 绑定游戏账号,管理菜单需要本群管理员权限"], - group_id, - ), - ) - - def on_qq_player_list(self, group_id: int, _sender: int, _): - """把在线玩家列表和可用 TPS 信息发到群里。""" - if not self._can_use_group_permission(group_id, _sender, "查看玩家人数权限"): - self._reply_menu_permission_denied(group_id, _sender) - return - group_cfg = self.group_cfgs[group_id] - if not group_cfg["指令设置"]["是否允许查看玩家列表"]: - self.sendmsg(group_id, "当前群未启用玩家列表查询") - return - players = [f"{i + 1}.{j}" for i, - j in enumerate(self.game_ctrl.allplayers)] - fmt_msg = ( - f"在线玩家有 {len(players)} 人:\n " - + "\n ".join(players) - + ( - f"\n当前 TPS: {round(self.tps_calc.get_tps(), 1)}/20" - if self.tps_calc - else "" - ) - ) - self.sendmsg(group_id, fmt_msg) - - def qq_inventory_menu(self, group_id: int, qqid: int): - """分页展示在线玩家,并允许在群里进一步查询某个人的背包。""" - if not self._can_use_group_permission(group_id, qqid, "查询背包权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - online_names = list(self.game_ctrl.allplayers) - if not online_names: - self._reply_to_qq(group_id, qqid, "当前没有在线玩家") - return - page = 1 - per_page = self.get_group_inventory_items_per_page(group_id) - while True: - # 这里保留一个循环式菜单,避免每次翻页都重新走一遍入口指令。 - total_pages, start_index, end_index = ( - utils.paginate(len(online_names), per_page, page) - if hasattr(utils, "paginate") - else self.simple_paginate(len(online_names), per_page, page) - ) - page_names = online_names[start_index - 1: end_index] - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - "查询背包", - page_names, - [ - f"当前第 {page}/{total_pages} 页", - f"输入 [1-{len(page_names)}] 之间的数字以选择 对应玩家", - "输入 - 转到上一页", - "输入 + 转到下一页", - "输入 正整数+页 转到对应页", - "输入 . 退出", - ], - group_id, - ) - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {text}", - do_remove_cq_code=False) - user_input = self.waitMsg(qqid, timeout=120, group_id=group_id) - if user_input is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - user_input = user_input.strip() - if self._is_menu_exit(user_input, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if user_input == "+": - # 菜单保持在同一条交互链里,翻页只改当前页码。 - if page < total_pages: - page += 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") - continue - if user_input == "-": - if page > 1: - page -= 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") - continue - if page_num := self.parse_page_jump(user_input): - if 1 <= page_num <= total_pages: - page = page_num - else: - self._reply_to_qq( - group_id, - qqid, - f"❀ 不存在第 {page_num} 页!请重新输入!", - ) - continue - choice = self.parse_displayed_menu_choice( - user_input, len(page_names)) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - continue - self.show_player_inventory(group_id, qqid, page_names[choice - 1]) - return - - @staticmethod - def simple_paginate(total_len: int, per_page: int, page: int): - """在缺少 utils.paginate 时使用的本地分页兜底实现。""" - total_pages = max(1, (total_len + per_page - 1) // per_page) - page = max(1, min(page, total_pages)) - start_index = (page - 1) * per_page + 1 - end_index = min(page * per_page, total_len) - return total_pages, start_index, end_index - - @staticmethod - def parse_displayed_menu_choice(user_input: str, displayed_count: int): - """Parse a choice using the numbers shown on the current menu page.""" - choice = utils.try_int(user_input.strip().strip("[]")) - if choice is None or choice not in range(1, displayed_count + 1): - return None - return choice - - @staticmethod - def parse_page_jump(user_input: str): - """Parse page jumps only when the input explicitly ends with a page suffix.""" - text = user_input.strip() - if len(text) < 2 or text[-1] not in ("页", "頁", "椤"): - return None - return utils.try_int(text[:-1]) - - def show_player_inventory( - self, - group_id: int, - qqid: int, - player_name: str): - """把背包槽位整理成适合群聊阅读的文本。""" - player_obj = self.game_ctrl.players.getPlayerByName(player_name) - if player_obj is None: - self._reply_to_qq(group_id, qqid, f"玩家 {player_name} 当前已不在线") - return - try: - inventory = player_obj.queryInventory() - except Exception as err: - self._reply_to_qq(group_id, qqid, f"查询背包失败: {err}") - return - items: list[str] = [] - for idx, slot in enumerate(inventory.slots): - if slot is None: - continue - # 这里把原始槽位信息尽量整理成适合群内阅读的文本。 - item_id = getattr(slot, "id", "未知ID") - display_name = self.translate_item_name(item_id) - stack_size = getattr(slot, "stackSize", 0) - aux = getattr(slot, "aux", 0) - line = f"槽位 {idx}: {display_name} x{stack_size}" - if display_name != item_id: - line += f" ({item_id})" - if aux not in (None, -1, 0): - line += f" (数据值:{aux})" - custom_name = self.get_item_custom_name(slot) - if custom_name: - line += f" (命名:{custom_name})" - ench_text = self.get_item_enchantments_text(slot) - if ench_text: - line += f" (附魔:{ench_text})" - items.append(line) - if not items: - items = ["该玩家背包为空"] - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - f"背包查询 - {player_name}", - items, - ["输入 . 退出"], - group_id, - ) - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {text}", - do_remove_cq_code=False) - - def ensure_task_system(self, group_id: int, sender: int): - """Implement the ensure task system operation.""" - if self.task_system is None: - self._reply_to_qq(group_id, sender, "相关插件未安装:任务系统云链联动版") - return False - return self.ensure_linked_plugin_enabled( - self.task_system, group_id, sender) - - @staticmethod - def _format_task_time(timestamp: int): - """Implement the format task time operation.""" - try: - return time.strftime( - "%Y-%m-%d %H:%M:%S", - time.localtime(timestamp)) - except Exception: - return str(timestamp) - - @staticmethod - def _format_task_label(task_info: dict[str, Any]): - """Implement the format task label operation.""" - show_name = str(task_info.get("show_name", "")).strip() - tag_name = str(task_info.get("tag_name", "")).strip() - if not show_name or show_name == tag_name: - return tag_name or show_name or "<未知任务>" - return f"{show_name} ({tag_name})" - - def _format_task_menu_item(self, task_info: dict[str, Any], status: str): - """Implement the format task menu item operation.""" - label = self._format_task_label(task_info) - lines = [f"{status} {label}"] - description = str(task_info.get("description", "")).strip() - if description: - lines.append(f" {description}") - if "finished_time" in task_info: - try: - finished_time = self._format_task_time( - int(task_info["finished_time"])) - except Exception: - finished_time = str(task_info.get("finished_time", "未知时间")) - lines.append(f" 完成时间:{finished_time}") - return "\n".join(lines) - - def _format_task_progress_text( - self, progress: dict[str, Any], group_id: int): - """Implement the format task progress text operation.""" - in_progress = progress.get("in_progress", []) - completed = progress.get("completed", []) - options = [] - if in_progress: - for task_info in in_progress: - options.append(self._format_task_menu_item(task_info, "未完成")) - else: - options.append("未完成任务:无") - if completed: - for task_info in completed: - options.append(self._format_task_menu_item(task_info, "已完成")) - else: - options.append("已完成任务:无") - player_name = progress.get("player_name", "<未知玩家>") - return self.plugin_ui_menu( - "群服互通云链版Ultra版", - f"任务进度 - {player_name}", - options, - [ - f"未完成任务:{len(in_progress)} 个", - f"已完成任务:{len(completed)} 个", - "输入 . 退出", - ], - group_id, - ) - - def qq_task_system_menu(self, group_id: int, qqid: int): - """Handle the qq task system menu QQ menu operation.""" - if not self._can_use_group_permission(group_id, qqid, "任务系统权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - if not self.ensure_task_system(group_id, qqid): - return - choice = self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - "任务系统菜单", - ["查看在线玩家任务进度", "给在线玩家下发任务", "直接完成在线玩家任务"], - ["输入 [1-3] 之间的数字以选择 对应功能", "输入 . 退出"], - group_id, - ), - timeout=120, - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") - return - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if choice == "1": - player_name = self.qq_select_online_player( - group_id, qqid, "查看任务进度") - if player_name is None: - return - self.on_qq_task_progress(group_id, qqid, [player_name]) - return - if choice == "2": - player_name = self.qq_select_online_player(group_id, qqid, "下发任务") - if player_name is None: - return - quest_tag = self.qq_select_task_from_catalog(group_id, qqid) - if quest_tag is None: - return - self.on_qq_task_add(group_id, qqid, [player_name, quest_tag]) - return - if choice == "3": - player_name = self.qq_select_online_player(group_id, qqid, "完成任务") - if player_name is None: - return - quest_tag = self.qq_select_in_progress_task( - group_id, qqid, player_name) - if quest_tag is None: - return - self.on_qq_task_finish(group_id, qqid, [player_name, quest_tag]) - return - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - - def qq_select_online_player(self, group_id: int, qqid: int, subtitle: str): - """Handle the qq select online player QQ menu operation.""" - online_names = list(self.game_ctrl.allplayers) - if not online_names: - self._reply_to_qq(group_id, qqid, "当前没有在线玩家") - return None - page = 1 - per_page = self.get_group_task_player_items_per_page(group_id) - while True: - total_pages, start_index, end_index = ( - utils.paginate(len(online_names), per_page, page) - if hasattr(utils, "paginate") - else self.simple_paginate(len(online_names), per_page, page) - ) - page_names = online_names[start_index - 1: end_index] - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - f"任务系统 - {subtitle}", - page_names, - [ - f"当前第 {page}/{total_pages} 页", - f"输入 [1-{len(page_names)}] 之间的数字以选择 对应玩家", - "输入 - 转到上一页", - "输入 + 转到下一页", - "输入 正整数+页 转到对应页", - "输入 . 退出", - ], - group_id, - ) - user_input = self.qq_prompt(group_id, qqid, text, timeout=120) - if user_input is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") - return None - if self._is_menu_exit(user_input, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if user_input == "+": - if page < total_pages: - page += 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") - continue - if user_input == "-": - if page > 1: - page -= 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") - continue - if page_num := self.parse_page_jump(user_input): - if 1 <= page_num <= total_pages: - page = page_num - else: - self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") - continue - choice = self.parse_displayed_menu_choice( - user_input, len(page_names)) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - continue - return page_names[choice - 1] - - def qq_select_task_from_catalog(self, group_id: int, qqid: int): - """Handle the qq select task from catalog QQ menu operation.""" - quests = self.task_system.list_available_quests() - if not quests: - self._reply_to_qq(group_id, qqid, "任务系统中暂无可用任务") - return None - page = 1 - per_page = self.get_group_task_items_per_page(group_id) - while True: - total_pages, start_index, end_index = ( - utils.paginate(len(quests), per_page, page) - if hasattr(utils, "paginate") - else self.simple_paginate(len(quests), per_page, page) - ) - page_quests = quests[start_index - 1: end_index] - options = [] - for quest_info in page_quests: - label = self._format_task_label(quest_info) - description = str(quest_info.get("description", "")).strip() - options.append( - label if not description else f"{label}\n {description}") - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - "任务系统 - 选择任务", - options, - [ - f"当前第 {page}/{total_pages} 页", - f"输入 [1-{len(page_quests)}] 之间的数字以选择 对应任务", - "输入 - 转到上一页", - "输入 + 转到下一页", - "输入 正整数+页 转到对应页", - "输入 . 退出", - ], - group_id, - ) - user_input = self.qq_prompt(group_id, qqid, text, timeout=120) - if user_input is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") - return None - if self._is_menu_exit(user_input, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if user_input == "+": - if page < total_pages: - page += 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") - continue - if user_input == "-": - if page > 1: - page -= 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") - continue - if page_num := self.parse_page_jump(user_input): - if 1 <= page_num <= total_pages: - page = page_num - else: - self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") - continue - choice = self.parse_displayed_menu_choice( - user_input, len(page_quests)) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - continue - return page_quests[choice - 1]["tag_name"] - - def qq_select_in_progress_task( - self, - group_id: int, - qqid: int, - player_name: str): - """Handle the qq select in progress task QQ menu operation.""" - ok, result = self.task_system.get_online_player_task_progress( - player_name) - if not ok: - self._reply_to_qq(group_id, qqid, str(result)) - return None - in_progress = result.get("in_progress", []) - if not in_progress: - self._reply_to_qq(group_id, qqid, f"玩家 {player_name} 当前没有进行中的任务") - return None - options = [] - for task_info in in_progress: - label = self._format_task_label(task_info) - description = str(task_info.get("description", "")).strip() - options.append( - label if not description else f"{label}\n {description}") - text = self.plugin_ui_menu( - "群服互通云链版Ultra版", - f"任务系统 - 完成任务 - {player_name}", - options, - [f"输入 [1-{len(options)}] 之间的数字以选择 对应任务", "输入 . 退出"], - group_id, - ) - user_input = self.qq_prompt(group_id, qqid, text, timeout=120) - if user_input is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") - return None - if self._is_menu_exit(user_input, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - choice = self.parse_displayed_menu_choice(user_input, len(options)) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return None - return in_progress[choice - 1]["tag_name"] - - def on_qq_task_progress(self, group_id: int, sender: int, args: list[str]): - """Implement the on qq task progress operation.""" - if not self._can_use_group_permission(group_id, sender, "任务系统权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_task_system(group_id, sender): - return - ok, result = self.task_system.get_online_player_task_progress(args[0]) - if not ok: - self._reply_to_qq(group_id, sender, str(result)) - return - self._reply_to_qq( - group_id, - sender, - self._format_task_progress_text(result, group_id), - ) - - def on_qq_task_add(self, group_id: int, sender: int, args: list[str]): - """Implement the on qq task add operation.""" - if not self._can_use_group_permission(group_id, sender, "任务系统权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_task_system(group_id, sender): - return - player_name = args[0] - quest_query = " ".join(args[1:]).strip() - ok, msg = self.task_system.add_quest_to_online_player( - player_name, quest_query) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_task_finish(self, group_id: int, sender: int, args: list[str]): - """Implement the on qq task finish operation.""" - if not self._can_use_group_permission(group_id, sender, "任务系统权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_task_system(group_id, sender): - return - player_name = args[0] - quest_query = " ".join(args[1:]).strip() - ok, msg = self.task_system.finish_quest_for_online_player( - player_name, quest_query - ) - self._reply_result(group_id, sender, ok, msg) - - def ensure_guild_system(self, group_id: int, sender: int): - """检查公会系统云链联动版 API 是否可用。""" - if self.guild_system is None: - self._reply_to_qq(group_id, sender, "相关插件未安装:公会系统云链联动版") - return False - return self.ensure_linked_plugin_enabled( - self.guild_system, group_id, sender) - - @staticmethod - def _guild_actor(group_id: int, qqid: int): - """Implement the guild actor operation.""" - return f"QQ群{group_id}:{qqid}" - - def _qq_guild_prompt( - self, - group_id: int, - qqid: int, - subtitle: str, - options: list[str], - hints: list[str], - ): - """Implement the qq guild prompt operation.""" - return self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "公会系统云链联动版", - subtitle, - options, - hints, - group_id, - ), - timeout=120, - ) - - def _qq_guild_prompt_text( - self, - group_id: int, - qqid: int, - subtitle: str, - prompt: str, - allow_empty: bool = False, - ): - """Implement the qq guild prompt text operation.""" - value = self._qq_guild_prompt( - group_id, qqid, subtitle, [], [ - prompt, "输入 . 退出"]) - if value is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - value = value.strip() - if self._is_menu_exit(value, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if not allow_empty and not value: - self._reply_to_qq(group_id, qqid, "❀ 输入不能为空") - return None - return value - - def _qq_guild_prompt_optional_query( - self, - group_id: int, - qqid: int, - subtitle: str, - prompt: str, - ): - """Implement the qq guild prompt optional query operation.""" - value = self._qq_guild_prompt_text( - group_id, qqid, subtitle, prompt, allow_empty=True) - if value is None: - return None - if value in ("", "全部", "全服", "all", "*", "-"): - return "" - return value - - def _qq_guild_prompt_int( - self, - group_id: int, - qqid: int, - subtitle: str, - prompt: str, - minimum: int | None = None, - default: int | None = None, - ): - """Implement the qq guild prompt int operation.""" - hints = [prompt, "输入 . 退出"] - if default is not None: - hints.insert(1, f"输入 默认 使用 {default}") - value = self._qq_guild_prompt(group_id, qqid, subtitle, [], hints) - if value is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - value = value.strip() - if self._is_menu_exit(value, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if default is not None and value in ("", "默认", "default"): - return default - parsed = utils.try_int(value) - if parsed is None: - self._reply_to_qq(group_id, qqid, "❀ 请输入整数") - return None - if minimum is not None and parsed < minimum: - self._reply_to_qq(group_id, qqid, f"❀ 数值不能小于 {minimum}") - return None - return parsed - - def _qq_guild_confirm( - self, - group_id: int, - qqid: int, - subtitle: str, - detail: str): - """Implement the qq guild confirm operation.""" - choice = self._qq_guild_prompt( - group_id, - qqid, - subtitle, - [detail], - ["请输入 确认 继续执行", "输入 . 取消"], - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已取消") - return False - choice = choice.strip() - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已取消") - return False - if choice != "确认": - self._reply_to_qq(group_id, qqid, "❀ 已取消") - return False - return True - - def _reply_guild_api_result(self, group_id: int, qqid: int, result): - """Implement the reply guild api result operation.""" - if not isinstance(result, tuple) or not result: - self._reply_to_qq(group_id, qqid, "公会系统接口返回异常") - return - ok = bool(result[0]) - msg = str( - result[1]) if len(result) >= 2 else ( - "操作成功" if ok else "操作失败") - if len(result) >= 3 and isinstance(result[2], str) and result[2]: - msg = f"{msg}\n{result[2]}" - self._reply_result(group_id, qqid, ok, msg) - - @staticmethod - def _format_guild_base(base: dict[str, Any] | None): - """Implement the format guild base operation.""" - if not base: - return "未设置" - return f"{ - base.get( - 'dimension', - 0)} ({ - base.get( - 'x', - 0):.1f}, { - base.get( - 'y', - 0):.1f}, { - base.get( - 'z', - 0):.1f})" - - @staticmethod - def _format_guild_line(item: dict[str, Any], index: int): - """Implement the format guild line operation.""" - frozen = " 冻结" if item.get("frozen") else "" - return ( - f"{index}. {item.get('name', '<未知>')} Lv.{item.get('level', 0)} " - f"成员 {item.get('member_count', 0)}/{item.get('max_members', 0)} " - f"会长 {item.get('owner', '<未知>')}{frozen}" - ) - - def _format_guild_summary(self, guild: dict[str, Any], group_id: int): - """Implement the format guild summary operation.""" - effects = guild.get("purchased_effects", {}) - effect_text = "无" - if isinstance(effects, dict) and effects: - effect_text = "、".join( - f"{key}:{level}" for key, - level in effects.items()) - return self.plugin_ui_menu( - "公会系统云链联动版", - f"公会信息 - {guild.get('name', '<未知>')}", - [ - f"ID:{guild.get('guild_id', '<未知>')}", - f"会长:{guild.get('owner', '<未知>')}", - f"等级/经验:{guild.get('level', 0)} / {guild.get('exp', 0)}", - f"成员:{guild.get('member_count', 0)}/{guild.get('max_members', 0)}", - f"仓库:{guild.get('vault_count', 0)}/{guild.get('vault_capacity', 0)}", - f"资金:{guild.get('funds', 0)}", - f"据点:{self._format_guild_base(guild.get('base'))}", - f"据点锁定:{'是' if guild.get('base_locked') else '否'}", - f"冻结:{'是' if guild.get('frozen') else '否'}", - f"冻结原因:{guild.get('frozen_reason') or '无'}", - ( - f"活跃/完成任务:{guild.get('active_tasks', 0)} / " - f"{guild.get('completed_tasks', 0)}" - ), - f"总贡献:{guild.get('total_contribution', 0)}", - f"效果:{effect_text}", - f"公告:{guild.get('announcement') or '无'}", - ], - ["输入 . 退出"], - group_id, - ) - - def _reply_guild_lines( - self, - group_id: int, - qqid: int, - title: str, - lines: list[str], - hints: list[str] | None = None, - ): - """Implement the reply guild lines operation.""" - self._reply_to_qq( - group_id, - qqid, - self.plugin_ui_menu( - "公会系统云链联动版", - title, - lines or ["暂无数据"], - hints or ["输入 . 退出"], - group_id, - ), - ) - - def _qq_guild_run_menu( - self, - group_id: int, - qqid: int, - subtitle: str, - options: list[str], - actions: list[Any], - allow_back: bool = False, - ): - """Implement the qq guild run menu operation.""" - if len(options) != len(actions): - self._reply_to_qq(group_id, qqid, "❀ 菜单配置错误") - return None - hints = [f"输入 [1-{len(options)}] 之间的数字以选择 对应功能"] - if allow_back: - hints.append("输入 0 返回上级菜单") - hints.append("输入 . 退出") - choice = self._qq_guild_prompt( - group_id, - qqid, - subtitle, - options, - hints, - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - choice = choice.strip() - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - if allow_back and self._is_menu_back(choice, group_id): - return "back" - selected = self.parse_displayed_menu_choice(choice, len(options)) - if selected is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return None - return actions[selected - 1]() - - def qq_guild_entry_menu(self, group_id: int, qqid: int): - """公会系统统一入口:群管理员进管理菜单,普通成员进绑定账号菜单。""" - if self._can_use_group_permission(group_id, qqid, "公会系统权限"): - self.qq_guild_system_menu(group_id, qqid) - return - self.qq_guild_player_menu(group_id, qqid) - - def _qq_guild_select_bound_player(self, group_id: int, qqid: int): - """选择一个 QQ 已绑定的游戏账号,返回玩家名。""" - bound_players = self.api_get_bound_players_by_qq(qqid) - usable_players = [ - item - for item in bound_players - if str(item.get("player_name", "")).strip() - ] - if not usable_players: - bind_hint = " / ".join(self.get_group_binding_triggers(group_id)) - if not self.api_is_binding_enabled(group_id): - self._reply_to_qq( - group_id, - qqid, - "请先绑定游戏账号后再使用公会菜单;当前群的 QQ 绑定功能未开启,请联系管理员。", - ) - return None - self._reply_to_qq( - group_id, - qqid, - f"请先绑定游戏账号后再使用公会菜单。绑定触发词:{bind_hint}", - ) - return None - if len(usable_players) == 1: - return str(usable_players[0].get("player_name", "")).strip() - - options = [ - f"{item.get('player_name', '<未知玩家>')} ({item.get('xuid', '<未知XUID>')})" - for item in usable_players - ] - choice = self._qq_guild_prompt( - group_id, - qqid, - "选择绑定账号", - options, - [f"输入 [1-{len(options)}] 之间的数字以选择 游戏账号", "输入 . 退出"], - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - selected = self.parse_displayed_menu_choice(choice, len(options)) - if selected is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return None - return str(usable_players[selected - 1].get("player_name", "")).strip() - - def _qq_guild_player_state( - self, - group_id: int, - qqid: int, - player_name: str): - """Implement the qq guild player state operation.""" - result = self.guild_system.api_get_player_guild_menu_state(player_name) - if not isinstance(result, tuple) or len(result) < 3: - self._reply_to_qq(group_id, qqid, "公会系统接口返回异常") - return None - ok, msg, state = result - if not ok or not isinstance(state, dict): - self._reply_result(group_id, qqid, bool(ok), str(msg)) - return None - return state - - def qq_guild_player_menu(self, group_id: int, qqid: int): # skipcq: PY-R1000 - """普通群成员可使用的公会菜单,要求 QQ 已绑定游戏账号。""" - if not self.ensure_guild_system(group_id, qqid): - return - player_name = self._qq_guild_select_bound_player(group_id, qqid) - if not player_name: - return - while True: - state = self._qq_guild_player_state(group_id, qqid, player_name) - if state is None: - return - if state.get("in_guild"): - guild = state.get("guild") if isinstance( - state.get("guild"), dict) else {} - member = state.get("member") if isinstance( - state.get("member"), dict) else {} - permissions = set(state.get("permissions") or []) - is_owner = bool(state.get("is_owner")) - is_frozen = bool(state.get("is_frozen")) - subtitle = ( - f"玩家菜单 - {player_name} | {guild.get('name', '<未知公会>')} " - f"({member.get('rank_name', member.get('rank', '成员'))})" - ) - options = [ - "我的公会信息", - "查看成员列表", - "查看公会日志", - "查看公会公告", - "查看公会排行", - "查看贡献排行", - ] - actions = [ - lambda: self.qq_guild_player_show_self( - group_id, qqid, player_name), - lambda: self.qq_guild_player_show_members( - group_id, qqid, player_name), - lambda: self.qq_guild_player_show_logs( - group_id, qqid, player_name), - lambda: self.qq_guild_player_show_announcement( - group_id, qqid, player_name), - lambda: self.qq_guild_show_rankings(group_id, qqid), - lambda: self.qq_guild_show_donation_rankings( - group_id, qqid),] - if not is_frozen: - if "announce" in permissions: - options.append("设置公会公告") - actions.append( - lambda: self.qq_guild_player_set_announcement( - group_id, qqid, player_name)) - if "vault" in permissions: - options.append("查看公会仓库") - actions.append(lambda: self.qq_guild_player_show_vault( - group_id, qqid, player_name)) - options.append("查看公会任务") - actions.append(lambda: self.qq_guild_player_show_tasks( - group_id, qqid, player_name)) - options.append("参与公会任务") - actions.append(lambda: self.qq_guild_player_join_task( - group_id, qqid, player_name)) - if "return_base" in permissions: - options.append("返回公会据点") - actions.append( - lambda: self.qq_guild_player_return_base( - group_id, qqid, player_name)) - if not is_owner: - options.append("退出公会") - actions.append(lambda: self.qq_guild_player_leave( - group_id, qqid, player_name)) - if is_owner and not is_frozen: - options.append("解散我的公会") - actions.append(lambda: self.qq_guild_player_disband( - group_id, qqid, player_name)) - else: - subtitle = f"玩家菜单 - {player_name} | 未加入公会" - options = ["查看公会列表", "申请加入公会", "查看公会排行", "查看贡献排行"] - actions = [ - lambda: self.qq_guild_list(group_id, qqid), - lambda: self.qq_guild_player_request_join( - group_id, qqid, player_name - ), - lambda: self.qq_guild_show_rankings(group_id, qqid), - lambda: self.qq_guild_show_donation_rankings(group_id, qqid), - ] - result = self._qq_guild_run_menu( - group_id, qqid, subtitle, options, actions) - if result == "back": - continue - return - - def qq_guild_player_show_self( - self, - group_id: int, - qqid: int, - player_name: str): - """Handle the qq guild player show self QQ menu operation.""" - state = self._qq_guild_player_state(group_id, qqid, player_name) - if state is None: - return - guild = state.get("guild") if isinstance( - state.get("guild"), dict) else {} - member = state.get("member") if isinstance( - state.get("member"), dict) else {} - if not guild: - self._reply_to_qq(group_id, qqid, f"{player_name} 暂未加入公会") - return - self._reply_guild_lines( - group_id, - qqid, - f"我的公会 - {player_name}", - [ - f"公会:{guild.get('name', '<未知>')} ({guild.get('guild_id', '<未知>')})", - f"职位:{member.get('rank_name', member.get('rank', '<未知>'))}", - f"贡献:{member.get('contribution', 0)}", - f"会长:{guild.get('owner', '<未知>')}", - f"等级/经验:{guild.get('level', 0)} / {guild.get('exp', 0)}", - f"成员:{guild.get('member_count', 0)}/{guild.get('max_members', 0)}", - f"仓库:{guild.get('vault_count', 0)}/{guild.get('vault_capacity', 0)}", - f"据点:{self._format_guild_base(guild.get('base'))}", - f"冻结:{'是' if guild.get('frozen') else '否'}", - f"公告:{guild.get('announcement') or '无'}", - ], - ) - - def qq_guild_player_show_members( - self, - group_id: int, - qqid: int, - player_name: str): - """Handle the qq guild player show members QQ menu operation.""" - state = self._qq_guild_player_state(group_id, qqid, player_name) - if state is None: - return - guild = state.get("guild") if isinstance( - state.get("guild"), dict) else {} - guild_id = str(guild.get("guild_id", "")).strip() - if not guild_id: - self._reply_to_qq(group_id, qqid, "你尚未加入任何公会") - return - ok, msg, data = self.guild_system.api_get_guild(guild_id) - if not ok or not data: - self._reply_result(group_id, qqid, False, msg) - return - members = data.get("members", []) - lines = [] - for index, member in enumerate(members[:30], start=1): - rank = member.get("rank_name", member.get("rank", "成员")) - name = member.get("name", "<未知>") - contribution = member.get("contribution", 0) - lines.append(f"{index}. {rank} {name} 贡献 {contribution}") - self._reply_guild_lines( - group_id, qqid, f"{ - data.get( - 'name', guild_id)} 成员", lines or ["暂无成员"], [ - f"共 { - len(members)} 名成员"]) - - def qq_guild_player_show_logs( - self, - group_id: int, - qqid: int, - player_name: str): - """Handle the qq guild player show logs QQ menu operation.""" - ok, msg, data = self.guild_system.api_get_own_guild_logs( - player_name, 20) - if not ok or not data: - self._reply_result(group_id, qqid, False, msg) - return - lines = [str(item) for item in data.get("logs", [])[-20:]] - audit_logs = data.get("audit_logs", []) - if audit_logs: - lines.append("--- 审计日志 ---") - for item in audit_logs[-10:]: - action = item.get("action", "<未知>") - actor = item.get("actor", "") - target = item.get("target", "") - detail = item.get("detail", "") - lines.append(f"{action} {actor} -> {target} {detail}".strip()) - self._reply_guild_lines( - group_id, qqid, f"{player_name} 的公会日志", lines or ["暂无日志"]) - - def qq_guild_player_show_announcement( - self, group_id: int, qqid: int, player_name: str): - """Handle the qq guild player show announcement QQ menu operation.""" - state = self._qq_guild_player_state(group_id, qqid, player_name) - if state is None: - return - guild = state.get("guild") if isinstance( - state.get("guild"), dict) else {} - if not guild: - self._reply_to_qq(group_id, qqid, "你尚未加入任何公会") - return - self._reply_guild_lines( - group_id, - qqid, - f"{guild.get('name', '<未知公会>')} 公告", - [guild.get("announcement") or "当前没有公告"], - ) - - def qq_guild_player_set_announcement( - self, group_id: int, qqid: int, player_name: str): - """Handle the qq guild player set announcement QQ menu operation.""" - text = self._qq_guild_prompt_text( - group_id, qqid, "设置公会公告", "请输入新的公告内容(不超过200字符)") - if text is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_announcement_as_player( - player_name, text), ) - - def qq_guild_player_show_vault( - self, - group_id: int, - qqid: int, - player_name: str): - """Handle the qq guild player show vault QQ menu operation.""" - ok, msg, data = self.guild_system.api_get_own_guild_vault(player_name) - if not ok or data is None: - self._reply_result(group_id, qqid, False, msg) - return - lines = [] - for index, item in enumerate(data[:30], start=1): - item_index = item.get("index", index) - item_id = item.get("item_id", "<未知>") - count = item.get("count", 0) - price = item.get("price", 0) - seller = item.get("seller", "<未知>") - lines.append(f"{item_index}. {item_id} x{count} 价格 {price} 卖家 {seller}") - self._reply_guild_lines(group_id, qqid, msg, lines or ["仓库为空"]) - - def qq_guild_player_show_tasks( - self, - group_id: int, - qqid: int, - player_name: str): - """Handle the qq guild player show tasks QQ menu operation.""" - ok, msg, data = self.guild_system.api_get_own_guild_tasks(player_name) - if not ok or data is None: - self._reply_result(group_id, qqid, False, msg) - return - lines = [] - for index, task in enumerate(data[:30], start=1): - status = "已完成" if task.get("completed") else f"进行中 {task.get( - 'current_count', 0)} /{task.get('target_count', 0)} " - joined = " 已参与" if player_name in task.get( - "participants", []) else "" - lines.append( - f"{index}. { - task.get( - 'name', - '<未知任务>')} [{status}{joined}] 奖励 { - task.get( - 'reward_contribution', - 0)}贡献/{ - task.get( - 'reward_exp', - 0)}经验") - self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无任务"]) - - def qq_guild_player_join_task( - self, - group_id: int, - qqid: int, - player_name: str): - """Handle the qq guild player join task QQ menu operation.""" - ok, msg, data = self.guild_system.api_get_own_guild_tasks(player_name) - if not ok or data is None: - self._reply_result(group_id, qqid, False, msg) - return - active_tasks = [task for task in data if not task.get("completed")] - if not active_tasks: - self._reply_to_qq(group_id, qqid, "暂无可参与的任务") - return - options = [ - ( - f"{task.get('name', '<未知任务>')} " - f"({task.get('current_count', 0)}/{task.get('target_count', 0)})" - ) - for task in active_tasks[:20] - ] - choice = self._qq_guild_prompt( - group_id, - qqid, - "参与公会任务", - options, - [f"输入 [1-{len(options)}] 之间的数字以选择 任务", "输入 . 退出"], - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - selected = self.parse_displayed_menu_choice(choice, len(options)) - if selected is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - task = active_tasks[selected - 1] - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_join_guild_task_as_player( - player_name, - task.get("task_id") or task.get("name", ""), - ), - ) - - def qq_guild_player_return_base( - self, - group_id: int, - qqid: int, - player_name: str): - """Handle the qq guild player return base QQ menu operation.""" - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_return_to_guild_base_as_player(player_name), - ) - - def qq_guild_player_request_join( - self, - group_id: int, - qqid: int, - player_name: str): - """Handle the qq guild player request join QQ menu operation.""" - guild = self._qq_guild_prompt_text( - group_id, qqid, "申请加入公会", "请输入公会名称或ID") - if guild is None: - return - reason = self._qq_guild_prompt_text( - group_id, - qqid, - "申请加入公会", - "请输入申请理由,可输入 无 跳过", - allow_empty=True, - ) - if reason is None: - return - if reason == "无": - reason = "" - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_request_join_guild_as_player( - player_name, guild, reason), - ) - - def qq_guild_player_leave( - self, - group_id: int, - qqid: int, - player_name: str): - """Handle the qq guild player leave QQ menu operation.""" - if not self._qq_guild_confirm( - group_id, qqid, "退出公会", f"{player_name} 将退出当前公会"): - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_leave_guild_as_player(player_name), - ) - - def qq_guild_player_disband( - self, - group_id: int, - qqid: int, - player_name: str): - """Handle the qq guild player disband QQ menu operation.""" - if not self._qq_guild_confirm( - group_id, - qqid, - "解散公会", - f"{player_name} 将解散自己担任会长的公会"): - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_disband_owned_guild_as_player(player_name), - ) - - def qq_guild_system_menu(self, group_id: int, qqid: int): - """在群里打开公会系统云链联动版管理菜单。""" - if not self._can_use_group_permission(group_id, qqid, "公会系统权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - if not self.ensure_guild_system(group_id, qqid): - return - while True: - result = self._qq_guild_run_menu( - group_id, - qqid, - "管理菜单", - ["查询与排行", "公会与成员管理", "仓库与效果管理", "任务与据点管理", "数据维护与活动"], - [ - lambda: self.qq_guild_query_menu(group_id, qqid), - lambda: self.qq_guild_member_manage_menu(group_id, qqid), - lambda: self.qq_guild_vault_effect_menu(group_id, qqid), - lambda: self.qq_guild_task_base_menu(group_id, qqid), - lambda: self.qq_guild_data_activity_menu(group_id, qqid), - ], - ) - if result == "back": - continue - return - - def qq_guild_query_menu(self, group_id: int, qqid: int): - """Handle the qq guild query menu QQ menu operation.""" - if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): - return - if not self.ensure_guild_system(group_id, qqid): - return - self._qq_guild_run_menu( - group_id, - qqid, - "查询与排行", - [ - "查看公会列表", - "查看公会信息", - "查看公会成员", - "查看公会仓库", - "查看公会日志", - "查询玩家公会记录", - "查看系统统计", - "查看公会排行", - "查看贡献排行", - "查看异常交易", - ], - [ - lambda: self.qq_guild_list(group_id, qqid), - lambda: self.qq_guild_show_info(group_id, qqid), - lambda: self.qq_guild_show_members(group_id, qqid), - lambda: self.qq_guild_show_vault(group_id, qqid), - lambda: self.qq_guild_show_logs(group_id, qqid), - lambda: self.qq_guild_show_player_record(group_id, qqid), - lambda: self.qq_guild_show_statistics(group_id, qqid), - lambda: self.qq_guild_show_rankings(group_id, qqid), - lambda: self.qq_guild_show_donation_rankings(group_id, qqid), - lambda: self.qq_guild_show_abnormal_trades(group_id, qqid), - ], - allow_back=True, - ) - - def qq_guild_member_manage_menu(self, group_id: int, qqid: int): - """Handle the qq guild member manage menu QQ menu operation.""" - if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): - return - if not self.ensure_guild_system(group_id, qqid): - return - self._qq_guild_run_menu( - group_id, - qqid, - "公会与成员管理", - [ - "强制解散公会", - "修改公会名称", - "设置公会等级", - "设置公会经验", - "转让公会会长", - "强制玩家加入公会", - "强制玩家退出公会", - "冻结公会", - "解冻公会", - "调整公会资金", - "设置公会资金", - "调整成员贡献", - "设置成员贡献", - "清空公会贡献", - "发送全服公会公告", - ], - [ - lambda: self.qq_guild_force_disband(group_id, qqid), - lambda: self.qq_guild_rename(group_id, qqid), - lambda: self.qq_guild_set_level(group_id, qqid), - lambda: self.qq_guild_set_exp(group_id, qqid), - lambda: self.qq_guild_transfer_owner(group_id, qqid), - lambda: self.qq_guild_force_join(group_id, qqid), - lambda: self.qq_guild_force_leave(group_id, qqid), - lambda: self.qq_guild_set_frozen(group_id, qqid, True), - lambda: self.qq_guild_set_frozen(group_id, qqid, False), - lambda: self.qq_guild_add_funds(group_id, qqid), - lambda: self.qq_guild_set_funds(group_id, qqid), - lambda: self.qq_guild_add_member_contribution(group_id, qqid), - lambda: self.qq_guild_set_member_contribution(group_id, qqid), - lambda: self.qq_guild_reset_contributions(group_id, qqid), - lambda: self.qq_guild_broadcast_announcement(group_id, qqid), - ], - allow_back=True, - ) - - def qq_guild_vault_effect_menu(self, group_id: int, qqid: int): - """Handle the qq guild vault effect menu QQ menu operation.""" - if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): - return - if not self.ensure_guild_system(group_id, qqid): - return - self._qq_guild_run_menu( - group_id, - qqid, - "仓库与效果管理", - [ - "备份公会仓库", - "清空公会仓库", - "删除仓库物品", - "回滚仓库备份", - "导出仓库数据", - "重置市场价格", - "清空公会效果", - "设置公会效果", - ], - [ - lambda: self.qq_guild_backup_vault(group_id, qqid), - lambda: self.qq_guild_clear_vault(group_id, qqid), - lambda: self.qq_guild_delete_vault_item(group_id, qqid), - lambda: self.qq_guild_rollback_vault(group_id, qqid), - lambda: self.qq_guild_export_vault(group_id, qqid), - lambda: self.qq_guild_reset_market_prices(group_id, qqid), - lambda: self.qq_guild_clear_effects(group_id, qqid), - lambda: self.qq_guild_set_effect(group_id, qqid), - ], - allow_back=True, - ) - - def qq_guild_task_base_menu(self, group_id: int, qqid: int): - """Handle the qq guild task base menu QQ menu operation.""" - if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): - return - if not self.ensure_guild_system(group_id, qqid): - return - self._qq_guild_run_menu( - group_id, - qqid, - "任务与据点管理", - [ - "刷新公会任务", - "创建全服任务", - "删除公会任务", - "重置任务进度", - "强制完成任务", - "传送玩家到公会据点", - "删除公会据点", - "设置公会据点", - "锁定公会据点", - "解锁公会据点", - ], - [ - lambda: self.qq_guild_refresh_tasks(group_id, qqid), - lambda: self.qq_guild_create_global_task(group_id, qqid), - lambda: self.qq_guild_delete_task(group_id, qqid), - lambda: self.qq_guild_reset_task(group_id, qqid), - lambda: self.qq_guild_complete_task(group_id, qqid), - lambda: self.qq_guild_teleport_base(group_id, qqid), - lambda: self.qq_guild_delete_base(group_id, qqid), - lambda: self.qq_guild_set_base(group_id, qqid), - lambda: self.qq_guild_set_base_locked(group_id, qqid, True), - lambda: self.qq_guild_set_base_locked(group_id, qqid, False), - ], - allow_back=True, - ) - - def qq_guild_data_activity_menu(self, group_id: int, qqid: int): - """Handle the qq guild data activity menu QQ menu operation.""" - if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): - return - if not self.ensure_guild_system(group_id, qqid): - return - self._qq_guild_run_menu( - group_id, qqid, "数据维护与活动", - ["重载公会配置", "保存公会数据", "备份公会数据", "修复公会数据", "查看活动状态", "开启双倍经验", - "开启双倍贡献", "开启公会争霸", "停止活动", "结算排行奖励",], - [lambda: self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_reload_guild_config()), - lambda: self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_save_guild_data()), - lambda: self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_backup_guild_data()), - lambda: self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_repair_guild_data( - self._guild_actor(group_id, qqid))), - lambda: self.qq_guild_show_activity_status(group_id, qqid), - lambda: self.qq_guild_start_activity( - group_id, qqid, "exp", 2.0), - lambda: self.qq_guild_start_activity( - group_id, qqid, "contribution", 2.0), - lambda: self.qq_guild_start_activity( - group_id, qqid, "contest", 1.0), - lambda: self.qq_guild_stop_activity(group_id, qqid), - lambda: self.qq_guild_settle_rewards(group_id, qqid),], - allow_back=True,) - - def qq_guild_list(self, group_id: int, qqid: int): - """Handle the qq guild list QQ menu operation.""" - ok, msg, guilds = self.guild_system.api_list_guilds() - if not ok: - self._reply_result(group_id, qqid, False, msg) - return - lines = [self._format_guild_line(item, index) - for index, item in enumerate(guilds[:20], start=1)] - self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无公会"]) - - def qq_guild_show_info(self, group_id: int, qqid: int): - """Handle the qq guild show info QQ menu operation.""" - query = self._qq_guild_prompt_text( - group_id, qqid, "查看公会信息", "请输入公会名称或ID") - if query is None: - return - ok, msg, data = self.guild_system.api_get_guild(query) - self._reply_to_qq(group_id, qqid, self._format_guild_summary( - data, group_id) if ok and data else msg) - - def qq_guild_show_members(self, group_id: int, qqid: int): - """Handle the qq guild show members QQ menu operation.""" - query = self._qq_guild_prompt_text( - group_id, qqid, "查看公会成员", "请输入公会名称或ID") - if query is None: - return - ok, msg, data = self.guild_system.api_get_guild(query) - if not ok or not data: - self._reply_result(group_id, qqid, False, msg) - return - members = data.get("members", []) - lines = [] - for index, member in enumerate(members[:30], start=1): - rank = member.get("rank_name", member.get("rank", "成员")) - name = member.get("name", "<未知>") - contribution = member.get("contribution", 0) - lines.append(f"{index}. {rank} {name} 贡献 {contribution}") - self._reply_guild_lines( - group_id, qqid, f"{ - data.get( - 'name', query)} 成员", lines or ["暂无成员"], [ - f"共 { - len(members)} 名成员"]) - - def qq_guild_show_vault(self, group_id: int, qqid: int): - """Handle the qq guild show vault QQ menu operation.""" - query = self._qq_guild_prompt_text( - group_id, qqid, "查看公会仓库", "请输入公会名称或ID") - if query is None: - return - ok, msg, data = self.guild_system.api_get_guild_vault(query) - if not ok or data is None: - self._reply_result(group_id, qqid, False, msg) - return - lines = [] - for index, item in enumerate(data[:30], start=1): - item_index = item.get("index", index) - item_id = item.get("item_id", "<未知>") - count = item.get("count", 0) - price = item.get("price", 0) - seller = item.get("seller", "<未知>") - lines.append(f"{item_index}. {item_id} x{count} 价格 {price} 卖家 {seller}") - self._reply_guild_lines(group_id, qqid, msg, lines or ["仓库为空"]) - - def qq_guild_show_logs(self, group_id: int, qqid: int): - """Handle the qq guild show logs QQ menu operation.""" - query = self._qq_guild_prompt_text( - group_id, qqid, "查看公会日志", "请输入公会名称或ID") - if query is None: - return - limit = self._qq_guild_prompt_int( - group_id, qqid, "查看公会日志", "请输入日志数量", minimum=1, default=10) - if limit is None: - return - ok, msg, data = self.guild_system.api_get_guild_logs(query, limit) - if not ok or not data: - self._reply_result(group_id, qqid, False, msg) - return - logs = [str(item) for item in data.get("logs", [])] - logs = logs[-int(limit):] - self._reply_guild_lines( - group_id, - qqid, - f"{query} 日志", - logs or ["暂无日志"]) - - def qq_guild_show_player_record(self, group_id: int, qqid: int): - """Handle the qq guild show player record QQ menu operation.""" - player_name = self._qq_guild_prompt_text( - group_id, qqid, "查询玩家公会记录", "请输入玩家名") - if player_name is None: - return - ok, msg, data = self.guild_system.api_get_player_record(player_name) - if not ok or not data: - self._reply_result(group_id, qqid, False, msg) - return - member = data.get("member", {}) - guild = data.get("guild", {}) - self._reply_guild_lines( - group_id, - qqid, - f"玩家记录 - {member.get('name', player_name)}", - [ - f"所属公会:{guild.get('name', '<未知>')}", - f"职位:{member.get('rank_name', member.get('rank', '<未知>'))}", - f"贡献:{member.get('contribution', 0)}", - f"相关审计:{len(data.get('audit_logs', []))} 条", - f"仓库交易:{len(data.get('vault_trade_logs', []))} 条", - ], - ) - - def qq_guild_show_statistics(self, group_id: int, qqid: int): - """Handle the qq guild show statistics QQ menu operation.""" - ok, msg, data = self.guild_system.api_get_guild_statistics() - if not ok: - self._reply_result(group_id, qqid, False, msg) - return - self._reply_guild_lines( - group_id, - qqid, - "公会系统统计", - [ - f"公会:{data.get('guild_count', 0)}", - f"成员:{data.get('member_count', 0)}", - f"仓库物品:{data.get('vault_item_count', 0)}", - f"任务:{data.get('task_count', 0)}", - f"活跃任务:{data.get('active_task_count', 0)}", - f"冻结公会:{data.get('frozen_guild_count', 0)}", - ], - ) - - def qq_guild_show_rankings(self, group_id: int, qqid: int): - """Handle the qq guild show rankings QQ menu operation.""" - sort_choice = self._qq_guild_prompt( - group_id, - qqid, - "查看公会排行", - ["等级", "成员", "贡献", "活跃"], - ["输入 [1-4] 之间的数字以选择 排行类型", "输入 . 退出"], - ) - if sort_choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(sort_choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - sort_map = { - "1": "level", - "2": "members", - "3": "contribution", - "4": "activity"} - sort_by = sort_map.get(sort_choice.strip()) - if sort_by is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - ok, msg, data = self.guild_system.api_get_guild_rankings(sort_by, 10) - if not ok: - self._reply_result(group_id, qqid, False, msg) - return - lines = [ - f"{ - item.get( - 'rank', index)}. { - item.get( - 'name', '<未知>')} 分值 { - item.get( - 'score', 0)} 会长 { - item.get( - 'owner', '<未知>')}" for index, item in enumerate( - data, start=1)] - self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无排行"]) - - def qq_guild_show_donation_rankings(self, group_id: int, qqid: int): - """Handle the qq guild show donation rankings QQ menu operation.""" - query = self._qq_guild_prompt_optional_query( - group_id, qqid, "查看贡献排行", "请输入公会名/ID,输入 全部 查看全服") - if query is None: - return - ok, msg, data = self.guild_system.api_get_donation_rankings( - query or None, 10) - if not ok: - self._reply_result(group_id, qqid, False, msg) - return - lines = [ - f"{index}. { - item.get( - 'player_name', - '<未知>')} { - item.get( - 'guild_name', - '<未知>')} 贡献 { - item.get( - 'contribution', - 0)}" for index, - item in enumerate( - data, - start=1)] - self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无排行"]) - - def qq_guild_show_abnormal_trades(self, group_id: int, qqid: int): - """Handle the qq guild show abnormal trades QQ menu operation.""" - query = self._qq_guild_prompt_optional_query( - group_id, qqid, "查看异常交易", "请输入公会名/ID,输入 全部 查看全服") - if query is None: - return - ok, msg, data = self.guild_system.api_get_abnormal_trades( - query or None) - if not ok: - self._reply_result(group_id, qqid, False, msg) - return - lines = [] - for item in data[:20]: - guild_name = item.get("guild_name", "<未知>") - item_id = item.get("item_id", "<未知>") - count = item.get("count", 0) - price = item.get("price", 0) - ratio = item.get("ratio", 0) - lines.append(f"{guild_name} {item_id} x{count} 价格 {price} 倍率 {ratio}") - self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无异常交易"]) - - def _qq_guild_prompt_guild(self, group_id: int, qqid: int, subtitle: str): - """Implement the qq guild prompt guild operation.""" - return self._qq_guild_prompt_text( - group_id, qqid, subtitle, "请输入公会名称或ID") - - def qq_guild_force_disband(self, group_id: int, qqid: int): - """Handle the qq guild force disband QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "强制解散公会") - if guild is None: - return - if not self._qq_guild_confirm( - group_id, qqid, "强制解散公会", f"即将解散公会:{guild}"): - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_force_disband_guild( - guild, self._guild_actor( - group_id, qqid))) - - def qq_guild_rename(self, group_id: int, qqid: int): - """Handle the qq guild rename QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "修改公会名称") - if guild is None: - return - new_name = self._qq_guild_prompt_text( - group_id, qqid, "修改公会名称", "请输入新公会名") - if new_name is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_rename_guild( - guild, new_name, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_level(self, group_id: int, qqid: int): - """Handle the qq guild set level QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会等级") - if guild is None: - return - level = self._qq_guild_prompt_int( - group_id, qqid, "设置公会等级", "请输入等级", minimum=1) - if level is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_guild_level( - guild, level, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_exp(self, group_id: int, qqid: int): - """Handle the qq guild set exp QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会经验") - if guild is None: - return - exp = self._qq_guild_prompt_int( - group_id, qqid, "设置公会经验", "请输入经验值", minimum=0) - if exp is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_guild_exp( - guild, exp, self._guild_actor( - group_id, qqid))) - - def qq_guild_transfer_owner(self, group_id: int, qqid: int): - """Handle the qq guild transfer owner QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "转让公会会长") - if guild is None: - return - player = self._qq_guild_prompt_text( - group_id, qqid, "转让公会会长", "请输入新会长玩家名") - if player is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_transfer_guild_owner( - guild, player, self._guild_actor( - group_id, qqid))) - - def qq_guild_force_join(self, group_id: int, qqid: int): - """Handle the qq guild force join QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "强制玩家加入公会") - if guild is None: - return - player = self._qq_guild_prompt_text( - group_id, qqid, "强制玩家加入公会", "请输入玩家名") - if player is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_force_join_guild( - guild, player, self._guild_actor( - group_id, qqid))) - - def qq_guild_force_leave(self, group_id: int, qqid: int): - """Handle the qq guild force leave QQ menu operation.""" - player = self._qq_guild_prompt_text( - group_id, qqid, "强制玩家退出公会", "请输入玩家名") - if player is None: - return - if not self._qq_guild_confirm( - group_id, - qqid, - "强制玩家退出公会", - f"即将移出玩家:{player}"): - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_force_leave_guild( - player, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_frozen(self, group_id: int, qqid: int, frozen: bool): - """Handle the qq guild set frozen QQ menu operation.""" - title = "冻结公会" if frozen else "解冻公会" - guild = self._qq_guild_prompt_guild(group_id, qqid, title) - if guild is None: - return - reason = "" - if frozen: - reason = self._qq_guild_prompt_text( - group_id, qqid, title, "请输入冻结原因,可输入 无", allow_empty=True) - if reason is None: - return - if reason == "无": - reason = "" - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_guild_frozen( - guild, frozen, reason, self._guild_actor( - group_id, qqid))) - - def qq_guild_add_funds(self, group_id: int, qqid: int): - """Handle the qq guild add funds QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "调整公会资金") - if guild is None: - return - amount = self._qq_guild_prompt_int( - group_id, qqid, "调整公会资金", "请输入调整数量,可为负数") - if amount is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_add_guild_funds( - guild, amount, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_funds(self, group_id: int, qqid: int): - """Handle the qq guild set funds QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会资金") - if guild is None: - return - amount = self._qq_guild_prompt_int( - group_id, qqid, "设置公会资金", "请输入资金余额", minimum=0) - if amount is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_guild_funds( - guild, amount, self._guild_actor( - group_id, qqid))) - - def qq_guild_add_member_contribution(self, group_id: int, qqid: int): - """Handle the qq guild add member contribution QQ menu operation.""" - player = self._qq_guild_prompt_text(group_id, qqid, "调整成员贡献", "请输入玩家名") - if player is None: - return - amount = self._qq_guild_prompt_int( - group_id, qqid, "调整成员贡献", "请输入调整数量,可为负数") - if amount is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_add_member_contribution( - player, amount, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_member_contribution(self, group_id: int, qqid: int): - """Handle the qq guild set member contribution QQ menu operation.""" - player = self._qq_guild_prompt_text(group_id, qqid, "设置成员贡献", "请输入玩家名") - if player is None: - return - amount = self._qq_guild_prompt_int( - group_id, qqid, "设置成员贡献", "请输入贡献值", minimum=0) - if amount is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_member_contribution( - player, amount, self._guild_actor( - group_id, qqid))) - - def qq_guild_reset_contributions(self, group_id: int, qqid: int): - """Handle the qq guild reset contributions QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "清空公会贡献") - if guild is None: - return - if not self._qq_guild_confirm( - group_id, qqid, "清空公会贡献", f"即将清空公会贡献:{guild}"): - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_reset_guild_contributions( - guild, - self._guild_actor( - group_id, - qqid))) - - def qq_guild_broadcast_announcement(self, group_id: int, qqid: int): - """Handle the qq guild broadcast announcement QQ menu operation.""" - message = self._qq_guild_prompt_text( - group_id, qqid, "发送全服公会公告", "请输入公告内容") - if message is None: - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_broadcast_guild_announcement( - message, - self._guild_actor( - group_id, - qqid))) - - def qq_guild_backup_vault(self, group_id: int, qqid: int): - """Handle the qq guild backup vault QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "备份公会仓库") - if guild is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_backup_guild_vault( - guild, "qq", self._guild_actor( - group_id, qqid))) - - def qq_guild_clear_vault(self, group_id: int, qqid: int): - """Handle the qq guild clear vault QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "清空公会仓库") - if guild is None: - return - if not self._qq_guild_confirm( - group_id, qqid, "清空公会仓库", f"即将清空仓库:{guild}"): - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_clear_guild_vault( - guild, self._guild_actor( - group_id, qqid))) - - def qq_guild_delete_vault_item(self, group_id: int, qqid: int): - """Handle the qq guild delete vault item QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "删除仓库物品") - if guild is None: - return - index = self._qq_guild_prompt_int( - group_id, qqid, "删除仓库物品", "请输入仓库序号", minimum=1) - if index is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_delete_guild_vault_item( - guild, index, self._guild_actor( - group_id, qqid))) - - def qq_guild_rollback_vault(self, group_id: int, qqid: int): - """Handle the qq guild rollback vault QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "回滚仓库备份") - if guild is None: - return - index = self._qq_guild_prompt_int( - group_id, qqid, "回滚仓库备份", "请输入备份序号", minimum=1, default=1) - if index is None: - return - if not self._qq_guild_confirm( - group_id, - qqid, - "回滚仓库备份", - f"即将回滚 {guild} 的仓库备份 {index}"): - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_rollback_guild_vault( - guild, index, self._guild_actor( - group_id, qqid))) - - def qq_guild_export_vault(self, group_id: int, qqid: int): - """Handle the qq guild export vault QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "导出仓库数据") - if guild is None: - return - ok, msg, data = self.guild_system.api_export_guild_vault(guild) - if not ok or not data: - self._reply_result(group_id, qqid, False, msg) - return - items = data.get("vault_items", []) - trade_logs = data.get("vault_trade_logs", []) - self._reply_guild_lines( - group_id, - qqid, - msg, - [f"仓库物品:{len(items)} 件", f"交易日志:{len(trade_logs)} 条"], - ) - - def qq_guild_reset_market_prices(self, group_id: int, qqid: int): - """Handle the qq guild reset market prices QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "重置市场价格") - if guild is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_reset_market_prices( - guild, self._guild_actor( - group_id, qqid))) - - def qq_guild_clear_effects(self, group_id: int, qqid: int): - """Handle the qq guild clear effects QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "清空公会效果") - if guild is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_clear_guild_effects( - guild, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_effect(self, group_id: int, qqid: int): - """Handle the qq guild set effect QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会效果") - if guild is None: - return - effect = self._qq_guild_prompt_text( - group_id, qqid, "设置公会效果", "请输入效果ID或名称") - if effect is None: - return - level = self._qq_guild_prompt_int( - group_id, qqid, "设置公会效果", "请输入效果等级,0 表示移除", minimum=0) - if level is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_guild_effect( - guild, effect, level, self._guild_actor( - group_id, qqid))) - - def qq_guild_refresh_tasks(self, group_id: int, qqid: int): - """Handle the qq guild refresh tasks QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "刷新公会任务") - if guild is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_refresh_guild_tasks( - guild, self._guild_actor( - group_id, qqid))) - - def qq_guild_create_global_task(self, group_id: int, qqid: int): - """Handle the qq guild create global task QQ menu operation.""" - name = self._qq_guild_prompt_text(group_id, qqid, "创建全服任务", "请输入任务名称") - if name is None: - return - task_type = self._qq_guild_prompt_text( - group_id, qqid, "创建全服任务", "请输入任务类型,如 trade/collect/kill/build") - if task_type is None: - return - target = self._qq_guild_prompt_text( - group_id, qqid, "创建全服任务", "请输入任务目标") - if target is None: - return - target_count = self._qq_guild_prompt_int( - group_id, qqid, "创建全服任务", "请输入目标数量", minimum=1) - if target_count is None: - return - reward_exp = self._qq_guild_prompt_int( - group_id, qqid, "创建全服任务", "请输入经验奖励", minimum=0, default=0) - if reward_exp is None: - return - reward_contribution = self._qq_guild_prompt_int( - group_id, qqid, "创建全服任务", "请输入贡献奖励", minimum=0, default=0) - if reward_contribution is None: - return - description = self._qq_guild_prompt_text( - group_id, qqid, "创建全服任务", "请输入任务描述,可输入 默认", allow_empty=True) - if description is None: - return - if description == "默认": - description = name - deadline = self._qq_guild_prompt_int( - group_id, qqid, "创建全服任务", "请输入截止秒数,0 表示无限期", minimum=0, default=0) - if deadline is None: - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_create_global_task( - name, - task_type, - target, - target_count, - reward_exp, - reward_contribution, - description, - deadline, - self._guild_actor(group_id, qqid), - ), - ) - - def qq_guild_delete_task(self, group_id: int, qqid: int): - """Handle the qq guild delete task QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "删除公会任务") - if guild is None: - return - task = self._qq_guild_prompt_text( - group_id, qqid, "删除公会任务", "请输入任务ID或名称") - if task is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_delete_guild_task( - guild, task, self._guild_actor( - group_id, qqid))) - - def qq_guild_reset_task(self, group_id: int, qqid: int): - """Handle the qq guild reset task QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "重置任务进度") - if guild is None: - return - task = self._qq_guild_prompt_text( - group_id, qqid, "重置任务进度", "请输入任务ID或名称") - if task is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_reset_guild_task_progress( - guild, task, self._guild_actor( - group_id, qqid))) - - def qq_guild_complete_task(self, group_id: int, qqid: int): - """Handle the qq guild complete task QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "强制完成任务") - if guild is None: - return - task = self._qq_guild_prompt_text( - group_id, qqid, "强制完成任务", "请输入任务ID或名称") - if task is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_force_complete_guild_task( - guild, task, self._guild_actor( - group_id, qqid))) - - def qq_guild_teleport_base(self, group_id: int, qqid: int): - """Handle the qq guild teleport base QQ menu operation.""" - player = self._qq_guild_prompt_text( - group_id, qqid, "传送玩家到公会据点", "请输入玩家名") - if player is None: - return - guild = self._qq_guild_prompt_optional_query( - group_id, qqid, "传送玩家到公会据点", "请输入公会名/ID,输入 全部 使用玩家所在公会") - if guild is None: - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_teleport_player_to_guild_base( - player, - guild or None)) - - def qq_guild_delete_base(self, group_id: int, qqid: int): - """Handle the qq guild delete base QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "删除公会据点") - if guild is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_delete_guild_base( - guild, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_base(self, group_id: int, qqid: int): - """Handle the qq guild set base QQ menu operation.""" - guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会据点") - if guild is None: - return - dimension = self._qq_guild_prompt_int( - group_id, qqid, "设置公会据点", "请输入维度ID") - if dimension is None: - return - x = self._qq_guild_prompt_text(group_id, qqid, "设置公会据点", "请输入 x 坐标") - if x is None: - return - y = self._qq_guild_prompt_text(group_id, qqid, "设置公会据点", "请输入 y 坐标") - if y is None: - return - z = self._qq_guild_prompt_text(group_id, qqid, "设置公会据点", "请输入 z 坐标") - if z is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_guild_base( - guild, dimension, x, y, z, self._guild_actor( - group_id, qqid))) - - def qq_guild_set_base_locked(self, group_id: int, qqid: int, locked: bool): - """Handle the qq guild set base locked QQ menu operation.""" - title = "锁定公会据点" if locked else "解锁公会据点" - guild = self._qq_guild_prompt_guild(group_id, qqid, title) - if guild is None: - return - self._reply_guild_api_result( - group_id, qqid, self.guild_system.api_set_guild_base_locked( - guild, locked, self._guild_actor( - group_id, qqid))) - - def qq_guild_show_activity_status(self, group_id: int, qqid: int): - """Handle the qq guild show activity status QQ menu operation.""" - ok, msg, data = self.guild_system.api_get_guild_activity_status() - if not ok: - self._reply_result(group_id, qqid, False, msg) - return - lines = [] - for key, event in data.items(): - lines.append( - f"{key}: 倍率 { - event.get( - 'multiplier', - 1)} 剩余 { - event.get( - 'remaining_seconds', - 0)} 秒 发起 { - event.get( - 'actor', - '<未知>')}") - self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无活动"]) - - def qq_guild_start_activity( - self, - group_id: int, - qqid: int, - activity: str, - multiplier: float): - """Handle the qq guild start activity QQ menu operation.""" - duration = self._qq_guild_prompt_int( - group_id, qqid, "开启公会活动", "请输入持续秒数", minimum=1, default=3600) - if duration is None: - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_start_guild_activity( - activity, - duration, - multiplier, - self._guild_actor( - group_id, - qqid))) - - def qq_guild_stop_activity(self, group_id: int, qqid: int): - """Handle the qq guild stop activity QQ menu operation.""" - choice = self._qq_guild_prompt( - group_id, - qqid, - "停止活动", - ["双倍经验", "双倍贡献", "公会争霸"], - ["输入 [1-3] 之间的数字以选择 活动", "输入 . 退出"], - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - activity = { - "1": "exp", - "2": "contribution", - "3": "contest"}.get( - choice.strip()) - if activity is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - self._reply_guild_api_result( - group_id, qqid, - self.guild_system.api_stop_guild_activity(activity)) - - def qq_guild_settle_rewards(self, group_id: int, qqid: int): - """Handle the qq guild settle rewards QQ menu operation.""" - sort_by = self._qq_guild_prompt_text( - group_id, qqid, "结算排行奖励", - "请输入排行类型:level/members/contribution/activity") - if sort_by is None: - return - top = self._qq_guild_prompt_int( - group_id, qqid, "结算排行奖励", "请输入结算名次", minimum=1, default=3) - if top is None: - return - reward_exp = self._qq_guild_prompt_int( - group_id, qqid, "结算排行奖励", "请输入经验奖励", minimum=0, default=0) - if reward_exp is None: - return - reward_funds = self._qq_guild_prompt_int( - group_id, qqid, "结算排行奖励", "请输入资金奖励", minimum=0, default=0) - if reward_funds is None: - return - self._reply_guild_api_result( - group_id, - qqid, - self.guild_system.api_settle_guild_ranking_rewards( - sort_by, - top, - reward_exp, - reward_funds, - self._guild_actor( - group_id, - qqid))) - - def ensure_land_system(self, group_id: int, sender: int): - """检查领地系统云链联动版 API 是否可用。""" - if self.land_system is None: - self._reply_to_qq(group_id, sender, "相关插件未安装:领地系统云链联动版") - return False - return self.ensure_linked_plugin_enabled( - self.land_system, group_id, sender) - - def _format_land_summary(self, land: dict[str, Any], group_id: int): - """Implement the format land summary operation.""" - center = land.get("center", (0, 0, 0)) - admins = "、".join(land.get("admins", [])) or "无" - members = "、".join(land.get("members", [])) or "无" - return self.plugin_ui_menu( - "领地系统云链联动版", - f"领地信息 - {land.get('name', '<未知>')}", - [ - f"领主:{land.get('owner', '<未知>')}", - f"中心:{center[0]}, {center[1]}, {center[2]}", - f"范围:{land.get('range_text', '<未知>')}", - f"管理员:{admins}", - f"成员:{members}", - ], - ["输入 . 退出"], - group_id, - ) - - def _qq_land_prompt( - self, - group_id: int, - qqid: int, - subtitle: str, - options: list[str], - hints: list[str], - ): - """Implement the qq land prompt operation.""" - return self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "领地系统云链联动版", - subtitle, - options, - hints, - group_id, - ), - timeout=120, - ) - - def qq_land_system_menu(self, group_id: int, qqid: int): - """在群里打开领地系统云链联动版管理菜单。""" - if not self._can_use_group_permission(group_id, qqid, "领地系统权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - if not self.ensure_land_system(group_id, qqid): - return - choice = self._qq_land_prompt( - group_id, - qqid, - "管理菜单", - [ - "查看领地列表", - "查看领地信息", - "新增玩家领地", - "删除玩家领地", - "添加领地成员", - "移除领地成员", - "添加领地管理员", - "移除领地管理员", - "修改领地所有者", - "修改领地中心点", - "修改领地范围", - ], - ["输入 [1-11] 之间的数字以选择 对应功能", "输入 . 退出"], - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - handler_map = { - "1": self.qq_land_list, - "2": self.qq_land_show_info, - "3": self.qq_land_add, - "4": self.qq_land_delete, - "5": lambda gid, - qid: self.qq_land_member_action( - gid, - qid, - is_add=True, - rank="member"), - "6": lambda gid, - qid: self.qq_land_member_action( - gid, - qid, - is_add=False, - rank="member"), - "7": lambda gid, - qid: self.qq_land_member_action( - gid, - qid, - is_add=True, - rank="admin"), - "8": lambda gid, - qid: self.qq_land_member_action( - gid, - qid, - is_add=False, - rank="admin"), - "9": self.qq_land_transfer_owner, - "10": self.qq_land_update_center, - "11": self.qq_land_update_range, - } - handler = handler_map.get(choice) - if handler is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - handler(group_id, qqid) - - def qq_land_list(self, group_id: int, qqid: int): # skipcq: PY-R1000 - """Handle the qq land list QQ menu operation.""" - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - if not self.ensure_land_system(group_id, qqid): - return - ok, msg, lands = self.land_system.api_list_lands() - if not ok: - self._reply_result(group_id, qqid, False, msg) - return - if not lands: - self._reply_to_qq(group_id, qqid, "暂无任何领地") - return - page = 1 - per_page = self.get_group_land_items_per_page(group_id) - while True: - total_pages, start_index, end_index = ( - utils.paginate(len(lands), per_page, page) - if hasattr(utils, "paginate") - else self.simple_paginate(len(lands), per_page, page) - ) - page_lands = lands[start_index - 1: end_index] - text = self.plugin_ui_menu( - "领地系统云链联动版", - "领地列表", - [ - f"{land['name']} - 领主: {land['owner']}, {land['range_text']}" - for land in page_lands - ], - [ - f"当前第 {page}/{total_pages} 页", - f"输入 [1-{len(page_lands)}] 之间的数字查看详情", - "输入 - 转到上一页", - "输入 + 转到下一页", - "输入 正整数+页 转到对应页", - "输入 . 退出", - ], - group_id, - ) - user_input = self.qq_prompt(group_id, qqid, text, timeout=120) - if user_input is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(user_input, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if user_input == "+": - if page < total_pages: - page += 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") - continue - if user_input == "-": - if page > 1: - page -= 1 - else: - self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") - continue - if page_num := self.parse_page_jump(user_input): - if 1 <= page_num <= total_pages: - page = page_num - else: - self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") - continue - choice = self.parse_displayed_menu_choice( - user_input, len(page_lands)) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - continue - self._reply_to_qq( - group_id, - qqid, - self._format_land_summary(page_lands[choice - 1], group_id), - ) - return - - def _qq_land_prompt_text( - self, - group_id: int, - qqid: int, - subtitle: str, - prompt: str): - """Implement the qq land prompt text operation.""" - value = self._qq_land_prompt( - group_id, qqid, subtitle, [], [ - prompt, "输入 . 退出"]) - if value is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - if self._is_menu_exit(value, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - return value - - @staticmethod - def _parse_land_pos(raw: str): - """Implement the parse land pos operation.""" - parts = raw.replace(",", " ").replace(",", " ").split() - if len(parts) != 3: - return None - try: - return (float(parts[0]), float(parts[1]), float(parts[2])) - except ValueError: - return None - - @staticmethod - def _parse_land_size(raw: str): - """Implement the parse land size operation.""" - parts = raw.replace(",", " ").replace(",", " ").split() - if len(parts) != 3: - return None - try: - size = (int(parts[0]), int(parts[1]), int(parts[2])) - except ValueError: - return None - if any(value <= 0 for value in size): - return None - return size - - def qq_land_show_info(self, group_id: int, qqid: int): - """Handle the qq land show info QQ menu operation.""" - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - query = self._qq_land_prompt_text(group_id, qqid, "查看领地信息", "请输入领地名称") - if query is None: - return - ok, msg, land = self.land_system.api_get_land(query) - self._reply_to_qq( - group_id, - qqid, - self._format_land_summary(land, group_id) if ok else msg, - ) - - def qq_land_add(self, group_id: int, qqid: int): - """Handle the qq land add QQ menu operation.""" - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - owner = self._qq_land_prompt_text( - group_id, qqid, "新增玩家领地", "请输入领地主人玩家名") - if owner is None: - return - name = self._qq_land_prompt_text(group_id, qqid, "新增玩家领地", "请输入领地名称") - if name is None: - return - center_text = self._qq_land_prompt_text( - group_id, qqid, "新增玩家领地", "请输入领地中心坐标,格式:x y z") - if center_text is None: - return - center = self._parse_land_pos(center_text) - if center is None: - self._reply_to_qq(group_id, qqid, "❀ 坐标格式有误") - return - shape_choice = self._qq_land_prompt( - group_id, - qqid, - "新增玩家领地", - ["圆形领地", "方形领地"], - ["输入 [1-2] 之间的数字以选择 领地类型", "输入 . 退出"], - ) - if shape_choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(shape_choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if shape_choice == "1": - radius_text = self._qq_land_prompt_text( - group_id, qqid, "新增圆形领地", "请输入领地半径") - if radius_text is None: - return - try: - radius = int(radius_text) - except ValueError: - self._reply_to_qq(group_id, qqid, "❀ 半径必须为整数") - return - ok, msg, _land = self.land_system.api_add_land( - owner, name, center, "圆形", radius=radius) - elif shape_choice == "2": - size_text = self._qq_land_prompt_text( - group_id, qqid, "新增方形领地", "请输入 长 高 宽") - if size_text is None: - return - size = self._parse_land_size(size_text) - if size is None: - self._reply_to_qq(group_id, qqid, "❀ 方形尺寸格式有误") - return - ok, msg, _land = self.land_system.api_add_land( - owner, name, center, "方形", size=size) - else: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - self._reply_result(group_id, qqid, ok, msg) - - def qq_land_delete(self, group_id: int, qqid: int): - """Handle the qq land delete QQ menu operation.""" - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - query = self._qq_land_prompt_text(group_id, qqid, "删除玩家领地", "请输入领地名称") - if query is None: - return - ok, msg, _data = self.land_system.api_delete_land(query) - self._reply_result(group_id, qqid, ok, msg) - - def qq_land_member_action( - self, - group_id: int, - qqid: int, - is_add: bool, - rank: str): - """Handle the qq land member action QQ menu operation.""" - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - title = "添加" if is_add else "移除" - role = "管理员" if rank == "admin" else "成员" - query = self._qq_land_prompt_text( - group_id, qqid, f"{title}领地{role}", "请输入领地名称") - if query is None: - return - player_name = self._qq_land_prompt_text( - group_id, qqid, f"{title}领地{role}", "请输入玩家名称") - if player_name is None: - return - if is_add: - ok, msg, _land = self.land_system.api_add_member( - query, player_name, rank=rank) - elif rank == "admin": - ok, msg, _land = self.land_system.api_set_member_rank( - query, player_name, "member") - else: - ok, msg, _land = self.land_system.api_remove_member( - query, player_name) - self._reply_result(group_id, qqid, ok, msg) - - def qq_land_transfer_owner(self, group_id: int, qqid: int): - """Handle the qq land transfer owner QQ menu operation.""" - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - query = self._qq_land_prompt_text(group_id, qqid, "修改领地所有者", "请输入领地名称") - if query is None: - return - owner = self._qq_land_prompt_text( - group_id, qqid, "修改领地所有者", "请输入新所有者玩家名") - if owner is None: - return - ok, msg, _land = self.land_system.api_transfer_owner(query, owner) - self._reply_result(group_id, qqid, ok, msg) - - def qq_land_update_center(self, group_id: int, qqid: int): - """Handle the qq land update center QQ menu operation.""" - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - query = self._qq_land_prompt_text(group_id, qqid, "修改领地中心点", "请输入领地名称") - if query is None: - return - center_text = self._qq_land_prompt_text( - group_id, qqid, "修改领地中心点", "请输入新中心坐标,格式:x y z") - if center_text is None: - return - center = self._parse_land_pos(center_text) - if center is None: - self._reply_to_qq(group_id, qqid, "❀ 坐标格式有误") - return - ok, msg, _land = self.land_system.api_update_land_center(query, center) - self._reply_result(group_id, qqid, ok, msg) - - def qq_land_update_range(self, group_id: int, qqid: int): - """Handle the qq land update range QQ menu operation.""" - if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): - return - query = self._qq_land_prompt_text(group_id, qqid, "修改领地范围", "请输入领地名称") - if query is None: - return - ok, msg, land = self.land_system.api_get_land(query) - if not ok: - self._reply_result(group_id, qqid, False, msg) - return - if land.get("shape") == "方形": - size_text = self._qq_land_prompt_text( - group_id, qqid, "修改方形领地范围", "请输入新的 长 高 宽") - if size_text is None: - return - size = self._parse_land_size(size_text) - if size is None: - self._reply_to_qq(group_id, qqid, "❀ 方形尺寸格式有误") - return - ok, msg, _land = self.land_system.api_update_land_range( - query, size=size) - else: - radius_text = self._qq_land_prompt_text( - group_id, qqid, "修改圆形领地范围", "请输入新的半径") - if radius_text is None: - return - try: - radius = int(radius_text) - except ValueError: - self._reply_to_qq(group_id, qqid, "❀ 半径必须为整数") - return - ok, msg, _land = self.land_system.api_update_land_range( - query, radius=radius) - self._reply_result(group_id, qqid, ok, msg) - - @staticmethod - def translate_item_name(item_id: str): - """尽量把物品 ID 翻译成中文显示名,失败时退回原始 ID。""" - if not isinstance(item_id, str) or item_id == "": - return "未知物品" - item_tail = item_id.split(":")[-1] - if translate is None: - return item_id - for key in (f"item.{item_tail}.name", f"tile.{item_tail}.name"): - try: - translated = translate(key) - except Exception: - continue - if isinstance( - translated, - str) and translated and translated != key: - return translated - return item_id - - @staticmethod - def get_item_custom_name(slot: Any): - """尝试从物品槽位对象里提取自定义名称。""" - for attr in ("customName", "custom_name", "name"): - value = getattr(slot, attr, None) - if ( - isinstance(value, str) - and value.strip() - and value.strip() != getattr(slot, "id", "") - ): - return value.strip() - return "" - - @staticmethod - def get_item_enchantments_text(slot: Any): - """把槽位上的附魔信息整理成单行文字。""" - enchants = getattr(slot, "enchantments", None) - if not isinstance(enchants, list) or not enchants: - return "" - outputs: list[str] = [] - for enchant in enchants: - if enchant is None: - continue - name = getattr(enchant, "name", None) - level = getattr(enchant, "level", None) - if isinstance(name, str) and name.strip(): - if isinstance(level, int): - outputs.append(f"{name.strip()} {level}") - else: - outputs.append(name.strip()) - else: - etype = getattr(enchant, "type", None) - if etype is not None: - if isinstance(level, int): - outputs.append(f"ID{etype} {level}") - else: - outputs.append(f"ID{etype}") - return "、".join(outputs) - - def on_qq_add_admin(self, group_id: int, sender: int, args: list[str]): - """给当前群添加普通管理员。""" - if not self._can_use_group_permission(group_id, sender, "QQ普通管理员菜单权限"): - self._reply_menu_permission_denied(group_id, sender) - return - try: - qqid = int(args[0]) - except (TypeError, ValueError): - self._reply_to_qq(group_id, sender, "QQ号格式有误") - return - _ok, msg = self.add_group_role(group_id, qqid, is_super=False) - self._reply_to_qq(group_id, sender, msg) - - def qq_admin_menu(self, group_id: int, qqid: int): - """QQ群管理员菜单。 - - 可管理的层级由“权限设置”中的管理员菜单权限决定。 - """ - options = [] - actions = [] - if self._can_use_group_permission(group_id, qqid, "QQ普通管理员菜单权限"): - options.append("普通管理员管理") - actions.append(lambda: self.qq_admin_role_menu( - group_id, qqid, is_super=False)) - if self._can_use_group_permission(group_id, qqid, "QQ超级管理员菜单权限"): - options.append("超级管理员管理") - actions.append(lambda: self.qq_admin_role_menu( - group_id, qqid, is_super=True)) - if not options: - self._reply_menu_permission_denied(group_id, qqid) - return - - if len(options) == 1: - actions[0]() - return - - choice = self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - "QQ群管理员 管理菜单", - options, - [f"输入 [1-{len(options)}] 之间的数字以选择 对应功能", "输入 . 退出"], - group_id, - ), - timeout=120, - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - choice = choice.strip() - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - selected = self.parse_displayed_menu_choice(choice, len(options)) - if selected is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - actions[selected - 1]() - - def qq_admin_role_menu(self, group_id: int, qqid: int, is_super: bool): - """增删指定层级的群管理员。""" - permission_name = "QQ超级管理员菜单权限" if is_super else "QQ普通管理员菜单权限" - if not self._can_use_group_permission(group_id, qqid, permission_name): - self._reply_menu_permission_denied(group_id, qqid) - return - role_name = "超级管理员" if is_super else "普通管理员" - choice = self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - f"{role_name} 管理菜单", - [f"添加{role_name}", f"删除{role_name}"], - ["输入 [1-2] 之间的数字以选择 对应功能", "输入 . 退出"], - group_id, - ), - timeout=120, - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if choice not in ("1", "2"): - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - qq_text = self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "群服互通云链版Ultra版", - f"{role_name} 管理菜单", - [], - [f"请输入要{'添加' if choice == '1' else '删除'}的 QQ 号", "输入 . 退出"], - group_id, - ), - timeout=120, - ) - if qq_text is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(qq_text, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - qqid_target = utils.try_int(qq_text) - if qqid_target is None or qqid_target <= 0: - self._reply_to_qq(group_id, qqid, "❀ QQ号格式有误") - return - if choice == "1": - ok, msg = self.add_group_role( - group_id, qqid_target, is_super=is_super) - else: - ok, msg = self.remove_group_role( - group_id, qqid_target, is_super=is_super) - self._reply_result(group_id, qqid, ok, msg) - - def ensure_whitelist_checker(self, group_id: int, sender: int): - """检查白名单联动插件是否可用,避免菜单点进去后才报空引用。""" - if self.whitelist_checker is None: - self._reply_to_qq(group_id, sender, "相关插件未安装:白名单&管理员检测云链联动版") - return False - return self.ensure_linked_plugin_enabled( - self.whitelist_checker, group_id, sender) - - def on_qq_whitelist_add(self, group_id: int, sender: int, args: list[str]): - """通过群命令把玩家加入白名单。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - ok, msg = self.whitelist_checker.add_whitelist_player(args[0]) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_whitelist_remove( - self, - group_id: int, - sender: int, - args: list[str]): - """通过群命令把玩家移出白名单。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - ok, msg = self.whitelist_checker.remove_whitelist_player(args[0]) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_server_admin_add( - self, - group_id: int, - sender: int, - args: list[str]): - """通过群命令把玩家登记为服务器管理员。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - ok, msg = self.whitelist_checker.add_admin_player(args[0]) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_server_admin_remove( - self, - group_id: int, - sender: int, - args: list[str]): - """通过群命令把玩家从服务器管理员名单中移除。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - ok, msg = self.whitelist_checker.remove_admin_player(args[0]) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_whitelist_toggle( - self, - group_id: int, - sender: int, - args: list[str]): - """通过群命令切换白名单检测开关。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - action = args[0].strip() - if action not in ("开启", "关闭", "on", "off"): - self._reply_to_qq(group_id, sender, "参数错误,格式:白名单检测 [开启/关闭]") - return - ok, msg = self.whitelist_checker.set_whitelist_enabled( - action in ("开启", "on")) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_admin_check_toggle( - self, - group_id: int, - sender: int, - args: list[str]): - """通过群命令切换管理员检测开关。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - action = args[0].strip() - if action not in ("开启", "关闭", "on", "off"): - self._reply_to_qq(group_id, sender, "参数错误,格式:管理员检测 [开启/关闭]") - return - ok, msg = self.whitelist_checker.set_admin_check_enabled( - action in ("开启", "on")) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_check_interval( - self, - group_id: int, - sender: int, - args: list[str]): - """通过群命令修改联动插件的轮询周期。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - try: - seconds = float(args[0]) - except (TypeError, ValueError): - self._reply_to_qq(group_id, sender, "参数错误,格式:检测周期 [秒数]") - return - ok, msg = self.whitelist_checker.set_check_interval(seconds) - self._reply_result(group_id, sender, ok, msg) - - def on_qq_check_status(self, group_id: int, sender: int, _args: list[str]): - """把联动插件当前状态摘要发回群里。""" - if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if not self.ensure_whitelist_checker(group_id, sender): - return - status = self.whitelist_checker.get_runtime_status() - output = ( - f"[CQ:at,qq={sender}] 白名单&管理员检测云链联动版状态:\n" - f"检测周期:{status['check_interval']} 秒\n" - f"白名单检测:{'开启' if status['whitelist_enabled'] else '关闭'}\n" - f"白名单人数:{status['whitelist_count']}\n" - f"管理员检测:{'开启' if status['admin_check_enabled'] else '关闭'}\n" - f"管理员人数:{status['admin_count']}" - ) - self.sendmsg(group_id, output, do_remove_cq_code=False) - - def _qq_checker_prompt( - self, - group_id: int, - qqid: int, - subtitle: str, - options: list[str], - hints: list[str], - ): - """统一构造白名单联动菜单提示并等待回复。""" - return self.qq_prompt( - group_id, - qqid, - self.plugin_ui_menu( - "白名单&管理员检测云链联动版", - subtitle, - options, - hints, - group_id, - ), - timeout=120, - ) - - def _qq_checker_handle_player_action( - self, group_id: int, qqid: int, choice: str): - """处理添加/移除白名单与服务器管理员这四类玩家操作。""" - title_map = { - "1": "白名单 添加玩家", - "2": "白名单 移除玩家", - "3": "管理员 添加玩家", - "4": "管理员 移除玩家", - } - handler_map = { - "1": self.on_qq_whitelist_add, - "2": self.on_qq_whitelist_remove, - "3": self.on_qq_server_admin_add, - "4": self.on_qq_server_admin_remove, - } - player_name = self._qq_checker_prompt( - group_id, - qqid, - title_map[choice], - [], - ["请输入玩家名称", "输入 . 退出"], - ) - if player_name is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(player_name, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - handler_map[choice](group_id, qqid, [player_name]) - - def _qq_checker_handle_toggle_action( - self, group_id: int, qqid: int, choice: str): - """处理白名单检测和管理员检测的开关菜单。""" - subtitle = "白名单检测 设置" if choice == "5" else "管理员检测 设置" - handler = ( - self.on_qq_whitelist_toggle - if choice == "5" - else self.on_qq_admin_check_toggle - ) - action = self._qq_checker_prompt( - group_id, - qqid, - subtitle, - ["开启", "关闭"], - ["输入 [1-2] 之间的数字以选择 对应操作", "输入 . 退出"], - ) - if action is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(action, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - action_arg = {"1": ["开启"], "2": ["关闭"]}.get(action) - if action_arg is None: - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return - handler(group_id, qqid, action_arg) - - def _qq_checker_handle_interval_action(self, group_id: int, qqid: int): - """处理检测周期设置菜单。""" - seconds = self._qq_checker_prompt( - group_id, - qqid, - "检测周期 设置", - [], - ["请输入检测周期秒数", "输入 . 退出"], - ) - if seconds is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(seconds, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - self.on_qq_check_interval(group_id, qqid, [seconds]) - - def qq_checker_menu(self, group_id: int, qqid: int): - """在群里打开白名单与管理员检测联动菜单。""" - if not self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - if not self.ensure_whitelist_checker(group_id, qqid): - return - # 这个菜单本质上是把白名单插件暴露出来的 API 做了一层群聊版操作面板。 - # 这样权限仍然统一归群服互通管理,而不是把原插件的控制台能力原样暴露出来。 - choice = self._qq_checker_prompt( - group_id, - qqid, - "管理系统", - [ - "添加玩家到白名单", - "从白名单中移除玩家", - "添加服务器管理员", - "移除服务器管理员", - "开启/关闭 白名单检测", - "开启/关闭 管理员检测", - "设置检测周期", - "查看当前状态", - ], - ["输入 [1-8] 之间的数字以选择 对应功能", "输入 . 退出"], - ) - if choice is None: - self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self._is_menu_exit(choice, group_id): - self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - - if choice in ("1", "2", "3", "4"): - self._qq_checker_handle_player_action(group_id, qqid, choice) - return - - if choice in ("5", "6"): - self._qq_checker_handle_toggle_action(group_id, qqid, choice) - return - - if choice == "7": - self._qq_checker_handle_interval_action(group_id, qqid) - return - - if choice == "8": - self.on_qq_check_status(group_id, qqid, []) - return - - self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") +"""QQ group menu and command handlers for Ultra.""" + +import time +from typing import Any + +from tooldelta import utils + +try: + from tooldelta.utils.mc_translator import translate +except ImportError: + translate = None + + +# 日常群聊交互放在这一层:帮助、背包查询、管理员菜单、联动检查菜单。 +class QQLinkerQQMixin: + """负责群聊菜单、查询类功能以及白名单联动命令。""" + + @utils.thread_func("群服执行指令并获取返回") + def on_qq_execute_cmd(self, group_id: int, qqid: int, cmd: list[str]): + """在群里执行 Minecraft 指令,并把执行结果回发到群里。""" + if not self._can_use_group_permission(group_id, qqid, "发送指令权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + res = self.execute_cmd_and_get_zhcn_cb(" ".join(cmd)) + self.sendmsg(group_id, res) + + def _reply_to_qq(self, group_id: int, qqid: int, text: str): + """向指定群成员回复一条文本消息。""" + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False, + ) + + def _reply_result(self, group_id: int, qqid: int, ok: bool, msg: str): + """统一回发成功/失败结果。""" + self._reply_to_qq(group_id, qqid, f"{'😄' if ok else '😭'} {msg}") + + def _can_use_group_permission( + self, + group_id: int, + qqid: int, + permission_name: str, + ) -> bool: + """Implement the can use group permission operation.""" + if hasattr(self, "_has_group_permission"): + return self._has_group_permission(group_id, qqid, permission_name) + return self.has_group_permission(group_id, qqid, permission_name) + + def _can_use_any_group_permission( + self, + group_id: int, + qqid: int, + permission_names: tuple[str, ...], + ) -> bool: + """Implement the can use any group permission operation.""" + return any( + self._can_use_group_permission(group_id, qqid, permission_name) + for permission_name in permission_names + ) + + def _reply_menu_permission_denied(self, group_id: int, qqid: int): + """Implement the reply menu permission denied operation.""" + if hasattr(self, "_reply_permission_denied"): + self._reply_permission_denied(group_id, qqid) + return + self._reply_to_qq(group_id, qqid, "你没有权限执行此指令") + + def _ensure_group_permission( + self, + group_id: int, + qqid: int, + permission_name: str, + ) -> bool: + """Implement the ensure group permission operation.""" + if self._can_use_group_permission(group_id, qqid, permission_name): + return True + self._reply_menu_permission_denied(group_id, qqid) + return False + + def _append_permission_action( + self, + group_id: int, + qqid: int, + options: list[str], + actions: list[Any], + permission_name: str, + label: str, + action, + ): + """Implement the append permission action operation.""" + if self._can_use_group_permission(group_id, qqid, permission_name): + options.append(label) + actions.append(action) + + def _has_help_admin_actions(self, group_id: int, qqid: int) -> bool: + """Implement the has help admin actions operation.""" + permission_names = [ + "QQ普通管理员菜单权限", + "QQ超级管理员菜单权限", + "发送指令权限", + "配置配置文件权限", + "查询背包权限", + "封禁/解封玩家权限", + "白名单&管理员检测权限", + "领地系统权限", + "公会系统权限", + ] + if self.task_system is not None: + permission_names.append("任务系统权限") + return self._can_use_any_group_permission( + group_id, + qqid, + tuple(permission_names), + ) + + @staticmethod + def _extract_plugin_enabled_flag(plugin: Any): # skipcq: PY-R1000 + """读取联动插件明确暴露的整体启用状态;缺少该项时返回 None。""" + for method_name in ("_plugin_enabled", "is_plugin_enabled"): + method = getattr(plugin, method_name, None) + if callable(method): + try: + return bool(method()) + except Exception: + return None + + enabled = getattr(plugin, "enabled", None) + if isinstance(enabled, bool): + return enabled + + for method_name in ("api_get_runtime_status", "get_runtime_status"): + method = getattr(plugin, method_name, None) + if not callable(method): + continue + try: + status = method() + except Exception: + continue + if isinstance(status, tuple) and len(status) >= 3: + status = status[2] + if isinstance(status, dict): + for key in ("enabled", "是否启用", "是否启用插件"): + if isinstance(status.get(key), bool): + return status[key] + + for attr_name in ("config", "cfg", "_cfg"): + config = getattr(plugin, attr_name, None) + if not isinstance(config, dict): + continue + for key in ("是否启用", "是否启用插件"): + if isinstance(config.get(key), bool): + return config[key] + base_config = config.get("基础配置") + if isinstance(base_config, dict): + for key in ("是否启用", "是否启用插件"): + if isinstance(base_config.get(key), bool): + return base_config[key] + + return None + + def ensure_linked_plugin_enabled( + self, + plugin: Any, + group_id: int, + sender: int) -> bool: + """联动插件明确配置为禁用时,不进入群内管理菜单。""" + enabled = self._extract_plugin_enabled_flag(plugin) + if not enabled and enabled is not None: + self._reply_to_qq(group_id, sender, "相关插件未启用") + return False + return True + + def on_qq_help(self, group_id: int, sender: int, _): + """打开可交互的群帮助主菜单。""" + self.qq_help_main_menu(group_id, sender) + + def _is_menu_exit(self, user_input: str, group_id: int | None = None): + """Implement the is menu exit operation.""" + return self.is_menu_exit_input(user_input, group_id) + + def _is_menu_back(self, user_input: str, group_id: int | None = None): + """Implement the is menu back operation.""" + return self.is_menu_back_input(user_input, group_id) + + def _prompt_help_menu( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + hints: list[str], + allow_back: bool = False, + ): + """Implement the prompt help menu operation.""" + choice = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + subtitle, + options, + hints, + group_id), + timeout=120, + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + choice = choice.strip() + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if allow_back and self._is_menu_back(choice, group_id): + return "back" + return choice + + def _parse_help_choice( + self, + group_id: int, + qqid: int, + choice: str, + count: int): + """Implement the parse help choice operation.""" + selected = self.parse_displayed_menu_choice(choice, count) + if selected is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return selected + + def _prompt_paginated_help_actions( # skipcq: PY-R1000 + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + actions: list[Any], + per_page: int, + ): + """Implement the prompt paginated help actions operation.""" + if len(options) != len(actions): + self._reply_to_qq(group_id, qqid, "❀ 菜单配置错误") + return None + if not options: + self._reply_to_qq(group_id, qqid, "当前没有可用功能") + return None + page = 1 + while True: + total_pages, start_index, end_index = ( + utils.paginate(len(options), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(options), per_page, page) + ) + page_options = options[start_index - 1: end_index] + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + subtitle, + page_options, + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(page_options)}] 之间的数字以选择 对应功能", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 0 返回上级菜单", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + user_input = user_input.strip() + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if self._is_menu_back(user_input, group_id): + return "back" + if user_input == "+": + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") + continue + selected = self._parse_help_choice( + group_id, + qqid, + user_input, + len(page_options), + ) + if selected is None: + continue + result = actions[start_index + selected - 2]() + return result if result == "back" else None + + def qq_help_main_menu(self, group_id: int, qqid: int): + """群帮助主菜单,负责路由到各个二级菜单。""" + options = ["非管理功能"] + handlers = [self.qq_help_basic_menu] + if self._has_help_admin_actions(group_id, qqid): + options.append("管理功能") + handlers.append(self.qq_help_admin_menu) + options.append("命令说明") + handlers.append(self.qq_help_show_all_reference) + while True: + choice = self._prompt_help_menu( + group_id, + qqid, + "帮助主菜单", + options, + [f"输入 [1-{len(options)}] 之间的数字以选择 对应菜单", "输入 . 退出"], + ) + if choice is None: + return + selected = self._parse_help_choice( + group_id, qqid, choice, len(options)) + if selected is None: + continue + if handlers[selected - 1](group_id, qqid) == "back": + continue + return + + def qq_help_basic_menu(self, group_id: int, qqid: int): + """非管理功能二级菜单。""" + options = [] + actions = [] + if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"] and ( + self._can_use_group_permission(group_id, qqid, "查看玩家人数权限") + ): + options.append("查看在线玩家列表") + actions.append(lambda: self.on_qq_player_list(group_id, qqid, [])) + options.append("公会系统菜单") + actions.append(lambda: self.qq_guild_player_menu(group_id, qqid)) + options.append("查看非管理功能触发词") + actions.append( + lambda: self.qq_help_show_basic_reference( + group_id, qqid)) + return self._prompt_paginated_help_actions( + group_id, + qqid, + "非管理功能", + options, + actions, + self.get_group_help_non_admin_items_per_page(group_id), + ) + + def qq_help_admin_menu(self, group_id: int, qqid: int): + """管理功能二级菜单。""" + options = [] + actions = [] + if self._can_use_any_group_permission( + group_id, + qqid, + ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), + ): + options.append("QQ群管理员管理菜单") + actions.append(lambda: self.qq_admin_menu(group_id, qqid)) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "发送指令权限", + "发送 Minecraft 指令", + lambda: self.qq_help_execute_command_prompt(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "配置配置文件权限", + "配置中心", + lambda: self.qq_config_center_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "查询背包权限", + "查询在线玩家背包", + lambda: self.qq_inventory_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "封禁/解封玩家权限", + "Orion QQ 封禁菜单", + lambda: self.qq_orion_ban_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "封禁/解封玩家权限", + "Orion QQ 解封菜单", + lambda: self.qq_orion_unban_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "白名单&管理员检测权限", + "白名单&管理员检测管理菜单", + lambda: self.qq_checker_menu(group_id, qqid), + ) + if self.task_system is not None: + self._append_permission_action( + group_id, + qqid, + options, + actions, + "任务系统权限", + "任务系统管理菜单", + lambda: self.qq_task_system_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "领地系统权限", + "领地系统云链联动版管理菜单", + lambda: self.qq_land_system_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "公会系统权限", + "公会系统管理菜单", + lambda: self.qq_guild_system_menu(group_id, qqid), + ) + options.append("查看管理功能触发词") + actions.append( + lambda: self.qq_help_show_admin_reference( + group_id, qqid)) + return self._prompt_paginated_help_actions( + group_id, + qqid, + "管理功能", + options, + actions, + self.get_group_help_admin_items_per_page(group_id), + ) + + def qq_help_integration_menu(self, group_id: int, qqid: int): + """联动系统二级菜单。""" + while True: + options = [] + actions = [] + self._append_permission_action( + group_id, + qqid, + options, + actions, + "封禁/解封玩家权限", + "Orion QQ 封禁菜单", + lambda: self.qq_orion_ban_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "封禁/解封玩家权限", + "Orion QQ 解封菜单", + lambda: self.qq_orion_unban_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "白名单&管理员检测权限", + "白名单&管理员检测管理菜单", + lambda: self.qq_checker_menu(group_id, qqid), + ) + if self.task_system is not None: + self._append_permission_action( + group_id, + qqid, + options, + actions, + "任务系统权限", + "任务系统管理菜单", + lambda: self.qq_task_system_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "领地系统权限", + "领地系统云链联动版管理菜单", + lambda: self.qq_land_system_menu(group_id, qqid), + ) + self._append_permission_action( + group_id, + qqid, + options, + actions, + "公会系统权限", + "公会系统管理菜单", + lambda: self.qq_guild_system_menu(group_id, qqid), + ) + if not options: + self._reply_to_qq(group_id, qqid, "当前没有可用功能") + return None + choice = self._prompt_help_menu( + group_id, + qqid, + "联动系统", + options, + [ + f"输入 [1-{len(options)}] 之间的数字以选择 对应功能", + "输入 0 返回上级菜单", + "输入 . 退出", + ], + allow_back=True, + ) + if choice is None: + return None + if choice == "back": + return "back" + selected = self._parse_help_choice( + group_id, qqid, choice, len(options)) + if selected is None: + continue + actions[selected - 1]() + return None + + def qq_help_show_all_reference(self, group_id: int, qqid: int): + """直接分页展示本群当前可用的全部命令触发词。""" + lines = self.qq_help_build_all_reference_lines(group_id, qqid) + if not lines: + self._reply_to_qq(group_id, qqid, "当前没有可展示的命令触发词") + return None + page = 1 + per_page = self.get_group_command_help_items_per_page(group_id) + while True: + total_pages, start_index, end_index = ( + utils.paginate(len(lines), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(lines), per_page, page) + ) + page_lines = lines[start_index - 1: end_index] + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + "命令说明", + page_lines, + [ + f"当前第 {page}/{total_pages} 页", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 0 返回上级菜单", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if self._is_menu_back(user_input, group_id): + return "back" + if user_input == "+": + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") + continue + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + + def qq_help_build_all_reference_lines( # skipcq: PY-R1000 + self, + group_id: int, + qqid: int, + ): + """按运行时触发分发入口整理全部命令触发词说明。""" + cmd_prefix = self.get_group_cmd_prefix(group_id) + lines = [ + f"{' / '.join(self.get_group_help_triggers(group_id))} - 打开帮助菜单", + ] + if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"] and ( + self._can_use_group_permission(group_id, qqid, "查看玩家人数权限") + ): + lines.append( + f"{' / '.join(self.get_group_player_list_triggers(group_id))} - 查看玩家列表" + ) + lines.append( + ( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " + "公会系统菜单(普通成员需绑定游戏账号;管理员进入管理菜单)" + ) + ) + if self._can_use_group_permission(group_id, qqid, "查询背包权限"): + lines.append( + ( + f"{' / '.join(self.get_group_inventory_menu_triggers(group_id))} - " + "查询在线玩家背包" + ) + ) + if self._can_use_group_permission(group_id, qqid, "发送指令权限"): + lines.append(f"{cmd_prefix}[指令] - 向租赁服发送指令") + if self._can_use_any_group_permission( + group_id, + qqid, + ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), + ): + lines.append( + ( + f"{' / '.join(self.get_group_admin_menu_triggers(group_id))} - " + "QQ群管理员管理菜单" + ) + ) + if self._can_use_group_permission(group_id, qqid, "配置配置文件权限"): + lines.append( + f"{' / '.join(self.get_group_config_menu_triggers(group_id))} - 配置中心" + ) + if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + lines.extend( + [ + ( + f"{' / '.join(self.get_group_orion_ban_triggers(group_id))} " + "[玩家名/xuid] [封禁时间] [原因可选] - Orion QQ 封禁" + ), + ( + f"{' / '.join(self.get_group_orion_unban_triggers(group_id))} " + "[玩家名/xuid] - Orion QQ 解封" + ), + ] + ) + if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): + lines.append( + ( + f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - " + "白名单&管理员检测管理菜单" + ) + ) + if self.task_system is not None and self._can_use_group_permission( + group_id, + qqid, + "任务系统权限", + ): + lines.append( + f"{' / '.join(self.get_group_task_menu_triggers(group_id))} - 任务系统管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "领地系统权限"): + lines.append( + ( + f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - " + "领地系统云链联动版管理菜单" + ) + ) + if self._can_use_group_permission(group_id, qqid, "公会系统权限"): + lines.append( + ( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " + "公会系统管理菜单(管理员)" + ) + ) + for trigger in self.triggers: + if trigger.op_only and not self.is_group_admin(group_id, qqid): + continue + trigger_text = " / ".join(trigger.triggers) + argument_hint = f" {trigger.argument_hint}" if trigger.argument_hint else "" + permission_hint = "(管理员)" if trigger.op_only else "" + lines.append( + f"外部|{trigger_text}{argument_hint} - {trigger.usage}{permission_hint}" + ) + return lines + + def qq_help_reference_menu(self, group_id: int, qqid: int): + """命令说明二级菜单。""" + while True: + options = ["非管理功能触发词"] + actions = [ + lambda: self.qq_help_show_basic_reference( + group_id, qqid)] + if self._has_help_admin_actions(group_id, qqid): + options.append("管理功能触发词") + actions.append( + lambda: self.qq_help_show_admin_reference(group_id, qqid)) + choice = self._prompt_help_menu( + group_id, + qqid, + "命令说明", + options, + [ + f"输入 [1-{len(options)}] 之间的数字以查看 对应说明", + "输入 0 返回上级菜单", + "输入 . 退出", + ], + allow_back=True, + ) + if choice is None: + return None + if choice == "back": + return "back" + selected = self._parse_help_choice( + group_id, qqid, choice, len(options)) + if selected is None: + continue + actions[selected - 1]() + return None + + def qq_help_execute_command_prompt(self, group_id: int, qqid: int): + """通过帮助菜单交互式发送一条 Minecraft 指令。""" + if not self._can_use_group_permission(group_id, qqid, "发送指令权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + cmd_prefix = self.get_group_cmd_prefix(group_id) + command = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "发送 Minecraft 指令", + ["在下一条消息中输入要发送到租赁服的指令"], + [f"可以省略或保留前缀 {cmd_prefix}", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if command is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + command = command.strip() + if self._is_menu_exit(command, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if command.startswith(cmd_prefix): + command = command.removeprefix(cmd_prefix).strip() + if not command: + self._reply_to_qq(group_id, qqid, f"参数错误,格式:{cmd_prefix}[指令]") + return + self.on_qq_execute_cmd(group_id, qqid, command.split()) + + def qq_help_show_basic_reference(self, group_id: int, qqid: int): + """Handle the qq help show basic reference QQ menu operation.""" + options = [ + f"{' / '.join(self.get_group_help_triggers(group_id))} - 打开帮助菜单", + ] + if self.group_cfgs[group_id]["指令设置"]["是否允许查看玩家列表"] and ( + self._can_use_group_permission(group_id, qqid, "查看玩家人数权限") + ): + options.append( + f"{' / '.join(self.get_group_player_list_triggers(group_id))} - 查看玩家列表" + ) + options.append( + ( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " + "公会系统菜单(需绑定游戏账号)" + ) + ) + self._reply_to_qq( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "非管理功能触发词", + options, + ["输入帮助菜单唤醒词可重新打开菜单"], + group_id, + ), + ) + + def qq_help_show_admin_reference(self, group_id: int, qqid: int): + """Handle the qq help show admin reference QQ menu operation.""" + cmd_prefix = self.get_group_cmd_prefix(group_id) + options = [] + if self._can_use_group_permission(group_id, qqid, "发送指令权限"): + options.append(f"{cmd_prefix}[指令] - 向租赁服发送指令") + if self._can_use_any_group_permission( + group_id, + qqid, + ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), + ): + options.append( + ( + f"{' / '.join(self.get_group_admin_menu_triggers(group_id))} - " + "QQ群管理员管理菜单" + ) + ) + if self._can_use_group_permission(group_id, qqid, "配置配置文件权限"): + options.append( + f"{' / '.join(self.get_group_config_menu_triggers(group_id))} - 配置中心" + ) + if self._can_use_group_permission(group_id, qqid, "查询背包权限"): + options.append( + ( + f"{' / '.join(self.get_group_inventory_menu_triggers(group_id))} - " + "查询在线玩家背包" + ) + ) + if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + orion_ban_triggers = " / ".join( + self.get_group_orion_ban_triggers(group_id) + ) + orion_unban_triggers = " / ".join( + self.get_group_orion_unban_triggers(group_id) + ) + options.extend( + [ + f"{orion_ban_triggers} - Orion QQ 封禁菜单", + f"{orion_unban_triggers} - Orion QQ 解封菜单", + ] + ) + if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): + options.append( + ( + f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - " + "白名单&管理员检测管理菜单" + ) + ) + if self.task_system is not None and self._can_use_group_permission( + group_id, + qqid, + "任务系统权限", + ): + options.append( + f"{' / '.join(self.get_group_task_menu_triggers(group_id))} - 任务系统管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "领地系统权限"): + options.append( + ( + f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - " + "领地系统云链联动版管理菜单" + ) + ) + if self._can_use_group_permission(group_id, qqid, "公会系统权限"): + options.append( + ( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " + "公会系统管理菜单(管理员)" + ) + ) + if not options: + self._reply_to_qq(group_id, qqid, "当前没有可展示的管理功能触发词") + return + self._reply_to_qq( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "管理功能触发词", + options, + ["这些功能需要本群所有者、超级管理员或普通管理员权限"], + group_id, + ), + ) + + def qq_help_show_integration_reference(self, group_id: int, qqid: int): + """Handle the qq help show integration reference QQ menu operation.""" + options = [] + if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + orion_ban_triggers = " / ".join( + self.get_group_orion_ban_triggers(group_id) + ) + orion_unban_triggers = " / ".join( + self.get_group_orion_unban_triggers(group_id) + ) + options.extend( + [ + f"{orion_ban_triggers} - Orion QQ 封禁菜单", + f"{orion_unban_triggers} - Orion QQ 解封菜单", + ] + ) + if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): + options.append( + ( + f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - " + "白名单&管理员检测管理菜单" + ) + ) + if self.task_system is not None and self._can_use_group_permission( + group_id, + qqid, + "任务系统权限", + ): + options.append( + f"{' / '.join(self.get_group_task_menu_triggers(group_id))} - 任务系统管理菜单" + ) + if self._can_use_group_permission(group_id, qqid, "领地系统权限"): + options.append( + ( + f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - " + "领地系统云链联动版管理菜单" + ) + ) + if self._can_use_group_permission(group_id, qqid, "公会系统权限"): + options.append( + ( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " + "公会系统菜单(普通成员需绑定;管理员进入管理菜单)" + ) + ) + if not options: + self._reply_to_qq(group_id, qqid, "当前没有可展示的联动系统触发词") + return + self._reply_to_qq( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "联动系统触发词", + options, + ["公会系统普通菜单需要 QQ 绑定游戏账号,管理菜单需要本群管理员权限"], + group_id, + ), + ) + + def on_qq_player_list(self, group_id: int, _sender: int, _): + """把在线玩家列表和可用 TPS 信息发到群里。""" + if not self._can_use_group_permission(group_id, _sender, "查看玩家人数权限"): + self._reply_menu_permission_denied(group_id, _sender) + return + group_cfg = self.group_cfgs[group_id] + if not group_cfg["指令设置"]["是否允许查看玩家列表"]: + self.sendmsg(group_id, "当前群未启用玩家列表查询") + return + players = [f"{i + 1}.{j}" for i, + j in enumerate(self.game_ctrl.allplayers)] + fmt_msg = ( + f"在线玩家有 {len(players)} 人:\n " + + "\n ".join(players) + + ( + f"\n当前 TPS: {round(self.tps_calc.get_tps(), 1)}/20" + if self.tps_calc + else "" + ) + ) + self.sendmsg(group_id, fmt_msg) + + def qq_inventory_menu(self, group_id: int, qqid: int): + """分页展示在线玩家,并允许在群里进一步查询某个人的背包。""" + if not self._can_use_group_permission(group_id, qqid, "查询背包权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + online_names = list(self.game_ctrl.allplayers) + if not online_names: + self._reply_to_qq(group_id, qqid, "当前没有在线玩家") + return + page = 1 + per_page = self.get_group_inventory_items_per_page(group_id) + while True: + # 这里保留一个循环式菜单,避免每次翻页都重新走一遍入口指令。 + total_pages, start_index, end_index = ( + utils.paginate(len(online_names), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(online_names), per_page, page) + ) + page_names = online_names[start_index - 1: end_index] + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + "查询背包", + page_names, + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(page_names)}] 之间的数字以选择 对应玩家", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 . 退出", + ], + group_id, + ) + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False) + user_input = self.waitMsg(qqid, timeout=120, group_id=group_id) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + user_input = user_input.strip() + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if user_input == "+": + # 菜单保持在同一条交互链里,翻页只改当前页码。 + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq( + group_id, + qqid, + f"❀ 不存在第 {page_num} 页!请重新输入!", + ) + continue + choice = self.parse_displayed_menu_choice( + user_input, len(page_names)) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + continue + self.show_player_inventory(group_id, qqid, page_names[choice - 1]) + return + + @staticmethod + def simple_paginate(total_len: int, per_page: int, page: int): + """在缺少 utils.paginate 时使用的本地分页兜底实现。""" + total_pages = max(1, (total_len + per_page - 1) // per_page) + page = max(1, min(page, total_pages)) + start_index = (page - 1) * per_page + 1 + end_index = min(page * per_page, total_len) + return total_pages, start_index, end_index + + @staticmethod + def parse_displayed_menu_choice(user_input: str, displayed_count: int): + """Parse a choice using the numbers shown on the current menu page.""" + choice = utils.try_int(user_input.strip().strip("[]")) + if choice is None or choice not in range(1, displayed_count + 1): + return None + return choice + + @staticmethod + def parse_page_jump(user_input: str): + """Parse page jumps only when the input explicitly ends with a page suffix.""" + text = user_input.strip() + if len(text) < 2 or text[-1] not in ("页", "頁", "椤"): + return None + return utils.try_int(text[:-1]) + + def show_player_inventory( + self, + group_id: int, + qqid: int, + player_name: str): + """把背包槽位整理成适合群聊阅读的文本。""" + player_obj = self.game_ctrl.players.getPlayerByName(player_name) + if player_obj is None: + self._reply_to_qq(group_id, qqid, f"玩家 {player_name} 当前已不在线") + return + try: + inventory = player_obj.queryInventory() + except Exception as err: + self._reply_to_qq(group_id, qqid, f"查询背包失败: {err}") + return + items: list[str] = [] + for idx, slot in enumerate(inventory.slots): + if slot is None: + continue + # 这里把原始槽位信息尽量整理成适合群内阅读的文本。 + item_id = getattr(slot, "id", "未知ID") + display_name = self.translate_item_name(item_id) + stack_size = getattr(slot, "stackSize", 0) + aux = getattr(slot, "aux", 0) + line = f"槽位 {idx}: {display_name} x{stack_size}" + if display_name != item_id: + line += f" ({item_id})" + if aux not in (None, -1, 0): + line += f" (数据值:{aux})" + custom_name = self.get_item_custom_name(slot) + if custom_name: + line += f" (命名:{custom_name})" + ench_text = self.get_item_enchantments_text(slot) + if ench_text: + line += f" (附魔:{ench_text})" + items.append(line) + if not items: + items = ["该玩家背包为空"] + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"背包查询 - {player_name}", + items, + ["输入 . 退出"], + group_id, + ) + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False) + + def ensure_task_system(self, group_id: int, sender: int): + """Implement the ensure task system operation.""" + if self.task_system is None: + self._reply_to_qq(group_id, sender, "相关插件未安装:任务系统云链联动版") + return False + return self.ensure_linked_plugin_enabled( + self.task_system, group_id, sender) + + @staticmethod + def _format_task_time(timestamp: int): + """Implement the format task time operation.""" + try: + return time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(timestamp)) + except Exception: + return str(timestamp) + + @staticmethod + def _format_task_label(task_info: dict[str, Any]): + """Implement the format task label operation.""" + show_name = str(task_info.get("show_name", "")).strip() + tag_name = str(task_info.get("tag_name", "")).strip() + if not show_name or show_name == tag_name: + return tag_name or show_name or "<未知任务>" + return f"{show_name} ({tag_name})" + + def _format_task_menu_item(self, task_info: dict[str, Any], status: str): + """Implement the format task menu item operation.""" + label = self._format_task_label(task_info) + lines = [f"{status} {label}"] + description = str(task_info.get("description", "")).strip() + if description: + lines.append(f" {description}") + if "finished_time" in task_info: + try: + finished_time = self._format_task_time( + int(task_info["finished_time"])) + except Exception: + finished_time = str(task_info.get("finished_time", "未知时间")) + lines.append(f" 完成时间:{finished_time}") + return "\n".join(lines) + + def _format_task_progress_text( + self, progress: dict[str, Any], group_id: int): + """Implement the format task progress text operation.""" + in_progress = progress.get("in_progress", []) + completed = progress.get("completed", []) + options = [] + if in_progress: + for task_info in in_progress: + options.append(self._format_task_menu_item(task_info, "未完成")) + else: + options.append("未完成任务:无") + if completed: + for task_info in completed: + options.append(self._format_task_menu_item(task_info, "已完成")) + else: + options.append("已完成任务:无") + player_name = progress.get("player_name", "<未知玩家>") + return self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"任务进度 - {player_name}", + options, + [ + f"未完成任务:{len(in_progress)} 个", + f"已完成任务:{len(completed)} 个", + "输入 . 退出", + ], + group_id, + ) + + def qq_task_system_menu(self, group_id: int, qqid: int): + """Handle the qq task system menu QQ menu operation.""" + if not self._can_use_group_permission(group_id, qqid, "任务系统权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if not self.ensure_task_system(group_id, qqid): + return + choice = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "任务系统菜单", + ["查看在线玩家任务进度", "给在线玩家下发任务", "直接完成在线玩家任务"], + ["输入 [1-3] 之间的数字以选择 对应功能", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if choice == "1": + player_name = self.qq_select_online_player( + group_id, qqid, "查看任务进度") + if player_name is None: + return + self.on_qq_task_progress(group_id, qqid, [player_name]) + return + if choice == "2": + player_name = self.qq_select_online_player(group_id, qqid, "下发任务") + if player_name is None: + return + quest_tag = self.qq_select_task_from_catalog(group_id, qqid) + if quest_tag is None: + return + self.on_qq_task_add(group_id, qqid, [player_name, quest_tag]) + return + if choice == "3": + player_name = self.qq_select_online_player(group_id, qqid, "完成任务") + if player_name is None: + return + quest_tag = self.qq_select_in_progress_task( + group_id, qqid, player_name) + if quest_tag is None: + return + self.on_qq_task_finish(group_id, qqid, [player_name, quest_tag]) + return + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + + def qq_select_online_player(self, group_id: int, qqid: int, subtitle: str): + """Handle the qq select online player QQ menu operation.""" + online_names = list(self.game_ctrl.allplayers) + if not online_names: + self._reply_to_qq(group_id, qqid, "当前没有在线玩家") + return None + page = 1 + per_page = self.get_group_task_player_items_per_page(group_id) + while True: + total_pages, start_index, end_index = ( + utils.paginate(len(online_names), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(online_names), per_page, page) + ) + page_names = online_names[start_index - 1: end_index] + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"任务系统 - {subtitle}", + page_names, + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(page_names)}] 之间的数字以选择 对应玩家", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") + return None + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if user_input == "+": + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") + continue + choice = self.parse_displayed_menu_choice( + user_input, len(page_names)) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + continue + return page_names[choice - 1] + + def qq_select_task_from_catalog(self, group_id: int, qqid: int): + """Handle the qq select task from catalog QQ menu operation.""" + quests = self.task_system.list_available_quests() + if not quests: + self._reply_to_qq(group_id, qqid, "任务系统中暂无可用任务") + return None + page = 1 + per_page = self.get_group_task_items_per_page(group_id) + while True: + total_pages, start_index, end_index = ( + utils.paginate(len(quests), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(quests), per_page, page) + ) + page_quests = quests[start_index - 1: end_index] + options = [] + for quest_info in page_quests: + label = self._format_task_label(quest_info) + description = str(quest_info.get("description", "")).strip() + options.append( + label if not description else f"{label}\n {description}") + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + "任务系统 - 选择任务", + options, + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(page_quests)}] 之间的数字以选择 对应任务", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") + return None + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if user_input == "+": + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") + continue + choice = self.parse_displayed_menu_choice( + user_input, len(page_quests)) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + continue + return page_quests[choice - 1]["tag_name"] + + def qq_select_in_progress_task( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq select in progress task QQ menu operation.""" + ok, result = self.task_system.get_online_player_task_progress( + player_name) + if not ok: + self._reply_to_qq(group_id, qqid, str(result)) + return None + in_progress = result.get("in_progress", []) + if not in_progress: + self._reply_to_qq(group_id, qqid, f"玩家 {player_name} 当前没有进行中的任务") + return None + options = [] + for task_info in in_progress: + label = self._format_task_label(task_info) + description = str(task_info.get("description", "")).strip() + options.append( + label if not description else f"{label}\n {description}") + text = self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"任务系统 - 完成任务 - {player_name}", + options, + [f"输入 [1-{len(options)}] 之间的数字以选择 对应任务", "输入 . 退出"], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时,已退出菜单") + return None + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + choice = self.parse_displayed_menu_choice(user_input, len(options)) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return None + return in_progress[choice - 1]["tag_name"] + + def on_qq_task_progress(self, group_id: int, sender: int, args: list[str]): + """Implement the on qq task progress operation.""" + if not self._can_use_group_permission(group_id, sender, "任务系统权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_task_system(group_id, sender): + return + ok, result = self.task_system.get_online_player_task_progress(args[0]) + if not ok: + self._reply_to_qq(group_id, sender, str(result)) + return + self._reply_to_qq( + group_id, + sender, + self._format_task_progress_text(result, group_id), + ) + + def on_qq_task_add(self, group_id: int, sender: int, args: list[str]): + """Implement the on qq task add operation.""" + if not self._can_use_group_permission(group_id, sender, "任务系统权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_task_system(group_id, sender): + return + player_name = args[0] + quest_query = " ".join(args[1:]).strip() + ok, msg = self.task_system.add_quest_to_online_player( + player_name, quest_query) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_task_finish(self, group_id: int, sender: int, args: list[str]): + """Implement the on qq task finish operation.""" + if not self._can_use_group_permission(group_id, sender, "任务系统权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_task_system(group_id, sender): + return + player_name = args[0] + quest_query = " ".join(args[1:]).strip() + ok, msg = self.task_system.finish_quest_for_online_player( + player_name, quest_query + ) + self._reply_result(group_id, sender, ok, msg) + + def ensure_guild_system(self, group_id: int, sender: int): + """检查公会系统云链联动版 API 是否可用。""" + if self.guild_system is None: + self._reply_to_qq(group_id, sender, "相关插件未安装:公会系统云链联动版") + return False + return self.ensure_linked_plugin_enabled( + self.guild_system, group_id, sender) + + @staticmethod + def _guild_actor(group_id: int, qqid: int): + """Implement the guild actor operation.""" + return f"QQ群{group_id}:{qqid}" + + def _qq_guild_prompt( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + hints: list[str], + ): + """Implement the qq guild prompt operation.""" + return self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "公会系统云链联动版", + subtitle, + options, + hints, + group_id, + ), + timeout=120, + ) + + def _qq_guild_prompt_text( + self, + group_id: int, + qqid: int, + subtitle: str, + prompt: str, + allow_empty: bool = False, + ): + """Implement the qq guild prompt text operation.""" + value = self._qq_guild_prompt( + group_id, qqid, subtitle, [], [ + prompt, "输入 . 退出"]) + if value is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + value = value.strip() + if self._is_menu_exit(value, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if not allow_empty and not value: + self._reply_to_qq(group_id, qqid, "❀ 输入不能为空") + return None + return value + + def _qq_guild_prompt_optional_query( + self, + group_id: int, + qqid: int, + subtitle: str, + prompt: str, + ): + """Implement the qq guild prompt optional query operation.""" + value = self._qq_guild_prompt_text( + group_id, qqid, subtitle, prompt, allow_empty=True) + if value is None: + return None + if value in ("", "全部", "全服", "all", "*", "-"): + return "" + return value + + def _qq_guild_prompt_int( + self, + group_id: int, + qqid: int, + subtitle: str, + prompt: str, + minimum: int | None = None, + default: int | None = None, + ): + """Implement the qq guild prompt int operation.""" + hints = [prompt, "输入 . 退出"] + if default is not None: + hints.insert(1, f"输入 默认 使用 {default}") + value = self._qq_guild_prompt(group_id, qqid, subtitle, [], hints) + if value is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + value = value.strip() + if self._is_menu_exit(value, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if default is not None and value in ("", "默认", "default"): + return default + parsed = utils.try_int(value) + if parsed is None: + self._reply_to_qq(group_id, qqid, "❀ 请输入整数") + return None + if minimum is not None and parsed < minimum: + self._reply_to_qq(group_id, qqid, f"❀ 数值不能小于 {minimum}") + return None + return parsed + + def _qq_guild_confirm( + self, + group_id: int, + qqid: int, + subtitle: str, + detail: str): + """Implement the qq guild confirm operation.""" + choice = self._qq_guild_prompt( + group_id, + qqid, + subtitle, + [detail], + ["请输入 确认 继续执行", "输入 . 取消"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已取消") + return False + choice = choice.strip() + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已取消") + return False + if choice != "确认": + self._reply_to_qq(group_id, qqid, "❀ 已取消") + return False + return True + + def _reply_guild_api_result(self, group_id: int, qqid: int, result): + """Implement the reply guild api result operation.""" + if not isinstance(result, tuple) or not result: + self._reply_to_qq(group_id, qqid, "公会系统接口返回异常") + return + ok = bool(result[0]) + msg = str( + result[1]) if len(result) >= 2 else ( + "操作成功" if ok else "操作失败") + if len(result) >= 3 and isinstance(result[2], str) and result[2]: + msg = f"{msg}\n{result[2]}" + self._reply_result(group_id, qqid, ok, msg) + + @staticmethod + def _format_guild_base(base: dict[str, Any] | None): + """Implement the format guild base operation.""" + if not base: + return "未设置" + return f"{base.get('dimension',0)} ({base.get('x',0):.1f}, {base.get('y',0):.1f}, {base.get('z',0):.1f})" + + @staticmethod + def _format_guild_line(item: dict[str, Any], index: int): + """Implement the format guild line operation.""" + frozen = " 冻结" if item.get("frozen") else "" + return ( + f"{index}. {item.get('name', '<未知>')} Lv.{item.get('level', 0)} " + f"成员 {item.get('member_count', 0)}/{item.get('max_members', 0)} " + f"会长 {item.get('owner', '<未知>')}{frozen}" + ) + + def _format_guild_summary(self, guild: dict[str, Any], group_id: int): + """Implement the format guild summary operation.""" + effects = guild.get("purchased_effects", {}) + effect_text = "无" + if isinstance(effects, dict) and effects: + effect_text = "、".join( + f"{key}:{level}" for key, + level in effects.items()) + return self.plugin_ui_menu( + "公会系统云链联动版", + f"公会信息 - {guild.get('name', '<未知>')}", + [ + f"ID:{guild.get('guild_id', '<未知>')}", + f"会长:{guild.get('owner', '<未知>')}", + f"等级/经验:{guild.get('level', 0)} / {guild.get('exp', 0)}", + f"成员:{guild.get('member_count', 0)}/{guild.get('max_members', 0)}", + f"仓库:{guild.get('vault_count', 0)}/{guild.get('vault_capacity', 0)}", + f"资金:{guild.get('funds', 0)}", + f"据点:{self._format_guild_base(guild.get('base'))}", + f"据点锁定:{'是' if guild.get('base_locked') else '否'}", + f"冻结:{'是' if guild.get('frozen') else '否'}", + f"冻结原因:{guild.get('frozen_reason') or '无'}", + ( + f"活跃/完成任务:{guild.get('active_tasks', 0)} / " + f"{guild.get('completed_tasks', 0)}" + ), + f"总贡献:{guild.get('total_contribution', 0)}", + f"效果:{effect_text}", + f"公告:{guild.get('announcement') or '无'}", + ], + ["输入 . 退出"], + group_id, + ) + + def _reply_guild_lines( + self, + group_id: int, + qqid: int, + title: str, + lines: list[str], + hints: list[str] | None = None, + ): + """Implement the reply guild lines operation.""" + self._reply_to_qq( + group_id, + qqid, + self.plugin_ui_menu( + "公会系统云链联动版", + title, + lines or ["暂无数据"], + hints or ["输入 . 退出"], + group_id, + ), + ) + + def _qq_guild_run_menu( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + actions: list[Any], + allow_back: bool = False, + ): + """Implement the qq guild run menu operation.""" + if len(options) != len(actions): + self._reply_to_qq(group_id, qqid, "❀ 菜单配置错误") + return None + hints = [f"输入 [1-{len(options)}] 之间的数字以选择 对应功能"] + if allow_back: + hints.append("输入 0 返回上级菜单") + hints.append("输入 . 退出") + choice = self._qq_guild_prompt( + group_id, + qqid, + subtitle, + options, + hints, + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + choice = choice.strip() + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + if allow_back and self._is_menu_back(choice, group_id): + return "back" + selected = self.parse_displayed_menu_choice(choice, len(options)) + if selected is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return None + return actions[selected - 1]() + + def qq_guild_entry_menu(self, group_id: int, qqid: int): + """公会系统统一入口:群管理员进管理菜单,普通成员进绑定账号菜单。""" + if self._can_use_group_permission(group_id, qqid, "公会系统权限"): + self.qq_guild_system_menu(group_id, qqid) + return + self.qq_guild_player_menu(group_id, qqid) + + def _qq_guild_select_bound_player(self, group_id: int, qqid: int): + """选择一个 QQ 已绑定的游戏账号,返回玩家名。""" + bound_players = self.api_get_bound_players_by_qq(qqid) + usable_players = [ + item + for item in bound_players + if str(item.get("player_name", "")).strip() + ] + if not usable_players: + bind_hint = " / ".join(self.get_group_binding_triggers(group_id)) + if not self.api_is_binding_enabled(group_id): + self._reply_to_qq( + group_id, + qqid, + "请先绑定游戏账号后再使用公会菜单;当前群的 QQ 绑定功能未开启,请联系管理员。", + ) + return None + self._reply_to_qq( + group_id, + qqid, + f"请先绑定游戏账号后再使用公会菜单。绑定触发词:{bind_hint}", + ) + return None + if len(usable_players) == 1: + return str(usable_players[0].get("player_name", "")).strip() + + options = [ + f"{item.get('player_name', '<未知玩家>')} ({item.get('xuid', '<未知XUID>')})" + for item in usable_players + ] + choice = self._qq_guild_prompt( + group_id, + qqid, + "选择绑定账号", + options, + [f"输入 [1-{len(options)}] 之间的数字以选择 游戏账号", "输入 . 退出"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + selected = self.parse_displayed_menu_choice(choice, len(options)) + if selected is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return None + return str(usable_players[selected - 1].get("player_name", "")).strip() + + def _qq_guild_player_state( + self, + group_id: int, + qqid: int, + player_name: str): + """Implement the qq guild player state operation.""" + result = self.guild_system.api_get_player_guild_menu_state(player_name) + if not isinstance(result, tuple) or len(result) < 3: + self._reply_to_qq(group_id, qqid, "公会系统接口返回异常") + return None + ok, msg, state = result + if not ok or not isinstance(state, dict): + self._reply_result(group_id, qqid, bool(ok), str(msg)) + return None + return state + + def qq_guild_player_menu(self, group_id: int, qqid: int): # skipcq: PY-R1000 + """普通群成员可使用的公会菜单,要求 QQ 已绑定游戏账号。""" + if not self.ensure_guild_system(group_id, qqid): + return + player_name = self._qq_guild_select_bound_player(group_id, qqid) + if not player_name: + return + while True: + state = self._qq_guild_player_state(group_id, qqid, player_name) + if state is None: + return + if state.get("in_guild"): + guild = state.get("guild") if isinstance( + state.get("guild"), dict) else {} + member = state.get("member") if isinstance( + state.get("member"), dict) else {} + permissions = set(state.get("permissions") or []) + is_owner = bool(state.get("is_owner")) + is_frozen = bool(state.get("is_frozen")) + subtitle = ( + f"玩家菜单 - {player_name} | {guild.get('name', '<未知公会>')} " + f"({member.get('rank_name', member.get('rank', '成员'))})" + ) + options = [ + "我的公会信息", + "查看成员列表", + "查看公会日志", + "查看公会公告", + "查看公会排行", + "查看贡献排行", + ] + actions = [ + lambda: self.qq_guild_player_show_self( + group_id, qqid, player_name), + lambda: self.qq_guild_player_show_members( + group_id, qqid, player_name), + lambda: self.qq_guild_player_show_logs( + group_id, qqid, player_name), + lambda: self.qq_guild_player_show_announcement( + group_id, qqid, player_name), + lambda: self.qq_guild_show_rankings(group_id, qqid), + lambda: self.qq_guild_show_donation_rankings( + group_id, qqid),] + if not is_frozen: + if "announce" in permissions: + options.append("设置公会公告") + actions.append( + lambda: self.qq_guild_player_set_announcement( + group_id, qqid, player_name)) + if "vault" in permissions: + options.append("查看公会仓库") + actions.append(lambda: self.qq_guild_player_show_vault( + group_id, qqid, player_name)) + options.append("查看公会任务") + actions.append(lambda: self.qq_guild_player_show_tasks( + group_id, qqid, player_name)) + options.append("参与公会任务") + actions.append(lambda: self.qq_guild_player_join_task( + group_id, qqid, player_name)) + if "return_base" in permissions: + options.append("返回公会据点") + actions.append( + lambda: self.qq_guild_player_return_base( + group_id, qqid, player_name)) + if not is_owner: + options.append("退出公会") + actions.append(lambda: self.qq_guild_player_leave( + group_id, qqid, player_name)) + if is_owner and not is_frozen: + options.append("解散我的公会") + actions.append(lambda: self.qq_guild_player_disband( + group_id, qqid, player_name)) + else: + subtitle = f"玩家菜单 - {player_name} | 未加入公会" + options = ["查看公会列表", "申请加入公会", "查看公会排行", "查看贡献排行"] + actions = [ + lambda: self.qq_guild_list(group_id, qqid), + lambda: self.qq_guild_player_request_join( + group_id, qqid, player_name + ), + lambda: self.qq_guild_show_rankings(group_id, qqid), + lambda: self.qq_guild_show_donation_rankings(group_id, qqid), + ] + result = self._qq_guild_run_menu( + group_id, qqid, subtitle, options, actions) + if result == "back": + continue + return + + def qq_guild_player_show_self( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player show self QQ menu operation.""" + state = self._qq_guild_player_state(group_id, qqid, player_name) + if state is None: + return + guild = state.get("guild") if isinstance( + state.get("guild"), dict) else {} + member = state.get("member") if isinstance( + state.get("member"), dict) else {} + if not guild: + self._reply_to_qq(group_id, qqid, f"{player_name} 暂未加入公会") + return + self._reply_guild_lines( + group_id, + qqid, + f"我的公会 - {player_name}", + [ + f"公会:{guild.get('name', '<未知>')} ({guild.get('guild_id', '<未知>')})", + f"职位:{member.get('rank_name', member.get('rank', '<未知>'))}", + f"贡献:{member.get('contribution', 0)}", + f"会长:{guild.get('owner', '<未知>')}", + f"等级/经验:{guild.get('level', 0)} / {guild.get('exp', 0)}", + f"成员:{guild.get('member_count', 0)}/{guild.get('max_members', 0)}", + f"仓库:{guild.get('vault_count', 0)}/{guild.get('vault_capacity', 0)}", + f"据点:{self._format_guild_base(guild.get('base'))}", + f"冻结:{'是' if guild.get('frozen') else '否'}", + f"公告:{guild.get('announcement') or '无'}", + ], + ) + + def qq_guild_player_show_members( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player show members QQ menu operation.""" + state = self._qq_guild_player_state(group_id, qqid, player_name) + if state is None: + return + guild = state.get("guild") if isinstance( + state.get("guild"), dict) else {} + guild_id = str(guild.get("guild_id", "")).strip() + if not guild_id: + self._reply_to_qq(group_id, qqid, "你尚未加入任何公会") + return + ok, msg, data = self.guild_system.api_get_guild(guild_id) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return + members = data.get("members", []) + lines = [] + for index, member in enumerate(members[:30], start=1): + rank = member.get("rank_name", member.get("rank", "成员")) + name = member.get("name", "<未知>") + contribution = member.get("contribution", 0) + lines.append(f"{index}. {rank} {name} 贡献 {contribution}") + self._reply_guild_lines( + group_id, qqid, f"{data.get('name', guild_id)} 成员", lines or ["暂无成员"], [ + f"共 {len(members)} 名成员"]) + + def qq_guild_player_show_logs( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player show logs QQ menu operation.""" + ok, msg, data = self.guild_system.api_get_own_guild_logs( + player_name, 20) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return + lines = [str(item) for item in data.get("logs", [])[-20:]] + audit_logs = data.get("audit_logs", []) + if audit_logs: + lines.append("--- 审计日志 ---") + for item in audit_logs[-10:]: + action = item.get("action", "<未知>") + actor = item.get("actor", "") + target = item.get("target", "") + detail = item.get("detail", "") + lines.append(f"{action} {actor} -> {target} {detail}".strip()) + self._reply_guild_lines( + group_id, qqid, f"{player_name} 的公会日志", lines or ["暂无日志"]) + + def qq_guild_player_show_announcement( + self, group_id: int, qqid: int, player_name: str): + """Handle the qq guild player show announcement QQ menu operation.""" + state = self._qq_guild_player_state(group_id, qqid, player_name) + if state is None: + return + guild = state.get("guild") if isinstance( + state.get("guild"), dict) else {} + if not guild: + self._reply_to_qq(group_id, qqid, "你尚未加入任何公会") + return + self._reply_guild_lines( + group_id, + qqid, + f"{guild.get('name', '<未知公会>')} 公告", + [guild.get("announcement") or "当前没有公告"], + ) + + def qq_guild_player_set_announcement( + self, group_id: int, qqid: int, player_name: str): + """Handle the qq guild player set announcement QQ menu operation.""" + text = self._qq_guild_prompt_text( + group_id, qqid, "设置公会公告", "请输入新的公告内容(不超过200字符)") + if text is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_announcement_as_player( + player_name, text), ) + + def qq_guild_player_show_vault( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player show vault QQ menu operation.""" + ok, msg, data = self.guild_system.api_get_own_guild_vault(player_name) + if not ok or data is None: + self._reply_result(group_id, qqid, False, msg) + return + lines = [] + for index, item in enumerate(data[:30], start=1): + item_index = item.get("index", index) + item_id = item.get("item_id", "<未知>") + count = item.get("count", 0) + price = item.get("price", 0) + seller = item.get("seller", "<未知>") + lines.append(f"{item_index}. {item_id} x{count} 价格 {price} 卖家 {seller}") + self._reply_guild_lines(group_id, qqid, msg, lines or ["仓库为空"]) + + def qq_guild_player_show_tasks( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player show tasks QQ menu operation.""" + ok, msg, data = self.guild_system.api_get_own_guild_tasks(player_name) + if not ok or data is None: + self._reply_result(group_id, qqid, False, msg) + return + lines = [] + for index, task in enumerate(data[:30], start=1): + status = "已完成" if task.get("completed") else f"进行中 {task.get('current_count', 0)} /{task.get('target_count', 0)} " + joined = " 已参与" if player_name in task.get( + "participants", []) else "" + lines.append( + f"{index}. {task.get('name','<未知任务>')} [{status}{joined}] 奖励 {task.get('reward_contribution',0)}贡献/{task.get('reward_exp',0)}经验") + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无任务"]) + + def qq_guild_player_join_task( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player join task QQ menu operation.""" + ok, msg, data = self.guild_system.api_get_own_guild_tasks(player_name) + if not ok or data is None: + self._reply_result(group_id, qqid, False, msg) + return + active_tasks = [task for task in data if not task.get("completed")] + if not active_tasks: + self._reply_to_qq(group_id, qqid, "暂无可参与的任务") + return + options = [ + ( + f"{task.get('name', '<未知任务>')} " + f"({task.get('current_count', 0)}/{task.get('target_count', 0)})" + ) + for task in active_tasks[:20] + ] + choice = self._qq_guild_prompt( + group_id, + qqid, + "参与公会任务", + options, + [f"输入 [1-{len(options)}] 之间的数字以选择 任务", "输入 . 退出"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + selected = self.parse_displayed_menu_choice(choice, len(options)) + if selected is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + task = active_tasks[selected - 1] + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_join_guild_task_as_player( + player_name, + task.get("task_id") or task.get("name", ""), + ), + ) + + def qq_guild_player_return_base( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player return base QQ menu operation.""" + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_return_to_guild_base_as_player(player_name), + ) + + def qq_guild_player_request_join( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player request join QQ menu operation.""" + guild = self._qq_guild_prompt_text( + group_id, qqid, "申请加入公会", "请输入公会名称或ID") + if guild is None: + return + reason = self._qq_guild_prompt_text( + group_id, + qqid, + "申请加入公会", + "请输入申请理由,可输入 无 跳过", + allow_empty=True, + ) + if reason is None: + return + if reason == "无": + reason = "" + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_request_join_guild_as_player( + player_name, guild, reason), + ) + + def qq_guild_player_leave( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player leave QQ menu operation.""" + if not self._qq_guild_confirm( + group_id, qqid, "退出公会", f"{player_name} 将退出当前公会"): + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_leave_guild_as_player(player_name), + ) + + def qq_guild_player_disband( + self, + group_id: int, + qqid: int, + player_name: str): + """Handle the qq guild player disband QQ menu operation.""" + if not self._qq_guild_confirm( + group_id, + qqid, + "解散公会", + f"{player_name} 将解散自己担任会长的公会"): + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_disband_owned_guild_as_player(player_name), + ) + + def qq_guild_system_menu(self, group_id: int, qqid: int): + """在群里打开公会系统云链联动版管理菜单。""" + if not self._can_use_group_permission(group_id, qqid, "公会系统权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if not self.ensure_guild_system(group_id, qqid): + return + while True: + result = self._qq_guild_run_menu( + group_id, + qqid, + "管理菜单", + ["查询与排行", "公会与成员管理", "仓库与效果管理", "任务与据点管理", "数据维护与活动"], + [ + lambda: self.qq_guild_query_menu(group_id, qqid), + lambda: self.qq_guild_member_manage_menu(group_id, qqid), + lambda: self.qq_guild_vault_effect_menu(group_id, qqid), + lambda: self.qq_guild_task_base_menu(group_id, qqid), + lambda: self.qq_guild_data_activity_menu(group_id, qqid), + ], + ) + if result == "back": + continue + return + + def qq_guild_query_menu(self, group_id: int, qqid: int): + """Handle the qq guild query menu QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): + return + if not self.ensure_guild_system(group_id, qqid): + return + self._qq_guild_run_menu( + group_id, + qqid, + "查询与排行", + [ + "查看公会列表", + "查看公会信息", + "查看公会成员", + "查看公会仓库", + "查看公会日志", + "查询玩家公会记录", + "查看系统统计", + "查看公会排行", + "查看贡献排行", + "查看异常交易", + ], + [ + lambda: self.qq_guild_list(group_id, qqid), + lambda: self.qq_guild_show_info(group_id, qqid), + lambda: self.qq_guild_show_members(group_id, qqid), + lambda: self.qq_guild_show_vault(group_id, qqid), + lambda: self.qq_guild_show_logs(group_id, qqid), + lambda: self.qq_guild_show_player_record(group_id, qqid), + lambda: self.qq_guild_show_statistics(group_id, qqid), + lambda: self.qq_guild_show_rankings(group_id, qqid), + lambda: self.qq_guild_show_donation_rankings(group_id, qqid), + lambda: self.qq_guild_show_abnormal_trades(group_id, qqid), + ], + allow_back=True, + ) + + def qq_guild_member_manage_menu(self, group_id: int, qqid: int): + """Handle the qq guild member manage menu QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): + return + if not self.ensure_guild_system(group_id, qqid): + return + self._qq_guild_run_menu( + group_id, + qqid, + "公会与成员管理", + [ + "强制解散公会", + "修改公会名称", + "设置公会等级", + "设置公会经验", + "转让公会会长", + "强制玩家加入公会", + "强制玩家退出公会", + "冻结公会", + "解冻公会", + "调整公会资金", + "设置公会资金", + "调整成员贡献", + "设置成员贡献", + "清空公会贡献", + "发送全服公会公告", + ], + [ + lambda: self.qq_guild_force_disband(group_id, qqid), + lambda: self.qq_guild_rename(group_id, qqid), + lambda: self.qq_guild_set_level(group_id, qqid), + lambda: self.qq_guild_set_exp(group_id, qqid), + lambda: self.qq_guild_transfer_owner(group_id, qqid), + lambda: self.qq_guild_force_join(group_id, qqid), + lambda: self.qq_guild_force_leave(group_id, qqid), + lambda: self.qq_guild_set_frozen(group_id, qqid, True), + lambda: self.qq_guild_set_frozen(group_id, qqid, False), + lambda: self.qq_guild_add_funds(group_id, qqid), + lambda: self.qq_guild_set_funds(group_id, qqid), + lambda: self.qq_guild_add_member_contribution(group_id, qqid), + lambda: self.qq_guild_set_member_contribution(group_id, qqid), + lambda: self.qq_guild_reset_contributions(group_id, qqid), + lambda: self.qq_guild_broadcast_announcement(group_id, qqid), + ], + allow_back=True, + ) + + def qq_guild_vault_effect_menu(self, group_id: int, qqid: int): + """Handle the qq guild vault effect menu QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): + return + if not self.ensure_guild_system(group_id, qqid): + return + self._qq_guild_run_menu( + group_id, + qqid, + "仓库与效果管理", + [ + "备份公会仓库", + "清空公会仓库", + "删除仓库物品", + "回滚仓库备份", + "导出仓库数据", + "重置市场价格", + "清空公会效果", + "设置公会效果", + ], + [ + lambda: self.qq_guild_backup_vault(group_id, qqid), + lambda: self.qq_guild_clear_vault(group_id, qqid), + lambda: self.qq_guild_delete_vault_item(group_id, qqid), + lambda: self.qq_guild_rollback_vault(group_id, qqid), + lambda: self.qq_guild_export_vault(group_id, qqid), + lambda: self.qq_guild_reset_market_prices(group_id, qqid), + lambda: self.qq_guild_clear_effects(group_id, qqid), + lambda: self.qq_guild_set_effect(group_id, qqid), + ], + allow_back=True, + ) + + def qq_guild_task_base_menu(self, group_id: int, qqid: int): + """Handle the qq guild task base menu QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): + return + if not self.ensure_guild_system(group_id, qqid): + return + self._qq_guild_run_menu( + group_id, + qqid, + "任务与据点管理", + [ + "刷新公会任务", + "创建全服任务", + "删除公会任务", + "重置任务进度", + "强制完成任务", + "传送玩家到公会据点", + "删除公会据点", + "设置公会据点", + "锁定公会据点", + "解锁公会据点", + ], + [ + lambda: self.qq_guild_refresh_tasks(group_id, qqid), + lambda: self.qq_guild_create_global_task(group_id, qqid), + lambda: self.qq_guild_delete_task(group_id, qqid), + lambda: self.qq_guild_reset_task(group_id, qqid), + lambda: self.qq_guild_complete_task(group_id, qqid), + lambda: self.qq_guild_teleport_base(group_id, qqid), + lambda: self.qq_guild_delete_base(group_id, qqid), + lambda: self.qq_guild_set_base(group_id, qqid), + lambda: self.qq_guild_set_base_locked(group_id, qqid, True), + lambda: self.qq_guild_set_base_locked(group_id, qqid, False), + ], + allow_back=True, + ) + + def qq_guild_data_activity_menu(self, group_id: int, qqid: int): + """Handle the qq guild data activity menu QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "公会系统权限"): + return + if not self.ensure_guild_system(group_id, qqid): + return + self._qq_guild_run_menu( + group_id, qqid, "数据维护与活动", + ["重载公会配置", "保存公会数据", "备份公会数据", "修复公会数据", "查看活动状态", "开启双倍经验", + "开启双倍贡献", "开启公会争霸", "停止活动", "结算排行奖励",], + [lambda: self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_reload_guild_config()), + lambda: self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_save_guild_data()), + lambda: self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_backup_guild_data()), + lambda: self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_repair_guild_data( + self._guild_actor(group_id, qqid))), + lambda: self.qq_guild_show_activity_status(group_id, qqid), + lambda: self.qq_guild_start_activity( + group_id, qqid, "exp", 2.0), + lambda: self.qq_guild_start_activity( + group_id, qqid, "contribution", 2.0), + lambda: self.qq_guild_start_activity( + group_id, qqid, "contest", 1.0), + lambda: self.qq_guild_stop_activity(group_id, qqid), + lambda: self.qq_guild_settle_rewards(group_id, qqid),], + allow_back=True,) + + def qq_guild_list(self, group_id: int, qqid: int): + """Handle the qq guild list QQ menu operation.""" + ok, msg, guilds = self.guild_system.api_list_guilds() + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + lines = [self._format_guild_line(item, index) + for index, item in enumerate(guilds[:20], start=1)] + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无公会"]) + + def qq_guild_show_info(self, group_id: int, qqid: int): + """Handle the qq guild show info QQ menu operation.""" + query = self._qq_guild_prompt_text( + group_id, qqid, "查看公会信息", "请输入公会名称或ID") + if query is None: + return + ok, msg, data = self.guild_system.api_get_guild(query) + self._reply_to_qq(group_id, qqid, self._format_guild_summary( + data, group_id) if ok and data else msg) + + def qq_guild_show_members(self, group_id: int, qqid: int): + """Handle the qq guild show members QQ menu operation.""" + query = self._qq_guild_prompt_text( + group_id, qqid, "查看公会成员", "请输入公会名称或ID") + if query is None: + return + ok, msg, data = self.guild_system.api_get_guild(query) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return + members = data.get("members", []) + lines = [] + for index, member in enumerate(members[:30], start=1): + rank = member.get("rank_name", member.get("rank", "成员")) + name = member.get("name", "<未知>") + contribution = member.get("contribution", 0) + lines.append(f"{index}. {rank} {name} 贡献 {contribution}") + self._reply_guild_lines( + group_id, qqid, f"{data.get('name', query)} 成员", lines or ["暂无成员"], [ + f"共 {len(members)} 名成员"]) + + def qq_guild_show_vault(self, group_id: int, qqid: int): + """Handle the qq guild show vault QQ menu operation.""" + query = self._qq_guild_prompt_text( + group_id, qqid, "查看公会仓库", "请输入公会名称或ID") + if query is None: + return + ok, msg, data = self.guild_system.api_get_guild_vault(query) + if not ok or data is None: + self._reply_result(group_id, qqid, False, msg) + return + lines = [] + for index, item in enumerate(data[:30], start=1): + item_index = item.get("index", index) + item_id = item.get("item_id", "<未知>") + count = item.get("count", 0) + price = item.get("price", 0) + seller = item.get("seller", "<未知>") + lines.append(f"{item_index}. {item_id} x{count} 价格 {price} 卖家 {seller}") + self._reply_guild_lines(group_id, qqid, msg, lines or ["仓库为空"]) + + def qq_guild_show_logs(self, group_id: int, qqid: int): + """Handle the qq guild show logs QQ menu operation.""" + query = self._qq_guild_prompt_text( + group_id, qqid, "查看公会日志", "请输入公会名称或ID") + if query is None: + return + limit = self._qq_guild_prompt_int( + group_id, qqid, "查看公会日志", "请输入日志数量", minimum=1, default=10) + if limit is None: + return + ok, msg, data = self.guild_system.api_get_guild_logs(query, limit) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return + logs = [str(item) for item in data.get("logs", [])] + logs = logs[-int(limit):] + self._reply_guild_lines( + group_id, + qqid, + f"{query} 日志", + logs or ["暂无日志"]) + + def qq_guild_show_player_record(self, group_id: int, qqid: int): + """Handle the qq guild show player record QQ menu operation.""" + player_name = self._qq_guild_prompt_text( + group_id, qqid, "查询玩家公会记录", "请输入玩家名") + if player_name is None: + return + ok, msg, data = self.guild_system.api_get_player_record(player_name) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return + member = data.get("member", {}) + guild = data.get("guild", {}) + self._reply_guild_lines( + group_id, + qqid, + f"玩家记录 - {member.get('name', player_name)}", + [ + f"所属公会:{guild.get('name', '<未知>')}", + f"职位:{member.get('rank_name', member.get('rank', '<未知>'))}", + f"贡献:{member.get('contribution', 0)}", + f"相关审计:{len(data.get('audit_logs', []))} 条", + f"仓库交易:{len(data.get('vault_trade_logs', []))} 条", + ], + ) + + def qq_guild_show_statistics(self, group_id: int, qqid: int): + """Handle the qq guild show statistics QQ menu operation.""" + ok, msg, data = self.guild_system.api_get_guild_statistics() + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + self._reply_guild_lines( + group_id, + qqid, + "公会系统统计", + [ + f"公会:{data.get('guild_count', 0)}", + f"成员:{data.get('member_count', 0)}", + f"仓库物品:{data.get('vault_item_count', 0)}", + f"任务:{data.get('task_count', 0)}", + f"活跃任务:{data.get('active_task_count', 0)}", + f"冻结公会:{data.get('frozen_guild_count', 0)}", + ], + ) + + def qq_guild_show_rankings(self, group_id: int, qqid: int): + """Handle the qq guild show rankings QQ menu operation.""" + sort_choice = self._qq_guild_prompt( + group_id, + qqid, + "查看公会排行", + ["等级", "成员", "贡献", "活跃"], + ["输入 [1-4] 之间的数字以选择 排行类型", "输入 . 退出"], + ) + if sort_choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(sort_choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + sort_map = { + "1": "level", + "2": "members", + "3": "contribution", + "4": "activity"} + sort_by = sort_map.get(sort_choice.strip()) + if sort_by is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + ok, msg, data = self.guild_system.api_get_guild_rankings(sort_by, 10) + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + lines = [ + f"{item.get('rank', index)}. {item.get('name', '<未知>')} 分值 {item.get('score', 0)} 会长 {item.get('owner', '<未知>')}" for index, item in enumerate( + data, start=1)] + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无排行"]) + + def qq_guild_show_donation_rankings(self, group_id: int, qqid: int): + """Handle the qq guild show donation rankings QQ menu operation.""" + query = self._qq_guild_prompt_optional_query( + group_id, qqid, "查看贡献排行", "请输入公会名/ID,输入 全部 查看全服") + if query is None: + return + ok, msg, data = self.guild_system.api_get_donation_rankings( + query or None, 10) + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + lines = [ + f"{index}. {item.get('player_name','<未知>')} {item.get('guild_name','<未知>')} 贡献 {item.get('contribution',0)}" for index, + item in enumerate( + data, + start=1)] + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无排行"]) + + def qq_guild_show_abnormal_trades(self, group_id: int, qqid: int): + """Handle the qq guild show abnormal trades QQ menu operation.""" + query = self._qq_guild_prompt_optional_query( + group_id, qqid, "查看异常交易", "请输入公会名/ID,输入 全部 查看全服") + if query is None: + return + ok, msg, data = self.guild_system.api_get_abnormal_trades( + query or None) + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + lines = [] + for item in data[:20]: + guild_name = item.get("guild_name", "<未知>") + item_id = item.get("item_id", "<未知>") + count = item.get("count", 0) + price = item.get("price", 0) + ratio = item.get("ratio", 0) + lines.append(f"{guild_name} {item_id} x{count} 价格 {price} 倍率 {ratio}") + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无异常交易"]) + + def _qq_guild_prompt_guild(self, group_id: int, qqid: int, subtitle: str): + """Implement the qq guild prompt guild operation.""" + return self._qq_guild_prompt_text( + group_id, qqid, subtitle, "请输入公会名称或ID") + + def qq_guild_force_disband(self, group_id: int, qqid: int): + """Handle the qq guild force disband QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "强制解散公会") + if guild is None: + return + if not self._qq_guild_confirm( + group_id, qqid, "强制解散公会", f"即将解散公会:{guild}"): + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_force_disband_guild( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_rename(self, group_id: int, qqid: int): + """Handle the qq guild rename QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "修改公会名称") + if guild is None: + return + new_name = self._qq_guild_prompt_text( + group_id, qqid, "修改公会名称", "请输入新公会名") + if new_name is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_rename_guild( + guild, new_name, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_level(self, group_id: int, qqid: int): + """Handle the qq guild set level QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会等级") + if guild is None: + return + level = self._qq_guild_prompt_int( + group_id, qqid, "设置公会等级", "请输入等级", minimum=1) + if level is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_level( + guild, level, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_exp(self, group_id: int, qqid: int): + """Handle the qq guild set exp QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会经验") + if guild is None: + return + exp = self._qq_guild_prompt_int( + group_id, qqid, "设置公会经验", "请输入经验值", minimum=0) + if exp is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_exp( + guild, exp, self._guild_actor( + group_id, qqid))) + + def qq_guild_transfer_owner(self, group_id: int, qqid: int): + """Handle the qq guild transfer owner QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "转让公会会长") + if guild is None: + return + player = self._qq_guild_prompt_text( + group_id, qqid, "转让公会会长", "请输入新会长玩家名") + if player is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_transfer_guild_owner( + guild, player, self._guild_actor( + group_id, qqid))) + + def qq_guild_force_join(self, group_id: int, qqid: int): + """Handle the qq guild force join QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "强制玩家加入公会") + if guild is None: + return + player = self._qq_guild_prompt_text( + group_id, qqid, "强制玩家加入公会", "请输入玩家名") + if player is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_force_join_guild( + guild, player, self._guild_actor( + group_id, qqid))) + + def qq_guild_force_leave(self, group_id: int, qqid: int): + """Handle the qq guild force leave QQ menu operation.""" + player = self._qq_guild_prompt_text( + group_id, qqid, "强制玩家退出公会", "请输入玩家名") + if player is None: + return + if not self._qq_guild_confirm( + group_id, + qqid, + "强制玩家退出公会", + f"即将移出玩家:{player}"): + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_force_leave_guild( + player, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_frozen(self, group_id: int, qqid: int, frozen: bool): + """Handle the qq guild set frozen QQ menu operation.""" + title = "冻结公会" if frozen else "解冻公会" + guild = self._qq_guild_prompt_guild(group_id, qqid, title) + if guild is None: + return + reason = "" + if frozen: + reason = self._qq_guild_prompt_text( + group_id, qqid, title, "请输入冻结原因,可输入 无", allow_empty=True) + if reason is None: + return + if reason == "无": + reason = "" + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_frozen( + guild, frozen, reason, self._guild_actor( + group_id, qqid))) + + def qq_guild_add_funds(self, group_id: int, qqid: int): + """Handle the qq guild add funds QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "调整公会资金") + if guild is None: + return + amount = self._qq_guild_prompt_int( + group_id, qqid, "调整公会资金", "请输入调整数量,可为负数") + if amount is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_add_guild_funds( + guild, amount, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_funds(self, group_id: int, qqid: int): + """Handle the qq guild set funds QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会资金") + if guild is None: + return + amount = self._qq_guild_prompt_int( + group_id, qqid, "设置公会资金", "请输入资金余额", minimum=0) + if amount is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_funds( + guild, amount, self._guild_actor( + group_id, qqid))) + + def qq_guild_add_member_contribution(self, group_id: int, qqid: int): + """Handle the qq guild add member contribution QQ menu operation.""" + player = self._qq_guild_prompt_text(group_id, qqid, "调整成员贡献", "请输入玩家名") + if player is None: + return + amount = self._qq_guild_prompt_int( + group_id, qqid, "调整成员贡献", "请输入调整数量,可为负数") + if amount is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_add_member_contribution( + player, amount, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_member_contribution(self, group_id: int, qqid: int): + """Handle the qq guild set member contribution QQ menu operation.""" + player = self._qq_guild_prompt_text(group_id, qqid, "设置成员贡献", "请输入玩家名") + if player is None: + return + amount = self._qq_guild_prompt_int( + group_id, qqid, "设置成员贡献", "请输入贡献值", minimum=0) + if amount is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_member_contribution( + player, amount, self._guild_actor( + group_id, qqid))) + + def qq_guild_reset_contributions(self, group_id: int, qqid: int): + """Handle the qq guild reset contributions QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "清空公会贡献") + if guild is None: + return + if not self._qq_guild_confirm( + group_id, qqid, "清空公会贡献", f"即将清空公会贡献:{guild}"): + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_reset_guild_contributions( + guild, + self._guild_actor( + group_id, + qqid))) + + def qq_guild_broadcast_announcement(self, group_id: int, qqid: int): + """Handle the qq guild broadcast announcement QQ menu operation.""" + message = self._qq_guild_prompt_text( + group_id, qqid, "发送全服公会公告", "请输入公告内容") + if message is None: + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_broadcast_guild_announcement( + message, + self._guild_actor( + group_id, + qqid))) + + def qq_guild_backup_vault(self, group_id: int, qqid: int): + """Handle the qq guild backup vault QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "备份公会仓库") + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_backup_guild_vault( + guild, "qq", self._guild_actor( + group_id, qqid))) + + def qq_guild_clear_vault(self, group_id: int, qqid: int): + """Handle the qq guild clear vault QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "清空公会仓库") + if guild is None: + return + if not self._qq_guild_confirm( + group_id, qqid, "清空公会仓库", f"即将清空仓库:{guild}"): + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_clear_guild_vault( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_delete_vault_item(self, group_id: int, qqid: int): + """Handle the qq guild delete vault item QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "删除仓库物品") + if guild is None: + return + index = self._qq_guild_prompt_int( + group_id, qqid, "删除仓库物品", "请输入仓库序号", minimum=1) + if index is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_delete_guild_vault_item( + guild, index, self._guild_actor( + group_id, qqid))) + + def qq_guild_rollback_vault(self, group_id: int, qqid: int): + """Handle the qq guild rollback vault QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "回滚仓库备份") + if guild is None: + return + index = self._qq_guild_prompt_int( + group_id, qqid, "回滚仓库备份", "请输入备份序号", minimum=1, default=1) + if index is None: + return + if not self._qq_guild_confirm( + group_id, + qqid, + "回滚仓库备份", + f"即将回滚 {guild} 的仓库备份 {index}"): + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_rollback_guild_vault( + guild, index, self._guild_actor( + group_id, qqid))) + + def qq_guild_export_vault(self, group_id: int, qqid: int): + """Handle the qq guild export vault QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "导出仓库数据") + if guild is None: + return + ok, msg, data = self.guild_system.api_export_guild_vault(guild) + if not ok or not data: + self._reply_result(group_id, qqid, False, msg) + return + items = data.get("vault_items", []) + trade_logs = data.get("vault_trade_logs", []) + self._reply_guild_lines( + group_id, + qqid, + msg, + [f"仓库物品:{len(items)} 件", f"交易日志:{len(trade_logs)} 条"], + ) + + def qq_guild_reset_market_prices(self, group_id: int, qqid: int): + """Handle the qq guild reset market prices QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "重置市场价格") + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_reset_market_prices( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_clear_effects(self, group_id: int, qqid: int): + """Handle the qq guild clear effects QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "清空公会效果") + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_clear_guild_effects( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_effect(self, group_id: int, qqid: int): + """Handle the qq guild set effect QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会效果") + if guild is None: + return + effect = self._qq_guild_prompt_text( + group_id, qqid, "设置公会效果", "请输入效果ID或名称") + if effect is None: + return + level = self._qq_guild_prompt_int( + group_id, qqid, "设置公会效果", "请输入效果等级,0 表示移除", minimum=0) + if level is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_effect( + guild, effect, level, self._guild_actor( + group_id, qqid))) + + def qq_guild_refresh_tasks(self, group_id: int, qqid: int): + """Handle the qq guild refresh tasks QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "刷新公会任务") + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_refresh_guild_tasks( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_create_global_task(self, group_id: int, qqid: int): + """Handle the qq guild create global task QQ menu operation.""" + name = self._qq_guild_prompt_text(group_id, qqid, "创建全服任务", "请输入任务名称") + if name is None: + return + task_type = self._qq_guild_prompt_text( + group_id, qqid, "创建全服任务", "请输入任务类型,如 trade/collect/kill/build") + if task_type is None: + return + target = self._qq_guild_prompt_text( + group_id, qqid, "创建全服任务", "请输入任务目标") + if target is None: + return + target_count = self._qq_guild_prompt_int( + group_id, qqid, "创建全服任务", "请输入目标数量", minimum=1) + if target_count is None: + return + reward_exp = self._qq_guild_prompt_int( + group_id, qqid, "创建全服任务", "请输入经验奖励", minimum=0, default=0) + if reward_exp is None: + return + reward_contribution = self._qq_guild_prompt_int( + group_id, qqid, "创建全服任务", "请输入贡献奖励", minimum=0, default=0) + if reward_contribution is None: + return + description = self._qq_guild_prompt_text( + group_id, qqid, "创建全服任务", "请输入任务描述,可输入 默认", allow_empty=True) + if description is None: + return + if description == "默认": + description = name + deadline = self._qq_guild_prompt_int( + group_id, qqid, "创建全服任务", "请输入截止秒数,0 表示无限期", minimum=0, default=0) + if deadline is None: + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_create_global_task( + name, + task_type, + target, + target_count, + reward_exp, + reward_contribution, + description, + deadline, + self._guild_actor(group_id, qqid), + ), + ) + + def qq_guild_delete_task(self, group_id: int, qqid: int): + """Handle the qq guild delete task QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "删除公会任务") + if guild is None: + return + task = self._qq_guild_prompt_text( + group_id, qqid, "删除公会任务", "请输入任务ID或名称") + if task is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_delete_guild_task( + guild, task, self._guild_actor( + group_id, qqid))) + + def qq_guild_reset_task(self, group_id: int, qqid: int): + """Handle the qq guild reset task QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "重置任务进度") + if guild is None: + return + task = self._qq_guild_prompt_text( + group_id, qqid, "重置任务进度", "请输入任务ID或名称") + if task is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_reset_guild_task_progress( + guild, task, self._guild_actor( + group_id, qqid))) + + def qq_guild_complete_task(self, group_id: int, qqid: int): + """Handle the qq guild complete task QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "强制完成任务") + if guild is None: + return + task = self._qq_guild_prompt_text( + group_id, qqid, "强制完成任务", "请输入任务ID或名称") + if task is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_force_complete_guild_task( + guild, task, self._guild_actor( + group_id, qqid))) + + def qq_guild_teleport_base(self, group_id: int, qqid: int): + """Handle the qq guild teleport base QQ menu operation.""" + player = self._qq_guild_prompt_text( + group_id, qqid, "传送玩家到公会据点", "请输入玩家名") + if player is None: + return + guild = self._qq_guild_prompt_optional_query( + group_id, qqid, "传送玩家到公会据点", "请输入公会名/ID,输入 全部 使用玩家所在公会") + if guild is None: + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_teleport_player_to_guild_base( + player, + guild or None)) + + def qq_guild_delete_base(self, group_id: int, qqid: int): + """Handle the qq guild delete base QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "删除公会据点") + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_delete_guild_base( + guild, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_base(self, group_id: int, qqid: int): + """Handle the qq guild set base QQ menu operation.""" + guild = self._qq_guild_prompt_guild(group_id, qqid, "设置公会据点") + if guild is None: + return + dimension = self._qq_guild_prompt_int( + group_id, qqid, "设置公会据点", "请输入维度ID") + if dimension is None: + return + x = self._qq_guild_prompt_text(group_id, qqid, "设置公会据点", "请输入 x 坐标") + if x is None: + return + y = self._qq_guild_prompt_text(group_id, qqid, "设置公会据点", "请输入 y 坐标") + if y is None: + return + z = self._qq_guild_prompt_text(group_id, qqid, "设置公会据点", "请输入 z 坐标") + if z is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_base( + guild, dimension, x, y, z, self._guild_actor( + group_id, qqid))) + + def qq_guild_set_base_locked(self, group_id: int, qqid: int, locked: bool): + """Handle the qq guild set base locked QQ menu operation.""" + title = "锁定公会据点" if locked else "解锁公会据点" + guild = self._qq_guild_prompt_guild(group_id, qqid, title) + if guild is None: + return + self._reply_guild_api_result( + group_id, qqid, self.guild_system.api_set_guild_base_locked( + guild, locked, self._guild_actor( + group_id, qqid))) + + def qq_guild_show_activity_status(self, group_id: int, qqid: int): + """Handle the qq guild show activity status QQ menu operation.""" + ok, msg, data = self.guild_system.api_get_guild_activity_status() + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + lines = [] + for key, event in data.items(): + lines.append( + f"{key}: 倍率 {event.get('multiplier',1)} 剩余 {event.get('remaining_seconds',0)} 秒 发起 {event.get('actor','<未知>')}") + self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无活动"]) + + def qq_guild_start_activity( + self, + group_id: int, + qqid: int, + activity: str, + multiplier: float): + """Handle the qq guild start activity QQ menu operation.""" + duration = self._qq_guild_prompt_int( + group_id, qqid, "开启公会活动", "请输入持续秒数", minimum=1, default=3600) + if duration is None: + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_start_guild_activity( + activity, + duration, + multiplier, + self._guild_actor( + group_id, + qqid))) + + def qq_guild_stop_activity(self, group_id: int, qqid: int): + """Handle the qq guild stop activity QQ menu operation.""" + choice = self._qq_guild_prompt( + group_id, + qqid, + "停止活动", + ["双倍经验", "双倍贡献", "公会争霸"], + ["输入 [1-3] 之间的数字以选择 活动", "输入 . 退出"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + activity = { + "1": "exp", + "2": "contribution", + "3": "contest"}.get( + choice.strip()) + if activity is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + self._reply_guild_api_result( + group_id, qqid, + self.guild_system.api_stop_guild_activity(activity)) + + def qq_guild_settle_rewards(self, group_id: int, qqid: int): + """Handle the qq guild settle rewards QQ menu operation.""" + sort_by = self._qq_guild_prompt_text( + group_id, qqid, "结算排行奖励", + "请输入排行类型:level/members/contribution/activity") + if sort_by is None: + return + top = self._qq_guild_prompt_int( + group_id, qqid, "结算排行奖励", "请输入结算名次", minimum=1, default=3) + if top is None: + return + reward_exp = self._qq_guild_prompt_int( + group_id, qqid, "结算排行奖励", "请输入经验奖励", minimum=0, default=0) + if reward_exp is None: + return + reward_funds = self._qq_guild_prompt_int( + group_id, qqid, "结算排行奖励", "请输入资金奖励", minimum=0, default=0) + if reward_funds is None: + return + self._reply_guild_api_result( + group_id, + qqid, + self.guild_system.api_settle_guild_ranking_rewards( + sort_by, + top, + reward_exp, + reward_funds, + self._guild_actor( + group_id, + qqid))) + + def ensure_land_system(self, group_id: int, sender: int): + """检查领地系统云链联动版 API 是否可用。""" + if self.land_system is None: + self._reply_to_qq(group_id, sender, "相关插件未安装:领地系统云链联动版") + return False + return self.ensure_linked_plugin_enabled( + self.land_system, group_id, sender) + + def _format_land_summary(self, land: dict[str, Any], group_id: int): + """Implement the format land summary operation.""" + center = land.get("center", (0, 0, 0)) + admins = "、".join(land.get("admins", [])) or "无" + members = "、".join(land.get("members", [])) or "无" + return self.plugin_ui_menu( + "领地系统云链联动版", + f"领地信息 - {land.get('name', '<未知>')}", + [ + f"领主:{land.get('owner', '<未知>')}", + f"中心:{center[0]}, {center[1]}, {center[2]}", + f"范围:{land.get('range_text', '<未知>')}", + f"管理员:{admins}", + f"成员:{members}", + ], + ["输入 . 退出"], + group_id, + ) + + def _qq_land_prompt( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + hints: list[str], + ): + """Implement the qq land prompt operation.""" + return self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "领地系统云链联动版", + subtitle, + options, + hints, + group_id, + ), + timeout=120, + ) + + def qq_land_system_menu(self, group_id: int, qqid: int): + """在群里打开领地系统云链联动版管理菜单。""" + if not self._can_use_group_permission(group_id, qqid, "领地系统权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if not self.ensure_land_system(group_id, qqid): + return + choice = self._qq_land_prompt( + group_id, + qqid, + "管理菜单", + [ + "查看领地列表", + "查看领地信息", + "新增玩家领地", + "删除玩家领地", + "添加领地成员", + "移除领地成员", + "添加领地管理员", + "移除领地管理员", + "修改领地所有者", + "修改领地中心点", + "修改领地范围", + ], + ["输入 [1-11] 之间的数字以选择 对应功能", "输入 . 退出"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + handler_map = { + "1": self.qq_land_list, + "2": self.qq_land_show_info, + "3": self.qq_land_add, + "4": self.qq_land_delete, + "5": lambda gid, + qid: self.qq_land_member_action( + gid, + qid, + is_add=True, + rank="member"), + "6": lambda gid, + qid: self.qq_land_member_action( + gid, + qid, + is_add=False, + rank="member"), + "7": lambda gid, + qid: self.qq_land_member_action( + gid, + qid, + is_add=True, + rank="admin"), + "8": lambda gid, + qid: self.qq_land_member_action( + gid, + qid, + is_add=False, + rank="admin"), + "9": self.qq_land_transfer_owner, + "10": self.qq_land_update_center, + "11": self.qq_land_update_range, + } + handler = handler_map.get(choice) + if handler is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + handler(group_id, qqid) + + def qq_land_list(self, group_id: int, qqid: int): # skipcq: PY-R1000 + """Handle the qq land list QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + if not self.ensure_land_system(group_id, qqid): + return + ok, msg, lands = self.land_system.api_list_lands() + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + if not lands: + self._reply_to_qq(group_id, qqid, "暂无任何领地") + return + page = 1 + per_page = self.get_group_land_items_per_page(group_id) + while True: + total_pages, start_index, end_index = ( + utils.paginate(len(lands), per_page, page) + if hasattr(utils, "paginate") + else self.simple_paginate(len(lands), per_page, page) + ) + page_lands = lands[start_index - 1: end_index] + text = self.plugin_ui_menu( + "领地系统云链联动版", + "领地列表", + [ + f"{land['name']} - 领主: {land['owner']}, {land['range_text']}" + for land in page_lands + ], + [ + f"当前第 {page}/{total_pages} 页", + f"输入 [1-{len(page_lands)}] 之间的数字查看详情", + "输入 - 转到上一页", + "输入 + 转到下一页", + "输入 正整数+页 转到对应页", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, text, timeout=120) + if user_input is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(user_input, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if user_input == "+": + if page < total_pages: + page += 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是最后一页啦~") + continue + if user_input == "-": + if page > 1: + page -= 1 + else: + self._reply_to_qq(group_id, qqid, "❀ 已经是第一页啦~") + continue + if page_num := self.parse_page_jump(user_input): + if 1 <= page_num <= total_pages: + page = page_num + else: + self._reply_to_qq(group_id, qqid, f"❀ 不存在第 {page_num} 页") + continue + choice = self.parse_displayed_menu_choice( + user_input, len(page_lands)) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + continue + self._reply_to_qq( + group_id, + qqid, + self._format_land_summary(page_lands[choice - 1], group_id), + ) + return + + def _qq_land_prompt_text( + self, + group_id: int, + qqid: int, + subtitle: str, + prompt: str): + """Implement the qq land prompt text operation.""" + value = self._qq_land_prompt( + group_id, qqid, subtitle, [], [ + prompt, "输入 . 退出"]) + if value is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + if self._is_menu_exit(value, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + return value + + @staticmethod + def _parse_land_pos(raw: str): + """Implement the parse land pos operation.""" + parts = raw.replace(",", " ").replace(",", " ").split() + if len(parts) != 3: + return None + try: + return (float(parts[0]), float(parts[1]), float(parts[2])) + except ValueError: + return None + + @staticmethod + def _parse_land_size(raw: str): + """Implement the parse land size operation.""" + parts = raw.replace(",", " ").replace(",", " ").split() + if len(parts) != 3: + return None + try: + size = (int(parts[0]), int(parts[1]), int(parts[2])) + except ValueError: + return None + if any(value <= 0 for value in size): + return None + return size + + def qq_land_show_info(self, group_id: int, qqid: int): + """Handle the qq land show info QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + query = self._qq_land_prompt_text(group_id, qqid, "查看领地信息", "请输入领地名称") + if query is None: + return + ok, msg, land = self.land_system.api_get_land(query) + self._reply_to_qq( + group_id, + qqid, + self._format_land_summary(land, group_id) if ok else msg, + ) + + def qq_land_add(self, group_id: int, qqid: int): + """Handle the qq land add QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + owner = self._qq_land_prompt_text( + group_id, qqid, "新增玩家领地", "请输入领地主人玩家名") + if owner is None: + return + name = self._qq_land_prompt_text(group_id, qqid, "新增玩家领地", "请输入领地名称") + if name is None: + return + center_text = self._qq_land_prompt_text( + group_id, qqid, "新增玩家领地", "请输入领地中心坐标,格式:x y z") + if center_text is None: + return + center = self._parse_land_pos(center_text) + if center is None: + self._reply_to_qq(group_id, qqid, "❀ 坐标格式有误") + return + shape_choice = self._qq_land_prompt( + group_id, + qqid, + "新增玩家领地", + ["圆形领地", "方形领地"], + ["输入 [1-2] 之间的数字以选择 领地类型", "输入 . 退出"], + ) + if shape_choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(shape_choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if shape_choice == "1": + radius_text = self._qq_land_prompt_text( + group_id, qqid, "新增圆形领地", "请输入领地半径") + if radius_text is None: + return + try: + radius = int(radius_text) + except ValueError: + self._reply_to_qq(group_id, qqid, "❀ 半径必须为整数") + return + ok, msg, _land = self.land_system.api_add_land( + owner, name, center, "圆形", radius=radius) + elif shape_choice == "2": + size_text = self._qq_land_prompt_text( + group_id, qqid, "新增方形领地", "请输入 长 高 宽") + if size_text is None: + return + size = self._parse_land_size(size_text) + if size is None: + self._reply_to_qq(group_id, qqid, "❀ 方形尺寸格式有误") + return + ok, msg, _land = self.land_system.api_add_land( + owner, name, center, "方形", size=size) + else: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + self._reply_result(group_id, qqid, ok, msg) + + def qq_land_delete(self, group_id: int, qqid: int): + """Handle the qq land delete QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + query = self._qq_land_prompt_text(group_id, qqid, "删除玩家领地", "请输入领地名称") + if query is None: + return + ok, msg, _data = self.land_system.api_delete_land(query) + self._reply_result(group_id, qqid, ok, msg) + + def qq_land_member_action( + self, + group_id: int, + qqid: int, + is_add: bool, + rank: str): + """Handle the qq land member action QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + title = "添加" if is_add else "移除" + role = "管理员" if rank == "admin" else "成员" + query = self._qq_land_prompt_text( + group_id, qqid, f"{title}领地{role}", "请输入领地名称") + if query is None: + return + player_name = self._qq_land_prompt_text( + group_id, qqid, f"{title}领地{role}", "请输入玩家名称") + if player_name is None: + return + if is_add: + ok, msg, _land = self.land_system.api_add_member( + query, player_name, rank=rank) + elif rank == "admin": + ok, msg, _land = self.land_system.api_set_member_rank( + query, player_name, "member") + else: + ok, msg, _land = self.land_system.api_remove_member( + query, player_name) + self._reply_result(group_id, qqid, ok, msg) + + def qq_land_transfer_owner(self, group_id: int, qqid: int): + """Handle the qq land transfer owner QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + query = self._qq_land_prompt_text(group_id, qqid, "修改领地所有者", "请输入领地名称") + if query is None: + return + owner = self._qq_land_prompt_text( + group_id, qqid, "修改领地所有者", "请输入新所有者玩家名") + if owner is None: + return + ok, msg, _land = self.land_system.api_transfer_owner(query, owner) + self._reply_result(group_id, qqid, ok, msg) + + def qq_land_update_center(self, group_id: int, qqid: int): + """Handle the qq land update center QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + query = self._qq_land_prompt_text(group_id, qqid, "修改领地中心点", "请输入领地名称") + if query is None: + return + center_text = self._qq_land_prompt_text( + group_id, qqid, "修改领地中心点", "请输入新中心坐标,格式:x y z") + if center_text is None: + return + center = self._parse_land_pos(center_text) + if center is None: + self._reply_to_qq(group_id, qqid, "❀ 坐标格式有误") + return + ok, msg, _land = self.land_system.api_update_land_center(query, center) + self._reply_result(group_id, qqid, ok, msg) + + def qq_land_update_range(self, group_id: int, qqid: int): + """Handle the qq land update range QQ menu operation.""" + if not self._ensure_group_permission(group_id, qqid, "领地系统权限"): + return + query = self._qq_land_prompt_text(group_id, qqid, "修改领地范围", "请输入领地名称") + if query is None: + return + ok, msg, land = self.land_system.api_get_land(query) + if not ok: + self._reply_result(group_id, qqid, False, msg) + return + if land.get("shape") == "方形": + size_text = self._qq_land_prompt_text( + group_id, qqid, "修改方形领地范围", "请输入新的 长 高 宽") + if size_text is None: + return + size = self._parse_land_size(size_text) + if size is None: + self._reply_to_qq(group_id, qqid, "❀ 方形尺寸格式有误") + return + ok, msg, _land = self.land_system.api_update_land_range( + query, size=size) + else: + radius_text = self._qq_land_prompt_text( + group_id, qqid, "修改圆形领地范围", "请输入新的半径") + if radius_text is None: + return + try: + radius = int(radius_text) + except ValueError: + self._reply_to_qq(group_id, qqid, "❀ 半径必须为整数") + return + ok, msg, _land = self.land_system.api_update_land_range( + query, radius=radius) + self._reply_result(group_id, qqid, ok, msg) + + @staticmethod + def translate_item_name(item_id: str): + """尽量把物品 ID 翻译成中文显示名,失败时退回原始 ID。""" + if not isinstance(item_id, str) or item_id == "": + return "未知物品" + item_tail = item_id.split(":")[-1] + if translate is None: + return item_id + for key in (f"item.{item_tail}.name", f"tile.{item_tail}.name"): + try: + translated = translate(key) + except Exception: + continue + if isinstance( + translated, + str) and translated and translated != key: + return translated + return item_id + + @staticmethod + def get_item_custom_name(slot: Any): + """尝试从物品槽位对象里提取自定义名称。""" + for attr in ("customName", "custom_name", "name"): + value = getattr(slot, attr, None) + if ( + isinstance(value, str) + and value.strip() + and value.strip() != getattr(slot, "id", "") + ): + return value.strip() + return "" + + @staticmethod + def get_item_enchantments_text(slot: Any): + """把槽位上的附魔信息整理成单行文字。""" + enchants = getattr(slot, "enchantments", None) + if not isinstance(enchants, list) or not enchants: + return "" + outputs: list[str] = [] + for enchant in enchants: + if enchant is None: + continue + name = getattr(enchant, "name", None) + level = getattr(enchant, "level", None) + if isinstance(name, str) and name.strip(): + if isinstance(level, int): + outputs.append(f"{name.strip()} {level}") + else: + outputs.append(name.strip()) + else: + etype = getattr(enchant, "type", None) + if etype is not None: + if isinstance(level, int): + outputs.append(f"ID{etype} {level}") + else: + outputs.append(f"ID{etype}") + return "、".join(outputs) + + def on_qq_add_admin(self, group_id: int, sender: int, args: list[str]): + """给当前群添加普通管理员。""" + if not self._can_use_group_permission(group_id, sender, "QQ普通管理员菜单权限"): + self._reply_menu_permission_denied(group_id, sender) + return + try: + qqid = int(args[0]) + except (TypeError, ValueError): + self._reply_to_qq(group_id, sender, "QQ号格式有误") + return + _ok, msg = self.add_group_role(group_id, qqid, is_super=False) + self._reply_to_qq(group_id, sender, msg) + + def qq_admin_menu(self, group_id: int, qqid: int): + """QQ群管理员菜单。 + + 可管理的层级由“权限设置”中的管理员菜单权限决定。 + """ + options = [] + actions = [] + if self._can_use_group_permission(group_id, qqid, "QQ普通管理员菜单权限"): + options.append("普通管理员管理") + actions.append(lambda: self.qq_admin_role_menu( + group_id, qqid, is_super=False)) + if self._can_use_group_permission(group_id, qqid, "QQ超级管理员菜单权限"): + options.append("超级管理员管理") + actions.append(lambda: self.qq_admin_role_menu( + group_id, qqid, is_super=True)) + if not options: + self._reply_menu_permission_denied(group_id, qqid) + return + + if len(options) == 1: + actions[0]() + return + + choice = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + "QQ群管理员 管理菜单", + options, + [f"输入 [1-{len(options)}] 之间的数字以选择 对应功能", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + choice = choice.strip() + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + selected = self.parse_displayed_menu_choice(choice, len(options)) + if selected is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + actions[selected - 1]() + + def qq_admin_role_menu(self, group_id: int, qqid: int, is_super: bool): + """增删指定层级的群管理员。""" + permission_name = "QQ超级管理员菜单权限" if is_super else "QQ普通管理员菜单权限" + if not self._can_use_group_permission(group_id, qqid, permission_name): + self._reply_menu_permission_denied(group_id, qqid) + return + role_name = "超级管理员" if is_super else "普通管理员" + choice = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"{role_name} 管理菜单", + [f"添加{role_name}", f"删除{role_name}"], + ["输入 [1-2] 之间的数字以选择 对应功能", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if choice not in ("1", "2"): + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + qq_text = self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "群服互通云链版Ultra版", + f"{role_name} 管理菜单", + [], + [f"请输入要{'添加' if choice == '1' else '删除'}的 QQ 号", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if qq_text is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(qq_text, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + qqid_target = utils.try_int(qq_text) + if qqid_target is None or qqid_target <= 0: + self._reply_to_qq(group_id, qqid, "❀ QQ号格式有误") + return + if choice == "1": + ok, msg = self.add_group_role( + group_id, qqid_target, is_super=is_super) + else: + ok, msg = self.remove_group_role( + group_id, qqid_target, is_super=is_super) + self._reply_result(group_id, qqid, ok, msg) + + def ensure_whitelist_checker(self, group_id: int, sender: int): + """检查白名单联动插件是否可用,避免菜单点进去后才报空引用。""" + if self.whitelist_checker is None: + self._reply_to_qq(group_id, sender, "相关插件未安装:白名单&管理员检测云链联动版") + return False + return self.ensure_linked_plugin_enabled( + self.whitelist_checker, group_id, sender) + + def on_qq_whitelist_add(self, group_id: int, sender: int, args: list[str]): + """通过群命令把玩家加入白名单。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + ok, msg = self.whitelist_checker.add_whitelist_player(args[0]) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_whitelist_remove( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令把玩家移出白名单。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + ok, msg = self.whitelist_checker.remove_whitelist_player(args[0]) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_server_admin_add( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令把玩家登记为服务器管理员。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + ok, msg = self.whitelist_checker.add_admin_player(args[0]) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_server_admin_remove( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令把玩家从服务器管理员名单中移除。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + ok, msg = self.whitelist_checker.remove_admin_player(args[0]) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_whitelist_toggle( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令切换白名单检测开关。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + action = args[0].strip() + if action not in ("开启", "关闭", "on", "off"): + self._reply_to_qq(group_id, sender, "参数错误,格式:白名单检测 [开启/关闭]") + return + ok, msg = self.whitelist_checker.set_whitelist_enabled( + action in ("开启", "on")) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_admin_check_toggle( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令切换管理员检测开关。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + action = args[0].strip() + if action not in ("开启", "关闭", "on", "off"): + self._reply_to_qq(group_id, sender, "参数错误,格式:管理员检测 [开启/关闭]") + return + ok, msg = self.whitelist_checker.set_admin_check_enabled( + action in ("开启", "on")) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_check_interval( + self, + group_id: int, + sender: int, + args: list[str]): + """通过群命令修改联动插件的轮询周期。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + try: + seconds = float(args[0]) + except (TypeError, ValueError): + self._reply_to_qq(group_id, sender, "参数错误,格式:检测周期 [秒数]") + return + ok, msg = self.whitelist_checker.set_check_interval(seconds) + self._reply_result(group_id, sender, ok, msg) + + def on_qq_check_status(self, group_id: int, sender: int, _args: list[str]): + """把联动插件当前状态摘要发回群里。""" + if not self._can_use_group_permission(group_id, sender, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if not self.ensure_whitelist_checker(group_id, sender): + return + status = self.whitelist_checker.get_runtime_status() + output = ( + f"[CQ:at,qq={sender}] 白名单&管理员检测云链联动版状态:\n" + f"检测周期:{status['check_interval']} 秒\n" + f"白名单检测:{'开启' if status['whitelist_enabled'] else '关闭'}\n" + f"白名单人数:{status['whitelist_count']}\n" + f"管理员检测:{'开启' if status['admin_check_enabled'] else '关闭'}\n" + f"管理员人数:{status['admin_count']}" + ) + self.sendmsg(group_id, output, do_remove_cq_code=False) + + def _qq_checker_prompt( + self, + group_id: int, + qqid: int, + subtitle: str, + options: list[str], + hints: list[str], + ): + """统一构造白名单联动菜单提示并等待回复。""" + return self.qq_prompt( + group_id, + qqid, + self.plugin_ui_menu( + "白名单&管理员检测云链联动版", + subtitle, + options, + hints, + group_id, + ), + timeout=120, + ) + + def _qq_checker_handle_player_action( + self, group_id: int, qqid: int, choice: str): + """处理添加/移除白名单与服务器管理员这四类玩家操作。""" + title_map = { + "1": "白名单 添加玩家", + "2": "白名单 移除玩家", + "3": "管理员 添加玩家", + "4": "管理员 移除玩家", + } + handler_map = { + "1": self.on_qq_whitelist_add, + "2": self.on_qq_whitelist_remove, + "3": self.on_qq_server_admin_add, + "4": self.on_qq_server_admin_remove, + } + player_name = self._qq_checker_prompt( + group_id, + qqid, + title_map[choice], + [], + ["请输入玩家名称", "输入 . 退出"], + ) + if player_name is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(player_name, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + handler_map[choice](group_id, qqid, [player_name]) + + def _qq_checker_handle_toggle_action( + self, group_id: int, qqid: int, choice: str): + """处理白名单检测和管理员检测的开关菜单。""" + subtitle = "白名单检测 设置" if choice == "5" else "管理员检测 设置" + handler = ( + self.on_qq_whitelist_toggle + if choice == "5" + else self.on_qq_admin_check_toggle + ) + action = self._qq_checker_prompt( + group_id, + qqid, + subtitle, + ["开启", "关闭"], + ["输入 [1-2] 之间的数字以选择 对应操作", "输入 . 退出"], + ) + if action is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(action, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + action_arg = {"1": ["开启"], "2": ["关闭"]}.get(action) + if action_arg is None: + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return + handler(group_id, qqid, action_arg) + + def _qq_checker_handle_interval_action(self, group_id: int, qqid: int): + """处理检测周期设置菜单。""" + seconds = self._qq_checker_prompt( + group_id, + qqid, + "检测周期 设置", + [], + ["请输入检测周期秒数", "输入 . 退出"], + ) + if seconds is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(seconds, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + self.on_qq_check_interval(group_id, qqid, [seconds]) + + def qq_checker_menu(self, group_id: int, qqid: int): + """在群里打开白名单与管理员检测联动菜单。""" + if not self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if not self.ensure_whitelist_checker(group_id, qqid): + return + # 这个菜单本质上是把白名单插件暴露出来的 API 做了一层群聊版操作面板。 + # 这样权限仍然统一归群服互通管理,而不是把原插件的控制台能力原样暴露出来。 + choice = self._qq_checker_prompt( + group_id, + qqid, + "管理系统", + [ + "添加玩家到白名单", + "从白名单中移除玩家", + "添加服务器管理员", + "移除服务器管理员", + "开启/关闭 白名单检测", + "开启/关闭 管理员检测", + "设置检测周期", + "查看当前状态", + ], + ["输入 [1-8] 之间的数字以选择 对应功能", "输入 . 退出"], + ) + if choice is None: + self._reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self._is_menu_exit(choice, group_id): + self._reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + + if choice in ("1", "2", "3", "4"): + self._qq_checker_handle_player_action(group_id, qqid, choice) + return + + if choice in ("5", "6"): + self._qq_checker_handle_toggle_action(group_id, qqid, choice) + return + + if choice == "7": + self._qq_checker_handle_interval_action(group_id, qqid) + return + + if choice == "8": + self.on_qq_check_status(group_id, qqid, []) + return + + self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") diff --git "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index 988bfc75..3c5cf4f8 100644 --- "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -1,2495 +1,2466 @@ -"""Land protection cloud interop ToolDelta plugin.""" - -import copy -import json -import time -import threading -import os -import re -import random -import math -import shutil -import uuid -from typing import Dict, List, Optional, Tuple, Any -from tooldelta import Plugin, cfg, fmts, Player, Chat, plugin_entry, utils, game_utils - -from .config import ( - CONFIG_FILE_DIR, - DYNAMIC_LOAD_DEFAULT_INTERVAL, - DYNAMIC_LOAD_ENABLED_KEY, - DYNAMIC_LOAD_INTERVAL_KEY, - DYNAMIC_LOAD_SETTINGS_KEY, - NO_CREATE_REGIONS_FILE, - default_config, - default_no_create_regions, -) -from .geometry import ( - box_radius_for_size, - bounds_from_center_size, - boxes_intersect, - distance_to_land, - land_overlaps_candidate, - sphere_intersects_box, -) -from .models import LandData, LandMember, LandRank - - -PLUGIN_NAME = "领地系统云链联动版" -LEGACY_PLUGIN_NAME = "领地系统" - - -class ConsoleMenuExit(Exception): - """Signal that the console menu should exit.""" - - -class LandPlugin(Plugin): - """ToolDelta plugin entrypoint for land protection cloud interop.""" - - name = PLUGIN_NAME - author = "小石潭记qwq" - version = (0, 1, 18) - - def __init__(self, frame): - super().__init__(frame) - self.ListenPreload(self.on_preload) - self._stop_event = threading.Event() - self._config_file_state = None - self._no_create_regions_file_state = None - self._cfg_default = default_config() - self._cfg_std = cfg.auto_to_std(self._cfg_default) - self.make_data_path() - self._migrate_legacy_data_path() - self.no_create_regions_file = self.format_data_path( - NO_CREATE_REGIONS_FILE) - self.cfg = self._load_config(self._cfg_default, self._cfg_std) - self.data_file = self.format_data_path(str(self.cfg["数据文件"])) - self._apply_runtime_config_fields(reload_data_file=False) - self.no_create_regions_raw = self._load_no_create_regions() - self.no_create_regions = self._normalize_no_create_regions( - self.no_create_regions_raw) - - # 数据 - self.lands: Dict[str, LandData] = {} # land_id -> LandData - # xuid -> list of land_id - self.player_land_cache: Dict[str, List[str]] = {} - self.xuid_getter = None - - self.coords: Dict[str, Tuple[float, float, float]] = {} - self.coords_lock = threading.Lock() - self.recent_tp: Dict[str, float] = {} # xuid -> last tp time - self.tp_cooldown = 5 - self._detection_started = False - - self._ensure_dirs() - self._load_data() - - # 事件监听 - self.ListenChat(self.on_chat) - self.ListenPlayerJoin(self.on_player_join) - self.ListenPlayerLeave(self.on_player_leave) - self.ListenActive(self.on_active) - self.ListenFrameExit(self.on_frame_exit) - self.refresh_config_file_state() - self.config_thread = utils.createThread( - self.config_reload_task, - usage="领地系统配置热更新任务", - ) - - # 控制台测试指令 - self.frame.add_console_cmd_trigger( - ["领地云链测试", "领地测试"], "[玩家]", "测试领地系统云链联动版", self.console_test) - self.frame.add_console_cmd_trigger( - ["领地系统云链联动版", "领地系统"], None, "打开领地系统云链联动版控制台管理菜单", self.console_manage) - - def on_preload(self): - """Implement the on preload operation.""" - self.xuid_getter = self.GetPluginAPI("XUID获取", (0, 0, 7)) - - def on_active(self): - """Implement the on active operation.""" - if self.enabled: - self._start_detection() - - def on_frame_exit(self, _): - """Implement the on frame exit operation.""" - self._stop_event.set() - - @staticmethod - def _ui_border() -> str: - """Implement the ui border operation.""" - return "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧" - - @staticmethod - def _ui_title(title: str) -> str: - """Implement the ui title operation.""" - return f"§l§d❐§f 『§6领地系统云链联动版§f』 §b{title}" - - def _ui_menu(self, - title: str, - options: List[str], - hints: Optional[List[str]] = None) -> str: - """Implement the ui menu operation.""" - lines = [self._ui_border(), self._ui_title(title)] - lines.extend(f"§l§b[ §e{i}§b ] §r§e{option}" for i, - option in enumerate(options, 1)) - lines.append(self._ui_border()) - for hint in hints or []: - lines.append(f"§a❀ §b{hint}") - return "\n".join(lines) - - def _ui_card(self, - title: str, - lines: List[str], - hints: Optional[List[str]] = None) -> str: - """Implement the ui card operation.""" - body = [self._ui_border(), self._ui_title(title)] - body.extend(f"§a❀ §b{line}" for line in lines) - body.append(self._ui_border()) - for hint in hints or []: - body.append(f"§a❀ §b{hint}") - return "\n".join(body) - - @staticmethod - def _success(text: str) -> str: - """Implement the success operation.""" - return f"§a❀ §b{text}" - - @staticmethod - def _error(text: str) -> str: - """Implement the error operation.""" - return f"§c❀ §e{text}" - - @staticmethod - def _warn(text: str) -> str: - """Implement the warn operation.""" - return f"§6❀ §e{text}" - - @staticmethod - def _notice(text: str) -> str: - """Implement the notice operation.""" - return f"§a❀ §b{text}" - - @staticmethod - def _normalize_wake_words(raw: Any) -> List[str]: - """Normalize wake words values.""" - if isinstance(raw, str): - words = [raw] - elif isinstance(raw, list): - words = [str(item) for item in raw if isinstance(item, str)] - else: - words = [] - cleaned = [] - for word in words: - word = word.strip() - if word and word not in cleaned: - cleaned.append(word) - return cleaned or [".领地"] - - @classmethod - def _merge_config_with_default(cls, raw: Any, default: Any): - """Implement the merge config with default operation.""" - if isinstance(default, dict): - result = { - key: cls._merge_config_with_default( - raw.get(key) if isinstance(raw, dict) else None, - value, - ) - for key, value in default.items() - } - if isinstance(raw, dict): - for key, value in raw.items(): - if key not in result: - result[key] = copy.deepcopy(value) - return result - return copy.deepcopy( - raw) if raw is not None else copy.deepcopy(default) - - @staticmethod - def _trim_fixed_keys(raw: Any, default: Dict[str, Any]) -> Dict[str, Any]: - """Implement the trim fixed keys operation.""" - raw = raw if isinstance(raw, dict) else {} - return { - key: copy.deepcopy(raw.get(key, value)) - for key, value in default.items() - } - - @staticmethod - def _normalize_config_bool(value: Any, fallback: bool) -> bool: - """Normalize config bool values.""" - if isinstance(value, bool): - return value - if isinstance(value, str): - text = value.strip().lower() - if text in ("true", "1", "yes", "y", "on", "启用", "是", "真"): - return True - if text in ("false", "0", "no", "n", "off", "禁用", "否", "假"): - return False - if isinstance(value, (int, float)) and not isinstance(value, bool): - return bool(value) - return bool(fallback) - - @staticmethod - def _normalize_config_positive_int(value: Any, fallback: int) -> int: - """Normalize config positive int values.""" - if isinstance(value, bool): - return fallback - try: - result = int(value) - except (TypeError, ValueError): - return fallback - return result if result > 0 else fallback - - @staticmethod - def _normalize_config_non_negative_int(value: Any, fallback: int) -> int: - """Normalize config non negative int values.""" - if isinstance(value, bool): - return fallback - try: - result = int(value) - except (TypeError, ValueError): - return fallback - return result if result >= 0 else fallback - - @staticmethod - def _normalize_config_str( - value: Any, - fallback: str, - *, - allow_empty: bool = False) -> str: - """Normalize config str values.""" - if value is None: - return fallback - text = str(value) - if text or allow_empty: - return text - return fallback - - @classmethod - def _normalize_config_string_list( - cls, - value: Any, - fallback: List[str], - *, - allow_empty: bool = False, - ) -> List[str]: - """Normalize config string list values.""" - if isinstance(value, str): - candidates = [value] - elif isinstance(value, list): - candidates = value - else: - return copy.deepcopy(fallback) - - result: List[str] = [] - for item in candidates: - text = cls._normalize_config_str( - item, "", allow_empty=True).strip() - if text and text not in result: - result.append(text) - if result or allow_empty: - return result - return copy.deepcopy(fallback) - - @classmethod - def _normalize_runtime_config( - cls, raw_cfg: Any, default_cfg: Dict[str, Any]) -> Dict[str, Any]: - """Normalize runtime config values.""" - merged_cfg = cls._merge_config_with_default(raw_cfg, default_cfg) - normalized = cls._trim_fixed_keys(merged_cfg, default_cfg) - - dynamic_default = default_cfg[DYNAMIC_LOAD_SETTINGS_KEY] - dynamic = cls._trim_fixed_keys( - normalized.get(DYNAMIC_LOAD_SETTINGS_KEY), - dynamic_default, - ) - dynamic[DYNAMIC_LOAD_ENABLED_KEY] = cls._normalize_config_bool( - dynamic.get(DYNAMIC_LOAD_ENABLED_KEY), - dynamic_default[DYNAMIC_LOAD_ENABLED_KEY], - ) - dynamic[DYNAMIC_LOAD_INTERVAL_KEY] = cls._normalize_config_positive_int( - dynamic.get(DYNAMIC_LOAD_INTERVAL_KEY), - dynamic_default[DYNAMIC_LOAD_INTERVAL_KEY], - ) - normalized[DYNAMIC_LOAD_SETTINGS_KEY] = dynamic - - normalized["是否启用"] = cls._normalize_config_bool( - normalized.get("是否启用"), - default_cfg["是否启用"], - ) - normalized["唤醒词"] = cls._normalize_config_string_list( - normalized.get("唤醒词"), - default_cfg["唤醒词"], - ) - normalized["数据文件"] = cls._normalize_config_str( - normalized.get("数据文件"), - default_cfg["数据文件"], - ) - for key in ( - "检测间隔", - "传送半径", - "最大领地半径", - "最大领地长", - "最大领地高", - "最大领地宽", - "最大领地数量", - ): - normalized[key] = cls._normalize_config_positive_int( - normalized.get(key), - default_cfg[key], - ) - normalized["缓冲区距离"] = cls._normalize_config_non_negative_int( - normalized.get("缓冲区距离"), - default_cfg["缓冲区距离"], - ) - normalized["白名单"] = cls._normalize_config_string_list( - normalized.get("白名单"), - default_cfg["白名单"], - allow_empty=True, - ) - return normalized - - def _load_config( - self, default_cfg: Dict[str, Any], cfg_std: Any) -> Dict[str, Any]: - """Load config data.""" - try: - raw_cfg, _ = cfg.get_plugin_config_and_version( - self.name, - {}, - default_cfg, - self.version, - ) - merged_cfg = self._normalize_runtime_config(raw_cfg, default_cfg) - cfg.check_auto(cfg_std, merged_cfg) - except Exception as err: - fmts.print_err(f"领地系统云链联动版配置文件自动更新失败,已使用默认配置: {err}") - merged_cfg = self._normalize_runtime_config({}, default_cfg) - cfg.check_auto(cfg_std, merged_cfg) - cfg.upgrade_plugin_config(self.name, merged_cfg, self.version) - return merged_cfg - - def _apply_runtime_config_fields(self, reload_data_file: bool = False): - """Implement the apply runtime config fields operation.""" - self.enabled = bool(self.cfg["是否启用"]) - self.wake_words = self._normalize_wake_words(self.cfg["唤醒词"]) - new_data_file = self.format_data_path(str(self.cfg["数据文件"])) - data_file_changed = getattr(self, "data_file", None) != new_data_file - self.data_file = new_data_file - self.check_interval = self._positive_float(self.cfg["检测间隔"], 2.0) - self.buffer_dist = self._non_negative_float(self.cfg["缓冲区距离"], 5.0) - self.tp_radius = self._positive_float(self.cfg["传送半径"], 5000.0) - self.max_radius = self._positive_int(self.cfg["最大领地半径"], 200) - self.max_length = self._positive_int(self.cfg["最大领地长"], 200) - self.max_height = self._positive_int(self.cfg["最大领地高"], 200) - self.max_width = self._positive_int(self.cfg["最大领地宽"], 200) - self.max_lands_per_player = self._positive_int(self.cfg["最大领地数量"], 4) - self.whitelist = {str(name).lower() for name in self.cfg["白名单"]} - if reload_data_file and data_file_changed: - self.lands.clear() - self.player_land_cache.clear() - self._ensure_dirs() - self._load_data() - - @staticmethod - def _positive_int(value: Any, fallback: int) -> int: - """Implement the positive int operation.""" - try: - result = int(value) - except (TypeError, ValueError): - return fallback - return result if result > 0 else fallback - - @staticmethod - def _positive_float(value: Any, fallback: float) -> float: - """Implement the positive float operation.""" - try: - result = float(value) - except (TypeError, ValueError): - return fallback - return result if result > 0 else fallback - - @staticmethod - def _non_negative_float(value: Any, fallback: float) -> float: - """Implement the non negative float operation.""" - try: - result = float(value) - except (TypeError, ValueError): - return fallback - return result if result >= 0 else fallback - - def config_file_path(self) -> str: - """Implement the config file path operation.""" - return os.path.join(CONFIG_FILE_DIR, f"{self.name}.json") - - @staticmethod - def file_state(path: str) -> tuple[int, int] | None: - """Implement the file state operation.""" - try: - stat = os.stat(path) - except OSError: - return None - return stat.st_mtime_ns, stat.st_size - - def refresh_config_file_state(self): - """Implement the refresh config file state operation.""" - self._config_file_state = self.file_state(self.config_file_path()) - self._no_create_regions_file_state = self.file_state( - self.no_create_regions_file) - - def is_dynamic_config_reload_enabled(self) -> bool: - """Implement the is dynamic config reload enabled operation.""" - settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) - if not isinstance(settings, dict): - return True - return bool(settings.get(DYNAMIC_LOAD_ENABLED_KEY, True)) - - def dynamic_config_reload_interval(self) -> int: - """Implement the dynamic config reload interval operation.""" - settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) - if not isinstance(settings, dict): - return DYNAMIC_LOAD_DEFAULT_INTERVAL - try: - interval = int(settings.get(DYNAMIC_LOAD_INTERVAL_KEY, - DYNAMIC_LOAD_DEFAULT_INTERVAL)) - except (TypeError, ValueError): - return DYNAMIC_LOAD_DEFAULT_INTERVAL - return interval if interval > 0 else DYNAMIC_LOAD_DEFAULT_INTERVAL - - def apply_runtime_config(self, config_items: Dict[str, Any]) -> None: - """Apply already parsed config-center data to this plugin.""" - self.cfg = self._normalize_runtime_config( - config_items, self._cfg_default) - cfg.check_auto(self._cfg_std, self.cfg) - self._apply_runtime_config_fields(reload_data_file=True) - if self.enabled: - self._start_detection() - self.refresh_config_file_state() - - def reload_runtime_config(self, announce: bool = False): - """Implement the reload runtime config operation.""" - self.cfg = self._load_config(self._cfg_default, self._cfg_std) - self._apply_runtime_config_fields(reload_data_file=True) - if self.enabled: - self._start_detection() - if announce: - fmts.print_suc(f"{self.name} 配置文件已热更新") - - def reload_no_create_regions_config(self, announce: bool = False): - """Implement the reload no create regions config operation.""" - self.no_create_regions_raw = self._load_no_create_regions() - self._reload_no_create_regions() - if announce: - fmts.print_suc(f"{self.name} 不可创建领地区域配置已热更新") - - def config_reload_task(self): - """Implement the config reload task operation.""" - while not self._stop_event.wait(self.dynamic_config_reload_interval()): - if not self.is_dynamic_config_reload_enabled(): - self.refresh_config_file_state() - continue - current_config_state = self.file_state(self.config_file_path()) - current_regions_state = self.file_state( - self.no_create_regions_file) - if ( - current_config_state == self._config_file_state - and current_regions_state == self._no_create_regions_file_state - ): - continue - try: - if current_config_state != self._config_file_state: - self.reload_runtime_config(announce=True) - if current_regions_state != self._no_create_regions_file_state: - self.reload_no_create_regions_config(announce=True) - self.refresh_config_file_state() - except Exception as err: - self._config_file_state = current_config_state - self._no_create_regions_file_state = current_regions_state - fmts.print_err(f"{self.name} 配置文件热更新失败: {err}") - - def api_reload_land_config(self) -> Tuple[bool, str, Dict[str, Any]]: - """Expose the api reload land config API operation.""" - try: - self.reload_runtime_config(announce=False) - self.reload_no_create_regions_config(announce=False) - self.refresh_config_file_state() - except Exception as err: - return False, f"领地系统配置重载失败: {err}", self.api_get_runtime_status() - return True, "领地系统配置已重载", self.api_get_runtime_status() - - def api_get_runtime_status(self) -> Dict[str, Any]: - """Expose the api get runtime status API operation.""" - return { - "enabled": self.enabled, - "wake_words": list(self.wake_words), - "data_file": self.data_file, - "check_interval": self.check_interval, - "land_count": len(self.lands), - "no_create_region_count": len(self.no_create_regions), - "dynamic_reload_enabled": self.is_dynamic_config_reload_enabled(), - "dynamic_reload_interval": self.dynamic_config_reload_interval(), - } - - def _load_no_create_regions(self) -> List[Dict[str, Any]]: - """Load no create regions data.""" - if not os.path.exists(self.no_create_regions_file): - regions = default_no_create_regions() - self._save_no_create_regions(regions) - return regions - try: - with open(self.no_create_regions_file, "r", encoding="utf-8") as f: - data = json.load(f) - if isinstance(data, list): - return data - except Exception as err: - fmts.print_err(f"读取不可创建领地区域数据失败: {err}") - regions = default_no_create_regions() - self._save_no_create_regions(regions) - return regions - - def _save_no_create_regions( - self, regions: Optional[List[Dict[str, Any]]] = None): - """Save no create regions data.""" - data = self.no_create_regions_raw if regions is None else regions - os.makedirs( - os.path.dirname( - self.no_create_regions_file), - exist_ok=True) - with open(self.no_create_regions_file, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - def _reload_no_create_regions(self): - """Implement the reload no create regions operation.""" - self.no_create_regions = self._normalize_no_create_regions( - self.no_create_regions_raw) - - @staticmethod - def _as_float_pos(raw: Any) -> Optional[Tuple[float, float, float]]: - """Implement the as float pos operation.""" - if not isinstance(raw, list) or len(raw) != 3: - return None - try: - return (float(raw[0]), float(raw[1]), float(raw[2])) - except (TypeError, ValueError): - return None - - def _normalize_no_create_regions(self, raw: Any) -> List[Dict[str, Any]]: - """Normalize no create regions values.""" - regions: List[Dict[str, Any]] = [] - if not isinstance(raw, list): - return regions - for index, item in enumerate(raw, 1): - if not isinstance(item, dict) or not item.get("启用", True): - continue - region_type = str(item.get("类型", "")).strip().lower() - name = str(item.get("名称", f"区域{index}")).strip() or f"区域{index}" - if region_type in ("圆形", "circle", "round"): - center = self._as_float_pos(item.get("中心")) - try: - radius = float(item.get("半径", 0)) - except (TypeError, ValueError): - radius = 0 - if center is None or radius <= 0: - fmts.print_war(f"领地系统云链联动版: 不可创建领地区域 {name} 配置无效,已忽略") - continue - regions.append({ - "名称": name, - "类型": "圆形", - "中心": center, - "半径": radius, - }) - elif region_type in ("方形", "矩形", "长方形", "square", "box", "立方体", "长方体"): - start = self._as_float_pos(item.get("起点")) - end = self._as_float_pos(item.get("终点")) - if start is None or end is None: - fmts.print_war(f"领地系统云链联动版: 不可创建领地区域 {name} 配置无效,已忽略") - continue - min_pos = tuple(min(start[i], end[i]) for i in range(3)) - max_pos = tuple(max(start[i], end[i]) for i in range(3)) - regions.append({ - "名称": name, - "类型": "方形", - "起点": min_pos, - "终点": max_pos, - }) - else: - fmts.print_war(f"领地系统云链联动版: 不可创建领地区域 {name} 类型未知,已忽略") - return regions - - def _get_no_create_overlap_reason( - self, - center: Tuple[float, float, float], - radius: int, - shape: str = "圆形", - size: Optional[Tuple[int, int, int]] = None, - ) -> Optional[str]: - """Return no create overlap reason data.""" - shape = LandData.normalize_shape(shape) - box_bounds = None - if shape == "方形": - box_bounds = bounds_from_center_size( - center, LandData.normalize_size(size, radius)) - x, y, z = center - for region in self.no_create_regions: - if shape == "方形" and box_bounds is not None: - if region["类型"] == "圆形": - if sphere_intersects_box( - region["中心"], - region["半径"], - box_bounds[0], - box_bounds[1]): - return region["名称"] - elif region["类型"] == "方形": - if boxes_intersect( - box_bounds[0], - box_bounds[1], - region["起点"], - region["终点"]): - return region["名称"] - elif region["类型"] == "圆形": - rx, ry, rz = region["中心"] - if math.sqrt((x - rx) ** 2 + (y - ry) ** 2 + - (z - rz) ** 2) <= radius + region["半径"]: - return region["名称"] - elif region["类型"] == "方形": - if sphere_intersects_box( - center, radius, region["起点"], region["终点"]): - return region["名称"] - return None - - @staticmethod - def _is_exit_input(text: str) -> bool: - """Implement the is exit input operation.""" - return text.strip().lower() in (".", "。", "q", "quit", "退出") - - def _wait_menu_input( - self, - player: Player, - timeout: int = 60) -> Optional[str]: - """Implement the wait menu input operation.""" - msg = game_utils.waitMsg(player.name, timeout) - if msg is None: - player.show(self._error("回复超时,已退出菜单")) - return None - msg = msg.strip() - if self._is_exit_input(msg): - player.show(self._success("已退出领地菜单")) - return None - return msg - - def _select_menu( - self, - player: Player, - title: str, - options: List[str], - timeout: int = 60) -> Optional[int]: - """Implement the select menu operation.""" - while True: - hints = [ - f"输入 §e[1-{len(options)}]§b 之间的数字以选择功能", - "输入 §c.§b 退出", - ] - player.show(self._ui_menu( - title, - options, - )) - player.show("\n".join(f"§a❀ §b{hint}" for hint in hints)) - msg = self._wait_menu_input(player, timeout) - if msg is None: - return None - choice = utils.try_int(msg.strip().strip("[]")) - if choice is None or choice not in range(1, len(options) + 1): - player.show(self._error("您的输入有误")) - continue - return choice - - def _prompt_text( - self, - player: Player, - title: str, - prompt: str, - timeout: int = 60) -> Optional[str]: - """Implement the prompt text operation.""" - player.show(self._ui_card(title, [prompt], ["输入 §c.§b 退出"])) - return self._wait_menu_input(player, timeout) - - def _parse_box_size_input( - self, raw: str) -> Tuple[Optional[Tuple[int, int, int]], Optional[str]]: - """Implement the parse box size input operation.""" - parts = raw.replace(",", " ").replace(",", " ").split() - if len(parts) != 3: - return None, "格式错误,需要输入 长 高 宽 三个整数" - try: - length, height, width = ( - int(parts[0]), int(parts[1]), int(parts[2])) - except ValueError: - return None, "长、高、宽必须为整数" - if length <= 0 or height <= 0 or width <= 0: - return None, "长、高、宽必须大于 0" - if length > self.max_length: - return None, f"长不能超过 {self.max_length}" - if height > self.max_height: - return None, f"高不能超过 {self.max_height}" - if width > self.max_width: - return None, f"宽不能超过 {self.max_width}" - return (length, height, width), None - - def _get_xuid_by_name( - self, - playername: str, - allow_offline: bool = False) -> Optional[str]: - """Return xuid by name data.""" - try: - return str( - self.xuid_getter.get_xuid_by_name( - playername, - allow_offline=allow_offline)) - except Exception as err: - self.print_war(f"无法获取玩家 {playername} 的 XUID: {err}") - return None - - def _get_player_xuid(self, player: Player) -> Optional[str]: - """Return player xuid data.""" - xuid = getattr(player, "xuid", None) - if xuid: - return str(xuid) - return self._get_xuid_by_name(player.name) - - def _make_member(self, playername: str, rank: LandRank, - allow_offline: bool = False) -> Optional[LandMember]: - """Implement the make member operation.""" - xuid = self._get_xuid_by_name(playername, allow_offline=allow_offline) - if xuid is None: - return None - return LandMember(name=playername, xuid=xuid, rank=rank) - - def _select_land( - self, - player: Player, - title: str, - lands: List[LandData]) -> Optional[LandData]: - """Implement the select land operation.""" - if not lands: - player.show(self._error("没有可选择的领地")) - return None - choice = self._select_menu( - player, - title, - [ - f"{land.name} §7- 领主: {land.owner}, {land.range_text()}" - for land in lands - ], - ) - if choice is None: - return None - return lands[choice - 1] - - def _land_summary(self, land: LandData) -> Dict[str, Any]: - """Implement the land summary operation.""" - _ = self - admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] - members = [m.name for m in land.members if m.rank == LandRank.MEMBER] - return { - "land_id": land.land_id, - "name": land.name, - "owner": land.owner, - "owner_xuid": land.owner_xuid, - "center": land.center, - "radius": land.radius, - "shape": land.shape, - "size": land.get_size() if land.is_box() else None, - "dimension": land.dimension, - "range_text": land.range_text(), - "admins": admins, - "members": members, - "member_count": len(land.members), - } - - def _find_land_by_name_or_id(self, query: str) -> Optional[LandData]: - """Implement the find land by name or id operation.""" - query = str(query).strip() - if not query: - return None - if query in self.lands: - return self.lands[query] - return next((land for land in self.lands.values() - if land.name == query), None) - - def _migrate_legacy_data_path(self): - """Implement the migrate legacy data path operation.""" - legacy_path = os.path.join( - os.path.dirname( - self.data_path), - LEGACY_PLUGIN_NAME) - if not os.path.isdir(legacy_path) or os.path.abspath( - legacy_path) == os.path.abspath(self.data_path): - return - os.makedirs(self.data_path, exist_ok=True) - for filename in ("领地数据.json", "不可创建领地区域.json"): - old_file = os.path.join(legacy_path, filename) - new_file = self.format_data_path(filename) - if os.path.isfile(old_file) and not os.path.exists(new_file): - try: - shutil.copy2(old_file, new_file) - except Exception as err: - self.print_war(f"迁移旧版领地系统数据文件 {filename} 失败: {err}") - - def _ensure_dirs(self): - """Implement the ensure dirs operation.""" - os.makedirs(os.path.dirname(self.data_file) or ".", exist_ok=True) - - # ---------- 玩家-领地 缓存维护 ---------- - def _add_player_land(self, xuid: str, land_id: str): - """Implement the add player land operation.""" - if xuid not in self.player_land_cache: - self.player_land_cache[xuid] = [] - if land_id not in self.player_land_cache[xuid]: - self.player_land_cache[xuid].append(land_id) - - def _remove_player_land(self, xuid: str, land_id: str): - """Implement the remove player land operation.""" - if xuid in self.player_land_cache: - lst = self.player_land_cache[xuid] - if land_id in lst: - lst.remove(land_id) - if not lst: - del self.player_land_cache[xuid] - - def _rebuild_player_land_cache(self): - """Implement the rebuild player land cache operation.""" - self.player_land_cache.clear() - for land_id, land in self.lands.items(): - for member in land.members: - self._add_player_land(member.xuid, land_id) - - # ---------- 数据加载/保存 ---------- - def _load_data(self): - """Load data data.""" - try: - if os.path.exists(self.data_file): - with open(self.data_file, "r", encoding="utf-8") as f: - raw = json.load(f) - self.player_land_cache.clear() - for land_id, data in raw.items(): - try: - land = LandData.from_dict(data) - self.lands[land_id] = land - for m in land.members: - self._add_player_land(m.xuid, land_id) - except Exception as e: - fmts.print_err(f"解析领地 {land_id} 失败: {e}") - except Exception as e: - fmts.print_err(f"加载数据失败: {e}") - - def _save_data(self): - """Save data data.""" - try: - raw = {lid: land.to_dict() for lid, land in self.lands.items()} - with open(self.data_file, "w", encoding="utf-8") as f: - json.dump(raw, f, ensure_ascii=False, indent=2) - except Exception as e: - fmts.print_err(f"保存数据失败: {e}") - - # ---------- 坐标获取 ---------- - def _get_player_coord( - self, player: str) -> Optional[Tuple[float, float, float]]: - """Return player coord data.""" - try: - pos_dict = game_utils.getPos(player) - if pos_dict and "position" in pos_dict: - p = pos_dict["position"] - return (p.get("x", 0), p.get("y", 0), p.get("z", 0)) - except Exception: - pass - cmd = f"/data get entity {player} Pos" - try: - resp = self.game_ctrl.sendwscmd_with_resp(cmd, timeout=2) - if resp.SuccessCount: - for out in resp.OutputMessages: - msg = out.Message - nums = re.findall(r"[-+]?\d*\.?\d+[df]?", msg) - if len(nums) >= 3: - x = float(nums[0].replace('d', '').replace('f', '')) - y = float(nums[1].replace('d', '').replace('f', '')) - z = float(nums[2].replace('d', '').replace('f', '')) - return (x, y, z) - except Exception: - pass - return None - - def _manual_coord( - self, player: Player) -> Optional[Tuple[float, float, float]]: - """Implement the manual coord operation.""" - player.show(self._ui_card( - "手动坐标输入", - [ - "请输入你的当前坐标", - "格式:x y z,例如 100 64 200", - "请在聊天栏直接输入数字,用空格分隔", - ], - ["30 秒内回复有效"], - )) - msg = game_utils.waitMsg(player.name, 30) - if not msg: - player.show(self._error("输入超时")) - return None - parts = msg.strip().split() - if len(parts) != 3: - player.show(self._error("格式错误,需要三个数字")) - return None - try: - x = float(parts[0]) - y = float(parts[1]) - z = float(parts[2]) - return (x, y, z) - except ValueError: - player.show(self._error("请输入有效的数字")) - return None - - # ---------- 领地查找辅助 ---------- - def _find_land_at(self, - pos: Tuple[float, - float, - float], - dimension: int = 0) -> Optional[LandData]: - """Implement the find land at operation.""" - x, y, z = pos - for land in self.lands.values(): - if land.dimension != dimension: - continue - if land.contains_pos((x, y, z)): - return land - return None - - def _find_land_at_player(self, player: str) -> Optional[LandData]: - """Implement the find land at player operation.""" - pos = self._get_player_coord(player) - if pos: - return self._find_land_at(pos) - return None - - # ---------- 检测线程 ---------- - def _start_detection(self): - """Implement the start detection operation.""" - if self._detection_started: - return - self._detection_started = True - - def loop(): - """Implement the loop operation.""" - while not self._stop_event.wait(self.check_interval): - try: - self._update_coords() - self._check_lands() - except Exception as e: - fmts.print_err(f"检测异常: {e}") - threading.Thread(target=loop, daemon=True).start() - - def _update_coords(self): - """Implement the update coords operation.""" - if not self.enabled: - return - players = self.game_ctrl.allplayers - new_coords = {} - for name in players: - pos = self._get_player_coord(name) - if pos: - new_coords[name] = pos - with self.coords_lock: - self.coords = new_coords - - def _check_lands(self): - """Implement the check lands operation.""" - if not self.enabled: - return - with self.coords_lock: - players = dict(self.coords) - - now = time.time() - expired = [ - p for p, - t in self.recent_tp.items() if now - - t > self.tp_cooldown] - for p in expired: - del self.recent_tp[p] - - for name, (x, y, z) in players.items(): - if name.lower() in self.whitelist: - continue - xuid = self._get_xuid_by_name(name) - if xuid is None: - continue - - in_land = self._find_land_at((x, y, z)) - - if in_land: - if in_land.get_member(xuid): - continue - if xuid in self.recent_tp: - continue - self._tp_random(name, x, y, z) - self.recent_tp[xuid] = now - self.game_ctrl.say_to(name, self._error( - f"你闯入了 {in_land.owner} 的领地,已被传送离开")) - else: - for land in self.lands.values(): - if land.dimension != 0: - continue - dist = distance_to_land((x, y, z), land) - if 0 <= dist <= self.buffer_dist: - if land.get_member(xuid): - continue - self.game_ctrl.sendwocmd(f"/gamemode adventure {name}") - self.game_ctrl.say_to(name, self._warn( - f"你正在靠近 {land.owner} 的领地,请勿进入")) - break - - def _tp_random(self, player: str, x: float, y: float, z: float): - """Implement the tp random operation.""" - angle = random.uniform(0, 2 * math.pi) - dist = random.uniform(10, self.tp_radius) - nx = x + dist * math.cos(angle) - nz = z + dist * math.sin(angle) - ny = y - cmd = f"/tp {player} {nx} {ny} {nz}" - self.game_ctrl.sendwocmd(cmd) - - # ---------- 实体标记(可选) ---------- - def _spawn_entity(self, land: LandData): - """Implement the spawn entity operation.""" - try: - x, y, z = land.center - self._remove_entity(land) - nbt = ( - "{" - "Duration:2147483647,WaitTime:2147483647,Tags:[\"land_" - + land.land_id - + "\"]" - "}" - ) - cmd = f"summon area_effect_cloud {x} {y} {z} {nbt}" - self.game_ctrl.sendwocmd(cmd) - except Exception: - pass - - def _remove_entity(self, land: LandData): - """Implement the remove entity operation.""" - try: - cmd = f"kill @e[type=area_effect_cloud,tag=land_{land.land_id}]" - self.game_ctrl.sendwocmd(cmd) - except Exception: - pass - - # ---------- 命令处理 ---------- - def on_chat(self, chat: Chat): - """Implement the on chat operation.""" - if not self.enabled: - return - msg = chat.msg.strip() - if msg not in self.wake_words: - return - self._main_menu(chat.player) - - def _main_menu(self, player: Player): - """Implement the main menu operation.""" - choice = self._select_menu( - player, - "功能菜单", - [ - "创建领地", - "删除领地", - "查看领地信息", - "成员管理", - "管理员管理", - "传送到领地", - "查看领地列表", - "测试当前位置", - ], - timeout=90, - ) - if choice is None: - return - if choice == 1: - self._menu_create(player) - elif choice == 2: - self._menu_delete(player) - elif choice == 3: - self._menu_info(player) - elif choice == 4: - self._menu_member(player) - elif choice == 5: - self._menu_admin(player) - elif choice == 6: - self._menu_tp(player) - elif choice == 7: - self._list(player) - elif choice == 8: - self._test(player) - - def _menu_create(self, player: Player): - """Implement the menu create operation.""" - name = self._prompt_text(player, "创建领地", "请输入领地名称") - if name is None: - return - shape_choice = self._select_menu(player, "创建领地类型", ["圆形领地", "方形领地"]) - if shape_choice is None: - return - if shape_choice == 1: - radius = self._prompt_text( - player, "创建圆形领地", f"请输入领地半径,范围 1~{self.max_radius}") - if radius is None: - return - self._create(player, [name, "圆形", radius]) - else: - size_text = self._prompt_text( - player, - "创建方形领地", - f"请输入 长 高 宽,最大 { - self.max_length} { - self.max_height} { - self.max_width}", - ) - if size_text is None: - return - size, err = self._parse_box_size_input(size_text) - if size is None: - player.show(self._error(err or "方形领地尺寸无效")) - return - self._create( - player, [ - name, "方形", str( - size[0]), str( - size[1]), str( - size[2])]) - - def _menu_delete(self, player: Player): - """Implement the menu delete operation.""" - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - owned = [land for land in self.lands.values( - ) if land.owner_xuid == player_xuid] - land = self._select_land(player, "删除领地", owned) - if land is None: - return - self._delete(player, [land.name]) - - def _menu_info(self, player: Player): - """Implement the menu info operation.""" - choice = self._select_menu( - player, - "查看领地信息", - ["查看当前所在领地", "从我的领地中选择", "从全部领地中选择", "输入领地名称"], - ) - if choice is None: - return - if choice == 1: - self._info(player, []) - elif choice == 2: - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - lands = [land for land in self.lands.values() if land.owner_xuid == - player_xuid] - land = self._select_land(player, "我的领地", lands) - if land: - self._info(player, [land.name]) - elif choice == 3: - land = self._select_land(player, "全部领地", list(self.lands.values())) - if land: - self._info(player, [land.name]) - elif choice == 4: - name = self._prompt_text(player, "查看领地信息", "请输入领地名称") - if name: - self._info(player, [name]) - - def _menu_member(self, player: Player): - """Implement the menu member operation.""" - choice = self._select_menu(player, "成员管理", ["添加成员", "移除成员", "查看成员列表"]) - if choice is None: - return - if choice == 3: - self._member(player, ["列表"]) - return - target = self._prompt_text( - player, - "成员管理", - "请输入玩家名", - ) - if target is None: - return - self._member(player, [["添加", "移除"][choice - 1], target]) - - def _menu_admin(self, player: Player): - """Implement the menu admin operation.""" - choice = self._select_menu(player, "管理员管理", ["添加管理员", "移除管理员"]) - if choice is None: - return - target = self._prompt_text(player, "管理员管理", "请输入玩家名") - if target is None: - return - self._admin(player, [["添加", "移除"][choice - 1], target]) - - def _menu_tp(self, player: Player): - """Implement the menu tp operation.""" - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - lands = [land for land in self.lands.values( - ) if land.has_permission(player_xuid, "tp")] - land = self._select_land(player, "传送到领地", lands) - if land is None: - return - self._tp(player, [land.name]) - - def _create(self, player: Player, args: List[str]): # skipcq: PY-R1000 - """Implement the create operation.""" - if len(args) < 2: - player.show(self._error( - f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入创建菜单")) - return - name = args[0] - shape_arg = str(args[1]).strip().lower() - explicit_circle = shape_arg in ("圆形", "circle", "round") - explicit_box = LandData.normalize_shape(shape_arg) == "方形" - shape = "方形" if explicit_box else "圆形" - size = None - if shape == "方形": - if len(args) < 5: - player.show(self._error("方形领地需要输入 长 高 宽")) - return - size, err = self._parse_box_size_input(" ".join(args[2:5])) - if size is None: - player.show(self._error(err or "方形领地尺寸无效")) - return - radius = box_radius_for_size(size) - else: - radius_arg = args[2] if len( - args) >= 3 and explicit_circle else args[1] - try: - radius = int(radius_arg) - except ValueError: - player.show(self._error("半径必须为整数")) - return - if radius <= 0 or radius > self.max_radius: - player.show(self._error(f"半径必须在 1~{self.max_radius} 之间")) - return - - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - owned = [ - land_item for land_item in self.lands.values() - if land_item.owner_xuid == player_xuid - ] - if len(owned) >= self.max_lands_per_player: - player.show( - self._error( - f"你最多只能拥有 { - self.max_lands_per_player} 个领地")) - return - - pos = self._get_player_coord(player.name) - if not pos: - player.show(self._warn("无法自动获取坐标,请手动输入")) - pos = self._manual_coord(player) - if not pos: - return - x, y, z = pos - - overlap = self._get_land_candidate_overlap_reason( - (x, y, z), radius, shape, size) - if overlap: - player.show(self._error(overlap)) - return - - land_id = str(uuid.uuid4()) - member = LandMember( - name=player.name, - xuid=player_xuid, - rank=LandRank.OWNER) - land = LandData( - land_id=land_id, - name=name, - owner=player.name, - owner_xuid=player_xuid, - center=(x, y, z), - radius=radius, - shape=shape, - size=size, - members=[member] - ) - self.lands[land_id] = land - self._add_player_land(player_xuid, land_id) - self._save_data() - player.show(self._success(f"成功创建领地 '{name}',{land.range_text()}")) - self._spawn_entity(land) - - def _delete(self, player: Player, args: List[str]): - """Implement the delete operation.""" - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - owned = [ - land_item for land_item in self.lands.values() - if land_item.owner_xuid == player_xuid - ] - if not owned: - player.show(self._error("你没有拥有任何领地")) - return - - if args: - name = args[0] - land = next( - (land_item for land_item in owned if land_item.name == name), - None, - ) - if not land: - player.show(self._error(f"你没有名为 '{name}' 的领地")) - return - else: - if len(owned) > 1: - names = "、".join(land_item.name for land_item in owned) - player.show(self._error(f"你拥有多个领地,请指定名称:{names}")) - return - land = owned[0] - - self._remove_entity(land) - for m in land.members: - self._remove_player_land(m.xuid, land.land_id) - del self.lands[land.land_id] - self._save_data() - player.show(self._success(f"已删除领地 '{land.name}'")) - - def _info(self, player: Player, args: List[str]): - """Implement the info operation.""" - land = None - if args: - name = args[0] - land = next( - ( - land_item for land_item in self.lands.values() - if land_item.name == name - ), - None, - ) - if not land: - player.show(self._error(f"领地 '{name}' 不存在")) - return - else: - land = self._find_land_at_player(player.name) - if not land: - player.show(self._error("你当前不在任何领地内,请指定领地名称")) - return - - admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] - members = [m.name for m in land.members if m.rank == LandRank.MEMBER] - player.show(self._ui_card( - f"领地信息 - {land.name}", - [ - f"中心:{land.center[0]}, {land.center[1]}, {land.center[2]}", - f"范围:{land.range_text()}", - f"领主:{land.owner}", - f"管理员:{', '.join(admins) or '无'}", - f"成员:{', '.join(members) or '无'}", - ], - )) - - def _member(self, player: Player, args: List[str]): # skipcq: PY-R1000 - """Implement the member operation.""" - if len(args) < 1: - player.show(self._error( - f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入成员管理菜单")) - return - sub = args[0].lower() - if sub == "列表": - land = self._find_land_at_player(player.name) - if not land: - player.show(self._error("你当前不在任何领地内")) - return - self._info(player, [land.name]) - return - - if len(args) < 2: - player.show(self._error( - f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入成员管理菜单")) - return - target = args[1] - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - target_member = self._make_member( - target, LandRank.MEMBER, allow_offline=True) - if target_member is None: - player.show(self._error(f"无法获取 {target} 的 XUID")) - return - - land = self._find_land_at_player(player.name) - if not land: - player.show(self._error("你当前不在任何领地内")) - return - - if not land.has_permission(player_xuid, "manage_member"): - player.show(self._error("你没有权限管理成员")) - return - - if sub == "添加": - if land.get_member(target_member.xuid): - player.show(self._warn(f"{target} 已是成员")) - return - land.members.append(target_member) - self._add_player_land(target_member.xuid, land.land_id) - self._save_data() - player.show(self._success(f"已将 {target} 添加为成员")) - elif sub == "移除": - if not land.get_member(target_member.xuid): - player.show(self._error(f"{target} 不是成员")) - return - if not land.can_manage_member(player_xuid, target_member.xuid): - player.show(self._error("你不能移除该成员")) - return - land.members = [ - m for m in land.members if m.xuid != target_member.xuid] - self._remove_player_land(target_member.xuid, land.land_id) - self._save_data() - player.show(self._success(f"已将 {target} 移除成员")) - else: - player.show(self._error("未知子命令")) - - def _admin(self, player: Player, args: List[str]): - """Implement the admin operation.""" - if len(args) < 2: - player.show(self._error( - f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入管理员管理菜单")) - return - sub = args[0].lower() - target = args[1] - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - target_xuid = self._get_xuid_by_name(target, allow_offline=True) - if target_xuid is None: - player.show(self._error(f"无法获取 {target} 的 XUID")) - return - - land = self._find_land_at_player(player.name) - if not land: - player.show(self._error("你当前不在任何领地内")) - return - - if land.owner_xuid != player_xuid: - player.show(self._error("只有领主可以管理管理员")) - return - - if sub == "添加": - member = land.get_member(target_xuid) - if not member: - player.show(self._error(f"{target} 不是成员")) - return - if member.rank == LandRank.ADMIN: - player.show(self._warn(f"{target} 已经是管理员")) - return - member.rank = LandRank.ADMIN - self._save_data() - player.show(self._success(f"已将 {target} 设为管理员")) - elif sub == "移除": - member = land.get_member(target_xuid) - if not member or member.rank != LandRank.ADMIN: - player.show(self._error(f"{target} 不是管理员")) - return - member.rank = LandRank.MEMBER - self._save_data() - player.show(self._success(f"已将 {target} 移除管理员")) - else: - player.show(self._error("未知子命令")) - - def _tp(self, player: Player, args: List[str]): - """Implement the tp operation.""" - land = None - if args: - name = args[0] - land = next( - ( - land_item for land_item in self.lands.values() - if land_item.name == name - ), - None, - ) - if not land: - player.show(self._error(f"领地 '{name}' 不存在")) - return - else: - land = self._find_land_at_player(player.name) - if not land: - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - owned = [ - land_item for land_item in self.lands.values() - if land_item.owner_xuid == player_xuid - ] - if owned: - land = owned[0] - else: - player.show(self._error("你当前不在任何领地内,且你没有拥有领地,请指定名称")) - return - - player_xuid = self._get_player_xuid(player) - if player_xuid is None: - player.show(self._error("无法获取你的 XUID")) - return - if not land.has_permission(player_xuid, "tp"): - player.show(self._error("你没有权限传送到该领地")) - return - - cx, cy, cz = land.center - self.game_ctrl.sendwocmd(f"/tp {player.name} {cx} {cy + 1} {cz}") - player.show(self._success(f"已传送到领地 '{land.name}'")) - - def _list(self, player: Player): - """Implement the list operation.""" - if not self.lands: - player.show(self._error("暂无任何领地")) - return - options = [ - f"{land.name} §7- 领主: {land.owner}, {land.range_text()}" - for land in self.lands.values() - ] - player.show(self._ui_menu("领地列表", options, [f"共 {len(options)} 个领地"])) - - def _test(self, player: Player): - """Implement the test operation.""" - pos = self._get_player_coord(player.name) - lines = [] - if pos: - lines.append(f"当前坐标:{pos[0]:.1f}, {pos[1]:.1f}, {pos[2]:.1f}") - else: - lines.append("无法获取坐标") - - in_land = self._find_land_at_player(player.name) - if in_land: - lines.append(f"当前位置:位于领地 '{in_land.name}' 内") - else: - lines.append("当前位置:不在任何领地内") - - if self.lands: - lines.append( - "所有领地:" - + "、".join( - f"{land.name}({land.owner})" for land in self.lands.values() - ) - ) - else: - lines.append("所有领地:无") - player.show(self._ui_card("测试信息", lines)) - - def on_player_join(self, player: Player): - """Implement the on player join operation.""" - _ = player - if not self.enabled: - return - return - - def on_player_leave(self, player: Player): - """Implement the on player leave operation.""" - _ = player - if not self.enabled: - return - return - - # ---------- 外部插件 API ---------- - def api_list_lands(self) -> Tuple[bool, str, List[Dict[str, Any]]]: - """Expose the api list lands API operation.""" - lands = [self._land_summary(land) for land in self.lands.values()] - return True, f"共 {len(lands)} 个领地", lands - - def api_get_land( - self, land_query: str) -> Tuple[bool, str, Optional[Dict[str, Any]]]: - """Expose the api get land API operation.""" - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - return True, "查询成功", self._land_summary(land) - - def api_add_land( # skipcq: PY-R1000 - self, - owner: str, - name: str, - center: Tuple[float, float, float], - shape: str = "圆形", - radius: Optional[int] = None, - size: Optional[Tuple[int, int, int]] = None, - dimension: int = 0, - ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: - """Expose the api add land API operation.""" - owner = str(owner).strip() - name = str(name).strip() - if not owner or not name: - return False, "领地主人和领地名称不能为空", None - if self._find_land_by_name_or_id(name): - return False, f"领地 '{name}' 已存在", None - owner_member = self._make_member( - owner, LandRank.OWNER, allow_offline=True) - if owner_member is None: - return False, f"无法获取 {owner} 的 XUID", None - if len([land for land in self.lands.values() if land.owner_xuid == - owner_member.xuid]) >= self.max_lands_per_player: - return False, f"{owner} 已达到最大领地数量 { - self.max_lands_per_player}", None - try: - center_tuple = ( - float( - center[0]), float( - center[1]), float( - center[2])) - except (TypeError, ValueError, IndexError): - return False, "中心坐标无效", None - - normalized_shape = LandData.normalize_shape(shape) - normalized_size = None - if normalized_shape == "方形": - if size is None: - return False, "方形领地需要提供 长 高 宽", None - normalized_size = LandData.normalize_size(size, radius or 1) - if normalized_size[0] > self.max_length: - return False, f"长不能超过 {self.max_length}", None - if normalized_size[1] > self.max_height: - return False, f"高不能超过 {self.max_height}", None - if normalized_size[2] > self.max_width: - return False, f"宽不能超过 {self.max_width}", None - land_radius = box_radius_for_size(normalized_size) - else: - try: - land_radius = int(radius) - except (TypeError, ValueError): - return False, "圆形领地半径必须为整数", None - if land_radius <= 0 or land_radius > self.max_radius: - return False, f"半径必须在 1~{self.max_radius} 之间", None - - overlap = self._get_land_candidate_overlap_reason( - center_tuple, - land_radius, - normalized_shape, - normalized_size, - dimension, - ) - if overlap: - return False, overlap, None - - land_id = str(uuid.uuid4()) - land = LandData( - land_id=land_id, - name=name, - owner=owner, - owner_xuid=owner_member.xuid, - center=center_tuple, - radius=land_radius, - shape=normalized_shape, - size=normalized_size, - dimension=dimension, - members=[owner_member], - ) - self.lands[land_id] = land - self._rebuild_player_land_cache() - self._save_data() - self._spawn_entity(land) - return True, f"已新增玩家 {owner} 的领地 '{name}',{ - land.range_text()}", self._land_summary(land) - - def api_delete_land(self, land_query: str) -> Tuple[bool, str, None]: - """Expose the api delete land API operation.""" - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - self._remove_entity(land) - del self.lands[land.land_id] - self._rebuild_player_land_cache() - self._save_data() - return True, f"已删除领地 '{land.name}'", None - - def api_add_member(self, - land_query: str, - player_name: str, - rank: str = "member") -> Tuple[bool, - str, - Optional[Dict[str, - Any]]]: - """Expose the api add member API operation.""" - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - target_rank = LandRank.ADMIN if str(rank).lower() in ( - "admin", "管理员", "管理") else LandRank.MEMBER - member = self._make_member( - player_name, target_rank, allow_offline=True) - if member is None: - return False, f"无法获取 {player_name} 的 XUID", None - old_member = land.get_member(member.xuid) - if old_member: - if old_member.rank == LandRank.OWNER: - return False, "所有者不能被改为成员或管理员", self._land_summary(land) - old_member.name = player_name - old_member.rank = target_rank - else: - land.members.append(member) - self._rebuild_player_land_cache() - self._save_data() - role_name = "管理员" if target_rank == LandRank.ADMIN else "成员" - return True, f"已将 {player_name} 添加为领地 '{ - land.name}' 的{role_name}", self._land_summary(land) - - def api_set_member_rank( - self, - land_query: str, - player_name: str, - rank: str, - ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: - """Expose the api set member rank API operation.""" - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - target_rank = LandRank.ADMIN if str(rank).lower() in ( - "admin", "管理员", "管理") else LandRank.MEMBER - target_xuid = self._get_xuid_by_name(player_name, allow_offline=True) - if target_xuid is None: - return False, f"无法获取 {player_name} 的 XUID", None - member = land.get_member(target_xuid) - if member is None: - return False, f"{player_name} 不在领地 '{ - land.name}' 中", self._land_summary(land) - if member.rank == LandRank.OWNER: - return False, "所有者不能修改身份", self._land_summary(land) - member.name = player_name - member.rank = target_rank - self._save_data() - role_name = "管理员" if target_rank == LandRank.ADMIN else "成员" - return True, f"已将 {player_name} 设为领地 '{ - land.name}' 的{role_name}", self._land_summary(land) - - def api_remove_member(self, - land_query: str, - player_name: str) -> Tuple[bool, - str, - Optional[Dict[str, - Any]]]: - """Expose the api remove member API operation.""" - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - target_xuid = self._get_xuid_by_name(player_name, allow_offline=True) - if target_xuid is None: - return False, f"无法获取 {player_name} 的 XUID", None - member = land.get_member(target_xuid) - if not member: - return False, f"{player_name} 不在领地 '{ - land.name}' 中", self._land_summary(land) - if member.rank == LandRank.OWNER: - return False, "不能删除领地所有者,请先转移所有者", self._land_summary(land) - land.members = [m for m in land.members if m.xuid != target_xuid] - self._rebuild_player_land_cache() - self._save_data() - return True, f"已从领地 '{ - land.name}' 移除 {player_name}", self._land_summary(land) - - def api_transfer_owner( - self, - land_query: str, - owner: str, - ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: - """Expose the api transfer owner API operation.""" - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - owner_member_data = self._make_member( - owner, LandRank.OWNER, allow_offline=True) - if owner_member_data is None: - return False, f"无法获取 {owner} 的 XUID", None - land.owner = owner - land.owner_xuid = owner_member_data.xuid - for member in land.members: - if member.rank == LandRank.OWNER: - member.rank = LandRank.ADMIN - owner_member = land.get_member(owner_member_data.xuid) - if owner_member: - owner_member.name = owner - owner_member.rank = LandRank.OWNER - else: - land.members.append(owner_member_data) - self._rebuild_player_land_cache() - self._save_data() - return True, f"已修改领地 '{ - land.name}' 所有者为 {owner}", self._land_summary(land) - - def api_update_land_center( - self, - land_query: str, - center: Tuple[float, float, float], - ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: - """Expose the api update land center API operation.""" - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - try: - center_tuple = ( - float( - center[0]), float( - center[1]), float( - center[2])) - except (TypeError, ValueError, IndexError): - return False, "中心坐标无效", self._land_summary(land) - overlap = self._get_land_edit_overlap_reason( - land, center_tuple, land.radius) - if overlap: - return False, overlap, self._land_summary(land) - land.center = center_tuple - self._save_data() - self._spawn_entity(land) - return True, f"已修改领地 '{land.name}' 中心点", self._land_summary(land) - - def api_update_land_range( - self, - land_query: str, - radius: Optional[int] = None, - size: Optional[Tuple[int, int, int]] = None, - ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: - """Expose the api update land range API operation.""" - land = self._find_land_by_name_or_id(land_query) - if land is None: - return False, f"领地 '{land_query}' 不存在", None - if land.is_box(): - if size is None: - return False, "方形领地需要提供 长 高 宽", self._land_summary(land) - normalized_size = LandData.normalize_size(size, land.radius) - if normalized_size[0] > self.max_length: - return False, f"长不能超过 { - self.max_length}", self._land_summary(land) - if normalized_size[1] > self.max_height: - return False, f"高不能超过 { - self.max_height}", self._land_summary(land) - if normalized_size[2] > self.max_width: - return False, f"宽不能超过 { - self.max_width}", self._land_summary(land) - land_radius = box_radius_for_size(normalized_size) - overlap = self._get_land_edit_overlap_reason( - land, land.center, land_radius, "方形", normalized_size) - if overlap: - return False, overlap, self._land_summary(land) - land.size = normalized_size - land.radius = land_radius - else: - try: - land_radius = int(radius) - except (TypeError, ValueError): - return False, "半径必须为整数", self._land_summary(land) - if land_radius <= 0 or land_radius > self.max_radius: - return False, f"半径必须在 1~{ - self.max_radius} 之间", self._land_summary(land) - overlap = self._get_land_edit_overlap_reason( - land, land.center, land_radius, "圆形", None) - if overlap: - return False, overlap, self._land_summary(land) - land.radius = land_radius - land.size = None - self._save_data() - return True, f"已修改领地 '{ - land.name}' 范围为 { - land.range_text()}", self._land_summary(land) - - def _console_print(self, text: str): - """Implement the console print operation.""" - _ = self - fmts.print_inf(text) - - def _console_prompt(self, prompt: str) -> Optional[str]: - """Implement the console prompt operation.""" - value = input(fmts.fmt_info( - f"§a❀ §b{prompt} §7(输入 . 退出整个领地系统云链联动版管理菜单): ")).strip() - if self._is_exit_input(value): - raise ConsoleMenuExit - return value - - def _console_select(self, title: str, options: List[str]) -> Optional[int]: - """Implement the console select operation.""" - while True: - self._console_print(self._ui_menu( - title, - options, - [f"输入 §e[1-{len(options)}]§b 之间的数字以选择", "输入 §c.§b 退出整个领地系统云链联动版管理菜单"], - )) - value = self._console_prompt("请输入序号") - if value is None: - return None - choice = utils.try_int(value.strip().strip("[]")) - if choice is None or choice not in range(1, len(options) + 1): - fmts.print_err(self._error("输入有误")) - continue - return choice - - def _console_prompt_float(self, prompt: str) -> Optional[float]: - """Implement the console prompt float operation.""" - value = self._console_prompt(prompt) - if value is None: - return None - try: - return float(value) - except ValueError: - fmts.print_err(self._error("请输入有效数字")) - return None - - def _console_prompt_int(self, prompt: str) -> Optional[int]: - """Implement the console prompt int operation.""" - value = self._console_prompt(prompt) - if value is None: - return None - try: - return int(value) - except ValueError: - fmts.print_err(self._error("请输入有效整数")) - return None - - def _console_prompt_pos(self, prompt: str) -> Optional[List[float]]: - """Implement the console prompt pos operation.""" - value = self._console_prompt(f"{prompt},格式 x y z") - if value is None: - return None - parts = value.split() - if len(parts) != 3: - fmts.print_err(self._error("坐标格式错误,需要 x y z 三个数字")) - return None - try: - return [float(parts[0]), float(parts[1]), float(parts[2])] - except ValueError: - fmts.print_err(self._error("坐标必须是数字")) - return None - - def _console_select_land( - self, - title: str, - lands: Optional[List[LandData]] = None, - ) -> Optional[LandData]: - """Implement the console select land operation.""" - lands = list(self.lands.values()) if lands is None else lands - if not lands: - fmts.print_err(self._error("暂无可选择的领地")) - return None - choice = self._console_select( - title, - [f"{land.name} - 领主: {land.owner}, {land.range_text()}" for land in lands], - ) - if choice is None: - return None - return lands[choice - 1] - - def console_manage(self, _args: List[str]): - """Implement the console manage operation.""" - try: - while True: - choice = self._console_select( - "控制台管理菜单", - ["新增不可创建领地区域", "删除不可创建领地区域", "新增玩家领地", "删除玩家领地", "管理玩家领地"], - ) - if choice is None: - fmts.print_inf(self._success("已退出领地系统云链联动版管理菜单")) - return - if choice == 1: - self._console_add_no_create_region() - elif choice == 2: - self._console_delete_no_create_region() - elif choice == 3: - self._console_add_land() - elif choice == 4: - self._console_delete_land() - elif choice == 5: - self._console_manage_land() - except ConsoleMenuExit: - fmts.print_inf(self._success("已退出领地系统云链联动版管理菜单")) - - def _console_add_no_create_region(self): - """Implement the console add no create region operation.""" - name = self._console_prompt("请输入区域名称") - if name is None: - return - region_choice = self._console_select("新增不可创建区域", ["圆形区域", "方形区域"]) - if region_choice is None: - return - if region_choice == 1: - center = self._console_prompt_pos("请输入圆形区域中心") - radius = self._console_prompt_float("请输入圆形区域半径") - if center is None or radius is None or radius <= 0: - fmts.print_err(self._error("区域参数无效")) - return - region = { - "名称": name, - "启用": True, - "类型": "圆形", - "中心": center, - "半径": radius} - else: - start = self._console_prompt_pos("请输入方形区域起点") - end = self._console_prompt_pos("请输入方形区域终点") - if start is None or end is None: - fmts.print_err(self._error("区域参数无效")) - return - region = { - "名称": name, - "启用": True, - "类型": "方形", - "起点": start, - "终点": end} - self.no_create_regions_raw.append(region) - self._save_no_create_regions() - self._reload_no_create_regions() - fmts.print_suc(self._success(f"已新增不可创建领地区域 '{name}'")) - - def _console_delete_no_create_region(self): - """Implement the console delete no create region operation.""" - if not self.no_create_regions_raw: - fmts.print_err(self._error("暂无不可创建领地区域")) - return - choice = self._console_select( - "删除不可创建区域", - [ - ( - f"{region.get('名称', f'区域{i}')} - " - f"{region.get('类型', '未知')} - " - f"{'启用' if region.get('启用', True) else '禁用'}" - ) - for i, region in enumerate(self.no_create_regions_raw, 1) - ], - ) - if choice is None: - return - region = self.no_create_regions_raw.pop(choice - 1) - self._save_no_create_regions() - self._reload_no_create_regions() - fmts.print_suc( - self._success( - f"已删除不可创建领地区域 '{ - region.get( - '名称', - choice)}'")) - - def _console_add_land(self): - """Implement the console add land operation.""" - owner = self._console_prompt("请输入领地主人玩家名") - name = self._console_prompt("请输入领地名称") - center = self._console_prompt_pos("请输入领地中心坐标") - shape_choice = self._console_select("请选择领地类型", ["圆形领地", "方形领地"]) - if owner is None or name is None or center is None or shape_choice is None: - return - owner_member = self._make_member( - owner, LandRank.OWNER, allow_offline=True) - if owner_member is None: - fmts.print_err(self._error(f"无法获取 {owner} 的 XUID")) - return - shape = "圆形" - size = None - if shape_choice == 1: - radius = self._console_prompt_int( - f"请输入领地半径,范围 1~{self.max_radius}") - if radius is None: - return - if radius <= 0 or radius > self.max_radius: - fmts.print_err(self._error(f"半径必须在 1~{self.max_radius} 之间")) - return - else: - size_text = self._console_prompt( - f"请输入方形领地 长 高 宽,最大 { - self.max_length} { - self.max_height} { - self.max_width}") - if size_text is None: - return - size, err = self._parse_box_size_input(size_text) - if size is None: - fmts.print_err(self._error(err or "方形领地尺寸无效")) - return - shape = "方形" - radius = box_radius_for_size(size) - overlap = self._get_land_candidate_overlap_reason( - tuple(center), radius, shape, size) - if overlap: - fmts.print_err(self._error(overlap)) - return - land_id = str(uuid.uuid4()) - land = LandData( - land_id=land_id, - name=name, - owner=owner, - owner_xuid=owner_member.xuid, - center=tuple(center), - radius=radius, - shape=shape, - size=size, - members=[owner_member], - ) - self.lands[land_id] = land - self._rebuild_player_land_cache() - self._save_data() - self._spawn_entity(land) - fmts.print_suc( - self._success( - f"已新增玩家 {owner} 的领地 '{name}',{ - land.range_text()}")) - - def _console_delete_land(self): - """Implement the console delete land operation.""" - land = self._console_select_land("删除玩家领地") - if land is None: - return - self._remove_entity(land) - del self.lands[land.land_id] - self._rebuild_player_land_cache() - self._save_data() - fmts.print_suc(self._success(f"已删除领地 '{land.name}'")) - - def _console_manage_land(self): - """Implement the console manage land operation.""" - land = self._console_select_land("管理玩家领地") - if land is None: - return - while True: - choice = self._console_select( - f"管理领地 - {land.name}", - ["管理用户", "管理管理人员", "管理所有者", "管理领地中心点", "管理领地范围", "查看领地信息"], - ) - if choice is None: - return - if choice == 1: - self._console_manage_land_users(land) - elif choice == 2: - self._console_manage_land_admins(land) - elif choice == 3: - self._console_manage_land_owner(land) - elif choice == 4: - self._console_manage_land_center(land) - elif choice == 5: - self._console_manage_land_radius(land) - elif choice == 6: - self._console_show_land_info(land) - - def _console_show_land_info(self, land: LandData): - """Implement the console show land info operation.""" - admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] - members = [m.name for m in land.members if m.rank == LandRank.MEMBER] - fmts.print_inf(self._ui_card( - f"领地信息 - {land.name}", - [ - f"领主:{land.owner}", - f"中心:{land.center[0]}, {land.center[1]}, {land.center[2]}", - f"范围:{land.range_text()}", - f"管理员:{', '.join(admins) or '无'}", - f"用户:{', '.join(members) or '无'}", - ], - )) - - def _console_manage_land_users(self, land: LandData): # skipcq: PY-R1000 - """Implement the console manage land users operation.""" - while True: - choice = self._console_select( - f"管理用户 - {land.name}", - ["添加用户", "删除用户", "查看用户列表"], - ) - if choice is None: - return - if choice == 1: - target = self._console_prompt("请输入要添加的玩家名") - if not target: - continue - member = self._make_member( - target, LandRank.MEMBER, allow_offline=True) - if member is None: - fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) - continue - if land.get_member(member.xuid): - fmts.print_err(self._warn(f"{target} 已在该领地中")) - continue - land.members.append(member) - self._rebuild_player_land_cache() - self._save_data() - fmts.print_suc(self._success(f"已添加用户 {target}")) - elif choice == 2: - target = self._console_prompt("请输入要删除的玩家名") - if not target: - continue - target_xuid = self._get_xuid_by_name( - target, allow_offline=True) - if target_xuid is None: - fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) - continue - member = land.get_member(target_xuid) - if not member: - fmts.print_err(self._error(f"{target} 不在该领地中")) - continue - if member.rank == LandRank.OWNER: - fmts.print_err(self._error("不能在用户管理中删除所有者,请先转移所有者")) - continue - land.members = [ - m for m in land.members if m.xuid != target_xuid] - self._rebuild_player_land_cache() - self._save_data() - fmts.print_suc(self._success(f"已删除用户 {target}")) - elif choice == 3: - users = [ - f"{m.name}({m.rank.display_name})" for m in land.members] - fmts.print_inf(self._ui_card( - f"用户列表 - {land.name}", - [", ".join(users) if users else "无用户"], - )) - - def _console_manage_land_admins(self, land: LandData): # skipcq: PY-R1000 - """Implement the console manage land admins operation.""" - while True: - choice = self._console_select( - f"管理管理人员 - {land.name}", - ["添加管理人员", "删除管理人员", "查看管理人员"], - ) - if choice is None: - return - if choice == 1: - target = self._console_prompt("请输入要设为管理人员的玩家名") - if not target: - continue - target_xuid = self._get_xuid_by_name( - target, allow_offline=True) - if target_xuid is None: - fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) - continue - member = land.get_member(target_xuid) - if member and member.rank == LandRank.OWNER: - fmts.print_err(self._error("所有者不能设为管理人员")) - continue - if member: - member.name = target - member.rank = LandRank.ADMIN - else: - land.members.append( - LandMember( - target, - target_xuid, - LandRank.ADMIN)) - self._rebuild_player_land_cache() - self._save_data() - fmts.print_suc(self._success(f"已将 {target} 设为管理人员")) - elif choice == 2: - target = self._console_prompt("请输入要删除管理权限的玩家名") - if not target: - continue - target_xuid = self._get_xuid_by_name( - target, allow_offline=True) - if target_xuid is None: - fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) - continue - member = land.get_member(target_xuid) - if not member or member.rank != LandRank.ADMIN: - fmts.print_err(self._error(f"{target} 不是管理人员")) - continue - member.rank = LandRank.MEMBER - self._rebuild_player_land_cache() - self._save_data() - fmts.print_suc(self._success(f"已删除 {target} 的管理权限")) - elif choice == 3: - admins = [ - m.name for m in land.members if m.rank == LandRank.ADMIN] - fmts.print_inf(self._ui_card( - f"管理人员 - {land.name}", - [", ".join(admins) if admins else "暂无管理人员"], - )) - - def _console_manage_land_owner(self, land: LandData): - """Implement the console manage land owner operation.""" - while True: - choice = self._console_select( - f"管理所有者 - {land.name}", - ["修改所有者", "查看所有者"], - ) - if choice is None: - return - if choice == 1: - owner = self._console_prompt("请输入新所有者玩家名") - if not owner: - continue - owner_member_data = self._make_member( - owner, LandRank.OWNER, allow_offline=True) - if owner_member_data is None: - fmts.print_err(self._error(f"无法获取 {owner} 的 XUID")) - continue - land.owner = owner - land.owner_xuid = owner_member_data.xuid - for member in land.members: - if member.rank == LandRank.OWNER: - member.rank = LandRank.ADMIN - owner_member = land.get_member(owner_member_data.xuid) - if owner_member: - owner_member.name = owner - owner_member.rank = LandRank.OWNER - else: - land.members.append(owner_member_data) - self._rebuild_player_land_cache() - self._save_data() - fmts.print_suc(self._success(f"已修改所有者为 {owner}")) - elif choice == 2: - fmts.print_inf(self._ui_card( - f"所有者 - {land.name}", [land.owner])) - - def _console_manage_land_center(self, land: LandData): - """Implement the console manage land center operation.""" - while True: - choice = self._console_select( - f"管理领地中心点 - {land.name}", - ["修改中心点", "查看中心点"], - ) - if choice is None: - return - if choice == 1: - center = self._console_prompt_pos("请输入新的领地中心点") - if center is None: - continue - center_tuple = tuple(center) - overlap = self._get_land_edit_overlap_reason( - land, center_tuple, land.radius) - if overlap: - fmts.print_err(self._error(overlap)) - continue - land.center = center_tuple - self._save_data() - self._spawn_entity(land) - fmts.print_suc(self._success( - f"已修改领地中心点为 {center[0]}, {center[1]}, {center[2]}")) - elif choice == 2: - fmts.print_inf(self._ui_card( - f"领地中心点 - {land.name}", - [f"{land.center[0]}, {land.center[1]}, {land.center[2]}"], - )) - - def _console_manage_land_radius(self, land: LandData): - """Implement the console manage land radius operation.""" - while True: - options = [ - "修改方形长高宽", - "查看方形长高宽"] if land.is_box() else [ - "修改范围半径", - "查看范围半径"] - choice = self._console_select( - f"管理领地范围 - {land.name}", - options, - ) - if choice is None: - return - if choice == 1: - if land.is_box(): - size_text = self._console_prompt( - f"请输入新的 长 高 宽,最大 { - self.max_length} { - self.max_height} { - self.max_width}") - if size_text is None: - continue - size, err = self._parse_box_size_input(size_text) - if size is None: - fmts.print_err(self._error(err or "方形领地尺寸无效")) - continue - radius = box_radius_for_size(size) - overlap = self._get_land_edit_overlap_reason( - land, land.center, radius, "方形", size) - if overlap: - fmts.print_err(self._error(overlap)) - continue - land.shape = "方形" - land.size = size - land.radius = radius - self._save_data() - fmts.print_suc(self._success( - f"已修改方形领地范围为 长:{size[0]}, 高:{size[1]}, 宽:{size[2]}")) - else: - radius = self._console_prompt_int( - f"请输入新范围半径,范围 1~{self.max_radius}") - if radius is None: - continue - if radius <= 0 or radius > self.max_radius: - fmts.print_err( - self._error( - f"半径必须在 1~{ - self.max_radius} 之间")) - continue - overlap = self._get_land_edit_overlap_reason( - land, land.center, radius, "圆形", None) - if overlap: - fmts.print_err(self._error(overlap)) - continue - land.shape = "圆形" - land.size = None - land.radius = radius - self._save_data() - fmts.print_suc(self._success(f"已修改领地范围半径为 {radius}")) - elif choice == 2: - fmts.print_inf(self._ui_card( - f"领地范围 - {land.name}", [land.range_text()])) - - def _get_land_candidate_overlap_reason( - self, - center: Tuple[float, float, float], - radius: int, - shape: str, - size: Optional[Tuple[int, int, int]] = None, - dimension: int = 0, - skip_land_id: Optional[str] = None, - ) -> Optional[str]: - """Return land candidate overlap reason data.""" - blocked_region = self._get_no_create_overlap_reason( - center, radius, shape, size) - if blocked_region: - return f"领地不能与不可创建区域 '{blocked_region}' 重叠" - for other in self.lands.values(): - if other.land_id == skip_land_id or other.dimension != dimension: - continue - if land_overlaps_candidate(other, center, radius, shape, size): - return f"领地与 '{other.name}' 重叠" - return None - - def _get_land_edit_overlap_reason( - self, - land: LandData, - center: Tuple[float, float, float], - radius: int, - shape: Optional[str] = None, - size: Optional[Tuple[int, int, int]] = None, - ) -> Optional[str]: - """Return land edit overlap reason data.""" - edit_shape = shape or land.shape - edit_size = size if size is not None else land.size - return self._get_land_candidate_overlap_reason( - center, - radius, - edit_shape, - edit_size, - land.dimension, - land.land_id, - ) - - def console_test(self, args: List[str]): - """Implement the console test operation.""" - if not self.enabled: - fmts.print_war(self._warn("领地系统云链联动版当前已在配置中禁用")) - return - if args: - target = args[0] - pos = self._get_player_coord(target) - if pos: - fmts.print_inf( - self._ui_card( - "控制台测试", [ - f"玩家 {target} 坐标:{pos}"])) - else: - fmts.print_err(self._error("无法获取坐标")) - else: - fmts.print_inf(self._notice("用法: 领地测试 <玩家名>")) - - -entry = plugin_entry(LandPlugin, "领地系统云链联动版", (0, 1, 18)) +"""Land protection cloud interop ToolDelta plugin.""" + +import copy +import json +import time +import threading +import os +import re +import random +import math +import shutil +import uuid +from typing import Dict, List, Optional, Tuple, Any +from tooldelta import Plugin, cfg, fmts, Player, Chat, plugin_entry, utils, game_utils + +from .config import ( + CONFIG_FILE_DIR, + DYNAMIC_LOAD_DEFAULT_INTERVAL, + DYNAMIC_LOAD_ENABLED_KEY, + DYNAMIC_LOAD_INTERVAL_KEY, + DYNAMIC_LOAD_SETTINGS_KEY, + NO_CREATE_REGIONS_FILE, + default_config, + default_no_create_regions, +) +from .geometry import ( + box_radius_for_size, + bounds_from_center_size, + boxes_intersect, + distance_to_land, + land_overlaps_candidate, + sphere_intersects_box, +) +from .models import LandData, LandMember, LandRank + + +PLUGIN_NAME = "领地系统云链联动版" +LEGACY_PLUGIN_NAME = "领地系统" + + +class ConsoleMenuExit(Exception): + """Signal that the console menu should exit.""" + + +class LandPlugin(Plugin): + """ToolDelta plugin entrypoint for land protection cloud interop.""" + + name = PLUGIN_NAME + author = "小石潭记qwq/小六神" + version = (0, 1, 18) + + def __init__(self, frame): + super().__init__(frame) + self.ListenPreload(self.on_preload) + self._stop_event = threading.Event() + self._config_file_state = None + self._no_create_regions_file_state = None + self._cfg_default = default_config() + self._cfg_std = cfg.auto_to_std(self._cfg_default) + self.make_data_path() + self._migrate_legacy_data_path() + self.no_create_regions_file = self.format_data_path( + NO_CREATE_REGIONS_FILE) + self.cfg = self._load_config(self._cfg_default, self._cfg_std) + self.data_file = self.format_data_path(str(self.cfg["数据文件"])) + self._apply_runtime_config_fields(reload_data_file=False) + self.no_create_regions_raw = self._load_no_create_regions() + self.no_create_regions = self._normalize_no_create_regions( + self.no_create_regions_raw) + + # 数据 + self.lands: Dict[str, LandData] = {} # land_id -> LandData + # xuid -> list of land_id + self.player_land_cache: Dict[str, List[str]] = {} + self.xuid_getter = None + + self.coords: Dict[str, Tuple[float, float, float]] = {} + self.coords_lock = threading.Lock() + self.recent_tp: Dict[str, float] = {} # xuid -> last tp time + self.tp_cooldown = 5 + self._detection_started = False + + self._ensure_dirs() + self._load_data() + + # 事件监听 + self.ListenChat(self.on_chat) + self.ListenPlayerJoin(self.on_player_join) + self.ListenPlayerLeave(self.on_player_leave) + self.ListenActive(self.on_active) + self.ListenFrameExit(self.on_frame_exit) + self.refresh_config_file_state() + self.config_thread = utils.createThread( + self.config_reload_task, + usage="领地系统配置热更新任务", + ) + + # 控制台测试指令 + self.frame.add_console_cmd_trigger( + ["领地云链测试", "领地测试"], "[玩家]", "测试领地系统云链联动版", self.console_test) + self.frame.add_console_cmd_trigger( + ["领地系统云链联动版", "领地系统"], None, "打开领地系统云链联动版控制台管理菜单", self.console_manage) + + def on_preload(self): + """Implement the on preload operation.""" + self.xuid_getter = self.GetPluginAPI("XUID获取", (0, 0, 7)) + + def on_active(self): + """Implement the on active operation.""" + if self.enabled: + self._start_detection() + + def on_frame_exit(self, _): + """Implement the on frame exit operation.""" + self._stop_event.set() + + @staticmethod + def _ui_border() -> str: + """Implement the ui border operation.""" + return "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧" + + @staticmethod + def _ui_title(title: str) -> str: + """Implement the ui title operation.""" + return f"§l§d❐§f 『§6领地系统云链联动版§f』 §b{title}" + + def _ui_menu(self, + title: str, + options: List[str], + hints: Optional[List[str]] = None) -> str: + """Implement the ui menu operation.""" + lines = [self._ui_border(), self._ui_title(title)] + lines.extend(f"§l§b[ §e{i}§b ] §r§e{option}" for i, + option in enumerate(options, 1)) + lines.append(self._ui_border()) + for hint in hints or []: + lines.append(f"§a❀ §b{hint}") + return "\n".join(lines) + + def _ui_card(self, + title: str, + lines: List[str], + hints: Optional[List[str]] = None) -> str: + """Implement the ui card operation.""" + body = [self._ui_border(), self._ui_title(title)] + body.extend(f"§a❀ §b{line}" for line in lines) + body.append(self._ui_border()) + for hint in hints or []: + body.append(f"§a❀ §b{hint}") + return "\n".join(body) + + @staticmethod + def _success(text: str) -> str: + """Implement the success operation.""" + return f"§a❀ §b{text}" + + @staticmethod + def _error(text: str) -> str: + """Implement the error operation.""" + return f"§c❀ §e{text}" + + @staticmethod + def _warn(text: str) -> str: + """Implement the warn operation.""" + return f"§6❀ §e{text}" + + @staticmethod + def _notice(text: str) -> str: + """Implement the notice operation.""" + return f"§a❀ §b{text}" + + @staticmethod + def _normalize_wake_words(raw: Any) -> List[str]: + """Normalize wake words values.""" + if isinstance(raw, str): + words = [raw] + elif isinstance(raw, list): + words = [str(item) for item in raw if isinstance(item, str)] + else: + words = [] + cleaned = [] + for word in words: + word = word.strip() + if word and word not in cleaned: + cleaned.append(word) + return cleaned or [".领地"] + + @classmethod + def _merge_config_with_default(cls, raw: Any, default: Any): + """Implement the merge config with default operation.""" + if isinstance(default, dict): + result = { + key: cls._merge_config_with_default( + raw.get(key) if isinstance(raw, dict) else None, + value, + ) + for key, value in default.items() + } + if isinstance(raw, dict): + for key, value in raw.items(): + if key not in result: + result[key] = copy.deepcopy(value) + return result + return copy.deepcopy( + raw) if raw is not None else copy.deepcopy(default) + + @staticmethod + def _trim_fixed_keys(raw: Any, default: Dict[str, Any]) -> Dict[str, Any]: + """Implement the trim fixed keys operation.""" + raw = raw if isinstance(raw, dict) else {} + return { + key: copy.deepcopy(raw.get(key, value)) + for key, value in default.items() + } + + @staticmethod + def _normalize_config_bool(value: Any, fallback: bool) -> bool: + """Normalize config bool values.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in ("true", "1", "yes", "y", "on", "启用", "是", "真"): + return True + if text in ("false", "0", "no", "n", "off", "禁用", "否", "假"): + return False + if isinstance(value, (int, float)) and not isinstance(value, bool): + return bool(value) + return bool(fallback) + + @staticmethod + def _normalize_config_positive_int(value: Any, fallback: int) -> int: + """Normalize config positive int values.""" + if isinstance(value, bool): + return fallback + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + @staticmethod + def _normalize_config_non_negative_int(value: Any, fallback: int) -> int: + """Normalize config non negative int values.""" + if isinstance(value, bool): + return fallback + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result >= 0 else fallback + + @staticmethod + def _normalize_config_str( + value: Any, + fallback: str, + *, + allow_empty: bool = False) -> str: + """Normalize config str values.""" + if value is None: + return fallback + text = str(value) + if text or allow_empty: + return text + return fallback + + @classmethod + def _normalize_config_string_list( + cls, + value: Any, + fallback: List[str], + *, + allow_empty: bool = False, + ) -> List[str]: + """Normalize config string list values.""" + if isinstance(value, str): + candidates = [value] + elif isinstance(value, list): + candidates = value + else: + return copy.deepcopy(fallback) + + result: List[str] = [] + for item in candidates: + text = cls._normalize_config_str( + item, "", allow_empty=True).strip() + if text and text not in result: + result.append(text) + if result or allow_empty: + return result + return copy.deepcopy(fallback) + + @classmethod + def _normalize_runtime_config( + cls, raw_cfg: Any, default_cfg: Dict[str, Any]) -> Dict[str, Any]: + """Normalize runtime config values.""" + merged_cfg = cls._merge_config_with_default(raw_cfg, default_cfg) + normalized = cls._trim_fixed_keys(merged_cfg, default_cfg) + + dynamic_default = default_cfg[DYNAMIC_LOAD_SETTINGS_KEY] + dynamic = cls._trim_fixed_keys( + normalized.get(DYNAMIC_LOAD_SETTINGS_KEY), + dynamic_default, + ) + dynamic[DYNAMIC_LOAD_ENABLED_KEY] = cls._normalize_config_bool( + dynamic.get(DYNAMIC_LOAD_ENABLED_KEY), + dynamic_default[DYNAMIC_LOAD_ENABLED_KEY], + ) + dynamic[DYNAMIC_LOAD_INTERVAL_KEY] = cls._normalize_config_positive_int( + dynamic.get(DYNAMIC_LOAD_INTERVAL_KEY), + dynamic_default[DYNAMIC_LOAD_INTERVAL_KEY], + ) + normalized[DYNAMIC_LOAD_SETTINGS_KEY] = dynamic + + normalized["是否启用"] = cls._normalize_config_bool( + normalized.get("是否启用"), + default_cfg["是否启用"], + ) + normalized["唤醒词"] = cls._normalize_config_string_list( + normalized.get("唤醒词"), + default_cfg["唤醒词"], + ) + normalized["数据文件"] = cls._normalize_config_str( + normalized.get("数据文件"), + default_cfg["数据文件"], + ) + for key in ( + "检测间隔", + "传送半径", + "最大领地半径", + "最大领地长", + "最大领地高", + "最大领地宽", + "最大领地数量", + ): + normalized[key] = cls._normalize_config_positive_int( + normalized.get(key), + default_cfg[key], + ) + normalized["缓冲区距离"] = cls._normalize_config_non_negative_int( + normalized.get("缓冲区距离"), + default_cfg["缓冲区距离"], + ) + normalized["白名单"] = cls._normalize_config_string_list( + normalized.get("白名单"), + default_cfg["白名单"], + allow_empty=True, + ) + return normalized + + def _load_config( + self, default_cfg: Dict[str, Any], cfg_std: Any) -> Dict[str, Any]: + """Load config data.""" + try: + raw_cfg, _ = cfg.get_plugin_config_and_version( + self.name, + {}, + default_cfg, + self.version, + ) + merged_cfg = self._normalize_runtime_config(raw_cfg, default_cfg) + cfg.check_auto(cfg_std, merged_cfg) + except Exception as err: + fmts.print_err(f"领地系统云链联动版配置文件自动更新失败,已使用默认配置: {err}") + merged_cfg = self._normalize_runtime_config({}, default_cfg) + cfg.check_auto(cfg_std, merged_cfg) + cfg.upgrade_plugin_config(self.name, merged_cfg, self.version) + return merged_cfg + + def _apply_runtime_config_fields(self, reload_data_file: bool = False): + """Implement the apply runtime config fields operation.""" + self.enabled = bool(self.cfg["是否启用"]) + self.wake_words = self._normalize_wake_words(self.cfg["唤醒词"]) + new_data_file = self.format_data_path(str(self.cfg["数据文件"])) + data_file_changed = getattr(self, "data_file", None) != new_data_file + self.data_file = new_data_file + self.check_interval = self._positive_float(self.cfg["检测间隔"], 2.0) + self.buffer_dist = self._non_negative_float(self.cfg["缓冲区距离"], 5.0) + self.tp_radius = self._positive_float(self.cfg["传送半径"], 5000.0) + self.max_radius = self._positive_int(self.cfg["最大领地半径"], 200) + self.max_length = self._positive_int(self.cfg["最大领地长"], 200) + self.max_height = self._positive_int(self.cfg["最大领地高"], 200) + self.max_width = self._positive_int(self.cfg["最大领地宽"], 200) + self.max_lands_per_player = self._positive_int(self.cfg["最大领地数量"], 4) + self.whitelist = {str(name).lower() for name in self.cfg["白名单"]} + if reload_data_file and data_file_changed: + self.lands.clear() + self.player_land_cache.clear() + self._ensure_dirs() + self._load_data() + + @staticmethod + def _positive_int(value: Any, fallback: int) -> int: + """Implement the positive int operation.""" + try: + result = int(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + @staticmethod + def _positive_float(value: Any, fallback: float) -> float: + """Implement the positive float operation.""" + try: + result = float(value) + except (TypeError, ValueError): + return fallback + return result if result > 0 else fallback + + @staticmethod + def _non_negative_float(value: Any, fallback: float) -> float: + """Implement the non negative float operation.""" + try: + result = float(value) + except (TypeError, ValueError): + return fallback + return result if result >= 0 else fallback + + def config_file_path(self) -> str: + """Implement the config file path operation.""" + return os.path.join(CONFIG_FILE_DIR, f"{self.name}.json") + + @staticmethod + def file_state(path: str) -> tuple[int, int] | None: + """Implement the file state operation.""" + try: + stat = os.stat(path) + except OSError: + return None + return stat.st_mtime_ns, stat.st_size + + def refresh_config_file_state(self): + """Implement the refresh config file state operation.""" + self._config_file_state = self.file_state(self.config_file_path()) + self._no_create_regions_file_state = self.file_state( + self.no_create_regions_file) + + def is_dynamic_config_reload_enabled(self) -> bool: + """Implement the is dynamic config reload enabled operation.""" + settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return True + return bool(settings.get(DYNAMIC_LOAD_ENABLED_KEY, True)) + + def dynamic_config_reload_interval(self) -> int: + """Implement the dynamic config reload interval operation.""" + settings = self.cfg.get(DYNAMIC_LOAD_SETTINGS_KEY, {}) + if not isinstance(settings, dict): + return DYNAMIC_LOAD_DEFAULT_INTERVAL + try: + interval = int(settings.get(DYNAMIC_LOAD_INTERVAL_KEY, + DYNAMIC_LOAD_DEFAULT_INTERVAL)) + except (TypeError, ValueError): + return DYNAMIC_LOAD_DEFAULT_INTERVAL + return interval if interval > 0 else DYNAMIC_LOAD_DEFAULT_INTERVAL + + def apply_runtime_config(self, config_items: Dict[str, Any]) -> None: + """Apply already parsed config-center data to this plugin.""" + self.cfg = self._normalize_runtime_config( + config_items, self._cfg_default) + cfg.check_auto(self._cfg_std, self.cfg) + self._apply_runtime_config_fields(reload_data_file=True) + if self.enabled: + self._start_detection() + self.refresh_config_file_state() + + def reload_runtime_config(self, announce: bool = False): + """Implement the reload runtime config operation.""" + self.cfg = self._load_config(self._cfg_default, self._cfg_std) + self._apply_runtime_config_fields(reload_data_file=True) + if self.enabled: + self._start_detection() + if announce: + fmts.print_suc(f"{self.name} 配置文件已热更新") + + def reload_no_create_regions_config(self, announce: bool = False): + """Implement the reload no create regions config operation.""" + self.no_create_regions_raw = self._load_no_create_regions() + self._reload_no_create_regions() + if announce: + fmts.print_suc(f"{self.name} 不可创建领地区域配置已热更新") + + def config_reload_task(self): + """Implement the config reload task operation.""" + while not self._stop_event.wait(self.dynamic_config_reload_interval()): + if not self.is_dynamic_config_reload_enabled(): + self.refresh_config_file_state() + continue + current_config_state = self.file_state(self.config_file_path()) + current_regions_state = self.file_state( + self.no_create_regions_file) + if ( + current_config_state == self._config_file_state + and current_regions_state == self._no_create_regions_file_state + ): + continue + try: + if current_config_state != self._config_file_state: + self.reload_runtime_config(announce=True) + if current_regions_state != self._no_create_regions_file_state: + self.reload_no_create_regions_config(announce=True) + self.refresh_config_file_state() + except Exception as err: + self._config_file_state = current_config_state + self._no_create_regions_file_state = current_regions_state + fmts.print_err(f"{self.name} 配置文件热更新失败: {err}") + + def api_reload_land_config(self) -> Tuple[bool, str, Dict[str, Any]]: + """Expose the api reload land config API operation.""" + try: + self.reload_runtime_config(announce=False) + self.reload_no_create_regions_config(announce=False) + self.refresh_config_file_state() + except Exception as err: + return False, f"领地系统配置重载失败: {err}", self.api_get_runtime_status() + return True, "领地系统配置已重载", self.api_get_runtime_status() + + def api_get_runtime_status(self) -> Dict[str, Any]: + """Expose the api get runtime status API operation.""" + return { + "enabled": self.enabled, + "wake_words": list(self.wake_words), + "data_file": self.data_file, + "check_interval": self.check_interval, + "land_count": len(self.lands), + "no_create_region_count": len(self.no_create_regions), + "dynamic_reload_enabled": self.is_dynamic_config_reload_enabled(), + "dynamic_reload_interval": self.dynamic_config_reload_interval(), + } + + def _load_no_create_regions(self) -> List[Dict[str, Any]]: + """Load no create regions data.""" + if not os.path.exists(self.no_create_regions_file): + regions = default_no_create_regions() + self._save_no_create_regions(regions) + return regions + try: + with open(self.no_create_regions_file, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return data + except Exception as err: + fmts.print_err(f"读取不可创建领地区域数据失败: {err}") + regions = default_no_create_regions() + self._save_no_create_regions(regions) + return regions + + def _save_no_create_regions( + self, regions: Optional[List[Dict[str, Any]]] = None): + """Save no create regions data.""" + data = self.no_create_regions_raw if regions is None else regions + os.makedirs( + os.path.dirname( + self.no_create_regions_file), + exist_ok=True) + with open(self.no_create_regions_file, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def _reload_no_create_regions(self): + """Implement the reload no create regions operation.""" + self.no_create_regions = self._normalize_no_create_regions( + self.no_create_regions_raw) + + @staticmethod + def _as_float_pos(raw: Any) -> Optional[Tuple[float, float, float]]: + """Implement the as float pos operation.""" + if not isinstance(raw, list) or len(raw) != 3: + return None + try: + return (float(raw[0]), float(raw[1]), float(raw[2])) + except (TypeError, ValueError): + return None + + def _normalize_no_create_regions(self, raw: Any) -> List[Dict[str, Any]]: + """Normalize no create regions values.""" + regions: List[Dict[str, Any]] = [] + if not isinstance(raw, list): + return regions + for index, item in enumerate(raw, 1): + if not isinstance(item, dict) or not item.get("启用", True): + continue + region_type = str(item.get("类型", "")).strip().lower() + name = str(item.get("名称", f"区域{index}")).strip() or f"区域{index}" + if region_type in ("圆形", "circle", "round"): + center = self._as_float_pos(item.get("中心")) + try: + radius = float(item.get("半径", 0)) + except (TypeError, ValueError): + radius = 0 + if center is None or radius <= 0: + fmts.print_war(f"领地系统云链联动版: 不可创建领地区域 {name} 配置无效,已忽略") + continue + regions.append({ + "名称": name, + "类型": "圆形", + "中心": center, + "半径": radius, + }) + elif region_type in ("方形", "矩形", "长方形", "square", "box", "立方体", "长方体"): + start = self._as_float_pos(item.get("起点")) + end = self._as_float_pos(item.get("终点")) + if start is None or end is None: + fmts.print_war(f"领地系统云链联动版: 不可创建领地区域 {name} 配置无效,已忽略") + continue + min_pos = tuple(min(start[i], end[i]) for i in range(3)) + max_pos = tuple(max(start[i], end[i]) for i in range(3)) + regions.append({ + "名称": name, + "类型": "方形", + "起点": min_pos, + "终点": max_pos, + }) + else: + fmts.print_war(f"领地系统云链联动版: 不可创建领地区域 {name} 类型未知,已忽略") + return regions + + def _get_no_create_overlap_reason( + self, + center: Tuple[float, float, float], + radius: int, + shape: str = "圆形", + size: Optional[Tuple[int, int, int]] = None, + ) -> Optional[str]: + """Return no create overlap reason data.""" + shape = LandData.normalize_shape(shape) + box_bounds = None + if shape == "方形": + box_bounds = bounds_from_center_size( + center, LandData.normalize_size(size, radius)) + x, y, z = center + for region in self.no_create_regions: + if shape == "方形" and box_bounds is not None: + if region["类型"] == "圆形": + if sphere_intersects_box( + region["中心"], + region["半径"], + box_bounds[0], + box_bounds[1]): + return region["名称"] + elif region["类型"] == "方形": + if boxes_intersect( + box_bounds[0], + box_bounds[1], + region["起点"], + region["终点"]): + return region["名称"] + elif region["类型"] == "圆形": + rx, ry, rz = region["中心"] + if math.sqrt((x - rx) ** 2 + (y - ry) ** 2 + + (z - rz) ** 2) <= radius + region["半径"]: + return region["名称"] + elif region["类型"] == "方形": + if sphere_intersects_box( + center, radius, region["起点"], region["终点"]): + return region["名称"] + return None + + @staticmethod + def _is_exit_input(text: str) -> bool: + """Implement the is exit input operation.""" + return text.strip().lower() in (".", "。", "q", "quit", "退出") + + def _wait_menu_input( + self, + player: Player, + timeout: int = 60) -> Optional[str]: + """Implement the wait menu input operation.""" + msg = game_utils.waitMsg(player.name, timeout) + if msg is None: + player.show(self._error("回复超时,已退出菜单")) + return None + msg = msg.strip() + if self._is_exit_input(msg): + player.show(self._success("已退出领地菜单")) + return None + return msg + + def _select_menu( + self, + player: Player, + title: str, + options: List[str], + timeout: int = 60) -> Optional[int]: + """Implement the select menu operation.""" + while True: + hints = [ + f"输入 §e[1-{len(options)}]§b 之间的数字以选择功能", + "输入 §c.§b 退出", + ] + player.show(self._ui_menu( + title, + options, + )) + player.show("\n".join(f"§a❀ §b{hint}" for hint in hints)) + msg = self._wait_menu_input(player, timeout) + if msg is None: + return None + choice = utils.try_int(msg.strip().strip("[]")) + if choice is None or choice not in range(1, len(options) + 1): + player.show(self._error("您的输入有误")) + continue + return choice + + def _prompt_text( + self, + player: Player, + title: str, + prompt: str, + timeout: int = 60) -> Optional[str]: + """Implement the prompt text operation.""" + player.show(self._ui_card(title, [prompt], ["输入 §c.§b 退出"])) + return self._wait_menu_input(player, timeout) + + def _parse_box_size_input( + self, raw: str) -> Tuple[Optional[Tuple[int, int, int]], Optional[str]]: + """Implement the parse box size input operation.""" + parts = raw.replace(",", " ").replace(",", " ").split() + if len(parts) != 3: + return None, "格式错误,需要输入 长 高 宽 三个整数" + try: + length, height, width = ( + int(parts[0]), int(parts[1]), int(parts[2])) + except ValueError: + return None, "长、高、宽必须为整数" + if length <= 0 or height <= 0 or width <= 0: + return None, "长、高、宽必须大于 0" + if length > self.max_length: + return None, f"长不能超过 {self.max_length}" + if height > self.max_height: + return None, f"高不能超过 {self.max_height}" + if width > self.max_width: + return None, f"宽不能超过 {self.max_width}" + return (length, height, width), None + + def _get_xuid_by_name( + self, + playername: str, + allow_offline: bool = False) -> Optional[str]: + """Return xuid by name data.""" + try: + return str( + self.xuid_getter.get_xuid_by_name( + playername, + allow_offline=allow_offline)) + except Exception as err: + self.print_war(f"无法获取玩家 {playername} 的 XUID: {err}") + return None + + def _get_player_xuid(self, player: Player) -> Optional[str]: + """Return player xuid data.""" + xuid = getattr(player, "xuid", None) + if xuid: + return str(xuid) + return self._get_xuid_by_name(player.name) + + def _make_member(self, playername: str, rank: LandRank, + allow_offline: bool = False) -> Optional[LandMember]: + """Implement the make member operation.""" + xuid = self._get_xuid_by_name(playername, allow_offline=allow_offline) + if xuid is None: + return None + return LandMember(name=playername, xuid=xuid, rank=rank) + + def _select_land( + self, + player: Player, + title: str, + lands: List[LandData]) -> Optional[LandData]: + """Implement the select land operation.""" + if not lands: + player.show(self._error("没有可选择的领地")) + return None + choice = self._select_menu( + player, + title, + [ + f"{land.name} §7- 领主: {land.owner}, {land.range_text()}" + for land in lands + ], + ) + if choice is None: + return None + return lands[choice - 1] + + def _land_summary(self, land: LandData) -> Dict[str, Any]: + """Implement the land summary operation.""" + _ = self + admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] + members = [m.name for m in land.members if m.rank == LandRank.MEMBER] + return { + "land_id": land.land_id, + "name": land.name, + "owner": land.owner, + "owner_xuid": land.owner_xuid, + "center": land.center, + "radius": land.radius, + "shape": land.shape, + "size": land.get_size() if land.is_box() else None, + "dimension": land.dimension, + "range_text": land.range_text(), + "admins": admins, + "members": members, + "member_count": len(land.members), + } + + def _find_land_by_name_or_id(self, query: str) -> Optional[LandData]: + """Implement the find land by name or id operation.""" + query = str(query).strip() + if not query: + return None + if query in self.lands: + return self.lands[query] + return next((land for land in self.lands.values() + if land.name == query), None) + + def _migrate_legacy_data_path(self): + """Implement the migrate legacy data path operation.""" + legacy_path = os.path.join( + os.path.dirname( + self.data_path), + LEGACY_PLUGIN_NAME) + if not os.path.isdir(legacy_path) or os.path.abspath( + legacy_path) == os.path.abspath(self.data_path): + return + os.makedirs(self.data_path, exist_ok=True) + for filename in ("领地数据.json", "不可创建领地区域.json"): + old_file = os.path.join(legacy_path, filename) + new_file = self.format_data_path(filename) + if os.path.isfile(old_file) and not os.path.exists(new_file): + try: + shutil.copy2(old_file, new_file) + except Exception as err: + self.print_war(f"迁移旧版领地系统数据文件 {filename} 失败: {err}") + + def _ensure_dirs(self): + """Implement the ensure dirs operation.""" + os.makedirs(os.path.dirname(self.data_file) or ".", exist_ok=True) + + # ---------- 玩家-领地 缓存维护 ---------- + def _add_player_land(self, xuid: str, land_id: str): + """Implement the add player land operation.""" + if xuid not in self.player_land_cache: + self.player_land_cache[xuid] = [] + if land_id not in self.player_land_cache[xuid]: + self.player_land_cache[xuid].append(land_id) + + def _remove_player_land(self, xuid: str, land_id: str): + """Implement the remove player land operation.""" + if xuid in self.player_land_cache: + lst = self.player_land_cache[xuid] + if land_id in lst: + lst.remove(land_id) + if not lst: + del self.player_land_cache[xuid] + + def _rebuild_player_land_cache(self): + """Implement the rebuild player land cache operation.""" + self.player_land_cache.clear() + for land_id, land in self.lands.items(): + for member in land.members: + self._add_player_land(member.xuid, land_id) + + # ---------- 数据加载/保存 ---------- + def _load_data(self): + """Load data data.""" + try: + if os.path.exists(self.data_file): + with open(self.data_file, "r", encoding="utf-8") as f: + raw = json.load(f) + self.player_land_cache.clear() + for land_id, data in raw.items(): + try: + land = LandData.from_dict(data) + self.lands[land_id] = land + for m in land.members: + self._add_player_land(m.xuid, land_id) + except Exception as e: + fmts.print_err(f"解析领地 {land_id} 失败: {e}") + except Exception as e: + fmts.print_err(f"加载数据失败: {e}") + + def _save_data(self): + """Save data data.""" + try: + raw = {lid: land.to_dict() for lid, land in self.lands.items()} + with open(self.data_file, "w", encoding="utf-8") as f: + json.dump(raw, f, ensure_ascii=False, indent=2) + except Exception as e: + fmts.print_err(f"保存数据失败: {e}") + + # ---------- 坐标获取 ---------- + def _get_player_coord( + self, player: str) -> Optional[Tuple[float, float, float]]: + """Return player coord data.""" + try: + pos_dict = game_utils.getPos(player) + if pos_dict and "position" in pos_dict: + p = pos_dict["position"] + return (p.get("x", 0), p.get("y", 0), p.get("z", 0)) + except Exception: + pass + cmd = f"/data get entity {player} Pos" + try: + resp = self.game_ctrl.sendwscmd_with_resp(cmd, timeout=2) + if resp.SuccessCount: + for out in resp.OutputMessages: + msg = out.Message + nums = re.findall(r"[-+]?\d*\.?\d+[df]?", msg) + if len(nums) >= 3: + x = float(nums[0].replace('d', '').replace('f', '')) + y = float(nums[1].replace('d', '').replace('f', '')) + z = float(nums[2].replace('d', '').replace('f', '')) + return (x, y, z) + except Exception: + pass + return None + + def _manual_coord( + self, player: Player) -> Optional[Tuple[float, float, float]]: + """Implement the manual coord operation.""" + player.show(self._ui_card( + "手动坐标输入", + [ + "请输入你的当前坐标", + "格式:x y z,例如 100 64 200", + "请在聊天栏直接输入数字,用空格分隔", + ], + ["30 秒内回复有效"], + )) + msg = game_utils.waitMsg(player.name, 30) + if not msg: + player.show(self._error("输入超时")) + return None + parts = msg.strip().split() + if len(parts) != 3: + player.show(self._error("格式错误,需要三个数字")) + return None + try: + x = float(parts[0]) + y = float(parts[1]) + z = float(parts[2]) + return (x, y, z) + except ValueError: + player.show(self._error("请输入有效的数字")) + return None + + # ---------- 领地查找辅助 ---------- + def _find_land_at(self, + pos: Tuple[float, + float, + float], + dimension: int = 0) -> Optional[LandData]: + """Implement the find land at operation.""" + x, y, z = pos + for land in self.lands.values(): + if land.dimension != dimension: + continue + if land.contains_pos((x, y, z)): + return land + return None + + def _find_land_at_player(self, player: str) -> Optional[LandData]: + """Implement the find land at player operation.""" + pos = self._get_player_coord(player) + if pos: + return self._find_land_at(pos) + return None + + # ---------- 检测线程 ---------- + def _start_detection(self): + """Implement the start detection operation.""" + if self._detection_started: + return + self._detection_started = True + + def loop(): + """Implement the loop operation.""" + while not self._stop_event.wait(self.check_interval): + try: + self._update_coords() + self._check_lands() + except Exception as e: + fmts.print_err(f"检测异常: {e}") + threading.Thread(target=loop, daemon=True).start() + + def _update_coords(self): + """Implement the update coords operation.""" + if not self.enabled: + return + players = self.game_ctrl.allplayers + new_coords = {} + for name in players: + pos = self._get_player_coord(name) + if pos: + new_coords[name] = pos + with self.coords_lock: + self.coords = new_coords + + def _check_lands(self): + """Implement the check lands operation.""" + if not self.enabled: + return + with self.coords_lock: + players = dict(self.coords) + + now = time.time() + expired = [ + p for p, + t in self.recent_tp.items() if now - + t > self.tp_cooldown] + for p in expired: + del self.recent_tp[p] + + for name, (x, y, z) in players.items(): + if name.lower() in self.whitelist: + continue + xuid = self._get_xuid_by_name(name) + if xuid is None: + continue + + in_land = self._find_land_at((x, y, z)) + + if in_land: + if in_land.get_member(xuid): + continue + if xuid in self.recent_tp: + continue + self._tp_random(name, x, y, z) + self.recent_tp[xuid] = now + self.game_ctrl.say_to(name, self._error( + f"你闯入了 {in_land.owner} 的领地,已被传送离开")) + else: + for land in self.lands.values(): + if land.dimension != 0: + continue + dist = distance_to_land((x, y, z), land) + if 0 <= dist <= self.buffer_dist: + if land.get_member(xuid): + continue + self.game_ctrl.sendwocmd(f"/gamemode adventure {name}") + self.game_ctrl.say_to(name, self._warn( + f"你正在靠近 {land.owner} 的领地,请勿进入")) + break + + def _tp_random(self, player: str, x: float, y: float, z: float): + """Implement the tp random operation.""" + angle = random.uniform(0, 2 * math.pi) + dist = random.uniform(10, self.tp_radius) + nx = x + dist * math.cos(angle) + nz = z + dist * math.sin(angle) + ny = y + cmd = f"/tp {player} {nx} {ny} {nz}" + self.game_ctrl.sendwocmd(cmd) + + # ---------- 实体标记(可选) ---------- + def _spawn_entity(self, land: LandData): + """Implement the spawn entity operation.""" + try: + x, y, z = land.center + self._remove_entity(land) + nbt = ( + "{" + "Duration:2147483647,WaitTime:2147483647,Tags:[\"land_" + + land.land_id + + "\"]" + "}" + ) + cmd = f"summon area_effect_cloud {x} {y} {z} {nbt}" + self.game_ctrl.sendwocmd(cmd) + except Exception: + pass + + def _remove_entity(self, land: LandData): + """Implement the remove entity operation.""" + try: + cmd = f"kill @e[type=area_effect_cloud,tag=land_{land.land_id}]" + self.game_ctrl.sendwocmd(cmd) + except Exception: + pass + + # ---------- 命令处理 ---------- + def on_chat(self, chat: Chat): + """Implement the on chat operation.""" + if not self.enabled: + return + msg = chat.msg.strip() + if msg not in self.wake_words: + return + self._main_menu(chat.player) + + def _main_menu(self, player: Player): + """Implement the main menu operation.""" + choice = self._select_menu( + player, + "功能菜单", + [ + "创建领地", + "删除领地", + "查看领地信息", + "成员管理", + "管理员管理", + "传送到领地", + "查看领地列表", + "测试当前位置", + ], + timeout=90, + ) + if choice is None: + return + if choice == 1: + self._menu_create(player) + elif choice == 2: + self._menu_delete(player) + elif choice == 3: + self._menu_info(player) + elif choice == 4: + self._menu_member(player) + elif choice == 5: + self._menu_admin(player) + elif choice == 6: + self._menu_tp(player) + elif choice == 7: + self._list(player) + elif choice == 8: + self._test(player) + + def _menu_create(self, player: Player): + """Implement the menu create operation.""" + name = self._prompt_text(player, "创建领地", "请输入领地名称") + if name is None: + return + shape_choice = self._select_menu(player, "创建领地类型", ["圆形领地", "方形领地"]) + if shape_choice is None: + return + if shape_choice == 1: + radius = self._prompt_text( + player, "创建圆形领地", f"请输入领地半径,范围 1~{self.max_radius}") + if radius is None: + return + self._create(player, [name, "圆形", radius]) + else: + size_text = self._prompt_text( + player, + "创建方形领地", + f"请输入 长 高 宽,最大 {self.max_length} {self.max_height} {self.max_width}", + ) + if size_text is None: + return + size, err = self._parse_box_size_input(size_text) + if size is None: + player.show(self._error(err or "方形领地尺寸无效")) + return + self._create( + player, [ + name, "方形", str( + size[0]), str( + size[1]), str( + size[2])]) + + def _menu_delete(self, player: Player): + """Implement the menu delete operation.""" + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + owned = [land for land in self.lands.values( + ) if land.owner_xuid == player_xuid] + land = self._select_land(player, "删除领地", owned) + if land is None: + return + self._delete(player, [land.name]) + + def _menu_info(self, player: Player): + """Implement the menu info operation.""" + choice = self._select_menu( + player, + "查看领地信息", + ["查看当前所在领地", "从我的领地中选择", "从全部领地中选择", "输入领地名称"], + ) + if choice is None: + return + if choice == 1: + self._info(player, []) + elif choice == 2: + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + lands = [land for land in self.lands.values() if land.owner_xuid == + player_xuid] + land = self._select_land(player, "我的领地", lands) + if land: + self._info(player, [land.name]) + elif choice == 3: + land = self._select_land(player, "全部领地", list(self.lands.values())) + if land: + self._info(player, [land.name]) + elif choice == 4: + name = self._prompt_text(player, "查看领地信息", "请输入领地名称") + if name: + self._info(player, [name]) + + def _menu_member(self, player: Player): + """Implement the menu member operation.""" + choice = self._select_menu(player, "成员管理", ["添加成员", "移除成员", "查看成员列表"]) + if choice is None: + return + if choice == 3: + self._member(player, ["列表"]) + return + target = self._prompt_text( + player, + "成员管理", + "请输入玩家名", + ) + if target is None: + return + self._member(player, [["添加", "移除"][choice - 1], target]) + + def _menu_admin(self, player: Player): + """Implement the menu admin operation.""" + choice = self._select_menu(player, "管理员管理", ["添加管理员", "移除管理员"]) + if choice is None: + return + target = self._prompt_text(player, "管理员管理", "请输入玩家名") + if target is None: + return + self._admin(player, [["添加", "移除"][choice - 1], target]) + + def _menu_tp(self, player: Player): + """Implement the menu tp operation.""" + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + lands = [land for land in self.lands.values( + ) if land.has_permission(player_xuid, "tp")] + land = self._select_land(player, "传送到领地", lands) + if land is None: + return + self._tp(player, [land.name]) + + def _create(self, player: Player, args: List[str]): # skipcq: PY-R1000 + """Implement the create operation.""" + if len(args) < 2: + player.show(self._error( + f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入创建菜单")) + return + name = args[0] + shape_arg = str(args[1]).strip().lower() + explicit_circle = shape_arg in ("圆形", "circle", "round") + explicit_box = LandData.normalize_shape(shape_arg) == "方形" + shape = "方形" if explicit_box else "圆形" + size = None + if shape == "方形": + if len(args) < 5: + player.show(self._error("方形领地需要输入 长 高 宽")) + return + size, err = self._parse_box_size_input(" ".join(args[2:5])) + if size is None: + player.show(self._error(err or "方形领地尺寸无效")) + return + radius = box_radius_for_size(size) + else: + radius_arg = args[2] if len( + args) >= 3 and explicit_circle else args[1] + try: + radius = int(radius_arg) + except ValueError: + player.show(self._error("半径必须为整数")) + return + if radius <= 0 or radius > self.max_radius: + player.show(self._error(f"半径必须在 1~{self.max_radius} 之间")) + return + + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + owned = [ + land_item for land_item in self.lands.values() + if land_item.owner_xuid == player_xuid + ] + if len(owned) >= self.max_lands_per_player: + player.show( + self._error( + f"你最多只能拥有 {self.max_lands_per_player} 个领地")) + return + + pos = self._get_player_coord(player.name) + if not pos: + player.show(self._warn("无法自动获取坐标,请手动输入")) + pos = self._manual_coord(player) + if not pos: + return + x, y, z = pos + + overlap = self._get_land_candidate_overlap_reason( + (x, y, z), radius, shape, size) + if overlap: + player.show(self._error(overlap)) + return + + land_id = str(uuid.uuid4()) + member = LandMember( + name=player.name, + xuid=player_xuid, + rank=LandRank.OWNER) + land = LandData( + land_id=land_id, + name=name, + owner=player.name, + owner_xuid=player_xuid, + center=(x, y, z), + radius=radius, + shape=shape, + size=size, + members=[member] + ) + self.lands[land_id] = land + self._add_player_land(player_xuid, land_id) + self._save_data() + player.show(self._success(f"成功创建领地 '{name}',{land.range_text()}")) + self._spawn_entity(land) + + def _delete(self, player: Player, args: List[str]): + """Implement the delete operation.""" + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + owned = [ + land_item for land_item in self.lands.values() + if land_item.owner_xuid == player_xuid + ] + if not owned: + player.show(self._error("你没有拥有任何领地")) + return + + if args: + name = args[0] + land = next( + (land_item for land_item in owned if land_item.name == name), + None, + ) + if not land: + player.show(self._error(f"你没有名为 '{name}' 的领地")) + return + else: + if len(owned) > 1: + names = "、".join(land_item.name for land_item in owned) + player.show(self._error(f"你拥有多个领地,请指定名称:{names}")) + return + land = owned[0] + + self._remove_entity(land) + for m in land.members: + self._remove_player_land(m.xuid, land.land_id) + del self.lands[land.land_id] + self._save_data() + player.show(self._success(f"已删除领地 '{land.name}'")) + + def _info(self, player: Player, args: List[str]): + """Implement the info operation.""" + land = None + if args: + name = args[0] + land = next( + ( + land_item for land_item in self.lands.values() + if land_item.name == name + ), + None, + ) + if not land: + player.show(self._error(f"领地 '{name}' 不存在")) + return + else: + land = self._find_land_at_player(player.name) + if not land: + player.show(self._error("你当前不在任何领地内,请指定领地名称")) + return + + admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] + members = [m.name for m in land.members if m.rank == LandRank.MEMBER] + player.show(self._ui_card( + f"领地信息 - {land.name}", + [ + f"中心:{land.center[0]}, {land.center[1]}, {land.center[2]}", + f"范围:{land.range_text()}", + f"领主:{land.owner}", + f"管理员:{', '.join(admins) or '无'}", + f"成员:{', '.join(members) or '无'}", + ], + )) + + def _member(self, player: Player, args: List[str]): # skipcq: PY-R1000 + """Implement the member operation.""" + if len(args) < 1: + player.show(self._error( + f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入成员管理菜单")) + return + sub = args[0].lower() + if sub == "列表": + land = self._find_land_at_player(player.name) + if not land: + player.show(self._error("你当前不在任何领地内")) + return + self._info(player, [land.name]) + return + + if len(args) < 2: + player.show(self._error( + f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入成员管理菜单")) + return + target = args[1] + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + target_member = self._make_member( + target, LandRank.MEMBER, allow_offline=True) + if target_member is None: + player.show(self._error(f"无法获取 {target} 的 XUID")) + return + + land = self._find_land_at_player(player.name) + if not land: + player.show(self._error("你当前不在任何领地内")) + return + + if not land.has_permission(player_xuid, "manage_member"): + player.show(self._error("你没有权限管理成员")) + return + + if sub == "添加": + if land.get_member(target_member.xuid): + player.show(self._warn(f"{target} 已是成员")) + return + land.members.append(target_member) + self._add_player_land(target_member.xuid, land.land_id) + self._save_data() + player.show(self._success(f"已将 {target} 添加为成员")) + elif sub == "移除": + if not land.get_member(target_member.xuid): + player.show(self._error(f"{target} 不是成员")) + return + if not land.can_manage_member(player_xuid, target_member.xuid): + player.show(self._error("你不能移除该成员")) + return + land.members = [ + m for m in land.members if m.xuid != target_member.xuid] + self._remove_player_land(target_member.xuid, land.land_id) + self._save_data() + player.show(self._success(f"已将 {target} 移除成员")) + else: + player.show(self._error("未知子命令")) + + def _admin(self, player: Player, args: List[str]): + """Implement the admin operation.""" + if len(args) < 2: + player.show(self._error( + f"请直接输入唤醒词 {' / '.join(self.wake_words)} 进入管理员管理菜单")) + return + sub = args[0].lower() + target = args[1] + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + target_xuid = self._get_xuid_by_name(target, allow_offline=True) + if target_xuid is None: + player.show(self._error(f"无法获取 {target} 的 XUID")) + return + + land = self._find_land_at_player(player.name) + if not land: + player.show(self._error("你当前不在任何领地内")) + return + + if land.owner_xuid != player_xuid: + player.show(self._error("只有领主可以管理管理员")) + return + + if sub == "添加": + member = land.get_member(target_xuid) + if not member: + player.show(self._error(f"{target} 不是成员")) + return + if member.rank == LandRank.ADMIN: + player.show(self._warn(f"{target} 已经是管理员")) + return + member.rank = LandRank.ADMIN + self._save_data() + player.show(self._success(f"已将 {target} 设为管理员")) + elif sub == "移除": + member = land.get_member(target_xuid) + if not member or member.rank != LandRank.ADMIN: + player.show(self._error(f"{target} 不是管理员")) + return + member.rank = LandRank.MEMBER + self._save_data() + player.show(self._success(f"已将 {target} 移除管理员")) + else: + player.show(self._error("未知子命令")) + + def _tp(self, player: Player, args: List[str]): + """Implement the tp operation.""" + land = None + if args: + name = args[0] + land = next( + ( + land_item for land_item in self.lands.values() + if land_item.name == name + ), + None, + ) + if not land: + player.show(self._error(f"领地 '{name}' 不存在")) + return + else: + land = self._find_land_at_player(player.name) + if not land: + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + owned = [ + land_item for land_item in self.lands.values() + if land_item.owner_xuid == player_xuid + ] + if owned: + land = owned[0] + else: + player.show(self._error("你当前不在任何领地内,且你没有拥有领地,请指定名称")) + return + + player_xuid = self._get_player_xuid(player) + if player_xuid is None: + player.show(self._error("无法获取你的 XUID")) + return + if not land.has_permission(player_xuid, "tp"): + player.show(self._error("你没有权限传送到该领地")) + return + + cx, cy, cz = land.center + self.game_ctrl.sendwocmd(f"/tp {player.name} {cx} {cy + 1} {cz}") + player.show(self._success(f"已传送到领地 '{land.name}'")) + + def _list(self, player: Player): + """Implement the list operation.""" + if not self.lands: + player.show(self._error("暂无任何领地")) + return + options = [ + f"{land.name} §7- 领主: {land.owner}, {land.range_text()}" + for land in self.lands.values() + ] + player.show(self._ui_menu("领地列表", options, [f"共 {len(options)} 个领地"])) + + def _test(self, player: Player): + """Implement the test operation.""" + pos = self._get_player_coord(player.name) + lines = [] + if pos: + lines.append(f"当前坐标:{pos[0]:.1f}, {pos[1]:.1f}, {pos[2]:.1f}") + else: + lines.append("无法获取坐标") + + in_land = self._find_land_at_player(player.name) + if in_land: + lines.append(f"当前位置:位于领地 '{in_land.name}' 内") + else: + lines.append("当前位置:不在任何领地内") + + if self.lands: + lines.append( + "所有领地:" + + "、".join( + f"{land.name}({land.owner})" for land in self.lands.values() + ) + ) + else: + lines.append("所有领地:无") + player.show(self._ui_card("测试信息", lines)) + + def on_player_join(self, player: Player): + """Implement the on player join operation.""" + _ = player + if not self.enabled: + return + return + + def on_player_leave(self, player: Player): + """Implement the on player leave operation.""" + _ = player + if not self.enabled: + return + return + + # ---------- 外部插件 API ---------- + def api_list_lands(self) -> Tuple[bool, str, List[Dict[str, Any]]]: + """Expose the api list lands API operation.""" + lands = [self._land_summary(land) for land in self.lands.values()] + return True, f"共 {len(lands)} 个领地", lands + + def api_get_land( + self, land_query: str) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Expose the api get land API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + return True, "查询成功", self._land_summary(land) + + def api_add_land( # skipcq: PY-R1000 + self, + owner: str, + name: str, + center: Tuple[float, float, float], + shape: str = "圆形", + radius: Optional[int] = None, + size: Optional[Tuple[int, int, int]] = None, + dimension: int = 0, + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Expose the api add land API operation.""" + owner = str(owner).strip() + name = str(name).strip() + if not owner or not name: + return False, "领地主人和领地名称不能为空", None + if self._find_land_by_name_or_id(name): + return False, f"领地 '{name}' 已存在", None + owner_member = self._make_member( + owner, LandRank.OWNER, allow_offline=True) + if owner_member is None: + return False, f"无法获取 {owner} 的 XUID", None + if len([land for land in self.lands.values() if land.owner_xuid == + owner_member.xuid]) >= self.max_lands_per_player: + return False, f"{owner} 已达到最大领地数量 {self.max_lands_per_player}", None + try: + center_tuple = ( + float( + center[0]), float( + center[1]), float( + center[2])) + except (TypeError, ValueError, IndexError): + return False, "中心坐标无效", None + + normalized_shape = LandData.normalize_shape(shape) + normalized_size = None + if normalized_shape == "方形": + if size is None: + return False, "方形领地需要提供 长 高 宽", None + normalized_size = LandData.normalize_size(size, radius or 1) + if normalized_size[0] > self.max_length: + return False, f"长不能超过 {self.max_length}", None + if normalized_size[1] > self.max_height: + return False, f"高不能超过 {self.max_height}", None + if normalized_size[2] > self.max_width: + return False, f"宽不能超过 {self.max_width}", None + land_radius = box_radius_for_size(normalized_size) + else: + try: + land_radius = int(radius) + except (TypeError, ValueError): + return False, "圆形领地半径必须为整数", None + if land_radius <= 0 or land_radius > self.max_radius: + return False, f"半径必须在 1~{self.max_radius} 之间", None + + overlap = self._get_land_candidate_overlap_reason( + center_tuple, + land_radius, + normalized_shape, + normalized_size, + dimension, + ) + if overlap: + return False, overlap, None + + land_id = str(uuid.uuid4()) + land = LandData( + land_id=land_id, + name=name, + owner=owner, + owner_xuid=owner_member.xuid, + center=center_tuple, + radius=land_radius, + shape=normalized_shape, + size=normalized_size, + dimension=dimension, + members=[owner_member], + ) + self.lands[land_id] = land + self._rebuild_player_land_cache() + self._save_data() + self._spawn_entity(land) + return True, f"已新增玩家 {owner} 的领地 '{name}',{land.range_text()}", self._land_summary(land) + + def api_delete_land(self, land_query: str) -> Tuple[bool, str, None]: + """Expose the api delete land API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + self._remove_entity(land) + del self.lands[land.land_id] + self._rebuild_player_land_cache() + self._save_data() + return True, f"已删除领地 '{land.name}'", None + + def api_add_member(self, + land_query: str, + player_name: str, + rank: str = "member") -> Tuple[bool, + str, + Optional[Dict[str, + Any]]]: + """Expose the api add member API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + target_rank = LandRank.ADMIN if str(rank).lower() in ( + "admin", "管理员", "管理") else LandRank.MEMBER + member = self._make_member( + player_name, target_rank, allow_offline=True) + if member is None: + return False, f"无法获取 {player_name} 的 XUID", None + old_member = land.get_member(member.xuid) + if old_member: + if old_member.rank == LandRank.OWNER: + return False, "所有者不能被改为成员或管理员", self._land_summary(land) + old_member.name = player_name + old_member.rank = target_rank + else: + land.members.append(member) + self._rebuild_player_land_cache() + self._save_data() + role_name = "管理员" if target_rank == LandRank.ADMIN else "成员" + return True, f"已将 {player_name} 添加为领地 '{land.name}' 的{role_name}", self._land_summary(land) + + def api_set_member_rank( + self, + land_query: str, + player_name: str, + rank: str, + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Expose the api set member rank API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + target_rank = LandRank.ADMIN if str(rank).lower() in ( + "admin", "管理员", "管理") else LandRank.MEMBER + target_xuid = self._get_xuid_by_name(player_name, allow_offline=True) + if target_xuid is None: + return False, f"无法获取 {player_name} 的 XUID", None + member = land.get_member(target_xuid) + if member is None: + return False, f"{player_name} 不在领地 '{land.name}' 中", self._land_summary(land) + if member.rank == LandRank.OWNER: + return False, "所有者不能修改身份", self._land_summary(land) + member.name = player_name + member.rank = target_rank + self._save_data() + role_name = "管理员" if target_rank == LandRank.ADMIN else "成员" + return True, f"已将 {player_name} 设为领地 '{land.name}' 的{role_name}", self._land_summary(land) + + def api_remove_member(self, + land_query: str, + player_name: str) -> Tuple[bool, + str, + Optional[Dict[str, + Any]]]: + """Expose the api remove member API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + target_xuid = self._get_xuid_by_name(player_name, allow_offline=True) + if target_xuid is None: + return False, f"无法获取 {player_name} 的 XUID", None + member = land.get_member(target_xuid) + if not member: + return False, f"{player_name} 不在领地 '{land.name}' 中", self._land_summary(land) + if member.rank == LandRank.OWNER: + return False, "不能删除领地所有者,请先转移所有者", self._land_summary(land) + land.members = [m for m in land.members if m.xuid != target_xuid] + self._rebuild_player_land_cache() + self._save_data() + return True, f"已从领地 '{land.name}' 移除 {player_name}", self._land_summary(land) + + def api_transfer_owner( + self, + land_query: str, + owner: str, + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Expose the api transfer owner API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + owner_member_data = self._make_member( + owner, LandRank.OWNER, allow_offline=True) + if owner_member_data is None: + return False, f"无法获取 {owner} 的 XUID", None + land.owner = owner + land.owner_xuid = owner_member_data.xuid + for member in land.members: + if member.rank == LandRank.OWNER: + member.rank = LandRank.ADMIN + owner_member = land.get_member(owner_member_data.xuid) + if owner_member: + owner_member.name = owner + owner_member.rank = LandRank.OWNER + else: + land.members.append(owner_member_data) + self._rebuild_player_land_cache() + self._save_data() + return True, f"已修改领地 '{land.name}' 所有者为 {owner}", self._land_summary(land) + + def api_update_land_center( + self, + land_query: str, + center: Tuple[float, float, float], + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Expose the api update land center API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + try: + center_tuple = ( + float( + center[0]), float( + center[1]), float( + center[2])) + except (TypeError, ValueError, IndexError): + return False, "中心坐标无效", self._land_summary(land) + overlap = self._get_land_edit_overlap_reason( + land, center_tuple, land.radius) + if overlap: + return False, overlap, self._land_summary(land) + land.center = center_tuple + self._save_data() + self._spawn_entity(land) + return True, f"已修改领地 '{land.name}' 中心点", self._land_summary(land) + + def api_update_land_range( + self, + land_query: str, + radius: Optional[int] = None, + size: Optional[Tuple[int, int, int]] = None, + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Expose the api update land range API operation.""" + land = self._find_land_by_name_or_id(land_query) + if land is None: + return False, f"领地 '{land_query}' 不存在", None + if land.is_box(): + if size is None: + return False, "方形领地需要提供 长 高 宽", self._land_summary(land) + normalized_size = LandData.normalize_size(size, land.radius) + if normalized_size[0] > self.max_length: + return False, f"长不能超过 {self.max_length}", self._land_summary(land) + if normalized_size[1] > self.max_height: + return False, f"高不能超过 {self.max_height}", self._land_summary(land) + if normalized_size[2] > self.max_width: + return False, f"宽不能超过 {self.max_width}", self._land_summary(land) + land_radius = box_radius_for_size(normalized_size) + overlap = self._get_land_edit_overlap_reason( + land, land.center, land_radius, "方形", normalized_size) + if overlap: + return False, overlap, self._land_summary(land) + land.size = normalized_size + land.radius = land_radius + else: + try: + land_radius = int(radius) + except (TypeError, ValueError): + return False, "半径必须为整数", self._land_summary(land) + if land_radius <= 0 or land_radius > self.max_radius: + return False, f"半径必须在 1~{self.max_radius} 之间", self._land_summary(land) + overlap = self._get_land_edit_overlap_reason( + land, land.center, land_radius, "圆形", None) + if overlap: + return False, overlap, self._land_summary(land) + land.radius = land_radius + land.size = None + self._save_data() + return True, f"已修改领地 '{land.name}' 范围为 {land.range_text()}", self._land_summary(land) + + def _console_print(self, text: str): + """Implement the console print operation.""" + _ = self + fmts.print_inf(text) + + def _console_prompt(self, prompt: str) -> Optional[str]: + """Implement the console prompt operation.""" + value = input(fmts.fmt_info( + f"§a❀ §b{prompt} §7(输入 . 退出整个领地系统云链联动版管理菜单): ")).strip() + if self._is_exit_input(value): + raise ConsoleMenuExit + return value + + def _console_select(self, title: str, options: List[str]) -> Optional[int]: + """Implement the console select operation.""" + while True: + self._console_print(self._ui_menu( + title, + options, + [f"输入 §e[1-{len(options)}]§b 之间的数字以选择", "输入 §c.§b 退出整个领地系统云链联动版管理菜单"], + )) + value = self._console_prompt("请输入序号") + if value is None: + return None + choice = utils.try_int(value.strip().strip("[]")) + if choice is None or choice not in range(1, len(options) + 1): + fmts.print_err(self._error("输入有误")) + continue + return choice + + def _console_prompt_float(self, prompt: str) -> Optional[float]: + """Implement the console prompt float operation.""" + value = self._console_prompt(prompt) + if value is None: + return None + try: + return float(value) + except ValueError: + fmts.print_err(self._error("请输入有效数字")) + return None + + def _console_prompt_int(self, prompt: str) -> Optional[int]: + """Implement the console prompt int operation.""" + value = self._console_prompt(prompt) + if value is None: + return None + try: + return int(value) + except ValueError: + fmts.print_err(self._error("请输入有效整数")) + return None + + def _console_prompt_pos(self, prompt: str) -> Optional[List[float]]: + """Implement the console prompt pos operation.""" + value = self._console_prompt(f"{prompt},格式 x y z") + if value is None: + return None + parts = value.split() + if len(parts) != 3: + fmts.print_err(self._error("坐标格式错误,需要 x y z 三个数字")) + return None + try: + return [float(parts[0]), float(parts[1]), float(parts[2])] + except ValueError: + fmts.print_err(self._error("坐标必须是数字")) + return None + + def _console_select_land( + self, + title: str, + lands: Optional[List[LandData]] = None, + ) -> Optional[LandData]: + """Implement the console select land operation.""" + lands = list(self.lands.values()) if lands is None else lands + if not lands: + fmts.print_err(self._error("暂无可选择的领地")) + return None + choice = self._console_select( + title, + [f"{land.name} - 领主: {land.owner}, {land.range_text()}" for land in lands], + ) + if choice is None: + return None + return lands[choice - 1] + + def console_manage(self, _args: List[str]): + """Implement the console manage operation.""" + try: + while True: + choice = self._console_select( + "控制台管理菜单", + ["新增不可创建领地区域", "删除不可创建领地区域", "新增玩家领地", "删除玩家领地", "管理玩家领地"], + ) + if choice is None: + fmts.print_inf(self._success("已退出领地系统云链联动版管理菜单")) + return + if choice == 1: + self._console_add_no_create_region() + elif choice == 2: + self._console_delete_no_create_region() + elif choice == 3: + self._console_add_land() + elif choice == 4: + self._console_delete_land() + elif choice == 5: + self._console_manage_land() + except ConsoleMenuExit: + fmts.print_inf(self._success("已退出领地系统云链联动版管理菜单")) + + def _console_add_no_create_region(self): + """Implement the console add no create region operation.""" + name = self._console_prompt("请输入区域名称") + if name is None: + return + region_choice = self._console_select("新增不可创建区域", ["圆形区域", "方形区域"]) + if region_choice is None: + return + if region_choice == 1: + center = self._console_prompt_pos("请输入圆形区域中心") + radius = self._console_prompt_float("请输入圆形区域半径") + if center is None or radius is None or radius <= 0: + fmts.print_err(self._error("区域参数无效")) + return + region = { + "名称": name, + "启用": True, + "类型": "圆形", + "中心": center, + "半径": radius} + else: + start = self._console_prompt_pos("请输入方形区域起点") + end = self._console_prompt_pos("请输入方形区域终点") + if start is None or end is None: + fmts.print_err(self._error("区域参数无效")) + return + region = { + "名称": name, + "启用": True, + "类型": "方形", + "起点": start, + "终点": end} + self.no_create_regions_raw.append(region) + self._save_no_create_regions() + self._reload_no_create_regions() + fmts.print_suc(self._success(f"已新增不可创建领地区域 '{name}'")) + + def _console_delete_no_create_region(self): + """Implement the console delete no create region operation.""" + if not self.no_create_regions_raw: + fmts.print_err(self._error("暂无不可创建领地区域")) + return + choice = self._console_select( + "删除不可创建区域", + [ + ( + f"{region.get('名称', f'区域{i}')} - " + f"{region.get('类型', '未知')} - " + f"{'启用' if region.get('启用', True) else '禁用'}" + ) + for i, region in enumerate(self.no_create_regions_raw, 1) + ], + ) + if choice is None: + return + region = self.no_create_regions_raw.pop(choice - 1) + self._save_no_create_regions() + self._reload_no_create_regions() + fmts.print_suc( + self._success( + f"已删除不可创建领地区域 '{region.get('名称',choice)}'")) + + def _console_add_land(self): + """Implement the console add land operation.""" + owner = self._console_prompt("请输入领地主人玩家名") + name = self._console_prompt("请输入领地名称") + center = self._console_prompt_pos("请输入领地中心坐标") + shape_choice = self._console_select("请选择领地类型", ["圆形领地", "方形领地"]) + if owner is None or name is None or center is None or shape_choice is None: + return + owner_member = self._make_member( + owner, LandRank.OWNER, allow_offline=True) + if owner_member is None: + fmts.print_err(self._error(f"无法获取 {owner} 的 XUID")) + return + shape = "圆形" + size = None + if shape_choice == 1: + radius = self._console_prompt_int( + f"请输入领地半径,范围 1~{self.max_radius}") + if radius is None: + return + if radius <= 0 or radius > self.max_radius: + fmts.print_err(self._error(f"半径必须在 1~{self.max_radius} 之间")) + return + else: + size_text = self._console_prompt( + f"请输入方形领地 长 高 宽,最大 {self.max_length} {self.max_height} {self.max_width}") + if size_text is None: + return + size, err = self._parse_box_size_input(size_text) + if size is None: + fmts.print_err(self._error(err or "方形领地尺寸无效")) + return + shape = "方形" + radius = box_radius_for_size(size) + overlap = self._get_land_candidate_overlap_reason( + tuple(center), radius, shape, size) + if overlap: + fmts.print_err(self._error(overlap)) + return + land_id = str(uuid.uuid4()) + land = LandData( + land_id=land_id, + name=name, + owner=owner, + owner_xuid=owner_member.xuid, + center=tuple(center), + radius=radius, + shape=shape, + size=size, + members=[owner_member], + ) + self.lands[land_id] = land + self._rebuild_player_land_cache() + self._save_data() + self._spawn_entity(land) + fmts.print_suc( + self._success( + f"已新增玩家 {owner} 的领地 '{name}',{land.range_text()}")) + + def _console_delete_land(self): + """Implement the console delete land operation.""" + land = self._console_select_land("删除玩家领地") + if land is None: + return + self._remove_entity(land) + del self.lands[land.land_id] + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已删除领地 '{land.name}'")) + + def _console_manage_land(self): + """Implement the console manage land operation.""" + land = self._console_select_land("管理玩家领地") + if land is None: + return + while True: + choice = self._console_select( + f"管理领地 - {land.name}", + ["管理用户", "管理管理人员", "管理所有者", "管理领地中心点", "管理领地范围", "查看领地信息"], + ) + if choice is None: + return + if choice == 1: + self._console_manage_land_users(land) + elif choice == 2: + self._console_manage_land_admins(land) + elif choice == 3: + self._console_manage_land_owner(land) + elif choice == 4: + self._console_manage_land_center(land) + elif choice == 5: + self._console_manage_land_radius(land) + elif choice == 6: + self._console_show_land_info(land) + + def _console_show_land_info(self, land: LandData): + """Implement the console show land info operation.""" + admins = [m.name for m in land.members if m.rank == LandRank.ADMIN] + members = [m.name for m in land.members if m.rank == LandRank.MEMBER] + fmts.print_inf(self._ui_card( + f"领地信息 - {land.name}", + [ + f"领主:{land.owner}", + f"中心:{land.center[0]}, {land.center[1]}, {land.center[2]}", + f"范围:{land.range_text()}", + f"管理员:{', '.join(admins) or '无'}", + f"用户:{', '.join(members) or '无'}", + ], + )) + + def _console_manage_land_users(self, land: LandData): # skipcq: PY-R1000 + """Implement the console manage land users operation.""" + while True: + choice = self._console_select( + f"管理用户 - {land.name}", + ["添加用户", "删除用户", "查看用户列表"], + ) + if choice is None: + return + if choice == 1: + target = self._console_prompt("请输入要添加的玩家名") + if not target: + continue + member = self._make_member( + target, LandRank.MEMBER, allow_offline=True) + if member is None: + fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) + continue + if land.get_member(member.xuid): + fmts.print_err(self._warn(f"{target} 已在该领地中")) + continue + land.members.append(member) + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已添加用户 {target}")) + elif choice == 2: + target = self._console_prompt("请输入要删除的玩家名") + if not target: + continue + target_xuid = self._get_xuid_by_name( + target, allow_offline=True) + if target_xuid is None: + fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) + continue + member = land.get_member(target_xuid) + if not member: + fmts.print_err(self._error(f"{target} 不在该领地中")) + continue + if member.rank == LandRank.OWNER: + fmts.print_err(self._error("不能在用户管理中删除所有者,请先转移所有者")) + continue + land.members = [ + m for m in land.members if m.xuid != target_xuid] + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已删除用户 {target}")) + elif choice == 3: + users = [ + f"{m.name}({m.rank.display_name})" for m in land.members] + fmts.print_inf(self._ui_card( + f"用户列表 - {land.name}", + [", ".join(users) if users else "无用户"], + )) + + def _console_manage_land_admins(self, land: LandData): # skipcq: PY-R1000 + """Implement the console manage land admins operation.""" + while True: + choice = self._console_select( + f"管理管理人员 - {land.name}", + ["添加管理人员", "删除管理人员", "查看管理人员"], + ) + if choice is None: + return + if choice == 1: + target = self._console_prompt("请输入要设为管理人员的玩家名") + if not target: + continue + target_xuid = self._get_xuid_by_name( + target, allow_offline=True) + if target_xuid is None: + fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) + continue + member = land.get_member(target_xuid) + if member and member.rank == LandRank.OWNER: + fmts.print_err(self._error("所有者不能设为管理人员")) + continue + if member: + member.name = target + member.rank = LandRank.ADMIN + else: + land.members.append( + LandMember( + target, + target_xuid, + LandRank.ADMIN)) + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已将 {target} 设为管理人员")) + elif choice == 2: + target = self._console_prompt("请输入要删除管理权限的玩家名") + if not target: + continue + target_xuid = self._get_xuid_by_name( + target, allow_offline=True) + if target_xuid is None: + fmts.print_err(self._error(f"无法获取 {target} 的 XUID")) + continue + member = land.get_member(target_xuid) + if not member or member.rank != LandRank.ADMIN: + fmts.print_err(self._error(f"{target} 不是管理人员")) + continue + member.rank = LandRank.MEMBER + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已删除 {target} 的管理权限")) + elif choice == 3: + admins = [ + m.name for m in land.members if m.rank == LandRank.ADMIN] + fmts.print_inf(self._ui_card( + f"管理人员 - {land.name}", + [", ".join(admins) if admins else "暂无管理人员"], + )) + + def _console_manage_land_owner(self, land: LandData): + """Implement the console manage land owner operation.""" + while True: + choice = self._console_select( + f"管理所有者 - {land.name}", + ["修改所有者", "查看所有者"], + ) + if choice is None: + return + if choice == 1: + owner = self._console_prompt("请输入新所有者玩家名") + if not owner: + continue + owner_member_data = self._make_member( + owner, LandRank.OWNER, allow_offline=True) + if owner_member_data is None: + fmts.print_err(self._error(f"无法获取 {owner} 的 XUID")) + continue + land.owner = owner + land.owner_xuid = owner_member_data.xuid + for member in land.members: + if member.rank == LandRank.OWNER: + member.rank = LandRank.ADMIN + owner_member = land.get_member(owner_member_data.xuid) + if owner_member: + owner_member.name = owner + owner_member.rank = LandRank.OWNER + else: + land.members.append(owner_member_data) + self._rebuild_player_land_cache() + self._save_data() + fmts.print_suc(self._success(f"已修改所有者为 {owner}")) + elif choice == 2: + fmts.print_inf(self._ui_card( + f"所有者 - {land.name}", [land.owner])) + + def _console_manage_land_center(self, land: LandData): + """Implement the console manage land center operation.""" + while True: + choice = self._console_select( + f"管理领地中心点 - {land.name}", + ["修改中心点", "查看中心点"], + ) + if choice is None: + return + if choice == 1: + center = self._console_prompt_pos("请输入新的领地中心点") + if center is None: + continue + center_tuple = tuple(center) + overlap = self._get_land_edit_overlap_reason( + land, center_tuple, land.radius) + if overlap: + fmts.print_err(self._error(overlap)) + continue + land.center = center_tuple + self._save_data() + self._spawn_entity(land) + fmts.print_suc(self._success( + f"已修改领地中心点为 {center[0]}, {center[1]}, {center[2]}")) + elif choice == 2: + fmts.print_inf(self._ui_card( + f"领地中心点 - {land.name}", + [f"{land.center[0]}, {land.center[1]}, {land.center[2]}"], + )) + + def _console_manage_land_radius(self, land: LandData): + """Implement the console manage land radius operation.""" + while True: + options = [ + "修改方形长高宽", + "查看方形长高宽"] if land.is_box() else [ + "修改范围半径", + "查看范围半径"] + choice = self._console_select( + f"管理领地范围 - {land.name}", + options, + ) + if choice is None: + return + if choice == 1: + if land.is_box(): + size_text = self._console_prompt( + f"请输入新的 长 高 宽,最大 {self.max_length} {self.max_height} {self.max_width}") + if size_text is None: + continue + size, err = self._parse_box_size_input(size_text) + if size is None: + fmts.print_err(self._error(err or "方形领地尺寸无效")) + continue + radius = box_radius_for_size(size) + overlap = self._get_land_edit_overlap_reason( + land, land.center, radius, "方形", size) + if overlap: + fmts.print_err(self._error(overlap)) + continue + land.shape = "方形" + land.size = size + land.radius = radius + self._save_data() + fmts.print_suc(self._success( + f"已修改方形领地范围为 长:{size[0]}, 高:{size[1]}, 宽:{size[2]}")) + else: + radius = self._console_prompt_int( + f"请输入新范围半径,范围 1~{self.max_radius}") + if radius is None: + continue + if radius <= 0 or radius > self.max_radius: + fmts.print_err( + self._error( + f"半径必须在 1~{self.max_radius} 之间")) + continue + overlap = self._get_land_edit_overlap_reason( + land, land.center, radius, "圆形", None) + if overlap: + fmts.print_err(self._error(overlap)) + continue + land.shape = "圆形" + land.size = None + land.radius = radius + self._save_data() + fmts.print_suc(self._success(f"已修改领地范围半径为 {radius}")) + elif choice == 2: + fmts.print_inf(self._ui_card( + f"领地范围 - {land.name}", [land.range_text()])) + + def _get_land_candidate_overlap_reason( + self, + center: Tuple[float, float, float], + radius: int, + shape: str, + size: Optional[Tuple[int, int, int]] = None, + dimension: int = 0, + skip_land_id: Optional[str] = None, + ) -> Optional[str]: + """Return land candidate overlap reason data.""" + blocked_region = self._get_no_create_overlap_reason( + center, radius, shape, size) + if blocked_region: + return f"领地不能与不可创建区域 '{blocked_region}' 重叠" + for other in self.lands.values(): + if other.land_id == skip_land_id or other.dimension != dimension: + continue + if land_overlaps_candidate(other, center, radius, shape, size): + return f"领地与 '{other.name}' 重叠" + return None + + def _get_land_edit_overlap_reason( + self, + land: LandData, + center: Tuple[float, float, float], + radius: int, + shape: Optional[str] = None, + size: Optional[Tuple[int, int, int]] = None, + ) -> Optional[str]: + """Return land edit overlap reason data.""" + edit_shape = shape or land.shape + edit_size = size if size is not None else land.size + return self._get_land_candidate_overlap_reason( + center, + radius, + edit_shape, + edit_size, + land.dimension, + land.land_id, + ) + + def console_test(self, args: List[str]): + """Implement the console test operation.""" + if not self.enabled: + fmts.print_war(self._warn("领地系统云链联动版当前已在配置中禁用")) + return + if args: + target = args[0] + pos = self._get_player_coord(target) + if pos: + fmts.print_inf( + self._ui_card( + "控制台测试", [ + f"玩家 {target} 坐标:{pos}"])) + else: + fmts.print_err(self._error("无法获取坐标")) + else: + fmts.print_inf(self._notice("用法: 领地测试 <玩家名>")) + + +entry = plugin_entry(LandPlugin, "领地系统云链联动版", (0, 1, 18)) diff --git "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" index bca57f40..ec0d10a4 100644 --- "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" +++ "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/datas.json" @@ -1,5 +1,5 @@ { - "author": "小石潭记qwq", + "author": "小石潭记qwq/小六神", "version": "0.1.18", "description": "领地系统云链联动版,支持创建圆形/方形领地、成员管理、自动防护、传送、配置热载入与外部插件 API 调用", "limit_launcher": null, From 916945eec57f263a40cf55dd0ab5109767d37aae Mon Sep 17 00:00:00 2001 From: ljxbx Date: Sat, 13 Jun 2026 18:36:06 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BE=A4=E6=9C=8D?= =?UTF-8?q?=E4=BA=92=E9=80=9A=E4=BA=91=E9=93=BE=E7=89=88Ultra=E7=89=88?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E5=8A=9F=E8=83=BD=E5=9C=A8=E4=B8=8D=E5=8A=A0?= =?UTF-8?q?=E5=A5=BD=E5=8F=8B=E7=9A=84=E6=83=85=E5=86=B5=E4=B8=8B=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E5=8F=91=E9=80=81=E9=AA=8C=E8=AF=81=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../binding_mixin.py" | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" index 479df461..e67ebdcf 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" @@ -576,6 +576,7 @@ def _start_binding_request( code, timeout_minutes, ), + group_id=group_id, ) return True, "绑定验证码已发送" @@ -595,8 +596,17 @@ def _handle_binding_trigger( self._reply_to_qq(group_id, qqid, message) return True - def send_private_msg(self, qqid: int, msg: str): - """向指定 QQ 发送私信。""" + def send_private_msg( + self, + qqid: int, + msg: str, + group_id: int | None = None): + """向指定 QQ 发送私信。 + + 当传入 group_id 时,按“群临时会话”下发私信。这样即使机器人没有 + 把对方加为好友,也能把消息送达(OneBot 通过共同所在的群发起临时会话)。 + 不传 group_id 时退回普通好友私信,行为与旧版本一致。 + """ if self.ws is None: raise RuntimeError("WebSocket 尚未初始化") if not self.available: @@ -607,9 +617,12 @@ def send_private_msg(self, qqid: int, msg: str): level="warn", ) return + params: dict[str, Any] = {"user_id": qqid, "message": msg} + if group_id is not None: + params["group_id"] = group_id payload = { "action": "send_private_msg", - "params": {"user_id": qqid, "message": msg}, + "params": params, } self.ws.send(json.dumps(payload)) From aba812ec3012c34ae87a252fde1c979f920188f9 Mon Sep 17 00:00:00 2001 From: ljxbx Date: Sat, 13 Jun 2026 19:36:19 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=E5=BD=BB=E5=BA=95=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E5=8D=AB=E7=94=9F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../guild_cloud_interop/api.py" | 12 ++++- .../guild_cloud_interop/config.py" | 18 +++++--- .../guild_cloud_interop/control.py" | 3 +- .../guild_cloud_interop/handlers.py" | 30 +++++++++---- .../guild_cloud_interop/handlers_quick.py" | 9 ++-- .../guild_cloud_interop/logic.py" | 7 ++- .../guild_cloud_interop/ui.py" | 10 ++++- .../qq_mixin.py" | 38 +++++++++++----- .../__init__.py" | 44 +++++++++++++++---- 9 files changed, 129 insertions(+), 42 deletions(-) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/api.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/api.py" index 7da174b6..27da16d7 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/api.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/api.py" @@ -480,7 +480,11 @@ def api_request_join_guild_as_player( if target_guild is None: return False, err, None if _ensure_settings(target_guild).get("frozen", False): - return False, f"公会 {target_guild.name} 已被冻结,暂不能提交申请", _guild_summary(target_guild) + return ( + False, + f"公会 {target_guild.name} 已被冻结,暂不能提交申请", + _guild_summary(target_guild), + ) if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: return False, "该公会已满员", _guild_summary(target_guild) if not target_guild.add_join_request(name, reason): @@ -1420,7 +1424,11 @@ def api_add_member_contribution(self, target=member.name, detail=str(parsed)) if not _save_guilds(self, guilds): return False, "保存公会数据失败", None - return True, f"已调整 {member.name} 贡献,当前 {member.contribution}", _member_summary(member) + return ( + True, + f"已调整 {member.name} 贡献,当前 {member.contribution}", + _member_summary(member), + ) def api_set_member_contribution(self, diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" index 94687813..f3dfb6c0 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" @@ -15,7 +15,8 @@ PLUGIN_ENABLED_KEY = "是否启用插件" -DEFAULT_CONFIG_JSON = r'''{ +DEFAULT_CONFIG_JSON = ( + r'''{ "配置版本": "0.1.7", "动态载入设置": { "是否启用动态载入配置文件(仅用于本插件)": true, @@ -357,13 +358,19 @@ "已有公会提示词": "§c❀ §r你已经有公会了", "快捷创建缺少名称提示词": "§a❀ §r请输入公会名称,例如: 公会创建 我的公会", "快捷创建名称长度无效提示词": "§c❀ §r公会名必须在2-16个字符之间", - "创建公会余额不足提示词": "§c❀ §r创建公会需要 §e{consume}§r 点 §b{scoreboard}§r 计分板积分\n§c❀ §r当前余额: §f{balance}", - "创建公会提示词": "§a❀ §r创建公会将消耗 §e{consume} §b{scoreboard} \n§a❀ §r当前余额: §f{balance}\n§a❀ §r输入 §a确认§7 继续创建,输入 §cq§7 取消", - "创建公会回复超时提示词": "§c❀ §r回复超时,已取消创建公会", +''' + r''' "创建公会余额不足提示词": "§c❀ §r创建公会需要 ''' + r'''§e{consume}§r 点 §b{scoreboard}§r 计分板积分\n§c❀ §r当前余额: §f{balance}", +''' + r''' "创建公会提示词": "§a❀ §r创建公会将消耗 §e{consume} ''' + r'''§b{scoreboard} \n§a❀ §r当前余额: §f{balance}\n§a❀ §r输入 §a确认§7 继续创建,输入 §cq§7 取消", +''' + r''' "创建公会回复超时提示词": "§c❀ §r回复超时,已取消创建公会", "创建公会取消提示词": "§c❀ §r已取消创建公会", "创建公会输入名称提示词": "§a❀ §r请输入公会名字:\n§a❀ §r要求: 2-20个字符,不能包含特殊符号", "创建公会名称无效提示词": "§c❀ §r{error}", - "创建公会二次余额不足提示词": "§c❀ §r当前 §b{scoreboard}§r 余额不足,需要 §e{consume}§r,当前 §f{balance}", + "创建公会二次余额不足提示词": "§c❀ §r当前 §b{scoreboard}§r ''' + r'''余额不足,需要 §e{consume}§r,当前 §f{balance}", "创建公会成功提示词": "§a❀ §r已创建公会 §e{guild}", "创建公会全服公告提示词": "§a❀ §r§e{player}§r 创建了公会 §e{guild}§r!", "创建公会名称已存在提示词": "§c❀ §r该公会名已存在", @@ -743,6 +750,7 @@ } } }''' +) DEFAULT_CONFIG: dict[str, Any] = json.loads(DEFAULT_CONFIG_JSON) LEVEL_EXP_CONFIG_KEY = "公会各等级升级所需经验" diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" index 2121e4e8..977c876d 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" @@ -48,7 +48,8 @@ def validate_guild_data( owners = [m for m in guild_data.members if m.rank == GuildRank.OWNER] if len(owners) != 1: errors.append( - f"公会应该有且仅有一个会长,当前有{len(owners)}个 Err:event.check.data.owner_length_LMAX") + f"公会应该有且仅有一个会长,当前有{len(owners)}个 " + "Err:event.check.data.owner_length_LMAX") # 检查成员数量 if len(guild_data.members) > Config.MAX_GUILD_MEMBERS: diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" index 764ab6b3..c1775324 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" @@ -225,7 +225,8 @@ def _handle_view_guild(self, player: Player) -> bool: required_exp = Config.GUILD_LEVEL_EXP.get(level + 1, "MAX") msg = f"§l§a{guild.name}§r §7(ID: {guild.guild_id[:8]}...)\n" - msg += f"§7创建时间: §f{datetime.fromtimestamp(guild.create_time).strftime('%Y-%m-%d')}\n" + create_date = datetime.fromtimestamp(guild.create_time).strftime("%Y-%m-%d") + msg += f"§7创建时间: §f{create_date}\n" msg += f"§7会长: §e{guild.owner}\n" msg += f"§7等级: §e{level} §7经验: §b{exp}/{required_exp}\n" msg += f"§7成员: §a{len(guild.members)}/{Config.MAX_GUILD_MEMBERS}\n" @@ -448,7 +449,9 @@ def _handle_view_tasks(self, player: Player, guild: GuildData) -> bool: def formatter(i, task: GuildTask): """Format one menu item for display.""" - status = "§a已完成" if task.completed else f"§e进行中 ({task.current_count}/{task.target_count})" + status = ( + "§a已完成" if task.completed + else f"§e进行中 ({task.current_count}/{task.target_count})") progress_bar = self._create_progress_bar( task.current_count, task.target_count) @@ -487,7 +490,10 @@ def formatter(i, task: GuildTask): participant_status = " §a[已参与]" if is_participant else " §7[未参与]" return ( - f"§e{i}. §f{task.name} [{status}§f]{participant_status}\n" f" §7{task.description}\n" f" §7奖励: §b{task.reward_contribution}贡献点 §7+ §e{task.reward_exp}经验\n") + f"§e{i}. §f{task.name} [{status}§f]{participant_status}\n" + f" §7{task.description}\n" + f" §7奖励: §b{task.reward_contribution}贡献点 " + f"§7+ §e{task.reward_exp}经验\n") idx = self._paginate_display( player, active_tasks, "选择参与任务", formatter, True) @@ -849,7 +855,10 @@ def formatter(i, task: GuildTask): def formatter(i, task: GuildTask): """Format one menu item for display.""" - return f"§e{i}. §f{task.name} §7({task.current_count}/{task.target_count})\n §7{task.description}\n" + return ( + f"§e{i}. §f{task.name} " + f"§7({task.current_count}/{task.target_count})\n" + f" §7{task.description}\n") idx = self._paginate_display( player, active_tasks, "选择完成任务", formatter, True) @@ -1685,7 +1694,9 @@ def formatter(i, data): time_str = datetime.fromtimestamp( item.timestamp).strftime("%m-%d %H:%M") return ( - f"§e{i}. §f{item_name} §7x{item.count}\n" f" §7价格: §e{item.price}贡献点 §7| 卖家: §a{item.seller} §7| 时间: §f{time_str}\n") + f"§e{i}. §f{item_name} §7x{item.count}\n" + f" §7价格: §e{item.price}贡献点 §7| 卖家: §a{item.seller} " + f"§7| 时间: §f{time_str}\n") idx = self._paginate_display( player, @@ -1891,7 +1902,8 @@ def _handle_create_guild(self, player: Player, player_xuid: str) -> bool: # 扣除配置的计分板积分并创建公会 self.game_ctrl.sendwocmd( - f"scoreboard players remove {player.name} {Config.GUILD_SCOREBOARD} {Config.GUILD_CREATION_COST}") + f"scoreboard players remove {player.name} " + f"{Config.GUILD_SCOREBOARD} {Config.GUILD_CREATION_COST}") if self.guild_manager.create_guild(player_xuid, player.name, guild_name): player.show(render_create_guild_prompt( @@ -1909,7 +1921,8 @@ def _handle_create_guild(self, player: Player, player_xuid: str) -> bool: player.show(render_create_guild_prompt( "创建公会名称已存在提示词", guild=guild_name, player=player.name)) self.game_ctrl.sendwocmd( - f"scoreboard players add {player.name} {Config.GUILD_SCOREBOARD} {Config.GUILD_CREATION_COST}") + f"scoreboard players add {player.name} " + f"{Config.GUILD_SCOREBOARD} {Config.GUILD_CREATION_COST}") return True @@ -2203,7 +2216,8 @@ def _handle_set_base(self, player: Player) -> bool: if guild_id in check_guilds and check_guilds[guild_id].base: check_base = check_guilds[guild_id].base fmts.print_inf( - f"据点设置成功: 公会={guild.name}, 维度={check_base.dimension}, 坐标=({check_base.x}, {check_base.y}, {check_base.z})") + f"据点设置成功: 公会={guild.name}, 维度={check_base.dimension}, " + f"坐标=({check_base.x}, {check_base.y}, {check_base.z})") player.show(f"§l§a公会 §d>> §r据点已设置为 ({x:.1f}, {y:.1f}, {z:.1f})") else: player.show("§l§a公会 §d>> §r据点设置可能失败,请重试") diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" index af681764..72d8d66e 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" @@ -39,7 +39,8 @@ def quick_create_guild(self, player: Player, args: tuple): # 扣除钻石并创建公会 self.game_ctrl.sendwocmd( - f"scoreboard players remove {player.name} {Config.GUILD_SCOREBOARD} {Config.GUILD_CREATION_COST}") + f"scoreboard players remove {player.name} " + f"{Config.GUILD_SCOREBOARD} {Config.GUILD_CREATION_COST}") if self.guild_manager.create_guild(player_xuid, player.name, guild_name): player.show(render_create_guild_prompt( @@ -57,7 +58,8 @@ def quick_create_guild(self, player: Player, args: tuple): player.show(render_create_guild_prompt( "创建公会名称已存在提示词", guild=guild_name, player=player.name)) self.game_ctrl.sendwocmd( - f"scoreboard players add {player.name} {Config.GUILD_SCOREBOARD} {Config.GUILD_CREATION_COST}") + f"scoreboard players add {player.name} " + f"{Config.GUILD_SCOREBOARD} {Config.GUILD_CREATION_COST}") return True @@ -211,7 +213,8 @@ def quick_base_action(self, player: Player, args: tuple): dim_name = Config.DIMENSION_NAMES.get( base.dimension, f"维度{base.dimension}") player.show( - f"§l§a公会据点§r\n§7位置: §f{dim_name} ({base.x:.1f}, {base.y:.1f}, {base.z:.1f})") + f"§l§a公会据点§r\n§7位置: §f{dim_name} " + f"({base.x:.1f}, {base.y:.1f}, {base.z:.1f})") player.show("§7使用 §f.公会据点 tp §7传送到据点") if is_owner: player.show("§7使用 §f.公会据点 set §7重新设置据点") diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" index 6efa1252..f1634172 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" @@ -85,7 +85,9 @@ def _show_menu( # skipcq: PY-R1000 # 调试日志 if guild: fmts.print_inf( - f"菜单显示 - 玩家: {player.name}, 公会: {guild.name}, 成员职位: {member.rank.value if member else 'None'}, 是否会长: {is_owner}") + f"菜单显示 - 玩家: {player.name}, 公会: {guild.name}, " + f"成员职位: {member.rank.value if member else 'None'}, " + f"是否会长: {is_owner}") menu_config = getattr(self, "_guild_menu_config_override", None) or _menu_config() base_items = [ @@ -1095,7 +1097,8 @@ def debug_base_function(self, player: Player, args: tuple): player.show(f" 维度: §f{base.dimension}") player.show(f" 坐标: §f({base.x}, {base.y}, {base.z})") player.show( - f" 坐标类型: §f{type(base.x).__name__}, {type(base.y).__name__}, {type(base.z).__name__}") + f" 坐标类型: §f{type(base.x).__name__}, " + f"{type(base.y).__name__}, {type(base.z).__name__}") # 验证坐标有效性 try: diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" index af171522..4254baad 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" @@ -113,11 +113,17 @@ def format_option(line: str) -> str | None: match = re.match( r"^(?:§[0-9a-frlomnk])*●\s*(.+?)(?:\s*§7[-—]\s*|\s+-\s+)(.+)$", clean) if match: - return f"§l§b[ §e-§b ] §r§e{match.group(1).strip()} §7- §f{match.group(2).strip()}" + return ( + f"§l§b[ §e-§b ] §r§e{match.group(1).strip()} " + f"§7- §f{match.group(2).strip()}" + ) match = re.match(r"^([^\s]+)\s+§7[-—]\s*(.+)$", clean) if match: - return f"§l§b[ §e-§b ] §r§e{match.group(1).strip()} §7- §f{match.group(2).strip()}" + return ( + f"§l§b[ §e-§b ] §r§e{match.group(1).strip()} " + f"§7- §f{match.group(2).strip()}" + ) return None diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" index dbfbae88..3808bae1 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" @@ -1607,7 +1607,11 @@ def _format_guild_base(base: dict[str, Any] | None): """Implement the format guild base operation.""" if not base: return "未设置" - return f"{base.get('dimension',0)} ({base.get('x',0):.1f}, {base.get('y',0):.1f}, {base.get('z',0):.1f})" + dim = base.get('dimension', 0) + x = base.get('x', 0) + y = base.get('y', 0) + z = base.get('z', 0) + return f"{dim} ({x:.1f}, {y:.1f}, {z:.1f})" @staticmethod def _format_guild_line(item: dict[str, Any], index: int): @@ -2023,11 +2027,20 @@ def qq_guild_player_show_tasks( return lines = [] for index, task in enumerate(data[:30], start=1): - status = "已完成" if task.get("completed") else f"进行中 {task.get('current_count', 0)} /{task.get('target_count', 0)} " + if task.get("completed"): + status = "已完成" + else: + status = ( + f"进行中 {task.get('current_count', 0)} " + f"/{task.get('target_count', 0)} " + ) joined = " 已参与" if player_name in task.get( "participants", []) else "" lines.append( - f"{index}. {task.get('name','<未知任务>')} [{status}{joined}] 奖励 {task.get('reward_contribution',0)}贡献/{task.get('reward_exp',0)}经验") + f"{index}. {task.get('name', '<未知任务>')} " + f"[{status}{joined}] 奖励 " + f"{task.get('reward_contribution', 0)}贡献" + f"/{task.get('reward_exp', 0)}经验") self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无任务"]) def qq_guild_player_join_task( @@ -2517,8 +2530,10 @@ def qq_guild_show_rankings(self, group_id: int, qqid: int): self._reply_result(group_id, qqid, False, msg) return lines = [ - f"{item.get('rank', index)}. {item.get('name', '<未知>')} 分值 {item.get('score', 0)} 会长 {item.get('owner', '<未知>')}" for index, item in enumerate( - data, start=1)] + f"{item.get('rank', index)}. {item.get('name', '<未知>')} " + f"分值 {item.get('score', 0)} 会长 {item.get('owner', '<未知>')}" + for index, item in enumerate(data, start=1) + ] self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无排行"]) def qq_guild_show_donation_rankings(self, group_id: int, qqid: int): @@ -2533,10 +2548,11 @@ def qq_guild_show_donation_rankings(self, group_id: int, qqid: int): self._reply_result(group_id, qqid, False, msg) return lines = [ - f"{index}. {item.get('player_name','<未知>')} {item.get('guild_name','<未知>')} 贡献 {item.get('contribution',0)}" for index, - item in enumerate( - data, - start=1)] + f"{index}. {item.get('player_name', '<未知>')} " + f"{item.get('guild_name', '<未知>')} " + f"贡献 {item.get('contribution', 0)}" + for index, item in enumerate(data, start=1) + ] self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无排行"]) def qq_guild_show_abnormal_trades(self, group_id: int, qqid: int): @@ -3058,7 +3074,9 @@ def qq_guild_show_activity_status(self, group_id: int, qqid: int): lines = [] for key, event in data.items(): lines.append( - f"{key}: 倍率 {event.get('multiplier',1)} 剩余 {event.get('remaining_seconds',0)} 秒 发起 {event.get('actor','<未知>')}") + f"{key}: 倍率 {event.get('multiplier', 1)} " + f"剩余 {event.get('remaining_seconds', 0)} 秒 " + f"发起 {event.get('actor', '<未知>')}") self._reply_guild_lines(group_id, qqid, msg, lines or ["暂无活动"]) def qq_guild_start_activity( diff --git "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index 3c5cf4f8..918b8561 100644 --- "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -1694,7 +1694,11 @@ def api_add_land( # skipcq: PY-R1000 self._rebuild_player_land_cache() self._save_data() self._spawn_entity(land) - return True, f"已新增玩家 {owner} 的领地 '{name}',{land.range_text()}", self._land_summary(land) + return ( + True, + f"已新增玩家 {owner} 的领地 '{name}',{land.range_text()}", + self._land_summary(land), + ) def api_delete_land(self, land_query: str) -> Tuple[bool, str, None]: """Expose the api delete land API operation.""" @@ -1735,7 +1739,11 @@ def api_add_member(self, self._rebuild_player_land_cache() self._save_data() role_name = "管理员" if target_rank == LandRank.ADMIN else "成员" - return True, f"已将 {player_name} 添加为领地 '{land.name}' 的{role_name}", self._land_summary(land) + return ( + True, + f"已将 {player_name} 添加为领地 '{land.name}' 的{role_name}", + self._land_summary(land), + ) def api_set_member_rank( self, @@ -1754,14 +1762,22 @@ def api_set_member_rank( return False, f"无法获取 {player_name} 的 XUID", None member = land.get_member(target_xuid) if member is None: - return False, f"{player_name} 不在领地 '{land.name}' 中", self._land_summary(land) + return ( + False, + f"{player_name} 不在领地 '{land.name}' 中", + self._land_summary(land), + ) if member.rank == LandRank.OWNER: return False, "所有者不能修改身份", self._land_summary(land) member.name = player_name member.rank = target_rank self._save_data() role_name = "管理员" if target_rank == LandRank.ADMIN else "成员" - return True, f"已将 {player_name} 设为领地 '{land.name}' 的{role_name}", self._land_summary(land) + return ( + True, + f"已将 {player_name} 设为领地 '{land.name}' 的{role_name}", + self._land_summary(land), + ) def api_remove_member(self, land_query: str, @@ -1778,7 +1794,11 @@ def api_remove_member(self, return False, f"无法获取 {player_name} 的 XUID", None member = land.get_member(target_xuid) if not member: - return False, f"{player_name} 不在领地 '{land.name}' 中", self._land_summary(land) + return ( + False, + f"{player_name} 不在领地 '{land.name}' 中", + self._land_summary(land), + ) if member.rank == LandRank.OWNER: return False, "不能删除领地所有者,请先转移所有者", self._land_summary(land) land.members = [m for m in land.members if m.xuid != target_xuid] @@ -1881,7 +1901,11 @@ def api_update_land_range( land.radius = land_radius land.size = None self._save_data() - return True, f"已修改领地 '{land.name}' 范围为 {land.range_text()}", self._land_summary(land) + return ( + True, + f"已修改领地 '{land.name}' 范围为 {land.range_text()}", + self._land_summary(land), + ) def _console_print(self, text: str): """Implement the console print operation.""" @@ -2052,7 +2076,7 @@ def _console_delete_no_create_region(self): self._reload_no_create_regions() fmts.print_suc( self._success( - f"已删除不可创建领地区域 '{region.get('名称',choice)}'")) + f"已删除不可创建领地区域 '{region.get('名称', choice)}'")) def _console_add_land(self): """Implement the console add land operation.""" @@ -2079,7 +2103,8 @@ def _console_add_land(self): return else: size_text = self._console_prompt( - f"请输入方形领地 长 高 宽,最大 {self.max_length} {self.max_height} {self.max_width}") + f"请输入方形领地 长 高 宽,最大 {self.max_length} " + f"{self.max_height} {self.max_width}") if size_text is None: return size, err = self._parse_box_size_input(size_text) @@ -2360,7 +2385,8 @@ def _console_manage_land_radius(self, land: LandData): if choice == 1: if land.is_box(): size_text = self._console_prompt( - f"请输入新的 长 高 宽,最大 {self.max_length} {self.max_height} {self.max_width}") + f"请输入新的 长 高 宽,最大 {self.max_length} " + f"{self.max_height} {self.max_width}") if size_text is None: continue size, err = self._parse_box_size_input(size_text)

    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 VCG)oIp*(tqLiW&gGesHZ{~r)_34;It literal 0 HcmV?d00001 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/prompts.cpython-312.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/prompts.cpython-312.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..b22159d065e4dd5d11548236f178fc0f571c4711 GIT binary patch literal 4149 zcmb7Hdr(u^89z7oCO{&h3y3SCK@^PwzP9V5j#Cz2%&IuxcBa%ejky;a4GF!u*(s9D z#O>k+g$lzeyDoO2gMh^cty>?#$BchJ=**|&* zzH`2FzVAEV<99v|e=jUFBly0a*?ji-5`_MRnar0*C(OGDOokATc*c#|nO26ubA#K^ z&bG2-&bqmFW2=!tF2r-ZaggCnd>+tzz5uA1w*Xzj7Xn?%7Xe+yF9-S_UktQ_F9o`S zF9W)ge;?>7z8vUkz5?hPel1WdUkP*_Uj=kMzX519zmea>Z-CJ@Y`n>|n)o_?^XFV^ z9>0~}0%JamH82)**4nnEa3D5Dwm3WymX>*IsA;zALfLLPY%DiMOa?xLjwVzML`yMLc zZlA9#H!G5);#!r3PZjk+zh)_b~ZvQ0cx&52V81Kk1D(n1_#2)n}AKce0|2EsQPChjpN{(NEYzbXXNVdOS2twwp ziO(Lx+UYKz(<=(6y$+El%gMW=O88>@#`DC)J(!raTq{opm7YF&`d(0N>pJalJ3LMy zNXw-=QgiEOR+4Es(qT_uj6Vn`Lql1fEp*8W4rEI7!w%?#Xc57TBQbh6d3Wr^#4c-M zW+E|jhaL_kofmQb2Jb3Yu7M+q(yV;*2W1R$+#Mf!pv6;{oo1a37zQX(WBNG#<)ETp zpdy;dY((d_K@hD=a2l!$5dHJ4WG_ZEz-q5PRpS@pe+FppsNJz`9&{M;0IlCWReHOX z{;5RIxrjp|cKAe*ffxA3LKzpSBq6)Sa)OYiP%0V<~x0>t^%9 z1dqup!%E-P{~zKPz)UI>Qoe{#R}=SV5;NbD`=4%gy*J;Qp!PK>0!{K~Ptlpz_DM6) z?bF;Xj6b*30*{;wc$E3@8^eMS^A7(l8A1W(tbR8wHI*I@pkGuYbjpBG&=6z;=q#Jw z&<#8jFq{XTCsZH}Owh*4MwcYC`{X=PfIHDoF^6n z@!@h>t>Q8m%FC!gOG8uh!H=5in-ACu6b-&LPzF`%0frpa2S3Os{?saMPY-#m~?`7$hu=$OpwEyf_ z)hkQ&t-W()mEj|AIrD3-be1daca4?ZUJ>1X=x>IXl^?(2j?T^RxW^9PJ{0``@2tl= zZ!9anS~+W}8Tl!YC|5&n&~XSFM?^A0E~toag=`W9$uD}yawac0X_|-#y`8Gr%(B^T zZ}WqKV7H4Hi?T`T>~ML`P-CldG`~8UK~h9<4K{`GCgNL9l6MOc|7f4c-`DQt{cd5u zxCQoc)ACgURau5%{(;shXoZ4mRa0f9VU8=0R_s?R0C7c!l)L^FGI%Fs=-ijj8Tg*zY6xFwx6lxm|2aC>!MW$)C$b3!I}tdK4MVU zV%|z5_oAT8o)FWZV+1Ii_yK%r+v{qHf2`4)7#e zv0L4VnGeX$KJ`bK`H(O_R(D}$H(I}E&URp~s(#J}Kl}e(&%kmNRlGaGa7@X&r3Pl} Mf6PmmT9U&50WdF~S^xk5 literal 0 HcmV?d00001 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/service.cpython-312.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/service.cpython-312.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..4670dffc23b278108675cbe83355e65fe5f962ba GIT binary patch literal 2752 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 str: - return COLOR_CODE_RE.sub("", str(text)) - - -def _now() -> float: - return time.time() - - -def _actor(actor: str | None) -> str: - text = str(actor or "").strip() - return text or "QQ管理" - - -def _to_int(value: object, field_name: str, minimum: int | - None = None) -> tuple[bool, str, int]: - try: - parsed = int(str(value).strip()) - except (TypeError, ValueError): - return False, f"{field_name}必须是整数", 0 - if minimum is not None and parsed < minimum: - return False, f"{field_name}不能小于 {minimum}", 0 - return True, "", parsed - - -def _to_float(value: object, field_name: str) -> tuple[bool, str, float]: - try: - return True, "", float(str(value).strip()) - except (TypeError, ValueError): - return False, f"{field_name}必须是数字", 0.0 - - -def _ensure_settings(guild: GuildData) -> dict[str, Any]: - if not isinstance(guild.settings, dict): - guild.settings = {} - return guild.settings - - -def _rebuild_player_cache(self, guilds: dict[str, GuildData]) -> None: - self.guild_manager.rebuild_player_cache(guilds) - - -def _save_guilds(self, - guilds: dict[str, - GuildData], - force: bool = True) -> bool: - _rebuild_player_cache(self, guilds) - ok = self.guild_manager.save_guilds(guilds, force=force) - if ok: - self.guild_manager.load_guilds(force_reload=True) - return ok - - -def _load_guilds(self) -> dict[str, GuildData]: - return self.guild_manager.load_guilds(force_reload=True) - - -def _find_guild( - self, - guild_query: object, - guilds: Optional[dict[str, GuildData]] = None, -) -> tuple[Optional[GuildData], str]: - query = str(guild_query or "").strip() - if not query: - return None, "公会不能为空" - - guild_map = guilds if guilds is not None else _load_guilds(self) - if query in guild_map: - return guild_map[query], "" - - exact = [guild for guild in guild_map.values() if guild.name == query] - if len(exact) == 1: - return exact[0], "" - if len(exact) > 1: - return None, f"存在多个同名公会:{query},请使用公会ID" - - query_lower = query.casefold() - fuzzy = [ - guild - for guild in guild_map.values() - if query_lower in guild.name.casefold() or query_lower in guild.guild_id.casefold() - ] - if len(fuzzy) == 1: - return fuzzy[0], "" - if len(fuzzy) > 1: - names = "、".join(guild.name for guild in fuzzy[:5]) - if len(fuzzy) > 5: - names += "……" - return None, f"匹配到多个公会:{names}" - return None, f"公会不存在:{query}" - - -def _find_player_guild( - self, - player_name: object, - guilds: Optional[dict[str, GuildData]] = None, -) -> tuple[Optional[GuildData], str]: - name = str(player_name or "").strip() - if not name: - return None, "玩家名不能为空" - - guild_map = guilds if guilds is not None else _load_guilds(self) - for guild in guild_map.values(): - if guild.get_member(name): - return guild, "" - return None, f"玩家 {name} 不在任何公会" - - -def _find_player_context( - self, - player_name: object, - guilds: Optional[dict[str, GuildData]] = None, -) -> tuple[str, Optional[GuildData], Optional[GuildMember], str]: - name = str(player_name or "").strip() - if not name: - return "", None, None, "玩家名不能为空" - guild, err = _find_player_guild(self, name, guilds) - if guild is None: - return name, None, None, err - member = guild.get_member(name) - if member is None: - return name, guild, None, "成员数据异常" - return name, guild, member, "" - - -def _find_task(guild: GuildData, - task_query: object) -> tuple[Optional[GuildTask], str]: - query = str(task_query or "").strip() + +from tooldelta import fmts + +from guild_cloud_interop.config import Config +from guild_cloud_interop.models import ( + GuildBase, + GuildData, + GuildMember, + GuildRank, + GuildStats, + GuildTask, + VaultItem, +) +from guild_cloud_interop.validators import InputValidator + + +COLOR_CODE_RE = re.compile(r"§.") + + +def _plain(text: object) -> str: + return COLOR_CODE_RE.sub("", str(text)) + + +def _now() -> float: + return time.time() + + +def _actor(actor: str | None) -> str: + text = str(actor or "").strip() + return text or "QQ管理" + + +def _to_int(value: object, field_name: str, minimum: int | + None = None) -> tuple[bool, str, int]: + try: + parsed = int(str(value).strip()) + except (TypeError, ValueError): + return False, f"{field_name}必须是整数", 0 + if minimum is not None and parsed < minimum: + return False, f"{field_name}不能小于 {minimum}", 0 + return True, "", parsed + + +def _to_float(value: object, field_name: str) -> tuple[bool, str, float]: + try: + return True, "", float(str(value).strip()) + except (TypeError, ValueError): + return False, f"{field_name}必须是数字", 0.0 + + +def _ensure_settings(guild: GuildData) -> dict[str, Any]: + if not isinstance(guild.settings, dict): + guild.settings = {} + return guild.settings + + +def _rebuild_player_cache(self, guilds: dict[str, GuildData]) -> None: + self.guild_manager.rebuild_player_cache(guilds) + + +def _save_guilds(self, + guilds: dict[str, + GuildData], + force: bool = True) -> bool: + _rebuild_player_cache(self, guilds) + ok = self.guild_manager.save_guilds(guilds, force=force) + if ok: + self.guild_manager.load_guilds(force_reload=True) + return ok + + +def _load_guilds(self) -> dict[str, GuildData]: + return self.guild_manager.load_guilds(force_reload=True) + + +def _find_guild( + self, + guild_query: object, + guilds: Optional[dict[str, GuildData]] = None, +) -> tuple[Optional[GuildData], str]: + query = str(guild_query or "").strip() + if not query: + return None, "公会不能为空" + + guild_map = guilds if guilds is not None else _load_guilds(self) + if query in guild_map: + return guild_map[query], "" + + exact = [guild for guild in guild_map.values() if guild.name == query] + if len(exact) == 1: + return exact[0], "" + if len(exact) > 1: + return None, f"存在多个同名公会:{query},请使用公会ID" + + query_lower = query.casefold() + fuzzy = [ + guild + for guild in guild_map.values() + if ( + query_lower in guild.name.casefold() + or query_lower in guild.guild_id.casefold() + ) + ] + if len(fuzzy) == 1: + return fuzzy[0], "" + if len(fuzzy) > 1: + names = "、".join(guild.name for guild in fuzzy[:5]) + if len(fuzzy) > 5: + names += "……" + return None, f"匹配到多个公会:{names}" + return None, f"公会不存在:{query}" + + +def _find_player_guild( + self, + player_name: object, + guilds: Optional[dict[str, GuildData]] = None, +) -> tuple[Optional[GuildData], str]: + name = str(player_name or "").strip() + if not name: + return None, "玩家名不能为空" + + guild_map = guilds if guilds is not None else _load_guilds(self) + for guild in guild_map.values(): + if guild.get_member(name): + return guild, "" + return None, f"玩家 {name} 不在任何公会" + + +def _find_player_context( + self, + player_name: object, + guilds: Optional[dict[str, GuildData]] = None, +) -> tuple[str, Optional[GuildData], Optional[GuildMember], str]: + name = str(player_name or "").strip() + if not name: + return "", None, None, "玩家名不能为空" + guild, err = _find_player_guild(self, name, guilds) + if guild is None: + return name, None, None, err + member = guild.get_member(name) + if member is None: + return name, guild, None, "成员数据异常" + return name, guild, member, "" + + +def _find_task(guild: GuildData, + task_query: object) -> tuple[Optional[GuildTask], str]: + query = str(task_query or "").strip() if not query: return None, "任务不能为空" for task in guild.tasks: if query in (task.task_id, task.name): return task, "" - query_lower = query.casefold() - matched = [ - task - for task in guild.tasks - if query_lower in task.task_id.casefold() or query_lower in task.name.casefold() - ] - if len(matched) == 1: - return matched[0], "" - if len(matched) > 1: - names = "、".join( - f"{task.name}({task.task_id})" for task in matched[:5]) - return None, f"匹配到多个任务:{names}" - return None, f"任务不存在:{query}" - - -def _member_summary(member: GuildMember) -> dict[str, Any]: - return { - "name": member.name, - "rank": member.rank.value, - "rank_name": _plain(member.rank.display_name), - "join_time": member.join_time, - "contribution": member.contribution, - "last_online": member.last_online, - } - - -def _base_summary(base: GuildBase | None) -> dict[str, Any] | None: - if base is None: - return None - return { - "dimension": base.dimension, - "x": base.x, - "y": base.y, - "z": base.z, - } - - -def _vault_item_summary(item: VaultItem, index: int | - None = None) -> dict[str, Any]: - data = item.to_dict() - if index is not None: - data["index"] = index - return data - - -def _task_summary(task: GuildTask) -> dict[str, Any]: - return task.to_dict() - - -def _guild_summary(guild: GuildData) -> dict[str, Any]: - settings = _ensure_settings(guild) - return { - "guild_id": guild.guild_id, - "name": guild.name, - "owner": guild.owner, - "level": guild.level, - "exp": guild.exp, - "create_time": guild.create_time, - "member_count": len(guild.members), - "max_members": Config.MAX_GUILD_MEMBERS, - "base": _base_summary(guild.base), - "vault_count": len(guild.vault_items), - "vault_capacity": Config.VAULT_INITIAL_SLOTS, - "announcement": guild.announcement, - "purchased_effects": dict(guild.purchased_effects), - "funds": int(settings.get("funds", 0) or 0), - "frozen": bool(settings.get("frozen", False)), - "frozen_reason": str(settings.get("frozen_reason", "")), - "base_locked": bool(settings.get("base_locked", False)), - "active_tasks": len([task for task in guild.tasks if not task.completed]), - "completed_tasks": len([task for task in guild.tasks if task.completed]), - "total_contribution": guild.stats.total_contribution, - } - - -def _apply_level_ups(guild: GuildData) -> list[int]: - level_ups: list[int] = [] - next_level = guild.level + 1 - required = Config.GUILD_LEVEL_EXP.get(next_level) - while required and guild.exp >= required: - guild.exp -= required - guild.level = next_level - level_ups.append(next_level) - guild.add_log(f"公会升级到 {next_level} 级") - next_level = guild.level + 1 - required = Config.GUILD_LEVEL_EXP.get(next_level) - return level_ups - - -def _activity_multiplier(self, key: str) -> float: - event = getattr(self, "_guild_runtime_events", {}).get(key) - if not isinstance(event, dict): - return 1.0 - expires_at = float(event.get("expires_at", 0) or 0) + query_lower = query.casefold() + matched = [ + task + for task in guild.tasks + if query_lower in task.task_id.casefold() or query_lower in task.name.casefold() + ] + if len(matched) == 1: + return matched[0], "" + if len(matched) > 1: + names = "、".join( + f"{task.name}({task.task_id})" for task in matched[:5]) + return None, f"匹配到多个任务:{names}" + return None, f"任务不存在:{query}" + + +def _member_summary(member: GuildMember) -> dict[str, Any]: + return { + "name": member.name, + "rank": member.rank.value, + "rank_name": _plain(member.rank.display_name), + "join_time": member.join_time, + "contribution": member.contribution, + "last_online": member.last_online, + } + + +def _base_summary(base: GuildBase | None) -> dict[str, Any] | None: + if base is None: + return None + return { + "dimension": base.dimension, + "x": base.x, + "y": base.y, + "z": base.z, + } + + +def _vault_item_summary(item: VaultItem, index: int | + None = None) -> dict[str, Any]: + data = item.to_dict() + if index is not None: + data["index"] = index + return data + + +def _task_summary(task: GuildTask) -> dict[str, Any]: + return task.to_dict() + + +def _guild_summary(guild: GuildData) -> dict[str, Any]: + settings = _ensure_settings(guild) + return { + "guild_id": guild.guild_id, + "name": guild.name, + "owner": guild.owner, + "level": guild.level, + "exp": guild.exp, + "create_time": guild.create_time, + "member_count": len(guild.members), + "max_members": Config.MAX_GUILD_MEMBERS, + "base": _base_summary(guild.base), + "vault_count": len(guild.vault_items), + "vault_capacity": Config.VAULT_INITIAL_SLOTS, + "announcement": guild.announcement, + "purchased_effects": dict(guild.purchased_effects), + "funds": int(settings.get("funds", 0) or 0), + "frozen": bool(settings.get("frozen", False)), + "frozen_reason": str(settings.get("frozen_reason", "")), + "base_locked": bool(settings.get("base_locked", False)), + "active_tasks": len([task for task in guild.tasks if not task.completed]), + "completed_tasks": len([task for task in guild.tasks if task.completed]), + "total_contribution": guild.stats.total_contribution, + } + + +def _apply_level_ups(guild: GuildData) -> list[int]: + level_ups: list[int] = [] + next_level = guild.level + 1 + required = Config.GUILD_LEVEL_EXP.get(next_level) + while required and guild.exp >= required: + guild.exp -= required + guild.level = next_level + level_ups.append(next_level) + guild.add_log(f"公会升级到 {next_level} 级") + next_level = guild.level + 1 + required = Config.GUILD_LEVEL_EXP.get(next_level) + return level_ups + + +def _activity_multiplier(self, key: str) -> float: + event = getattr(self, "_guild_runtime_events", {}).get(key) + if not isinstance(event, dict): + return 1.0 + expires_at = float(event.get("expires_at", 0) or 0) if 0 < expires_at <= _now(): getattr(self, "_guild_runtime_events", {}).pop(key, None) return 1.0 - try: - multiplier = float(event.get("multiplier", 1.0)) - except (TypeError, ValueError): - return 1.0 - return max(1.0, multiplier) - - -def guild_get_activity_multiplier(self, key: str) -> float: - return _activity_multiplier(self, key) - - -def guild_apply_reward_multipliers( - self, - exp: int | float = 0, - contribution: int | float = 0, -) -> tuple[int, int]: - exp_out = int(float(exp) * _activity_multiplier(self, "exp")) - contribution_out = int(float(contribution) * - _activity_multiplier(self, "contribution")) - return max(0, exp_out), max(0, contribution_out) - - -def guild_is_frozen(self, guild: GuildData | None) -> bool: - if guild is None: - return False - settings = getattr(guild, "settings", {}) - return isinstance(settings, dict) and bool(settings.get("frozen", False)) - - -def guild_frozen_message(self, guild: GuildData | None) -> str: - if guild is None: - return "公会已冻结" - settings = getattr(guild, "settings", {}) - reason = "" - if isinstance(settings, dict): - reason = str(settings.get("frozen_reason", "") or "").strip() - suffix = f":{reason}" if reason else "" - return f"公会 {guild.name} 已被冻结{suffix}" - - -def show_guild_frozen(self, player, guild: GuildData | None) -> None: - player.show(f"§l§a公会 §d>> §c{self.guild_frozen_message(guild)}") - - -def api_list_guilds(self) -> tuple[bool, str, list[dict[str, Any]]]: - guilds = _load_guilds(self) - data = [_guild_summary(guild) for guild in guilds.values()] - data.sort(key=lambda item: (-int(item["level"]), - - int(item["member_count"]), item["name"])) - return True, f"共 {len(data)} 个公会", data - - -def api_get_guild( - self, guild_query: str) -> tuple[bool, str, Optional[dict[str, Any]]]: - guild, err = _find_guild(self, guild_query) - if guild is None: - return False, err, None - data = _guild_summary(guild) - data["members"] = [_member_summary(member) for member in guild.members] - data["tasks"] = [_task_summary(task) for task in guild.tasks] - return True, "查询成功", data - - -def api_get_player_record( - self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: - guilds = _load_guilds(self) - guild, err = _find_player_guild(self, player_name, guilds) - if guild is None: - return False, err, None - member = guild.get_member(str(player_name).strip()) - if member is None: - return False, "成员数据异常", None + try: + multiplier = float(event.get("multiplier", 1.0)) + except (TypeError, ValueError): + return 1.0 + return max(1.0, multiplier) + + +def guild_get_activity_multiplier(self, key: str) -> float: + """Return guild get activity multiplier.""" + return _activity_multiplier(self, key) + + +def guild_apply_reward_multipliers( + self, + exp: int | float = 0, + contribution: int | float = 0, +) -> tuple[int, int]: + """Apply guild apply reward multipliers.""" + exp_out = int(float(exp) * _activity_multiplier(self, "exp")) + contribution_out = int(float(contribution) * + _activity_multiplier(self, "contribution")) + return max(0, exp_out), max(0, contribution_out) + + +def guild_is_frozen(self, guild: GuildData | None) -> bool: + """Return whether frozen.""" + if guild is None: + return False + settings = getattr(guild, "settings", {}) + return isinstance(settings, dict) and bool(settings.get("frozen", False)) + + +def guild_frozen_message(self, guild: GuildData | None) -> str: + """Run guild frozen message.""" + if guild is None: + return "公会已冻结" + settings = getattr(guild, "settings", {}) + reason = "" + if isinstance(settings, dict): + reason = str(settings.get("frozen_reason", "") or "").strip() + suffix = f":{reason}" if reason else "" + return f"公会 {guild.name} 已被冻结{suffix}" + + +def show_guild_frozen(self, player, guild: GuildData | None) -> None: + """Show guild frozen.""" + player.show(f"§l§a公会 §d>> §c{self.guild_frozen_message(guild)}") + + +def api_list_guilds(self) -> tuple[bool, str, list[dict[str, Any]]]: + """Return api list guilds.""" + guilds = _load_guilds(self) + data = [_guild_summary(guild) for guild in guilds.values()] + data.sort(key=lambda item: (-int(item["level"]), - + int(item["member_count"]), item["name"])) + return True, f"共 {len(data)} 个公会", data + + +def api_get_guild( + self, guild_query: str) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Return api get guild.""" + guild, err = _find_guild(self, guild_query) + if guild is None: + return False, err, None + data = _guild_summary(guild) + data["members"] = [_member_summary(member) for member in guild.members] + data["tasks"] = [_task_summary(task) for task in guild.tasks] + return True, "查询成功", data + + +def api_get_player_record( + self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Return api get player record.""" + guilds = _load_guilds(self) + guild, err = _find_player_guild(self, player_name, guilds) + if guild is None: + return False, err, None + member = guild.get_member(str(player_name).strip()) + if member is None: + return False, "成员数据异常", None records = [ log.to_dict() for log in guild.audit_logs @@ -338,193 +351,194 @@ def api_get_player_record( for log in guild.vault_trade_logs if member.name in (log.actor, log.seller, log.buyer) ] - return True, "查询成功", { - "guild": _guild_summary(guild), - "member": _member_summary(member), - "audit_logs": records[-50:], - "vault_trade_logs": vault_records[-50:], - } - - -def api_get_player_guild_menu_state( - self, player_name: str) -> tuple[bool, str, dict[str, Any]]: - """Return safe QQ-side guild menu state for one player identity.""" - guilds = _load_guilds(self) - name, guild, member, err = _find_player_context(self, player_name, guilds) - if not name: - return False, err, {} - if guild is None: - return True, f"玩家 {name} 暂未加入公会", { - "player_name": name, - "in_guild": False, - "guild": None, - "member": None, - "permissions": [], - "is_owner": False, - "is_frozen": False, - } - if member is None: - return False, err, {} - return True, "查询成功", { - "player_name": name, - "in_guild": True, - "guild": _guild_summary(guild), - "member": _member_summary(member), - "permissions": guild.get_member_permissions(name), - "is_owner": member.rank == GuildRank.OWNER, - "is_frozen": bool(_ensure_settings(guild).get("frozen", False)), - } - - -def api_get_own_guild_logs(self, - player_name: str, - limit: int = 20) -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - """Return logs for the guild that the player belongs to.""" - ok, _err, parsed_limit = _to_int(limit, "日志数量", 1) - if not ok: - parsed_limit = 20 - guilds = _load_guilds(self) - name, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err, None - data: dict[str, Any] = {"logs": guild.logs[-parsed_limit:]} - if guild.has_permission(name, "audit_log"): - data["audit_logs"] = [log.to_dict() - for log in guild.audit_logs[-parsed_limit:]] - data["vault_trade_logs"] = [log.to_dict() - for log in guild.vault_trade_logs[-parsed_limit:]] - else: - data["audit_logs"] = [] - data["vault_trade_logs"] = [] - return True, "查询成功", data - - -def api_get_own_guild_vault( - self, player_name: str) -> tuple[bool, str, Optional[list[dict[str, Any]]]]: - """Return the current player's guild vault after normal guild permission checks.""" - guilds = _load_guilds(self) - name, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err, None - if not guild.has_permission(name, "vault"): - return False, "你没有使用仓库权限", None - data = [_vault_item_summary(item, index) - for index, item in enumerate(guild.vault_items, start=1)] - return True, f"{guild.name} 仓库共有 {len(data)} 件上架物品", data - - -def api_get_own_guild_tasks( - self, player_name: str) -> tuple[bool, str, Optional[list[dict[str, Any]]]]: - """Return the task list for the guild that the player belongs to.""" - guilds = _load_guilds(self) - _name, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err, None - return True, f"{guild.name} 共有 {len(guild.tasks)} 个任务", [ - _task_summary(task) for task in guild.tasks] - - -def api_request_join_guild_as_player( - self, - player_name: str, - guild_query: str, - reason: str = "", -) -> tuple[bool, str, Optional[dict[str, Any]]]: - """Submit a normal player join request for a guild.""" - name = str(player_name or "").strip() - if not name: - return False, "玩家名不能为空", None - guilds = _load_guilds(self) - current_guild, _ = _find_player_guild(self, name, guilds) - if current_guild is not None: - return False, f"你已经加入了公会 { - current_guild.name}", _guild_summary(current_guild) - target_guild, err = _find_guild(self, guild_query, guilds) - if target_guild is None: - return False, err, None - if _ensure_settings(target_guild).get("frozen", False): - return False, f"公会 { - target_guild.name} 已被冻结,暂不能提交申请", _guild_summary(target_guild) - if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: - return False, "该公会已满员", _guild_summary(target_guild) - if not target_guild.add_join_request(name, reason): - return False, "申请提交失败,可能已有待处理申请或队列已满", _guild_summary(target_guild) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - notify = getattr(self, "_notify_join_request_admins", None) - if callable(notify): - notify(target_guild, name) - return True, f"申请已提交至 { - target_guild.name} 的申请队列", _guild_summary(target_guild) - - -def api_leave_guild_as_player( - self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: - """Leave the current guild as a normal member.""" - guilds = _load_guilds(self) - name, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err, None - if member.rank == GuildRank.OWNER: - return False, "会长不能退出公会,只能解散公会", _guild_summary(guild) - guild.members = [item for item in guild.members if item.name != name] - guild.add_log(f"{name} 退出公会") - guild.add_audit_log("member_leave", name, target=name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已退出公会 {guild.name}", _guild_summary(guild) - - -def api_disband_owned_guild_as_player( - self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: + return True, "查询成功", { + "guild": _guild_summary(guild), + "member": _member_summary(member), + "audit_logs": records[-50:], + "vault_trade_logs": vault_records[-50:], + } + + +def api_get_player_guild_menu_state( + self, player_name: str) -> tuple[bool, str, dict[str, Any]]: + """Return safe QQ-side guild menu state for one player identity.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if not name: + return False, err, {} + if guild is None: + return True, f"玩家 {name} 暂未加入公会", { + "player_name": name, + "in_guild": False, + "guild": None, + "member": None, + "permissions": [], + "is_owner": False, + "is_frozen": False, + } + if member is None: + return False, err, {} + return True, "查询成功", { + "player_name": name, + "in_guild": True, + "guild": _guild_summary(guild), + "member": _member_summary(member), + "permissions": guild.get_member_permissions(name), + "is_owner": member.rank == GuildRank.OWNER, + "is_frozen": bool(_ensure_settings(guild).get("frozen", False)), + } + + +def api_get_own_guild_logs(self, + player_name: str, + limit: int = 20) -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Return logs for the guild that the player belongs to.""" + ok, _err, parsed_limit = _to_int(limit, "日志数量", 1) + if not ok: + parsed_limit = 20 + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + data: dict[str, Any] = {"logs": guild.logs[-parsed_limit:]} + if guild.has_permission(name, "audit_log"): + data["audit_logs"] = [log.to_dict() + for log in guild.audit_logs[-parsed_limit:]] + data["vault_trade_logs"] = [log.to_dict() + for log in guild.vault_trade_logs[-parsed_limit:]] + else: + data["audit_logs"] = [] + data["vault_trade_logs"] = [] + return True, "查询成功", data + + +def api_get_own_guild_vault( + self, player_name: str) -> tuple[bool, str, Optional[list[dict[str, Any]]]]: + """Return the current player's guild vault after normal guild permission checks.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + if not guild.has_permission(name, "vault"): + return False, "你没有使用仓库权限", None + data = [_vault_item_summary(item, index) + for index, item in enumerate(guild.vault_items, start=1)] + return True, f"{guild.name} 仓库共有 {len(data)} 件上架物品", data + + +def api_get_own_guild_tasks( + self, player_name: str) -> tuple[bool, str, Optional[list[dict[str, Any]]]]: + """Return the task list for the guild that the player belongs to.""" + guilds = _load_guilds(self) + _name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + return True, f"{guild.name} 共有 {len(guild.tasks)} 个任务", [ + _task_summary(task) for task in guild.tasks] + + +def api_request_join_guild_as_player( + self, + player_name: str, + guild_query: str, + reason: str = "", +) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Submit a normal player join request for a guild.""" + name = str(player_name or "").strip() + if not name: + return False, "玩家名不能为空", None + guilds = _load_guilds(self) + current_guild, _ = _find_player_guild(self, name, guilds) + if current_guild is not None: + return False, f"你已经加入了公会 { + current_guild.name}", _guild_summary(current_guild) + target_guild, err = _find_guild(self, guild_query, guilds) + if target_guild is None: + return False, err, None + if _ensure_settings(target_guild).get("frozen", False): + return False, f"公会 { + target_guild.name} 已被冻结,暂不能提交申请", _guild_summary(target_guild) + if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: + return False, "该公会已满员", _guild_summary(target_guild) + if not target_guild.add_join_request(name, reason): + return False, "申请提交失败,可能已有待处理申请或队列已满", _guild_summary(target_guild) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + notify = getattr(self, "_notify_join_request_admins", None) + if callable(notify): + notify(target_guild, name) + return True, f"申请已提交至 { + target_guild.name} 的申请队列", _guild_summary(target_guild) + + +def api_leave_guild_as_player( + self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Leave the current guild as a normal member.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + if member.rank == GuildRank.OWNER: + return False, "会长不能退出公会,只能解散公会", _guild_summary(guild) + guild.members = [item for item in guild.members if item.name != name] + guild.add_log(f"{name} 退出公会") + guild.add_audit_log("member_leave", name, target=name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已退出公会 {guild.name}", _guild_summary(guild) + + +def api_disband_owned_guild_as_player( + self, player_name: str) -> tuple[bool, str, Optional[dict[str, Any]]]: """Disband the player's own guild, only when the player is the owner.""" guilds = _load_guilds(self) _, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err, None - if member.rank != GuildRank.OWNER: - return False, "你不是当前公会的会长", _guild_summary(guild) - summary = _guild_summary(guild) - guild_name = guild.name - online_members = [ - item.name - for item in guild.members - if item.name in getattr(self.game_ctrl, "allplayers", []) - ] - del guilds[guild.guild_id] - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - for member_name in online_members: - self.game_ctrl.sendcmd( - f'/tellraw {member_name} {{"rawtext":[{{"text":"§l§a公会 §d>> §r公会 §e{guild_name}§r 已被解散"}}]}}' - ) - return True, f"已解散公会 {guild_name}", summary - - -def api_set_announcement_as_player( - self, - player_name: str, - announcement: str, -) -> tuple[bool, str, Optional[dict[str, Any]]]: - """Set the current guild announcement through normal guild permission checks.""" - text = str(announcement or "").strip() - is_valid, error_msg = InputValidator.validate_announcement(text) - if not is_valid: - return False, error_msg, None - guilds = _load_guilds(self) - name, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err, None - if _ensure_settings(guild).get("frozen", False): - return False, guild_frozen_message(self, guild), _guild_summary(guild) - if not guild.has_permission(name, "announce"): - return False, "你没有设置公会公告权限", _guild_summary(guild) - guild.announcement = text - guild.add_log(f"{name} 更新了公告") - guild.add_audit_log("announcement_set", name, detail=text) + if guild is None or member is None: + return False, err, None + if member.rank != GuildRank.OWNER: + return False, "你不是当前公会的会长", _guild_summary(guild) + summary = _guild_summary(guild) + guild_name = guild.name + online_members = [ + item.name + for item in guild.members + if item.name in getattr(self.game_ctrl, "allplayers", []) + ] + del guilds[guild.guild_id] + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + for member_name in online_members: + message = f"§l§a公会 §d>> §r公会 §e{guild_name}§r 已被解散" + self.game_ctrl.sendcmd( + f'/tellraw {member_name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + return True, f"已解散公会 {guild_name}", summary + + +def api_set_announcement_as_player( + self, + player_name: str, + announcement: str, +) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Set the current guild announcement through normal guild permission checks.""" + text = str(announcement or "").strip() + is_valid, error_msg = InputValidator.validate_announcement(text) + if not is_valid: + return False, error_msg, None + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + if _ensure_settings(guild).get("frozen", False): + return False, guild_frozen_message(self, guild), _guild_summary(guild) + if not guild.has_permission(name, "announce"): + return False, "你没有设置公会公告权限", _guild_summary(guild) + guild.announcement = text + guild.add_log(f"{name} 更新了公告") + guild.add_audit_log("announcement_set", name, detail=text) if not _save_guilds(self, guilds): return False, "保存公会数据失败", None message = "§l§a公会 §d>> §r公告已更新,输入 .公会 公告 查看" @@ -534,1290 +548,1348 @@ def api_set_announcement_as_player( f'/tellraw {member_item.name} {{"rawtext":[{{"text":"{message}"}}]}}' ) return True, "公告已更新", _guild_summary(guild) - - -def api_join_guild_task_as_player( - self, - player_name: str, - task_query: str, -) -> tuple[bool, str, Optional[dict[str, Any]]]: - """Join an active guild task as the current player.""" - guilds = _load_guilds(self) - name, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err, None - if _ensure_settings(guild).get("frozen", False): - return False, guild_frozen_message(self, guild), None - task, err = _find_task(guild, task_query) - if task is None: - return False, err, None - if task.completed: - return False, "该任务已完成", _task_summary(task) - if name in task.participants: - return True, f"你已经参与了任务:{task.name}", _task_summary(task) - task.participants.append(name) - guild.add_log(f"{name} 参与了任务: {task.name}") - guild.add_audit_log("task_join", name, detail=task.name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已参与任务:{task.name}", _task_summary(task) - - -def api_return_to_guild_base_as_player( - self, player_name: str) -> tuple[bool, str]: - """Teleport the player to their guild base after normal permission checks.""" - guilds = _load_guilds(self) - name, guild, member, err = _find_player_context(self, player_name, guilds) - if guild is None or member is None: - return False, err - if name not in getattr(self.game_ctrl, "allplayers", []): - return False, f"玩家 {name} 当前不在线,无法传送" - if _ensure_settings(guild).get("frozen", False): - return False, guild_frozen_message(self, guild) - if not guild.has_permission(name, "return_base"): - return False, "你没有返回公会据点权限" - if _ensure_settings(guild).get("base_locked", False): - return False, f"公会 {guild.name} 据点已锁定" - if not guild.base: - return False, f"公会 {guild.name} 尚未设置据点" - base = guild.base - self.game_ctrl.sendwocmd( - f"tp {name} {float(base.x)} {float(base.y)} {float(base.z)}") - return True, f"已传送到公会 {guild.name} 据点" - - -def api_force_disband_guild(self, guild_query: str, - actor: str = "QQ管理") -> tuple[bool, str]: - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err - name = guild.name - del guilds[guild.guild_id] - if not _save_guilds(self, guilds): - return False, "保存公会数据失败" - return True, f"已强制解散公会 {name}" - - -def api_rename_guild(self, guild_query: str, new_name: str, - actor: str = "QQ管理") -> tuple[bool, str, Optional[dict[str, Any]]]: - new_name = str(new_name or "").strip() - if not new_name: - return False, "新公会名不能为空", None - if len(new_name) < 2 or len(new_name) > 20: - return False, "新公会名长度必须在 2-20 之间", None - guilds = _load_guilds(self) - if any(guild.name == new_name for guild in guilds.values()): - return False, f"公会名已存在:{new_name}", None - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - old_name = guild.name - guild.name = new_name - guild.add_log(f"{_actor(actor)} 将公会名从 {old_name} 修改为 {new_name}") - guild.add_audit_log( - "guild_rename", - _actor(actor), - target=old_name, - detail=new_name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已修改公会名称:{old_name} -> {new_name}", _guild_summary(guild) - - -def api_set_guild_level(self, - guild_query: str, - level: int, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - ok, err, parsed = _to_int(level, "公会等级", 1) - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - old = guild.level - guild.level = parsed - guild.add_log(f"{_actor(actor)} 将公会等级从 {old} 修改为 {parsed}") - guild.add_audit_log( - "guild_set_level", - _actor(actor), - detail=f"{old}->{parsed}") - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已设置 {guild.name} 等级为 {parsed}", _guild_summary(guild) - - -def api_set_guild_exp(self, guild_query: str, exp: int, - actor: str = "QQ管理") -> tuple[bool, str, Optional[dict[str, Any]]]: - ok, err, parsed = _to_int(exp, "公会经验", 0) - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - old = guild.exp - guild.exp = parsed - level_ups = _apply_level_ups(guild) - guild.add_log(f"{_actor(actor)} 将公会经验从 {old} 修改为 {parsed}") - guild.add_audit_log( - "guild_set_exp", - _actor(actor), - detail=f"{old}->{parsed}") - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - suffix = f",触发升级到 {level_ups[-1]} 级" if level_ups else "" - return True, f"已设置 { - guild.name} 经验为 { - guild.exp}{suffix}", _guild_summary(guild) - - -def api_transfer_guild_owner(self, - guild_query: str, - new_owner: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - target_name = str(new_owner or "").strip() - if not target_name: - return False, "新会长不能为空", None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - target = guild.get_member(target_name) - if target is None: - target = GuildMember( - name=target_name, - rank=GuildRank.MEMBER, - join_time=_now()) - guild.members.append(target) - for member in guild.members: - if member.rank == GuildRank.OWNER and member.name != target_name: - member.rank = GuildRank.DEPUTY - target.rank = GuildRank.OWNER - old_owner = guild.owner - guild.owner = target.name - guild.add_log(f"{_actor(actor)} 强制将会长从 {old_owner} 转让给 {target.name}") - guild.add_audit_log("guild_force_transfer_owner", _actor(actor), - target=target.name, detail=old_owner) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已将 {guild.name} 会长转让给 {target.name}", _guild_summary(guild) - - -def api_force_join_guild(self, - guild_query: str, - player_name: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - name = str(player_name or "").strip() - if not name: - return False, "玩家名不能为空", None - guilds = _load_guilds(self) - target_guild, err = _find_guild(self, guild_query, guilds) - if target_guild is None: - return False, err, None - old_guild, _ = _find_player_guild(self, name, guilds) - if old_guild and old_guild.guild_id == target_guild.guild_id: - return True, f"{name} 已在公会 { - target_guild.name}", _guild_summary(target_guild) - if old_guild: - old_guild.members = [ - member for member in old_guild.members if member.name != name] - old_guild.add_log(f"{_actor(actor)} 强制移出 {name}") - old_guild.add_audit_log("guild_force_leave", _actor( - actor), target=name, detail=target_guild.name) - target_guild.members.append(GuildMember( - name=name, rank=GuildRank.MEMBER, join_time=_now())) - target_guild.add_log(f"{_actor(actor)} 强制加入成员 {name}") - target_guild.add_audit_log("guild_force_join", _actor(actor), target=name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已将 {name} 加入公会 { - target_guild.name}", _guild_summary(target_guild) - - -def api_force_leave_guild(self, - player_name: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - name = str(player_name or "").strip() - if not name: - return False, "玩家名不能为空", None - guilds = _load_guilds(self) - guild, err = _find_player_guild(self, name, guilds) - if guild is None: - return False, err, None - guild.members = [member for member in guild.members if member.name != name] - if not guild.members: - old_name = guild.name - del guilds[guild.guild_id] - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已移除 {name},公会 {old_name} 已无成员并被解散", None - if guild.owner == name or not any( - member.rank == GuildRank.OWNER for member in guild.members): - new_owner = guild.members[0] - new_owner.rank = GuildRank.OWNER - guild.owner = new_owner.name - guild.add_log(f"{_actor(actor)} 强制移出成员 {name}") - guild.add_audit_log("guild_force_leave", _actor(actor), target=name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已将 {name} 移出公会 {guild.name}", _guild_summary(guild) - - -def api_force_kick_member(self, - player_name: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - return api_force_leave_guild(self, player_name, actor) - - -def api_set_guild_frozen(self, - guild_query: str, - frozen: bool, - reason: str = "", - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - settings = _ensure_settings(guild) - settings["frozen"] = bool(frozen) - settings["frozen_reason"] = str(reason or "") - settings["frozen_at"] = _now() if frozen else 0 - action = "冻结" if frozen else "解冻" - guild.add_log(f"{_actor(actor)} {action}了公会") - guild.add_audit_log("guild_freeze" if frozen else "guild_unfreeze", - _actor(actor), detail=str(reason or "")) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已{action}公会 {guild.name}", _guild_summary(guild) - - -def api_get_guild_vault( - self, guild_query: str) -> tuple[bool, str, Optional[list[dict[str, Any]]]]: - guild, err = _find_guild(self, guild_query) - if guild is None: - return False, err, None - data = [_vault_item_summary(item, index) - for index, item in enumerate(guild.vault_items, start=1)] - return True, f"{guild.name} 仓库共有 {len(data)} 件上架物品", data - - -def api_backup_guild_vault(self, - guild_query: str, - label: str = "", - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - settings = _ensure_settings(guild) - backups = settings.setdefault("vault_backups", []) - if not isinstance(backups, list): - backups = [] - settings["vault_backups"] = backups - backup = { - "label": str(label or ""), - "actor": _actor(actor), - "created_at": _now(), - "items": [item.to_dict() for item in guild.vault_items], - } - backups.insert(0, backup) - settings["vault_backups"] = backups[:10] - guild.add_audit_log("vault_backup", _actor(actor), detail=str(label or "")) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已备份 { - guild.name} 仓库,当前保留 { - len( - settings['vault_backups'])} 份", backup - - -def api_clear_guild_vault(self, - guild_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - api_backup_guild_vault(self, guild.guild_id, "clear-before", actor) - guilds = _load_guilds(self) - guild = guilds[guild.guild_id] - removed = len(guild.vault_items) - guild.vault_items = [] - guild.add_log(f"{_actor(actor)} 清空了公会仓库") - guild.add_audit_log( - "vault_clear", - _actor(actor), - detail=f"removed={removed}") - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已清空 { - guild.name} 仓库,共删除 {removed} 件物品", _guild_summary(guild) - - -def api_delete_guild_vault_item(self, - guild_query: str, - index: int, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - ok, err, parsed = _to_int(index, "仓库序号", 1) - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - zero_index = parsed - 1 - if zero_index >= len(guild.vault_items): - return False, f"仓库序号超出范围:{parsed}", None - item = guild.vault_items.pop(zero_index) - guild.add_vault_trade_log( - "admin_delete", - item, - _actor(actor), - detail="管理员删除") - guild.add_audit_log("vault_item_delete", _actor( - actor), target=item.seller, detail=item.item_id) - guild.add_log(f"{_actor(actor)} 删除了仓库物品 {item.item_id} x{item.count}") - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已删除 { - guild.name} 仓库第 {parsed} 件物品", _vault_item_summary( - item, parsed) - - -def api_rollback_guild_vault(self, - guild_query: str, - backup_index: int = 1, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - ok, err, parsed = _to_int(backup_index, "备份序号", 1) - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - backups = _ensure_settings(guild).get("vault_backups", []) - if not isinstance(backups, list) or parsed > len(backups): - return False, f"仓库备份不存在:{parsed}", None - selected_backup = copy.deepcopy(backups[parsed - 1]) - api_backup_guild_vault(self, guild.guild_id, "rollback-before", actor) - guilds = _load_guilds(self) - guild = guilds[guild.guild_id] - guild.vault_items = [ - VaultItem.from_dict(item) - for item in selected_backup.get("items", []) - if isinstance(item, dict) - ] - guild.add_log(f"{_actor(actor)} 回滚了公会仓库") - guild.add_audit_log("vault_rollback", _actor(actor), detail=str(parsed)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已回滚 {guild.name} 仓库到备份 {parsed}", { - "guild": _guild_summary(guild), - "backup": selected_backup, - } - - -def api_export_guild_vault( - self, guild_query: str) -> tuple[bool, str, Optional[dict[str, Any]]]: - guild, err = _find_guild(self, guild_query) - if guild is None: - return False, err, None - data = { - "guild": _guild_summary(guild), "vault_items": [ - _vault_item_summary( - item, index) for index, item in enumerate( - guild.vault_items, start=1)], "vault_trade_logs": [ - log.to_dict() for log in guild.vault_trade_logs], } - data["json"] = json.dumps(data, ensure_ascii=False, indent=2) - return True, f"已导出 {guild.name} 仓库数据", data - - -def _make_task_from_template( - template: dict[str, Any], prefix: str = "auto") -> GuildTask: - now = _now() - deadline_seconds = int(getattr(Config, "GUILD_TASK_CONFIG", - {}).get("自动任务默认有效期秒", 172800)) - return GuildTask( - task_id=f"{prefix} -{uuid.uuid4().hex[: 8]} ", - name=str(template.get("name", "公会任务"))[: 20], - description=str(template.get("description", ""))[: 100], - task_type=str(template.get("task_type", "trade")), - target=str(template.get("target", "trade_count")), - target_count=max(1, int(template.get("target_count", 1))), - current_count=max(0, int(template.get("current_count", 0))), - reward_exp=max(0, int(template.get("reward_exp", 0))), - reward_contribution=max( - 0, int(template.get("reward_contribution", 0))), - create_time=now, deadline=now + deadline_seconds - if deadline_seconds > 0 else 0,) - - -def api_refresh_guild_tasks( - self, guild_query: str, actor: str = "QQ管理") -> tuple[bool, str, list[dict[str, Any]]]: - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, [] - templates = getattr(Config, "GUILD_TASK_CONFIG", {}).get("自动任务模板列表", []) - if not templates: - return False, "没有配置自动任务模板", [] - active_keys = {(task.name, task.task_type, task.target) - for task in guild.tasks if not task.completed} - created: list[GuildTask] = [] - for template in templates: - key = ( - template.get("name"), - template.get("task_type"), - template.get("target")) - if key in active_keys: - continue - task = _make_task_from_template(template) - guild.tasks.append(task) - created.append(task) - guild.add_log(f"{_actor(actor)} 刷新了公会任务,新增 {len(created)} 个") - guild.add_audit_log( - "task_refresh", - _actor(actor), - detail=str( - len(created))) - if created and not _save_guilds(self, guilds): - return False, "保存公会数据失败", [] - return True, f"已为 { - guild.name} 刷新任务,新增 { - len(created)} 个", [ - _task_summary(task) for task in created] - - -def api_create_global_task( - self, - name: str, - task_type: str, - target: str, - target_count: int, - reward_exp: int = 0, - reward_contribution: int = 0, - description: str = "", - deadline_seconds: int = 0, - actor: str = "QQ管理", -) -> tuple[bool, str, list[dict[str, Any]]]: - task_name = str(name or "").strip() - if not task_name: - return False, "任务名称不能为空", [] - ok, err, count = _to_int(target_count, "目标数量", 1) - if not ok: - return False, err, [] - _, _, exp = _to_int(reward_exp, "经验奖励", 0) - _, _, contribution = _to_int(reward_contribution, "贡献奖励", 0) - _, _, seconds = _to_int(deadline_seconds, "截止秒数", 0) - guilds = _load_guilds(self) - now = _now() - created: list[dict[str, Any]] = [] - for guild in guilds.values(): - task = GuildTask( - task_id=f"global-{uuid.uuid4().hex[:8]}", - name=task_name[:20], - description=str(description or task_name)[:100], - task_type=str(task_type or "trade"), - target=str(target or "trade_count"), - target_count=count, - reward_exp=exp, - reward_contribution=contribution, - create_time=now, - deadline=now + seconds if seconds > 0 else 0, - ) - guild.tasks.append(task) - guild.add_log(f"{_actor(actor)} 创建了全服任务 {task.name}") - created.append({"guild_id": guild.guild_id, - "guild_name": guild.name, "task": _task_summary(task)}) - if created and not _save_guilds(self, guilds): - return False, "保存公会数据失败", [] - return True, f"已向 {len(created)} 个公会创建全服任务", created - - -def api_delete_guild_task(self, - guild_query: str, - task_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - task, err = _find_task(guild, task_query) - if task is None: - return False, err, None - guild.tasks = [ - item for item in guild.tasks if item.task_id != task.task_id] - guild.add_log(f"{_actor(actor)} 删除了任务 {task.name}") - guild.add_audit_log("task_delete", _actor(actor), detail=task.name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已删除任务 {task.name}", _task_summary(task) - - -def api_reset_guild_task_progress(self, - guild_query: str, - task_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - task, err = _find_task(guild, task_query) - if task is None: - return False, err, None - task.current_count = 0 - task.completed = False - task.participants = [] - guild.add_log(f"{_actor(actor)} 重置了任务 {task.name}") - guild.add_audit_log("task_reset", _actor(actor), detail=task.name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已重置任务 {task.name}", _task_summary(task) - - -def api_force_complete_guild_task(self, - guild_query: str, - task_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - task, err = _find_task(guild, task_query) - if task is None: - return False, err, None - task.current_count = task.target_count - task.completed = True - guild.add_log(f"{_actor(actor)} 强制完成了任务 {task.name}") - guild.add_audit_log("task_force_complete", _actor(actor), detail=task.name) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已强制完成任务 {task.name}", _task_summary(task) - - -def api_teleport_player_to_guild_base( - self, player_name: str, guild_query: str | None = None) -> tuple[bool, str]: - name = str(player_name or "").strip() - if not name: - return False, "玩家名不能为空" - guilds = _load_guilds(self) - if guild_query: - guild, err = _find_guild(self, guild_query, guilds) - else: - guild, err = _find_player_guild(self, name, guilds) - if guild is None: - return False, err - if _ensure_settings(guild).get("base_locked", False): - return False, f"公会 {guild.name} 据点已锁定" - if not guild.base: - return False, f"公会 {guild.name} 尚未设置据点" - base = guild.base - self.game_ctrl.sendwocmd( - f"tp {name} {float(base.x)} {float(base.y)} {float(base.z)}") - return True, f"已传送 {name} 到公会 {guild.name} 据点" - - -def api_delete_guild_base(self, - guild_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - guild.base = None - guild.add_log(f"{_actor(actor)} 删除了公会据点") - guild.add_audit_log("base_delete", _actor(actor)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已删除 {guild.name} 据点", _guild_summary(guild) - - -def api_set_guild_base(self, - guild_query: str, - dimension: int, - x: float, - y: float, - z: float, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - ok, err, dim = _to_int(dimension, "维度") - if not ok: - return False, err, None - coord_values = [] - for field_name, value in (("x", x), ("y", y), ("z", z)): - ok, err, parsed = _to_float(value, field_name) - if not ok: - return False, err, None - coord_values.append(parsed) - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - guild.base = GuildBase( - dim, - coord_values[0], - coord_values[1], - coord_values[2]) - guild.add_log(f"{_actor(actor)} 修改了公会据点") - guild.add_audit_log("base_set", _actor( - actor), detail=f"{dim},{coord_values}") - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已设置 {guild.name} 据点", _guild_summary(guild) - - -def api_set_guild_base_locked(self, - guild_query: str, - locked: bool, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - _ensure_settings(guild)["base_locked"] = bool(locked) - action = "锁定" if locked else "解锁" - guild.add_log(f"{_actor(actor)} {action}了公会据点") - guild.add_audit_log( - "base_lock" if locked else "base_unlock", - _actor(actor)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已{action} {guild.name} 据点", _guild_summary(guild) - - -def api_clear_guild_effects(self, - guild_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - guild.purchased_effects = {} - guild.add_log(f"{_actor(actor)} 清空了公会效果") - guild.add_audit_log("effect_clear", _actor(actor)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已清空 {guild.name} 效果", _guild_summary(guild) - - -def api_set_guild_effect(self, - guild_query: str, - effect_key: str, - level: int, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - key = str(effect_key or "").strip() - if key not in Config.EFFECTS_CONFIG: - names = { - str(value.get("name", "")): effect_id - for effect_id, value in Config.EFFECTS_CONFIG.items() - if isinstance(value, dict) - } - key = names.get(key, key) - if key not in Config.EFFECTS_CONFIG: - return False, f"效果不存在:{effect_key}", None - ok, err, parsed_level = _to_int(level, "效果等级", 0) - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - if parsed_level <= 0: - guild.purchased_effects.pop(key, None) - else: - guild.purchased_effects[key] = parsed_level - guild.add_log(f"{_actor(actor)} 设置效果 {key} 为 {parsed_level} 级") - guild.add_audit_log( - "effect_set", - _actor(actor), - detail=f"{key}={parsed_level}") - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已设置 { - guild.name} 效果 {key} 为 {parsed_level} 级", _guild_summary(guild) - - -def api_add_guild_funds(self, - guild_query: str, - amount: int, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - ok, err, parsed = _to_int(amount, "资金数量") - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - settings = _ensure_settings(guild) - settings["funds"] = int(settings.get("funds", 0) or 0) + parsed - guild.add_log(f"{_actor(actor)} 调整公会资金 {parsed:+d}") - guild.add_audit_log("funds_add", _actor(actor), detail=str(parsed)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已调整 { - guild.name} 资金,当前 { - settings['funds']}", _guild_summary(guild) - - -def api_set_guild_funds(self, - guild_query: str, - amount: int, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - ok, err, parsed = _to_int(amount, "资金余额", 0) - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - _ensure_settings(guild)["funds"] = parsed - guild.add_log(f"{_actor(actor)} 设置公会资金为 {parsed}") - guild.add_audit_log("funds_set", _actor(actor), detail=str(parsed)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已设置 {guild.name} 资金为 {parsed}", _guild_summary(guild) - - -def api_add_member_contribution(self, - player_name: str, - amount: int, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - ok, err, parsed = _to_int(amount, "贡献值") - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_player_guild(self, player_name, guilds) - if guild is None: - return False, err, None - member = guild.get_member(str(player_name).strip()) - if member is None: - return False, "成员数据异常", None - member.contribution += parsed - guild.stats.total_contribution += max(0, parsed) - guild.add_log(f"{_actor(actor)} 调整 {member.name} 贡献 {parsed:+d}") - guild.add_audit_log("member_contribution_add", _actor(actor), - target=member.name, detail=str(parsed)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已调整 { - member.name} 贡献,当前 { - member.contribution}", _member_summary(member) - - -def api_set_member_contribution(self, - player_name: str, - amount: int, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - ok, err, parsed = _to_int(amount, "贡献值", 0) - if not ok: - return False, err, None - guilds = _load_guilds(self) - guild, err = _find_player_guild(self, player_name, guilds) - if guild is None: - return False, err, None - member = guild.get_member(str(player_name).strip()) - if member is None: - return False, "成员数据异常", None - old = member.contribution - member.contribution = parsed - guild.add_log(f"{_actor(actor)} 将 {member.name} 贡献从 {old} 设置为 {parsed}") - guild.add_audit_log("member_contribution_set", _actor( - actor), target=member.name, detail=f"{old}->{parsed}") - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已设置 {member.name} 贡献为 {parsed}", _member_summary(member) - - -def api_reset_guild_contributions(self, - guild_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - for member in guild.members: - member.contribution = 0 - guild.stats.total_contribution = 0 - guild.add_log(f"{_actor(actor)} 重置了所有成员贡献") - guild.add_audit_log("member_contribution_reset_all", _actor(actor)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已重置 {guild.name} 所有成员贡献", _guild_summary(guild) - - -def api_reset_market_prices(self, - guild_query: str, - actor: str = "QQ管理") -> tuple[bool, - str, - Optional[dict[str, - Any]]]: - guilds = _load_guilds(self) - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, None - removed = len(guild.custom_item_values) - guild.custom_item_values = {} - guild.add_log(f"{_actor(actor)} 重置了市场价格") - guild.add_audit_log( - "market_price_reset", - _actor(actor), - detail=str(removed)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", None - return True, f"已重置 { - guild.name} 市场价格,删除 {removed} 条自定义价格", _guild_summary(guild) - - -def api_get_guild_logs(self, guild_query: str, - limit: int = 20) -> tuple[bool, str, Optional[dict[str, Any]]]: - ok, _err, parsed_limit = _to_int(limit, "日志数量", 1) - if not ok: - parsed_limit = 20 - guild, err = _find_guild(self, guild_query) - if guild is None: - return False, err, None - return True, "查询成功", { - "logs": guild.logs[-parsed_limit:], - "audit_logs": [log.to_dict() for log in guild.audit_logs[-parsed_limit:]], - "vault_trade_logs": [log.to_dict() for log in guild.vault_trade_logs[-parsed_limit:]], - } - - -def api_get_abnormal_trades(self, - guild_query: str | None = None, - ratio: float = 3.0) -> tuple[bool, - str, - list[dict[str, - Any]]]: - try: - threshold = float(ratio) - except (TypeError, ValueError): - threshold = 3.0 - guilds = _load_guilds(self) - target_guilds: list[GuildData] - if guild_query: - guild, err = _find_guild(self, guild_query, guilds) - if guild is None: - return False, err, [] - target_guilds = [guild] - else: - target_guilds = list(guilds.values()) - results = [] - for guild in target_guilds: - for log in guild.vault_trade_logs: - suggested = guild.get_item_value( - log.item_id) * max(1, int(log.count or 1)) - if suggested > 0 and log.price >= suggested * threshold: - data = log.to_dict() - data["guild_id"] = guild.guild_id - data["guild_name"] = guild.name - data["suggested_price"] = suggested - data["ratio"] = round(log.price / suggested, 2) - results.append(data) - results.sort(key=lambda item: item.get("timestamp", 0), reverse=True) - return True, f"共 {len(results)} 条异常交易记录", results - - -def api_get_donation_rankings(self, - guild_query: str | None = None, - limit: int = 10) -> tuple[bool, - str, - list[dict[str, - Any]]]: - ok, _err, parsed_limit = _to_int(limit, "排行数量", 1) - if not ok: - parsed_limit = 10 - guilds = _load_guilds(self) - records = [] - for guild in guilds.values(): - if guild_query and guild.name != guild_query and guild.guild_id != guild_query: - continue - for member in guild.members: - records.append({ - "guild_id": guild.guild_id, - "guild_name": guild.name, - "player_name": member.name, - "rank": member.rank.value, - "contribution": member.contribution, - }) - records.sort(key=lambda item: int(item["contribution"]), reverse=True) - return True, f"贡献排行前 {min(parsed_limit, len(records))} 名", records[:parsed_limit] - - -def api_get_guild_rankings(self, sort_by: str = "level", - limit: int = 10) -> tuple[bool, str, list[dict[str, Any]]]: - """返回适合外部插件消费的公会排行榜数据。""" - raw_sort_by = str(sort_by or "level").strip() - sort_key = { - "等级": "level", - "level": "level", - "成员": "members", - "members": "members", - "贡献": "contribution", - "contribution": "contribution", - "活跃": "activity", - "activity": "activity", - }.get(raw_sort_by, raw_sort_by) - ok, _err, parsed_limit = _to_int(limit, "排行数量", 1) - if not ok: - parsed_limit = 10 - rankings = _get_guild_rankings(self, sort_key)[:parsed_limit] - data: list[dict[str, Any]] = [] - for index, (guild, score) in enumerate(rankings, start=1): - item = _guild_summary(guild) - item["rank"] = index - item["score"] = score - item["sort_by"] = sort_key - data.append(item) - return True, f"公会排行前 {len(data)} 名", data - - -def api_reload_guild_config(self) -> tuple[bool, str, dict[str, Any]]: - self.config = Config.load(self.name, self.version) - return True, "公会系统配置已重新加载", copy.deepcopy(self.config) - - -def api_save_guild_data(self) -> tuple[bool, str]: - guilds = _load_guilds(self) - if not _save_guilds(self, guilds, force=True): - return False, "保存公会数据失败" - return True, "公会数据已强制保存" - - -def api_backup_guild_data(self) -> tuple[bool, str, Optional[str]]: - if not os.path.exists(self.guilds_file): - return False, "公会数据文件不存在", None - data_dir = os.path.dirname(self.guilds_file) - backup_dir = os.path.join(data_dir, "公会数据备份") - os.makedirs(backup_dir, exist_ok=True) - backup_path = os.path.join( - backup_dir, f"公会数据文件-api-{time.strftime('%Y%m%d-%H%M%S')}.json") - shutil.copy2(self.guilds_file, backup_path) - return True, "公会数据备份已创建", backup_path - - -def api_repair_guild_data( - self, actor: str = "QQ管理") -> tuple[bool, str, dict[str, Any]]: - guilds = _load_guilds(self) - fixed = {"guild_id": 0, "level": 0, "exp": 0, - "owner": 0, "vault": 0, "removed_empty": 0} - for outer_id in list(guilds.keys()): - guild = guilds[outer_id] - if not guild.members: - del guilds[outer_id] - fixed["removed_empty"] += 1 - continue - if guild.guild_id != outer_id: - guild.guild_id = outer_id - fixed["guild_id"] += 1 - if not isinstance(guild.level, int) or guild.level < 1: - guild.level = 1 - fixed["level"] += 1 - if not isinstance(guild.exp, (int, float)) or guild.exp < 0: - guild.exp = 0 - fixed["exp"] += 1 - owners = [member for member in guild.members - if member.rank == GuildRank.OWNER] - if len(owners) != 1: - preferred = guild.get_member(guild.owner) or guild.members[0] - for member in guild.members: - member.rank = GuildRank.MEMBER - preferred.rank = GuildRank.OWNER - guild.owner = preferred.name - fixed["owner"] += 1 - if len(guild.vault_items) > Config.VAULT_INITIAL_SLOTS: - guild.vault_items = guild.vault_items[:Config.VAULT_INITIAL_SLOTS] - fixed["vault"] += 1 - if not isinstance(guild.stats, GuildStats): - guild.stats = GuildStats() - guild.add_audit_log("data_repair", _actor( - actor), detail=json.dumps(fixed, ensure_ascii=False)) - if not _save_guilds(self, guilds): - return False, "保存公会数据失败", fixed - return True, "公会数据修复完成", fixed - - -def api_get_guild_statistics(self) -> tuple[bool, str, dict[str, Any]]: - guilds = _load_guilds(self) - total_members = sum(len(guild.members) for guild in guilds.values()) - total_vault_items = sum(len(guild.vault_items) - for guild in guilds.values()) - total_tasks = sum(len(guild.tasks) for guild in guilds.values()) - active_tasks = sum( - len([task for task in guild.tasks if not task.completed]) - for guild in guilds.values()) - frozen_count = sum(1 for guild in guilds.values() - if _ensure_settings(guild).get("frozen", False)) - data = { - "guild_count": len(guilds), - "member_count": total_members, - "vault_item_count": total_vault_items, - "task_count": total_tasks, - "active_task_count": active_tasks, - "frozen_guild_count": frozen_count, - "activity_status": api_get_guild_activity_status(self)[2], - } - return True, "查询成功", data - - -def api_start_guild_activity( - self, - activity: str, - duration_seconds: int = 3600, - multiplier: float = 2.0, - actor: str = "QQ管理", -) -> tuple[bool, str, dict[str, Any]]: - activity_key = str(activity or "").strip().lower() - aliases = { - "双倍经验": "exp", - "经验": "exp", - "exp": "exp", - "双倍贡献": "contribution", - "贡献": "contribution", - "contribution": "contribution", - "公会争霸": "contest", - "争霸": "contest", - "contest": "contest", - } - activity_key = aliases.get(activity_key, activity_key) - if activity_key not in ("exp", "contribution", "contest"): - return False, f"未知活动类型:{activity}", {} - ok, err, seconds = _to_int(duration_seconds, "活动时长秒", 1) - if not ok: - return False, err, {} - try: - parsed_multiplier = max(1.0, float(multiplier)) - except (TypeError, ValueError): - parsed_multiplier = 2.0 - if not hasattr(self, "_guild_runtime_events"): - self._guild_runtime_events = {} - event = { - "activity": activity_key, - "multiplier": parsed_multiplier, - "started_at": _now(), - "expires_at": _now() + seconds, - "actor": _actor(actor), - } - self._guild_runtime_events[activity_key] = event - return True, f"已开启 {activity_key} 活动 {seconds} 秒,倍率 {parsed_multiplier}", copy.deepcopy( - event) - - -def api_stop_guild_activity(self, activity: str) -> tuple[bool, str]: - activity_key = str(activity or "").strip().lower() - aliases = {"经验": "exp", "双倍经验": "exp", "贡献": "contribution", - "双倍贡献": "contribution", "争霸": "contest", "公会争霸": "contest"} - activity_key = aliases.get(activity_key, activity_key) - removed = getattr( - self, - "_guild_runtime_events", - {}).pop( - activity_key, - None) - if removed is None: - return False, f"活动未开启:{activity}" - return True, f"已停止活动 {activity_key}" - - -def api_get_guild_activity_status(self) -> tuple[bool, str, dict[str, Any]]: - events = getattr(self, "_guild_runtime_events", {}) - now = _now() - active = {} - for key, event in list(events.items()): - expires_at = float(event.get("expires_at", 0) or 0) + + +def api_join_guild_task_as_player( + self, + player_name: str, + task_query: str, +) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Join an active guild task as the current player.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err, None + if _ensure_settings(guild).get("frozen", False): + return False, guild_frozen_message(self, guild), None + task, err = _find_task(guild, task_query) + if task is None: + return False, err, None + if task.completed: + return False, "该任务已完成", _task_summary(task) + if name in task.participants: + return True, f"你已经参与了任务:{task.name}", _task_summary(task) + task.participants.append(name) + guild.add_log(f"{name} 参与了任务: {task.name}") + guild.add_audit_log("task_join", name, detail=task.name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已参与任务:{task.name}", _task_summary(task) + + +def api_return_to_guild_base_as_player( + self, player_name: str) -> tuple[bool, str]: + """Teleport the player to their guild base after normal permission checks.""" + guilds = _load_guilds(self) + name, guild, member, err = _find_player_context(self, player_name, guilds) + if guild is None or member is None: + return False, err + if name not in getattr(self.game_ctrl, "allplayers", []): + return False, f"玩家 {name} 当前不在线,无法传送" + if _ensure_settings(guild).get("frozen", False): + return False, guild_frozen_message(self, guild) + if not guild.has_permission(name, "return_base"): + return False, "你没有返回公会据点权限" + if _ensure_settings(guild).get("base_locked", False): + return False, f"公会 {guild.name} 据点已锁定" + if not guild.base: + return False, f"公会 {guild.name} 尚未设置据点" + base = guild.base + self.game_ctrl.sendwocmd( + f"tp {name} {float(base.x)} {float(base.y)} {float(base.z)}") + return True, f"已传送到公会 {guild.name} 据点" + + +def api_force_disband_guild(self, guild_query: str, + actor: str = "QQ管理") -> tuple[bool, str]: + """Force api force disband guild.""" + _ = actor + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err + name = guild.name + del guilds[guild.guild_id] + if not _save_guilds(self, guilds): + return False, "保存公会数据失败" + return True, f"已强制解散公会 {name}" + + +def api_rename_guild(self, guild_query: str, new_name: str, + actor: str = "QQ管理") -> tuple[bool, str, Optional[dict[str, Any]]]: + """Run api rename guild.""" + new_name = str(new_name or "").strip() + if not new_name: + return False, "新公会名不能为空", None + if len(new_name) < 2 or len(new_name) > 20: + return False, "新公会名长度必须在 2-20 之间", None + guilds = _load_guilds(self) + if any(guild.name == new_name for guild in guilds.values()): + return False, f"公会名已存在:{new_name}", None + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + old_name = guild.name + guild.name = new_name + guild.add_log(f"{_actor(actor)} 将公会名从 {old_name} 修改为 {new_name}") + guild.add_audit_log( + "guild_rename", + _actor(actor), + target=old_name, + detail=new_name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已修改公会名称:{old_name} -> {new_name}", _guild_summary(guild) + + +def api_set_guild_level(self, + guild_query: str, + level: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Update api set guild level.""" + ok, err, parsed = _to_int(level, "公会等级", 1) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + old = guild.level + guild.level = parsed + guild.add_log(f"{_actor(actor)} 将公会等级从 {old} 修改为 {parsed}") + guild.add_audit_log( + "guild_set_level", + _actor(actor), + detail=f"{old}->{parsed}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已设置 {guild.name} 等级为 {parsed}", _guild_summary(guild) + + +def api_set_guild_exp( + self, + guild_query: str, + exp: int, + actor: str = "QQ管理", +) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Update api set guild exp.""" + ok, err, parsed = _to_int(exp, "公会经验", 0) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + old = guild.exp + guild.exp = parsed + level_ups = _apply_level_ups(guild) + guild.add_log(f"{_actor(actor)} 将公会经验从 {old} 修改为 {parsed}") + guild.add_audit_log( + "guild_set_exp", + _actor(actor), + detail=f"{old}->{parsed}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + suffix = f",触发升级到 {level_ups[-1]} 级" if level_ups else "" + return True, f"已设置 { + guild.name} 经验为 { + guild.exp}{suffix}", _guild_summary(guild) + + +def api_transfer_guild_owner(self, + guild_query: str, + new_owner: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Run api transfer guild owner.""" + target_name = str(new_owner or "").strip() + if not target_name: + return False, "新会长不能为空", None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + target = guild.get_member(target_name) + if target is None: + target = GuildMember( + name=target_name, + rank=GuildRank.MEMBER, + join_time=_now()) + guild.members.append(target) + for member in guild.members: + if member.rank == GuildRank.OWNER and member.name != target_name: + member.rank = GuildRank.DEPUTY + target.rank = GuildRank.OWNER + old_owner = guild.owner + guild.owner = target.name + guild.add_log(f"{_actor(actor)} 强制将会长从 {old_owner} 转让给 {target.name}") + guild.add_audit_log("guild_force_transfer_owner", _actor(actor), + target=target.name, detail=old_owner) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已将 {guild.name} 会长转让给 {target.name}", _guild_summary(guild) + + +def api_force_join_guild(self, + guild_query: str, + player_name: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Force api force join guild.""" + name = str(player_name or "").strip() + if not name: + return False, "玩家名不能为空", None + guilds = _load_guilds(self) + target_guild, err = _find_guild(self, guild_query, guilds) + if target_guild is None: + return False, err, None + old_guild, _ = _find_player_guild(self, name, guilds) + if old_guild and old_guild.guild_id == target_guild.guild_id: + return True, f"{name} 已在公会 { + target_guild.name}", _guild_summary(target_guild) + if old_guild: + old_guild.members = [ + member for member in old_guild.members if member.name != name] + old_guild.add_log(f"{_actor(actor)} 强制移出 {name}") + old_guild.add_audit_log("guild_force_leave", _actor( + actor), target=name, detail=target_guild.name) + target_guild.members.append(GuildMember( + name=name, rank=GuildRank.MEMBER, join_time=_now())) + target_guild.add_log(f"{_actor(actor)} 强制加入成员 {name}") + target_guild.add_audit_log("guild_force_join", _actor(actor), target=name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已将 {name} 加入公会 { + target_guild.name}", _guild_summary(target_guild) + + +def api_force_leave_guild(self, + player_name: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Force api force leave guild.""" + name = str(player_name or "").strip() + if not name: + return False, "玩家名不能为空", None + guilds = _load_guilds(self) + guild, err = _find_player_guild(self, name, guilds) + if guild is None: + return False, err, None + guild.members = [member for member in guild.members if member.name != name] + if not guild.members: + old_name = guild.name + del guilds[guild.guild_id] + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已移除 {name},公会 {old_name} 已无成员并被解散", None + if guild.owner == name or not any( + member.rank == GuildRank.OWNER for member in guild.members): + new_owner = guild.members[0] + new_owner.rank = GuildRank.OWNER + guild.owner = new_owner.name + guild.add_log(f"{_actor(actor)} 强制移出成员 {name}") + guild.add_audit_log("guild_force_leave", _actor(actor), target=name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已将 {name} 移出公会 {guild.name}", _guild_summary(guild) + + +def api_force_kick_member(self, + player_name: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Force api force kick member.""" + return api_force_leave_guild(self, player_name, actor) + + +def api_set_guild_frozen(self, + guild_query: str, + frozen: bool, + reason: str = "", + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Update api set guild frozen.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + settings = _ensure_settings(guild) + settings["frozen"] = bool(frozen) + settings["frozen_reason"] = str(reason or "") + settings["frozen_at"] = _now() if frozen else 0 + action = "冻结" if frozen else "解冻" + guild.add_log(f"{_actor(actor)} {action}了公会") + guild.add_audit_log("guild_freeze" if frozen else "guild_unfreeze", + _actor(actor), detail=str(reason or "")) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已{action}公会 {guild.name}", _guild_summary(guild) + + +def api_get_guild_vault( + self, guild_query: str) -> tuple[bool, str, Optional[list[dict[str, Any]]]]: + """Return api get guild vault.""" + guild, err = _find_guild(self, guild_query) + if guild is None: + return False, err, None + data = [_vault_item_summary(item, index) + for index, item in enumerate(guild.vault_items, start=1)] + return True, f"{guild.name} 仓库共有 {len(data)} 件上架物品", data + + +def api_backup_guild_vault(self, + guild_query: str, + label: str = "", + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Back up api backup guild vault.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + settings = _ensure_settings(guild) + backups = settings.setdefault("vault_backups", []) + if not isinstance(backups, list): + backups = [] + settings["vault_backups"] = backups + backup = { + "label": str(label or ""), + "actor": _actor(actor), + "created_at": _now(), + "items": [item.to_dict() for item in guild.vault_items], + } + backups.insert(0, backup) + settings["vault_backups"] = backups[:10] + guild.add_audit_log("vault_backup", _actor(actor), detail=str(label or "")) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已备份 { + guild.name} 仓库,当前保留 { + len( + settings['vault_backups'])} 份", backup + + +def api_clear_guild_vault(self, + guild_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Clear api clear guild vault.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + api_backup_guild_vault(self, guild.guild_id, "clear-before", actor) + guilds = _load_guilds(self) + guild = guilds[guild.guild_id] + removed = len(guild.vault_items) + guild.vault_items = [] + guild.add_log(f"{_actor(actor)} 清空了公会仓库") + guild.add_audit_log( + "vault_clear", + _actor(actor), + detail=f"removed={removed}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已清空 { + guild.name} 仓库,共删除 {removed} 件物品", _guild_summary(guild) + + +def api_delete_guild_vault_item(self, + guild_query: str, + index: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Delete api delete guild vault item.""" + ok, err, parsed = _to_int(index, "仓库序号", 1) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + zero_index = parsed - 1 + if zero_index >= len(guild.vault_items): + return False, f"仓库序号超出范围:{parsed}", None + item = guild.vault_items.pop(zero_index) + guild.add_vault_trade_log( + "admin_delete", + item, + _actor(actor), + detail="管理员删除") + guild.add_audit_log("vault_item_delete", _actor( + actor), target=item.seller, detail=item.item_id) + guild.add_log(f"{_actor(actor)} 删除了仓库物品 {item.item_id} x{item.count}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已删除 { + guild.name} 仓库第 {parsed} 件物品", _vault_item_summary( + item, parsed) + + +def api_rollback_guild_vault(self, + guild_query: str, + backup_index: int = 1, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Run api rollback guild vault.""" + ok, err, parsed = _to_int(backup_index, "备份序号", 1) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + backups = _ensure_settings(guild).get("vault_backups", []) + if not isinstance(backups, list) or parsed > len(backups): + return False, f"仓库备份不存在:{parsed}", None + selected_backup = copy.deepcopy(backups[parsed - 1]) + api_backup_guild_vault(self, guild.guild_id, "rollback-before", actor) + guilds = _load_guilds(self) + guild = guilds[guild.guild_id] + guild.vault_items = [ + VaultItem.from_dict(item) + for item in selected_backup.get("items", []) + if isinstance(item, dict) + ] + guild.add_log(f"{_actor(actor)} 回滚了公会仓库") + guild.add_audit_log("vault_rollback", _actor(actor), detail=str(parsed)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已回滚 {guild.name} 仓库到备份 {parsed}", { + "guild": _guild_summary(guild), + "backup": selected_backup, + } + + +def api_export_guild_vault( + self, guild_query: str) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Export api export guild vault.""" + guild, err = _find_guild(self, guild_query) + if guild is None: + return False, err, None + data = { + "guild": _guild_summary(guild), "vault_items": [ + _vault_item_summary( + item, index) for index, item in enumerate( + guild.vault_items, start=1)], "vault_trade_logs": [ + log.to_dict() for log in guild.vault_trade_logs], } + data["json"] = json.dumps(data, ensure_ascii=False, indent=2) + return True, f"已导出 {guild.name} 仓库数据", data + + +def _make_task_from_template( + template: dict[str, Any], prefix: str = "auto") -> GuildTask: + now = _now() + deadline_seconds = int(getattr(Config, "GUILD_TASK_CONFIG", + {}).get("自动任务默认有效期秒", 172800)) + return GuildTask( + task_id=f"{prefix} -{uuid.uuid4().hex[: 8]} ", + name=str(template.get("name", "公会任务"))[: 20], + description=str(template.get("description", ""))[: 100], + task_type=str(template.get("task_type", "trade")), + target=str(template.get("target", "trade_count")), + target_count=max(1, int(template.get("target_count", 1))), + current_count=max(0, int(template.get("current_count", 0))), + reward_exp=max(0, int(template.get("reward_exp", 0))), + reward_contribution=max( + 0, int(template.get("reward_contribution", 0))), + create_time=now, deadline=now + deadline_seconds + if deadline_seconds > 0 else 0,) + + +def api_refresh_guild_tasks( + self, + guild_query: str, + actor: str = "QQ管理", +) -> tuple[bool, str, list[dict[str, Any]]]: + """Run api refresh guild tasks.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, [] + templates = getattr(Config, "GUILD_TASK_CONFIG", {}).get("自动任务模板列表", []) + if not templates: + return False, "没有配置自动任务模板", [] + active_keys = {(task.name, task.task_type, task.target) + for task in guild.tasks if not task.completed} + created: list[GuildTask] = [] + for template in templates: + key = ( + template.get("name"), + template.get("task_type"), + template.get("target")) + if key in active_keys: + continue + task = _make_task_from_template(template) + guild.tasks.append(task) + created.append(task) + guild.add_log(f"{_actor(actor)} 刷新了公会任务,新增 {len(created)} 个") + guild.add_audit_log( + "task_refresh", + _actor(actor), + detail=str( + len(created))) + if created and not _save_guilds(self, guilds): + return False, "保存公会数据失败", [] + return True, f"已为 { + guild.name} 刷新任务,新增 { + len(created)} 个", [ + _task_summary(task) for task in created] + + +def api_create_global_task( + self, + name: str, + task_type: str, + target: str, + target_count: int, + reward_exp: int = 0, + reward_contribution: int = 0, + description: str = "", + deadline_seconds: int = 0, + actor: str = "QQ管理", +) -> tuple[bool, str, list[dict[str, Any]]]: + """Create api create global task.""" + task_name = str(name or "").strip() + if not task_name: + return False, "任务名称不能为空", [] + ok, err, count = _to_int(target_count, "目标数量", 1) + if not ok: + return False, err, [] + _, _, exp = _to_int(reward_exp, "经验奖励", 0) + _, _, contribution = _to_int(reward_contribution, "贡献奖励", 0) + _, _, seconds = _to_int(deadline_seconds, "截止秒数", 0) + guilds = _load_guilds(self) + now = _now() + created: list[dict[str, Any]] = [] + for guild in guilds.values(): + task = GuildTask( + task_id=f"global-{uuid.uuid4().hex[:8]}", + name=task_name[:20], + description=str(description or task_name)[:100], + task_type=str(task_type or "trade"), + target=str(target or "trade_count"), + target_count=count, + reward_exp=exp, + reward_contribution=contribution, + create_time=now, + deadline=now + seconds if seconds > 0 else 0, + ) + guild.tasks.append(task) + guild.add_log(f"{_actor(actor)} 创建了全服任务 {task.name}") + created.append({"guild_id": guild.guild_id, + "guild_name": guild.name, "task": _task_summary(task)}) + if created and not _save_guilds(self, guilds): + return False, "保存公会数据失败", [] + return True, f"已向 {len(created)} 个公会创建全服任务", created + + +def api_delete_guild_task(self, + guild_query: str, + task_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Delete api delete guild task.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + task, err = _find_task(guild, task_query) + if task is None: + return False, err, None + guild.tasks = [ + item for item in guild.tasks if item.task_id != task.task_id] + guild.add_log(f"{_actor(actor)} 删除了任务 {task.name}") + guild.add_audit_log("task_delete", _actor(actor), detail=task.name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已删除任务 {task.name}", _task_summary(task) + + +def api_reset_guild_task_progress(self, + guild_query: str, + task_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Reset api reset guild task progress.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + task, err = _find_task(guild, task_query) + if task is None: + return False, err, None + task.current_count = 0 + task.completed = False + task.participants = [] + guild.add_log(f"{_actor(actor)} 重置了任务 {task.name}") + guild.add_audit_log("task_reset", _actor(actor), detail=task.name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已重置任务 {task.name}", _task_summary(task) + + +def api_force_complete_guild_task(self, + guild_query: str, + task_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Force api force complete guild task.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + task, err = _find_task(guild, task_query) + if task is None: + return False, err, None + task.current_count = task.target_count + task.completed = True + guild.add_log(f"{_actor(actor)} 强制完成了任务 {task.name}") + guild.add_audit_log("task_force_complete", _actor(actor), detail=task.name) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已强制完成任务 {task.name}", _task_summary(task) + + +def api_teleport_player_to_guild_base( + self, player_name: str, guild_query: str | None = None) -> tuple[bool, str]: + """Run api teleport player to guild base.""" + name = str(player_name or "").strip() + if not name: + return False, "玩家名不能为空" + guilds = _load_guilds(self) + if guild_query: + guild, err = _find_guild(self, guild_query, guilds) + else: + guild, err = _find_player_guild(self, name, guilds) + if guild is None: + return False, err + if _ensure_settings(guild).get("base_locked", False): + return False, f"公会 {guild.name} 据点已锁定" + if not guild.base: + return False, f"公会 {guild.name} 尚未设置据点" + base = guild.base + self.game_ctrl.sendwocmd( + f"tp {name} {float(base.x)} {float(base.y)} {float(base.z)}") + return True, f"已传送 {name} 到公会 {guild.name} 据点" + + +def api_delete_guild_base(self, + guild_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Delete api delete guild base.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + guild.base = None + guild.add_log(f"{_actor(actor)} 删除了公会据点") + guild.add_audit_log("base_delete", _actor(actor)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已删除 {guild.name} 据点", _guild_summary(guild) + + +def api_set_guild_base(self, + guild_query: str, + dimension: int, + x: float, + y: float, + z: float, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Update api set guild base.""" + ok, err, dim = _to_int(dimension, "维度") + if not ok: + return False, err, None + coord_values = [] + for field_name, value in (("x", x), ("y", y), ("z", z)): + ok, err, parsed = _to_float(value, field_name) + if not ok: + return False, err, None + coord_values.append(parsed) + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + guild.base = GuildBase( + dim, + coord_values[0], + coord_values[1], + coord_values[2]) + guild.add_log(f"{_actor(actor)} 修改了公会据点") + guild.add_audit_log("base_set", _actor( + actor), detail=f"{dim},{coord_values}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已设置 {guild.name} 据点", _guild_summary(guild) + + +def api_set_guild_base_locked(self, + guild_query: str, + locked: bool, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Update api set guild base locked.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + _ensure_settings(guild)["base_locked"] = bool(locked) + action = "锁定" if locked else "解锁" + guild.add_log(f"{_actor(actor)} {action}了公会据点") + guild.add_audit_log( + "base_lock" if locked else "base_unlock", + _actor(actor)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已{action} {guild.name} 据点", _guild_summary(guild) + + +def api_clear_guild_effects(self, + guild_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Clear api clear guild effects.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + guild.purchased_effects = {} + guild.add_log(f"{_actor(actor)} 清空了公会效果") + guild.add_audit_log("effect_clear", _actor(actor)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已清空 {guild.name} 效果", _guild_summary(guild) + + +def api_set_guild_effect(self, + guild_query: str, + effect_key: str, + level: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Update api set guild effect.""" + key = str(effect_key or "").strip() + if key not in Config.EFFECTS_CONFIG: + names = { + str(value.get("name", "")): effect_id + for effect_id, value in Config.EFFECTS_CONFIG.items() + if isinstance(value, dict) + } + key = names.get(key, key) + if key not in Config.EFFECTS_CONFIG: + return False, f"效果不存在:{effect_key}", None + ok, err, parsed_level = _to_int(level, "效果等级", 0) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + if parsed_level <= 0: + guild.purchased_effects.pop(key, None) + else: + guild.purchased_effects[key] = parsed_level + guild.add_log(f"{_actor(actor)} 设置效果 {key} 为 {parsed_level} 级") + guild.add_audit_log( + "effect_set", + _actor(actor), + detail=f"{key}={parsed_level}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已设置 { + guild.name} 效果 {key} 为 {parsed_level} 级", _guild_summary(guild) + + +def api_add_guild_funds(self, + guild_query: str, + amount: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Add api add guild funds.""" + ok, err, parsed = _to_int(amount, "资金数量") + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + settings = _ensure_settings(guild) + settings["funds"] = int(settings.get("funds", 0) or 0) + parsed + guild.add_log(f"{_actor(actor)} 调整公会资金 {parsed:+d}") + guild.add_audit_log("funds_add", _actor(actor), detail=str(parsed)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已调整 { + guild.name} 资金,当前 { + settings['funds']}", _guild_summary(guild) + + +def api_set_guild_funds(self, + guild_query: str, + amount: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Update api set guild funds.""" + ok, err, parsed = _to_int(amount, "资金余额", 0) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + _ensure_settings(guild)["funds"] = parsed + guild.add_log(f"{_actor(actor)} 设置公会资金为 {parsed}") + guild.add_audit_log("funds_set", _actor(actor), detail=str(parsed)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已设置 {guild.name} 资金为 {parsed}", _guild_summary(guild) + + +def api_add_member_contribution(self, + player_name: str, + amount: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Add api add member contribution.""" + ok, err, parsed = _to_int(amount, "贡献值") + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_player_guild(self, player_name, guilds) + if guild is None: + return False, err, None + member = guild.get_member(str(player_name).strip()) + if member is None: + return False, "成员数据异常", None + member.contribution += parsed + guild.stats.total_contribution += max(0, parsed) + guild.add_log(f"{_actor(actor)} 调整 {member.name} 贡献 {parsed:+d}") + guild.add_audit_log("member_contribution_add", _actor(actor), + target=member.name, detail=str(parsed)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已调整 { + member.name} 贡献,当前 { + member.contribution}", _member_summary(member) + + +def api_set_member_contribution(self, + player_name: str, + amount: int, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Update api set member contribution.""" + ok, err, parsed = _to_int(amount, "贡献值", 0) + if not ok: + return False, err, None + guilds = _load_guilds(self) + guild, err = _find_player_guild(self, player_name, guilds) + if guild is None: + return False, err, None + member = guild.get_member(str(player_name).strip()) + if member is None: + return False, "成员数据异常", None + old = member.contribution + member.contribution = parsed + guild.add_log(f"{_actor(actor)} 将 {member.name} 贡献从 {old} 设置为 {parsed}") + guild.add_audit_log("member_contribution_set", _actor( + actor), target=member.name, detail=f"{old}->{parsed}") + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已设置 {member.name} 贡献为 {parsed}", _member_summary(member) + + +def api_reset_guild_contributions(self, + guild_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Reset api reset guild contributions.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + for member in guild.members: + member.contribution = 0 + guild.stats.total_contribution = 0 + guild.add_log(f"{_actor(actor)} 重置了所有成员贡献") + guild.add_audit_log("member_contribution_reset_all", _actor(actor)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已重置 {guild.name} 所有成员贡献", _guild_summary(guild) + + +def api_reset_market_prices(self, + guild_query: str, + actor: str = "QQ管理") -> tuple[bool, + str, + Optional[dict[str, + Any]]]: + """Reset api reset market prices.""" + guilds = _load_guilds(self) + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, None + removed = len(guild.custom_item_values) + guild.custom_item_values = {} + guild.add_log(f"{_actor(actor)} 重置了市场价格") + guild.add_audit_log( + "market_price_reset", + _actor(actor), + detail=str(removed)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", None + return True, f"已重置 { + guild.name} 市场价格,删除 {removed} 条自定义价格", _guild_summary(guild) + + +def api_get_guild_logs(self, guild_query: str, + limit: int = 20) -> tuple[bool, str, Optional[dict[str, Any]]]: + """Return api get guild logs.""" + ok, _err, parsed_limit = _to_int(limit, "日志数量", 1) + if not ok: + parsed_limit = 20 + guild, err = _find_guild(self, guild_query) + if guild is None: + return False, err, None + return True, "查询成功", { + "logs": guild.logs[-parsed_limit:], + "audit_logs": [log.to_dict() for log in guild.audit_logs[-parsed_limit:]], + "vault_trade_logs": [ + log.to_dict() for log in guild.vault_trade_logs[-parsed_limit:] + ], + } + + +def api_get_abnormal_trades(self, + guild_query: str | None = None, + ratio: float = 3.0) -> tuple[bool, + str, + list[dict[str, + Any]]]: + """Return api get abnormal trades.""" + try: + threshold = float(ratio) + except (TypeError, ValueError): + threshold = 3.0 + guilds = _load_guilds(self) + target_guilds: list[GuildData] + if guild_query: + guild, err = _find_guild(self, guild_query, guilds) + if guild is None: + return False, err, [] + target_guilds = [guild] + else: + target_guilds = list(guilds.values()) + results = [] + for guild in target_guilds: + for log in guild.vault_trade_logs: + suggested = guild.get_item_value( + log.item_id) * max(1, int(log.count or 1)) + if suggested > 0 and log.price >= suggested * threshold: + data = log.to_dict() + data["guild_id"] = guild.guild_id + data["guild_name"] = guild.name + data["suggested_price"] = suggested + data["ratio"] = round(log.price / suggested, 2) + results.append(data) + results.sort(key=lambda item: item.get("timestamp", 0), reverse=True) + return True, f"共 {len(results)} 条异常交易记录", results + + +def api_get_donation_rankings(self, + guild_query: str | None = None, + limit: int = 10) -> tuple[bool, + str, + list[dict[str, + Any]]]: + """Return api get donation rankings.""" + ok, _err, parsed_limit = _to_int(limit, "排行数量", 1) + if not ok: + parsed_limit = 10 + guilds = _load_guilds(self) + records = [] + for guild in guilds.values(): + if guild_query and guild.name != guild_query and guild.guild_id != guild_query: + continue + for member in guild.members: + records.append({ + "guild_id": guild.guild_id, + "guild_name": guild.name, + "player_name": member.name, + "rank": member.rank.value, + "contribution": member.contribution, + }) + records.sort(key=lambda item: int(item["contribution"]), reverse=True) + return True, f"贡献排行前 {min(parsed_limit, len(records))} 名", records[:parsed_limit] + + +def api_get_guild_rankings(self, sort_by: str = "level", + limit: int = 10) -> tuple[bool, str, list[dict[str, Any]]]: + """返回适合外部插件消费的公会排行榜数据。""" + raw_sort_by = str(sort_by or "level").strip() + sort_key = { + "等级": "level", + "level": "level", + "成员": "members", + "members": "members", + "贡献": "contribution", + "contribution": "contribution", + "活跃": "activity", + "activity": "activity", + }.get(raw_sort_by, raw_sort_by) + ok, _err, parsed_limit = _to_int(limit, "排行数量", 1) + if not ok: + parsed_limit = 10 + rankings = _get_guild_rankings(self, sort_key)[:parsed_limit] + data: list[dict[str, Any]] = [] + for index, (guild, score) in enumerate(rankings, start=1): + item = _guild_summary(guild) + item["rank"] = index + item["score"] = score + item["sort_by"] = sort_key + data.append(item) + return True, f"公会排行前 {len(data)} 名", data + + +def api_reload_guild_config(self) -> tuple[bool, str, dict[str, Any]]: + """Reload api reload guild config.""" + self.config = Config.load(self.name, self.version) + return True, "公会系统配置已重新加载", copy.deepcopy(self.config) + + +def api_save_guild_data(self) -> tuple[bool, str]: + """Save api save guild data.""" + guilds = _load_guilds(self) + if not _save_guilds(self, guilds, force=True): + return False, "保存公会数据失败" + return True, "公会数据已强制保存" + + +def api_backup_guild_data(self) -> tuple[bool, str, Optional[str]]: + """Back up api backup guild data.""" + if not os.path.exists(self.guilds_file): + return False, "公会数据文件不存在", None + data_dir = os.path.dirname(self.guilds_file) + backup_dir = os.path.join(data_dir, "公会数据备份") + os.makedirs(backup_dir, exist_ok=True) + backup_path = os.path.join( + backup_dir, f"公会数据文件-api-{time.strftime('%Y%m%d-%H%M%S')}.json") + shutil.copy2(self.guilds_file, backup_path) + return True, "公会数据备份已创建", backup_path + + +def api_repair_guild_data( + self, actor: str = "QQ管理") -> tuple[bool, str, dict[str, Any]]: + """Repair api repair guild data.""" + guilds = _load_guilds(self) + fixed = {"guild_id": 0, "level": 0, "exp": 0, + "owner": 0, "vault": 0, "removed_empty": 0} + for outer_id in list(guilds.keys()): + guild = guilds[outer_id] + if not guild.members: + del guilds[outer_id] + fixed["removed_empty"] += 1 + continue + if guild.guild_id != outer_id: + guild.guild_id = outer_id + fixed["guild_id"] += 1 + if not isinstance(guild.level, int) or guild.level < 1: + guild.level = 1 + fixed["level"] += 1 + if not isinstance(guild.exp, (int, float)) or guild.exp < 0: + guild.exp = 0 + fixed["exp"] += 1 + owners = [member for member in guild.members + if member.rank == GuildRank.OWNER] + if len(owners) != 1: + preferred = guild.get_member(guild.owner) or guild.members[0] + for member in guild.members: + member.rank = GuildRank.MEMBER + preferred.rank = GuildRank.OWNER + guild.owner = preferred.name + fixed["owner"] += 1 + if len(guild.vault_items) > Config.VAULT_INITIAL_SLOTS: + guild.vault_items = guild.vault_items[:Config.VAULT_INITIAL_SLOTS] + fixed["vault"] += 1 + if not isinstance(guild.stats, GuildStats): + guild.stats = GuildStats() + guild.add_audit_log("data_repair", _actor( + actor), detail=json.dumps(fixed, ensure_ascii=False)) + if not _save_guilds(self, guilds): + return False, "保存公会数据失败", fixed + return True, "公会数据修复完成", fixed + + +def api_get_guild_statistics(self) -> tuple[bool, str, dict[str, Any]]: + """Return api get guild statistics.""" + guilds = _load_guilds(self) + total_members = sum(len(guild.members) for guild in guilds.values()) + total_vault_items = sum(len(guild.vault_items) + for guild in guilds.values()) + total_tasks = sum(len(guild.tasks) for guild in guilds.values()) + active_tasks = sum( + len([task for task in guild.tasks if not task.completed]) + for guild in guilds.values()) + frozen_count = sum(1 for guild in guilds.values() + if _ensure_settings(guild).get("frozen", False)) + data = { + "guild_count": len(guilds), + "member_count": total_members, + "vault_item_count": total_vault_items, + "task_count": total_tasks, + "active_task_count": active_tasks, + "frozen_guild_count": frozen_count, + "activity_status": api_get_guild_activity_status(self)[2], + } + return True, "查询成功", data + + +def api_start_guild_activity( + self, + activity: str, + duration_seconds: int = 3600, + multiplier: float = 2.0, + actor: str = "QQ管理", +) -> tuple[bool, str, dict[str, Any]]: + """Start api start guild activity.""" + activity_key = str(activity or "").strip().lower() + aliases = { + "双倍经验": "exp", + "经验": "exp", + "exp": "exp", + "双倍贡献": "contribution", + "贡献": "contribution", + "contribution": "contribution", + "公会争霸": "contest", + "争霸": "contest", + "contest": "contest", + } + activity_key = aliases.get(activity_key, activity_key) + if activity_key not in ("exp", "contribution", "contest"): + return False, f"未知活动类型:{activity}", {} + ok, err, seconds = _to_int(duration_seconds, "活动时长秒", 1) + if not ok: + return False, err, {} + try: + parsed_multiplier = max(1.0, float(multiplier)) + except (TypeError, ValueError): + parsed_multiplier = 2.0 + if not hasattr(self, "_guild_runtime_events"): + self._guild_runtime_events = {} + event = { + "activity": activity_key, + "multiplier": parsed_multiplier, + "started_at": _now(), + "expires_at": _now() + seconds, + "actor": _actor(actor), + } + self._guild_runtime_events[activity_key] = event + return ( + True, + f"已开启 {activity_key} 活动 {seconds} 秒,倍率 {parsed_multiplier}", + copy.deepcopy(event), + ) + + +def api_stop_guild_activity(self, activity: str) -> tuple[bool, str]: + """Stop api stop guild activity.""" + activity_key = str(activity or "").strip().lower() + aliases = {"经验": "exp", "双倍经验": "exp", "贡献": "contribution", + "双倍贡献": "contribution", "争霸": "contest", "公会争霸": "contest"} + activity_key = aliases.get(activity_key, activity_key) + removed = getattr( + self, + "_guild_runtime_events", + {}).pop( + activity_key, + None) + if removed is None: + return False, f"活动未开启:{activity}" + return True, f"已停止活动 {activity_key}" + + +def api_get_guild_activity_status(self) -> tuple[bool, str, dict[str, Any]]: + """Return api get guild activity status.""" + events = getattr(self, "_guild_runtime_events", {}) + now = _now() + active = {} + for key, event in list(events.items()): + expires_at = float(event.get("expires_at", 0) or 0) if 0 < expires_at <= now: events.pop(key, None) continue - active[key] = copy.deepcopy(event) - active[key]["remaining_seconds"] = max( - 0, int(expires_at - now)) if expires_at > 0 else 0 - return True, f"当前 {len(active)} 个活动运行中", active - - -def api_settle_guild_ranking_rewards( - self, - sort_by: str = "level", - top: int = 3, - reward_exp: int = 0, - reward_funds: int = 0, - actor: str = "QQ管理", -) -> tuple[bool, str, list[dict[str, Any]]]: - ok, err, parsed_top = _to_int(top, "排行数量", 1) - if not ok: - return False, err, [] - _, _, exp = _to_int(reward_exp, "经验奖励", 0) - _, _, funds = _to_int(reward_funds, "资金奖励", 0) - guilds = _load_guilds(self) - ranking = _get_guild_rankings(self, sort_by) - rewarded = [] - for guild, score in ranking[:parsed_top]: - latest = guilds.get(guild.guild_id) - if latest is None: - continue - latest.exp += exp - _apply_level_ups(latest) - settings = _ensure_settings(latest) - settings["funds"] = int(settings.get("funds", 0) or 0) + funds - latest.add_log(f"{_actor(actor)} 发放排行榜奖励:经验 {exp},资金 {funds}") - latest.add_audit_log("ranking_reward", _actor(actor), - detail=f"{sort_by}:{score}") - rewarded.append({"guild_id": latest.guild_id, - "guild_name": latest.name, "score": score}) - if rewarded and not _save_guilds(self, guilds): - return False, "保存公会数据失败", [] - return True, f"已结算 {len(rewarded)} 个公会的排行榜奖励", rewarded - - -def api_broadcast_guild_announcement( - self, message: str, actor: str = "QQ管理") -> tuple[bool, str]: - text = str(message or "").strip() - if not text: - return False, "公告内容不能为空" - payload = json.dumps( - {"rawtext": [{"text": f"§l§a公会公告 §d>> §r{text}"}]}, ensure_ascii=False) - self.game_ctrl.sendcmd(f"/tellraw @a {payload}") - fmts.print_inf(f"{_actor(actor)} 发布公会全服公告:{text}") - return True, "全服公告已发送" - - -def _get_guild_rankings( - self, sort_by: str = "level") -> list[tuple[GuildData, Any]]: - return self.get_guild_rankings(sort_by) - - -guild_api_functions = { - "guild_get_activity_multiplier": guild_get_activity_multiplier, - "guild_apply_reward_multipliers": guild_apply_reward_multipliers, - "guild_is_frozen": guild_is_frozen, - "guild_frozen_message": guild_frozen_message, - "show_guild_frozen": show_guild_frozen, - "api_list_guilds": api_list_guilds, - "api_get_guild": api_get_guild, - "api_get_player_record": api_get_player_record, - "api_get_player_guild_menu_state": api_get_player_guild_menu_state, - "api_get_own_guild_logs": api_get_own_guild_logs, - "api_get_own_guild_vault": api_get_own_guild_vault, - "api_get_own_guild_tasks": api_get_own_guild_tasks, - "api_request_join_guild_as_player": api_request_join_guild_as_player, - "api_leave_guild_as_player": api_leave_guild_as_player, - "api_disband_owned_guild_as_player": api_disband_owned_guild_as_player, - "api_set_announcement_as_player": api_set_announcement_as_player, - "api_join_guild_task_as_player": api_join_guild_task_as_player, - "api_return_to_guild_base_as_player": api_return_to_guild_base_as_player, - "api_force_disband_guild": api_force_disband_guild, - "api_rename_guild": api_rename_guild, - "api_set_guild_level": api_set_guild_level, - "api_set_guild_exp": api_set_guild_exp, - "api_transfer_guild_owner": api_transfer_guild_owner, - "api_force_join_guild": api_force_join_guild, - "api_force_leave_guild": api_force_leave_guild, - "api_force_kick_member": api_force_kick_member, - "api_set_guild_frozen": api_set_guild_frozen, - "api_get_guild_vault": api_get_guild_vault, - "api_backup_guild_vault": api_backup_guild_vault, - "api_clear_guild_vault": api_clear_guild_vault, - "api_delete_guild_vault_item": api_delete_guild_vault_item, - "api_rollback_guild_vault": api_rollback_guild_vault, - "api_export_guild_vault": api_export_guild_vault, - "api_refresh_guild_tasks": api_refresh_guild_tasks, - "api_create_global_task": api_create_global_task, - "api_delete_guild_task": api_delete_guild_task, - "api_reset_guild_task_progress": api_reset_guild_task_progress, - "api_force_complete_guild_task": api_force_complete_guild_task, - "api_teleport_player_to_guild_base": api_teleport_player_to_guild_base, - "api_delete_guild_base": api_delete_guild_base, - "api_set_guild_base": api_set_guild_base, - "api_set_guild_base_locked": api_set_guild_base_locked, - "api_clear_guild_effects": api_clear_guild_effects, - "api_set_guild_effect": api_set_guild_effect, - "api_add_guild_funds": api_add_guild_funds, - "api_set_guild_funds": api_set_guild_funds, - "api_add_member_contribution": api_add_member_contribution, - "api_set_member_contribution": api_set_member_contribution, - "api_reset_guild_contributions": api_reset_guild_contributions, - "api_reset_market_prices": api_reset_market_prices, - "api_get_guild_logs": api_get_guild_logs, - "api_get_abnormal_trades": api_get_abnormal_trades, - "api_get_donation_rankings": api_get_donation_rankings, - "api_get_guild_rankings": api_get_guild_rankings, - "api_reload_guild_config": api_reload_guild_config, - "api_save_guild_data": api_save_guild_data, - "api_backup_guild_data": api_backup_guild_data, - "api_repair_guild_data": api_repair_guild_data, - "api_get_guild_statistics": api_get_guild_statistics, - "api_start_guild_activity": api_start_guild_activity, - "api_stop_guild_activity": api_stop_guild_activity, - "api_get_guild_activity_status": api_get_guild_activity_status, - "api_settle_guild_ranking_rewards": api_settle_guild_ranking_rewards, - "api_broadcast_guild_announcement": api_broadcast_guild_announcement, -} + active[key] = copy.deepcopy(event) + active[key]["remaining_seconds"] = max( + 0, int(expires_at - now)) if expires_at > 0 else 0 + return True, f"当前 {len(active)} 个活动运行中", active + + +def api_settle_guild_ranking_rewards( + self, + sort_by: str = "level", + top: int = 3, + reward_exp: int = 0, + reward_funds: int = 0, + actor: str = "QQ管理", +) -> tuple[bool, str, list[dict[str, Any]]]: + """Update api settle guild ranking rewards.""" + ok, err, parsed_top = _to_int(top, "排行数量", 1) + if not ok: + return False, err, [] + _, _, exp = _to_int(reward_exp, "经验奖励", 0) + _, _, funds = _to_int(reward_funds, "资金奖励", 0) + guilds = _load_guilds(self) + ranking = _get_guild_rankings(self, sort_by) + rewarded = [] + for guild, score in ranking[:parsed_top]: + latest = guilds.get(guild.guild_id) + if latest is None: + continue + latest.exp += exp + _apply_level_ups(latest) + settings = _ensure_settings(latest) + settings["funds"] = int(settings.get("funds", 0) or 0) + funds + latest.add_log(f"{_actor(actor)} 发放排行榜奖励:经验 {exp},资金 {funds}") + latest.add_audit_log("ranking_reward", _actor(actor), + detail=f"{sort_by}:{score}") + rewarded.append({"guild_id": latest.guild_id, + "guild_name": latest.name, "score": score}) + if rewarded and not _save_guilds(self, guilds): + return False, "保存公会数据失败", [] + return True, f"已结算 {len(rewarded)} 个公会的排行榜奖励", rewarded + + +def api_broadcast_guild_announcement( + self, message: str, actor: str = "QQ管理") -> tuple[bool, str]: + """Broadcast api broadcast guild announcement.""" + text = str(message or "").strip() + if not text: + return False, "公告内容不能为空" + payload = json.dumps( + {"rawtext": [{"text": f"§l§a公会公告 §d>> §r{text}"}]}, ensure_ascii=False) + self.game_ctrl.sendcmd(f"/tellraw @a {payload}") + fmts.print_inf(f"{_actor(actor)} 发布公会全服公告:{text}") + return True, "全服公告已发送" + + +def _get_guild_rankings( + self, sort_by: str = "level") -> list[tuple[GuildData, Any]]: + return self.get_guild_rankings(sort_by) + + +guild_api_functions = { + "guild_get_activity_multiplier": guild_get_activity_multiplier, + "guild_apply_reward_multipliers": guild_apply_reward_multipliers, + "guild_is_frozen": guild_is_frozen, + "guild_frozen_message": guild_frozen_message, + "show_guild_frozen": show_guild_frozen, + "api_list_guilds": api_list_guilds, + "api_get_guild": api_get_guild, + "api_get_player_record": api_get_player_record, + "api_get_player_guild_menu_state": api_get_player_guild_menu_state, + "api_get_own_guild_logs": api_get_own_guild_logs, + "api_get_own_guild_vault": api_get_own_guild_vault, + "api_get_own_guild_tasks": api_get_own_guild_tasks, + "api_request_join_guild_as_player": api_request_join_guild_as_player, + "api_leave_guild_as_player": api_leave_guild_as_player, + "api_disband_owned_guild_as_player": api_disband_owned_guild_as_player, + "api_set_announcement_as_player": api_set_announcement_as_player, + "api_join_guild_task_as_player": api_join_guild_task_as_player, + "api_return_to_guild_base_as_player": api_return_to_guild_base_as_player, + "api_force_disband_guild": api_force_disband_guild, + "api_rename_guild": api_rename_guild, + "api_set_guild_level": api_set_guild_level, + "api_set_guild_exp": api_set_guild_exp, + "api_transfer_guild_owner": api_transfer_guild_owner, + "api_force_join_guild": api_force_join_guild, + "api_force_leave_guild": api_force_leave_guild, + "api_force_kick_member": api_force_kick_member, + "api_set_guild_frozen": api_set_guild_frozen, + "api_get_guild_vault": api_get_guild_vault, + "api_backup_guild_vault": api_backup_guild_vault, + "api_clear_guild_vault": api_clear_guild_vault, + "api_delete_guild_vault_item": api_delete_guild_vault_item, + "api_rollback_guild_vault": api_rollback_guild_vault, + "api_export_guild_vault": api_export_guild_vault, + "api_refresh_guild_tasks": api_refresh_guild_tasks, + "api_create_global_task": api_create_global_task, + "api_delete_guild_task": api_delete_guild_task, + "api_reset_guild_task_progress": api_reset_guild_task_progress, + "api_force_complete_guild_task": api_force_complete_guild_task, + "api_teleport_player_to_guild_base": api_teleport_player_to_guild_base, + "api_delete_guild_base": api_delete_guild_base, + "api_set_guild_base": api_set_guild_base, + "api_set_guild_base_locked": api_set_guild_base_locked, + "api_clear_guild_effects": api_clear_guild_effects, + "api_set_guild_effect": api_set_guild_effect, + "api_add_guild_funds": api_add_guild_funds, + "api_set_guild_funds": api_set_guild_funds, + "api_add_member_contribution": api_add_member_contribution, + "api_set_member_contribution": api_set_member_contribution, + "api_reset_guild_contributions": api_reset_guild_contributions, + "api_reset_market_prices": api_reset_market_prices, + "api_get_guild_logs": api_get_guild_logs, + "api_get_abnormal_trades": api_get_abnormal_trades, + "api_get_donation_rankings": api_get_donation_rankings, + "api_get_guild_rankings": api_get_guild_rankings, + "api_reload_guild_config": api_reload_guild_config, + "api_save_guild_data": api_save_guild_data, + "api_backup_guild_data": api_backup_guild_data, + "api_repair_guild_data": api_repair_guild_data, + "api_get_guild_statistics": api_get_guild_statistics, + "api_start_guild_activity": api_start_guild_activity, + "api_stop_guild_activity": api_stop_guild_activity, + "api_get_guild_activity_status": api_get_guild_activity_status, + "api_settle_guild_ranking_rewards": api_settle_guild_ranking_rewards, + "api_broadcast_guild_announcement": api_broadcast_guild_announcement, +} diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" index 9e22b669..81df85b1 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/config.py" @@ -371,7 +371,10 @@ "创建公会取消提示词": "§c❀ §r已取消创建公会", "创建公会输入名称提示词": "§a❀ §r请输入公会名字:\n§a❀ §r要求: 2-20个字符,不能包含特殊符号", "创建公会名称无效提示词": "§c❀ §r{error}", - "创建公会二次余额不足提示词": "§c❀ §r当前 §b{scoreboard}§r 余额不足,需要 §e{consume}§r,当前 §f{balance}", + "创建公会二次余额不足提示词": ( + "§c❀ §r当前 §b{scoreboard}§r 余额不足,需要 " + "§e{consume}§r,当前 §f{balance}" + ), "创建公会成功提示词": "§a❀ §r已创建公会 §e{guild}", "创建公会全服公告提示词": "§a❀ §r§e{player}§r 创建了公会 §e{guild}§r!", "创建公会名称已存在提示词": "§c❀ §r该公会名已存在", @@ -1012,7 +1015,10 @@ def _normalize_effects_config( effect_cfg = {} default = fallback.get(str(effect_id), default_effect) result[str(effect_id)] = { - "name": _normalize_str(effect_cfg.get("name"), default.get("name", str(effect_id))), + "name": _normalize_str( + effect_cfg.get("name"), + default.get("name", str(effect_id)), + ), "levels": _normalize_any_key_dict( effect_cfg.get("levels"), default.get("levels", {}), @@ -1091,7 +1097,9 @@ def _normalize_task_templates( return result or copy.deepcopy(fallback) -def _normalize_grouped_config(raw: dict[str, Any]) -> dict[str, Any]: +def _normalize_grouped_config( # skipcq: PY-R1000 + raw: dict[str, Any], +) -> dict[str, Any]: """Normalize grouped config values.""" default = DEFAULT_CONFIG config = _merge_config(raw, default) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" index dbba8c1f..23733357 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/control.py" @@ -1,55 +1,55 @@ -"""Guild data persistence and cache management.""" - -import os -import shutil -import time -from typing import Dict, List, Optional, Tuple, Any, Set - - -from tooldelta import fmts -from tooldelta.utils import tempjson -from guild_cloud_interop.config import Config -from guild_cloud_interop.models import GuildData, GuildMember, GuildRank -from guild_cloud_interop.service import DataTransaction - -# FIRE 公会管理器 FIRE - - -class GuildManager: - """公会管理器,负责公会数据的增删改查""" - - def __init__(self, file_path: str): - self.file_path = file_path - self._cache: Optional[Dict[str, GuildData]] = None - self._last_load_time = 0 - self.cache_duration = Config.CACHE_DURATION - self._player_guild_cache: Dict[str, str] = {} - self._dirty_guilds: Set[str] = set() # 标记需要保存的公会 - self._last_save_time = 0 - self._batch_operations: List[callable] = [] # 批量操作队列 - - def validate_guild_data( - self, guild_data: GuildData) -> Tuple[bool, List[str]]: - """验证公会数据的完整性""" - errors = [] - - # 检查基本字段 - if not guild_data.name: - errors.append("公会名称为空 Err:event.check.data.guild_name") - - if not guild_data.guild_id: - errors.append("公会ID为空 Err:event.check.data.guild_id") - - if not guild_data.members: - errors.append("公会没有成员 Msg:event.check.data.member_length_LMIN") - - # 检查会长 - owners = [m for m in guild_data.members if m.rank == GuildRank.OWNER] - if len(owners) != 1: - errors.append( - f"公会应该有且仅有一个会长,当前有{ - len(owners)}个 Err:event.check.data.owner_length_LMAX") - +"""Guild data persistence and cache management.""" + +import os +import shutil +import time +from typing import Dict, List, Optional, Tuple, Any, Set + + +from tooldelta import fmts +from tooldelta.utils import tempjson +from guild_cloud_interop.config import Config +from guild_cloud_interop.models import GuildData, GuildMember, GuildRank +from guild_cloud_interop.service import DataTransaction + +# FIRE 公会管理器 FIRE + + +class GuildManager: + """公会管理器,负责公会数据的增删改查""" + + def __init__(self, file_path: str): + self.file_path = file_path + self._cache: Optional[Dict[str, GuildData]] = None + self._last_load_time = 0 + self.cache_duration = Config.CACHE_DURATION + self._player_guild_cache: Dict[str, str] = {} + self._dirty_guilds: Set[str] = set() # 标记需要保存的公会 + self._last_save_time = 0 + self._batch_operations: List[callable] = [] # 批量操作队列 + + def validate_guild_data( + self, guild_data: GuildData) -> Tuple[bool, List[str]]: + """验证公会数据的完整性""" + errors = [] + + # 检查基本字段 + if not guild_data.name: + errors.append("公会名称为空 Err:event.check.data.guild_name") + + if not guild_data.guild_id: + errors.append("公会ID为空 Err:event.check.data.guild_id") + + if not guild_data.members: + errors.append("公会没有成员 Msg:event.check.data.member_length_LMIN") + + # 检查会长 + owners = [m for m in guild_data.members if m.rank == GuildRank.OWNER] + if len(owners) != 1: + errors.append( + f"公会应该有且仅有一个会长,当前有{ + len(owners)}个 Err:event.check.data.owner_length_LMAX") + # 检查成员数量 if len(guild_data.members) > Config.MAX_GUILD_MEMBERS: member_count = len(guild_data.members) @@ -65,361 +65,364 @@ def validate_guild_data( f"仓库物品超过容量:{vault_count}/{Config.VAULT_INITIAL_SLOTS} " "Err:event.check.data.vault_items_LMAX" ) - - # 检查数据类型 - if not isinstance(guild_data.exp, (int, float)) or guild_data.exp < 0: - errors.append("公会经验值无效 Err:event.check.data.exp_type_or_negative") - - if not isinstance(guild_data.level, int) or guild_data.level < 1: - errors.append("公会等级无效 Err:event.check.data.level_type_or_range") - - return len(errors) == 0, errors - - def create_transaction(self): - """创建数据事务""" - return DataTransaction(self) - - def _load_guilds(self, force_reload: bool = False) -> Dict[str, GuildData]: - """加载公会数据,带缓存""" - current_time = time.time() - if (not force_reload and self._cache is not None and - current_time - self._last_load_time < self.cache_duration): - return self._cache - - raw_data = tempjson.load_and_read( - self.file_path, need_file_exists=False, default={}) - self._cache = {} - self._player_guild_cache.clear() - - load_errors = [] - for guild_id, guild_dict in raw_data.items(): - try: - guild = GuildData.from_dict(guild_dict, outer_key=guild_id) - - # 验证数据完整性 - is_valid, errors = self.validate_guild_data(guild) - if not is_valid: - load_errors.extend( - [f"公会{guild_id}: {error}" for error in errors]) - continue - - self._cache[guild_id] = guild - # 更新玩家-公会缓存 - for member in guild.members: - self._player_guild_cache[member.name] = guild_id - except Exception as e: - load_errors.append(f"加载公会 {guild_id} 时出错: {e}") - continue - - # 记录加载错误 - if load_errors: - fmts.print_err(f"加载公会数据时发现 {len(load_errors)} 个错误") - for error in load_errors[:3]: # 只显示前3个错误 - fmts.print_err(f" - {error}") - if len(load_errors) > 3: - fmts.print_err(f" ... 还有 {len(load_errors) - 3} 个错误") - - self._last_load_time = current_time - return self._cache - - def load_guilds(self, force_reload: bool = False) -> Dict[str, GuildData]: - """Return guild data through the manager cache boundary.""" - return self._load_guilds(force_reload=force_reload) - - def rebuild_player_cache(self, guilds: Dict[str, GuildData]) -> None: - """Rebuild the player-to-guild lookup from the provided guild map.""" - self._player_guild_cache.clear() - for guild_id, guild in guilds.items(): - for member in guild.members: - self._player_guild_cache[member.name] = guild_id - - def cache_player_guild(self, player_name: str, guild_id: str) -> None: - """Record one player's guild id in the runtime cache.""" - self._player_guild_cache[player_name] = guild_id - - def get_cached_guild_id(self, player_name: str) -> Optional[str]: - """Return the cached guild id for a player, if present.""" - return self._player_guild_cache.get(player_name) - - def reset_runtime_state(self) -> None: - """Clear in-memory guild caches and pending save state.""" - self._cache = None - self._player_guild_cache.clear() - self._dirty_guilds.clear() - self._last_load_time = 0 - self._last_save_time = 0 - - def save_guilds(self, - guilds: Dict[str, - GuildData], - force: bool = False) -> bool: - """保存公会数据,支持批量保存优化""" - try: - current_time = time.time() - - # 如果不是强制保存且距离上次保存时间不足,则延迟保存 - if not force and current_time - self._last_save_time < Config.BATCH_SAVE_INTERVAL: - self._dirty_guilds.update(guilds.keys()) - return True - - # 确保数据目录存在 - data_dir = os.path.dirname(self.file_path) - if not os.path.exists(data_dir): - try: - os.makedirs(data_dir) - fmts.print_inf(f"创建公会系统数据目录: {data_dir}") - except Exception as e: - fmts.print_err(f"创建公会系统数据目录失败: {e}") - return False - - self._backup_before_save(force=force) - - raw_data = {} - for gid, guild in guilds.items(): - try: - guild_dict = guild.to_dict() - raw_data[gid] = guild_dict - except Exception as e: - fmts.print_err(f"转换公会 {gid} 数据时出错: {e}") - continue - - # 写入文件 - tempjson.write(self.file_path, raw_data) - if force: - tempjson.flush(self.file_path) - - # 更新缓存和状态 - self._cache = guilds.copy() - self._last_load_time = current_time - self._last_save_time = current_time - self._dirty_guilds.clear() - - return True - - except Exception as e: - fmts.print_err(f"保存公会数据时出错: {e}") - import traceback - fmts.print_err(traceback.format_exc()) - return False - - def _backup_before_save(self, force: bool = False) -> None: - """按配置在写入前备份当前数据文件。""" - safety_config = getattr(Config, "GUILD_DATA_SAFETY_CONFIG", {}) - if not safety_config.get("启用保存前备份", True): - return - if force and not safety_config.get("强制保存时也备份", True): - return - if not self.file_path or not os.path.exists(self.file_path): - return - - data_dir = os.path.dirname(self.file_path) - backup_dir = os.path.join( - data_dir, safety_config.get( - "备份目录名", "公会数据备份")) - os.makedirs(backup_dir, exist_ok=True) - - timestamp = time.strftime("%Y%m%d-%H%M%S") - backup_path = os.path.join(backup_dir, f"公会数据文件-{timestamp}.json") - shutil.copy2(self.file_path, backup_path) - - max_backups = int(safety_config.get("最大备份数量", 10)) - if max_backups <= 0: - return - - backups = [ - os.path.join(backup_dir, name) - for name in os.listdir(backup_dir) - if name.startswith("公会数据文件-") and name.endswith(".json") - ] - backups.sort(key=lambda path: os.path.getmtime(path), reverse=True) - for old_path in backups[max_backups:]: - try: - os.remove(old_path) - except OSError as err: - fmts.print_err(f"删除旧公会备份失败: {err}") - - def mark_guild_dirty(self, guild_id: str) -> None: - """标记公会数据已修改,需要保存""" - self._dirty_guilds.add(guild_id) - - def flush_dirty_guilds(self) -> bool: - """强制保存所有标记为脏的公会数据""" - if not self._dirty_guilds: - return True - - if self._cache is None: - self._load_guilds(force_reload=True) - if self._cache is None: - return False - return self.save_guilds(self._cache, force=True) - - def get_guild_by_player( - self, - player_name: str, - force_reload: bool = False) -> Optional[GuildData]: - """根据玩家名获取其所在公会""" - guilds = self._load_guilds(force_reload=force_reload) - guild_id = self._player_guild_cache.get(player_name) - return guilds.get(guild_id) if guild_id else None - - def get_guild_by_name(self, guild_name: str) -> Optional[GuildData]: - """根据公会名获取公会""" - guilds = self._load_guilds() - for guild in guilds.values(): - if guild.name == guild_name: - return guild - return None - - def create_guild( - self, - owner_xuid: str, - owner_name: str, - guild_name: str) -> bool: - """创建公会""" - guilds = self._load_guilds(force_reload=True) - - # 检查是否已有同名公会 - if any(g.name == guild_name for g in guilds.values()): - return False - - owner_member = GuildMember( - name=owner_name, - rank=GuildRank.OWNER, - join_time=time.time() - ) - - new_guild = GuildData( - guild_id=owner_xuid, - name=guild_name, - owner=owner_name, - members=[owner_member] - ) - new_guild.add_log(f"公会 {guild_name} 成立") - - guilds[owner_xuid] = new_guild - self.save_guilds(guilds) - return True - - def add_member( - self, - guild_name: str, - player_name: str, - inviter: str = None) -> bool: - """添加成员到公会""" - guilds = self._load_guilds(force_reload=True) - guild = self.get_guild_by_name(guild_name) - - if not guild or len(guild.members) >= Config.MAX_GUILD_MEMBERS: - return False - - new_member = GuildMember( - name=player_name, - rank=GuildRank.MEMBER, - join_time=time.time() - ) - - guild.members.append(new_member) - guild.add_log(f"{player_name} 加入公会" + - (f" (邀请人: {inviter})" if inviter else "")) - - # 更新缓存 - self._player_guild_cache[player_name] = guild.guild_id - self.save_guilds(guilds) - return True - - def remove_member(self, player_name: str) -> Optional[str]: - """从公会移除成员,返回公会名""" - guilds = self._load_guilds(force_reload=True) - guild = self.get_guild_by_player(player_name) - - if not guild: - return None - - # 检查是否是会长 - member = guild.get_member(player_name) - if member and member.rank == GuildRank.OWNER: - return None # 会长不能退出公会,只能解散 - - guild.members = [m for m in guild.members if m.name != player_name] - guild.add_log(f"{player_name} 退出公会") - - # 更新缓存 - if player_name in self._player_guild_cache: - del self._player_guild_cache[player_name] - self.save_guilds(guilds) - return guild.name - - def set_member_rank( - self, - guild: GuildData, - player_name: str, - new_rank: GuildRank) -> bool: - """设置成员职位""" - guilds = self._load_guilds(force_reload=True) - current_guild = guilds.get(guild.guild_id) - if not current_guild: - return False - - member = current_guild.get_member(player_name) - - if not member or member.rank == GuildRank.OWNER: - return False - - old_rank = member.rank - member.rank = new_rank - current_guild.add_log( - f"{player_name} 职位变更: {old_rank.display_name} -> {new_rank.display_name}") - - self.save_guilds(guilds, force=True) - return True - - def update_contribution(self, player_name: str, amount: int) -> bool: - """更新成员贡献度""" - guild = self.get_guild_by_player(player_name) - - if not guild: - return False - - member = guild.get_member(player_name) - if member: - member.contribution += amount - guild.stats.total_contribution += amount - self.mark_guild_dirty(guild.guild_id) - return True - return False - - def batch_update_members(self, updates: List[Tuple[str, str, Any]]) -> int: - """批量更新成员信息""" - success_count = 0 - - for player_name, field_name, value in updates: - guild = self.get_guild_by_player(player_name) - if not guild: - continue - - member = guild.get_member(player_name) - if not member: - continue - - if hasattr(member, field_name): - setattr(member, field_name, value) - self.mark_guild_dirty(guild.guild_id) - success_count += 1 - - # 批量保存 - if success_count > 0: - self.flush_dirty_guilds() - - return success_count - - def update_online_status(self, online_players: List[str]) -> None: - """更新在线状态""" - guilds = self._load_guilds(force_reload=True) - current_time = time.time() - - for guild in guilds.values(): - for member in guild.members: - if member.name in online_players: - member.last_online = current_time - - self.save_guilds(guilds) + + # 检查数据类型 + if not isinstance(guild_data.exp, (int, float)) or guild_data.exp < 0: + errors.append("公会经验值无效 Err:event.check.data.exp_type_or_negative") + + if not isinstance(guild_data.level, int) or guild_data.level < 1: + errors.append("公会等级无效 Err:event.check.data.level_type_or_range") + + return len(errors) == 0, errors + + def create_transaction(self): + """创建数据事务""" + return DataTransaction(self) + + def _load_guilds(self, force_reload: bool = False) -> Dict[str, GuildData]: + """加载公会数据,带缓存""" + current_time = time.time() + if (not force_reload and self._cache is not None and + current_time - self._last_load_time < self.cache_duration): + return self._cache + + raw_data = tempjson.load_and_read( + self.file_path, need_file_exists=False, default={}) + self._cache = {} + self._player_guild_cache.clear() + + load_errors = [] + for guild_id, guild_dict in raw_data.items(): + try: + guild = GuildData.from_dict(guild_dict, outer_key=guild_id) + + # 验证数据完整性 + is_valid, errors = self.validate_guild_data(guild) + if not is_valid: + load_errors.extend( + [f"公会{guild_id}: {error}" for error in errors]) + continue + + self._cache[guild_id] = guild + # 更新玩家-公会缓存 + for member in guild.members: + self._player_guild_cache[member.name] = guild_id + except Exception as e: + load_errors.append(f"加载公会 {guild_id} 时出错: {e}") + continue + + # 记录加载错误 + if load_errors: + fmts.print_err(f"加载公会数据时发现 {len(load_errors)} 个错误") + for error in load_errors[:3]: # 只显示前3个错误 + fmts.print_err(f" - {error}") + if len(load_errors) > 3: + fmts.print_err(f" ... 还有 {len(load_errors) - 3} 个错误") + + self._last_load_time = current_time + return self._cache + + def load_guilds(self, force_reload: bool = False) -> Dict[str, GuildData]: + """Return guild data through the manager cache boundary.""" + return self._load_guilds(force_reload=force_reload) + + def rebuild_player_cache(self, guilds: Dict[str, GuildData]) -> None: + """Rebuild the player-to-guild lookup from the provided guild map.""" + self._player_guild_cache.clear() + for guild_id, guild in guilds.items(): + for member in guild.members: + self._player_guild_cache[member.name] = guild_id + + def cache_player_guild(self, player_name: str, guild_id: str) -> None: + """Record one player's guild id in the runtime cache.""" + self._player_guild_cache[player_name] = guild_id + + def get_cached_guild_id(self, player_name: str) -> Optional[str]: + """Return the cached guild id for a player, if present.""" + return self._player_guild_cache.get(player_name) + + def reset_runtime_state(self) -> None: + """Clear in-memory guild caches and pending save state.""" + self._cache = None + self._player_guild_cache.clear() + self._dirty_guilds.clear() + self._last_load_time = 0 + self._last_save_time = 0 + + def save_guilds(self, + guilds: Dict[str, + GuildData], + force: bool = False) -> bool: + """保存公会数据,支持批量保存优化""" + try: + current_time = time.time() + + # 如果不是强制保存且距离上次保存时间不足,则延迟保存 + if ( + not force + and current_time - self._last_save_time < Config.BATCH_SAVE_INTERVAL + ): + self._dirty_guilds.update(guilds.keys()) + return True + + # 确保数据目录存在 + data_dir = os.path.dirname(self.file_path) + if not os.path.exists(data_dir): + try: + os.makedirs(data_dir) + fmts.print_inf(f"创建公会系统数据目录: {data_dir}") + except Exception as e: + fmts.print_err(f"创建公会系统数据目录失败: {e}") + return False + + self._backup_before_save(force=force) + + raw_data = {} + for gid, guild in guilds.items(): + try: + guild_dict = guild.to_dict() + raw_data[gid] = guild_dict + except Exception as e: + fmts.print_err(f"转换公会 {gid} 数据时出错: {e}") + continue + + # 写入文件 + tempjson.write(self.file_path, raw_data) + if force: + tempjson.flush(self.file_path) + + # 更新缓存和状态 + self._cache = guilds.copy() + self._last_load_time = current_time + self._last_save_time = current_time + self._dirty_guilds.clear() + + return True + + except Exception as e: + fmts.print_err(f"保存公会数据时出错: {e}") + import traceback + fmts.print_err(traceback.format_exc()) + return False + + def _backup_before_save(self, force: bool = False) -> None: + """按配置在写入前备份当前数据文件。""" + safety_config = getattr(Config, "GUILD_DATA_SAFETY_CONFIG", {}) + if not safety_config.get("启用保存前备份", True): + return + if force and not safety_config.get("强制保存时也备份", True): + return + if not self.file_path or not os.path.exists(self.file_path): + return + + data_dir = os.path.dirname(self.file_path) + backup_dir = os.path.join( + data_dir, safety_config.get( + "备份目录名", "公会数据备份")) + os.makedirs(backup_dir, exist_ok=True) + + timestamp = time.strftime("%Y%m%d-%H%M%S") + backup_path = os.path.join(backup_dir, f"公会数据文件-{timestamp}.json") + shutil.copy2(self.file_path, backup_path) + + max_backups = int(safety_config.get("最大备份数量", 10)) + if max_backups <= 0: + return + + backups = [ + os.path.join(backup_dir, name) + for name in os.listdir(backup_dir) + if name.startswith("公会数据文件-") and name.endswith(".json") + ] + backups.sort(key=lambda path: os.path.getmtime(path), reverse=True) + for old_path in backups[max_backups:]: + try: + os.remove(old_path) + except OSError as err: + fmts.print_err(f"删除旧公会备份失败: {err}") + + def mark_guild_dirty(self, guild_id: str) -> None: + """标记公会数据已修改,需要保存""" + self._dirty_guilds.add(guild_id) + + def flush_dirty_guilds(self) -> bool: + """强制保存所有标记为脏的公会数据""" + if not self._dirty_guilds: + return True + + if self._cache is None: + self._load_guilds(force_reload=True) + if self._cache is None: + return False + return self.save_guilds(self._cache, force=True) + + def get_guild_by_player( + self, + player_name: str, + force_reload: bool = False) -> Optional[GuildData]: + """根据玩家名获取其所在公会""" + guilds = self._load_guilds(force_reload=force_reload) + guild_id = self._player_guild_cache.get(player_name) + return guilds.get(guild_id) if guild_id else None + + def get_guild_by_name(self, guild_name: str) -> Optional[GuildData]: + """根据公会名获取公会""" + guilds = self._load_guilds() + for guild in guilds.values(): + if guild.name == guild_name: + return guild + return None + + def create_guild( + self, + owner_xuid: str, + owner_name: str, + guild_name: str) -> bool: + """创建公会""" + guilds = self._load_guilds(force_reload=True) + + # 检查是否已有同名公会 + if any(g.name == guild_name for g in guilds.values()): + return False + + owner_member = GuildMember( + name=owner_name, + rank=GuildRank.OWNER, + join_time=time.time() + ) + + new_guild = GuildData( + guild_id=owner_xuid, + name=guild_name, + owner=owner_name, + members=[owner_member] + ) + new_guild.add_log(f"公会 {guild_name} 成立") + + guilds[owner_xuid] = new_guild + self.save_guilds(guilds) + return True + + def add_member( + self, + guild_name: str, + player_name: str, + inviter: str = None) -> bool: + """添加成员到公会""" + guilds = self._load_guilds(force_reload=True) + guild = self.get_guild_by_name(guild_name) + + if not guild or len(guild.members) >= Config.MAX_GUILD_MEMBERS: + return False + + new_member = GuildMember( + name=player_name, + rank=GuildRank.MEMBER, + join_time=time.time() + ) + + guild.members.append(new_member) + guild.add_log(f"{player_name} 加入公会" + + (f" (邀请人: {inviter})" if inviter else "")) + + # 更新缓存 + self._player_guild_cache[player_name] = guild.guild_id + self.save_guilds(guilds) + return True + + def remove_member(self, player_name: str) -> Optional[str]: + """从公会移除成员,返回公会名""" + guilds = self._load_guilds(force_reload=True) + guild = self.get_guild_by_player(player_name) + + if not guild: + return None + + # 检查是否是会长 + member = guild.get_member(player_name) + if member and member.rank == GuildRank.OWNER: + return None # 会长不能退出公会,只能解散 + + guild.members = [m for m in guild.members if m.name != player_name] + guild.add_log(f"{player_name} 退出公会") + + # 更新缓存 + if player_name in self._player_guild_cache: + del self._player_guild_cache[player_name] + self.save_guilds(guilds) + return guild.name + + def set_member_rank( + self, + guild: GuildData, + player_name: str, + new_rank: GuildRank) -> bool: + """设置成员职位""" + guilds = self._load_guilds(force_reload=True) + current_guild = guilds.get(guild.guild_id) + if not current_guild: + return False + + member = current_guild.get_member(player_name) + + if not member or member.rank == GuildRank.OWNER: + return False + + old_rank = member.rank + member.rank = new_rank + current_guild.add_log( + f"{player_name} 职位变更: {old_rank.display_name} -> {new_rank.display_name}") + + self.save_guilds(guilds, force=True) + return True + + def update_contribution(self, player_name: str, amount: int) -> bool: + """更新成员贡献度""" + guild = self.get_guild_by_player(player_name) + + if not guild: + return False + + member = guild.get_member(player_name) + if member: + member.contribution += amount + guild.stats.total_contribution += amount + self.mark_guild_dirty(guild.guild_id) + return True + return False + + def batch_update_members(self, updates: List[Tuple[str, str, Any]]) -> int: + """批量更新成员信息""" + success_count = 0 + + for player_name, field_name, value in updates: + guild = self.get_guild_by_player(player_name) + if not guild: + continue + + member = guild.get_member(player_name) + if not member: + continue + + if hasattr(member, field_name): + setattr(member, field_name, value) + self.mark_guild_dirty(guild.guild_id) + success_count += 1 + + # 批量保存 + if success_count > 0: + self.flush_dirty_guilds() + + return success_count + + def update_online_status(self, online_players: List[str]) -> None: + """更新在线状态""" + guilds = self._load_guilds(force_reload=True) + current_time = time.time() + + for guild in guilds.values(): + for member in guild.members: + if member.name in online_players: + member.last_online = current_time + + self.save_guilds(guilds) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" index 54750f00..6722b975 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers.py" @@ -1,8 +1,10 @@ -"""Interactive guild menu handlers.""" - -import json -import time -import uuid +"""Interactive guild menu handlers.""" + +# pylint: disable=protected-access + +import json +import time +import uuid from datetime import datetime from tooldelta import Player, game_utils, fmts @@ -14,341 +16,348 @@ GuildTask, VaultItem, ) -from guild_cloud_interop.config import Config -from guild_cloud_interop.prompts import render_config_prompt, render_create_guild_prompt -from guild_cloud_interop.ui import ORION_BORDER, TITLE_PREFIX, format_page_footer -from guild_cloud_interop.validators import InputValidator - - -def _handle_effect(self, player: Player) -> bool: - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - if not guild.has_permission(player.name, "effect_buy"): - player.show("§l§a公会 §d>> §r你没有购买公会效果权限") - return True - +from guild_cloud_interop.config import Config +from guild_cloud_interop.prompts import render_config_prompt, render_create_guild_prompt +from guild_cloud_interop.ui import ORION_BORDER, TITLE_PREFIX, format_page_footer +from guild_cloud_interop.validators import InputValidator + + +def _handle_effect(self, player: Player) -> bool: # skipcq: PY-R1000 + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + if not guild.has_permission(player.name, "effect_buy"): + player.show("§l§a公会 §d>> §r你没有购买公会效果权限") + return True + # 显示可选效果 player.show("§l§a公会 §d>> §r可用效果列表:") - - for key, val in Config.EFFECTS_CONFIG.items(): - try: - # 检查配置完整性 - if "name" not in val: - fmts.print_err(f"效果配置错误: {key} 缺少 name 字段") - continue - - if "costs" not in val: - fmts.print_err(f"效果配置错误: {key} 缺少 costs 字段") - continue - - purchased_lv = guild.purchased_effects.get(key) - costs_str_list = [] - - for lv, cost in val["costs"].items(): - if purchased_lv == lv: - color = "§b" - else: - color = "§7" - costs_str_list.append(f"{color}Lv{lv}:{cost}钻§r") - - costs_str = " ".join(costs_str_list) - player.show(f"§r§f>> {val['name']} §r({costs_str})") - - except Exception as e: - fmts.print_err(f"处理效果 {key} 时出错: {e}") - continue - - player.show("§r§7>> 输入效果选择升级") - choice = game_utils.waitMsg(player.name) - - effect_key = None - for k, v in Config.EFFECTS_CONFIG.items(): - if v['name'] == choice: - effect_key = k - break - - if not effect_key: - player.show("§c无效效果") - return True - - selected = Config.EFFECTS_CONFIG[effect_key] - - # 判断钻石数量 - diamond_count = player.getItemCount("minecraft:diamond") - player.show(f"§7当前钻石数量: {diamond_count}") - - player.show("§7请输入等级 (1/2/3)") - lv_choice = game_utils.waitMsg(player.name) - if not lv_choice.isdigit() or int(lv_choice) not in selected["costs"]: - player.show("§c无效等级") - return True - - lv_choice = int(lv_choice) - cost = selected["costs"][lv_choice] - - if diamond_count < cost: - player.show(f"§c钻石不足,{cost}钻石才能购买") - return True - - # 扣除钻石 - self.game_ctrl.sendwocmd( - f"clear {player.safe_name} minecraft:diamond 0 {cost}") - - # 保存公会已购买效果 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild_id = guild.guild_id - if guild_id in guilds: - guild = guilds[guild_id] - guild.purchased_effects[effect_key] = lv_choice - guild.add_log( - f"{player.name} 为公会购买了 {selected['name']} 等级{lv_choice} 效果") - self.guild_manager.save_guilds(guilds) - else: - player.show("§c保存失败,公会不存在") - return True - - # 购买后立即给在线成员补发效果,后续由低频兜底刷新,避免周期性全量刷命令。 - for member in guild.members: - if member.name in self.game_ctrl.allplayers: - self._apply_guild_effects_to_player( - member.name, - guild=guild, - force=True, - effect_names={effect_key}, - ) - - player.show(f"§a已激活效果: {selected['name']} 等级{lv_choice}") - - return True - - -def _handle_rankings(self, player: Player) -> bool: - """处理公会排行榜""" - player.show("§r========== §a公会排行榜§r ==========") - player.show("§e1. §f等级排行") - player.show("§e2. §f成员数量排行") - player.show("§e3. §f贡献度排行") - player.show("§e4. §f活跃度排行") - player.show("§7输入选项序号:") - - choice = game_utils.waitMsg(player.name, timeout=30) - - if choice == "1": - rankings = self.get_guild_rankings("level") - title = "公会等级排行榜" - - def formatter(i, data): return ( - f"§e{i}. §r{data[0].name} §7Lv.{data[1]}\n" - f" §7会长: §f{data[0].owner} §7| 成员: §a{len(data[0].members)}\n" - ) - elif choice == "2": - rankings = self.get_guild_rankings("members") - title = "公会成员数排行榜" - - def formatter(i, data): return ( - f"§e{i}. §r{data[0].name} §7成员: §a{data[1]}\n" - f" §7会长: §f{data[0].owner} §7| 等级: §e{data[0].level}\n" - ) - elif choice == "3": - rankings = self.get_guild_rankings("contribution") - title = "公会贡献度排行榜" - - def formatter(i, data): return ( - f"§e{i}. §r{data[0].name} §7贡献: §b{data[1]}\n" - f" §7会长: §f{data[0].owner} §7| 等级: §e{data[0].level}\n" - ) - elif choice == "4": - rankings = self.get_guild_rankings("activity") - title = "公会活跃度排行榜" - - def formatter( - i, - data): return ( - f"§e{i}. §r{ - data[0].name}\n" f" §7会长: §f{ - data[0].owner} §7| 最近活跃: §a{ - self._format_time_ago( - data[1])}\n") - else: - player.show("§c无效选项") - return True - - if not rankings: - player.show("§l§a公会 §d>> §r暂无公会数据") - return True - - self._paginate_display(player, rankings, title, formatter) - return True - - -def _format_time_ago(self, timestamp: float) -> str: - """格式化时间差显示""" - if timestamp == 0: - return "从未" - - current_time = time.time() - diff = current_time - timestamp - - if diff < 60: - return "刚刚" - elif diff < 3600: - return f"{int(diff // 60)}分钟前" - elif diff < 86400: - return f"{int(diff // 3600)}小时前" - else: - return f"{int(diff // 86400)}天前" - - -def _handle_view_guild(self, player: Player) -> bool: - """查看公会详细信息""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - - level = guild.level - exp = guild.exp - required_exp = Config.GUILD_LEVEL_EXP.get(level + 1, "MAX") - - msg = f"§l§a{guild.name}§r §7(ID: {guild.guild_id[:8]}...)\n" - msg += f"§7创建时间: §f{ - datetime.fromtimestamp( - guild.create_time).strftime('%Y-%m-%d')}\n" - msg += f"§7会长: §e{guild.owner}\n" - msg += f"§7等级: §e{level} §7经验: §b{exp}/{required_exp}\n" - msg += f"§7成员: §a{len(guild.members)}/{Config.MAX_GUILD_MEMBERS}\n" - msg += f"§7仓库容量: §a{Config.VAULT_INITIAL_SLOTS} 格\n" - - if guild.announcement: - msg += f"\n§e公告: §f{guild.announcement}\n" - - if guild.base: - base = guild.base - dim_name = Config.DIMENSION_NAMES.get( - base.dimension, f"维度{base.dimension}") - msg += f"\n§7据点: §f{dim_name} ({ - base.x:.1f}, { - base.y:.1f}, { - base.z:.1f})" - - player.show(msg) - return True - - -def _handle_view_members(self, player: Player) -> bool: - """查看成员列表""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - - # 按职位和贡献度排序 - sorted_members = sorted( - guild.members, - key=lambda m: ( - ["owner", "deputy", "elder", "member"].index(m.rank.value), - -m.contribution - ) - ) - - def formatter(i, member: GuildMember): - online = member.name in self.game_ctrl.allplayers - online_status = "§a在线" if online else "§7离线" - days_since_join = (time.time() - member.join_time) / 86400 - - return (f"§e{i}. {member.rank.display_name} §f{member.name} " - f"[{online_status}§f]\n" - f" §7贡献: §b{member.contribution} §7| " - f"加入: §f{int(days_since_join)}天前\n") - - self._paginate_display( - player, sorted_members, f"{ - guild.name} 成员列表", formatter) - return True - - -def _handle_view_logs(self, player: Player) -> bool: - """查看公会日志""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - - msg = f"§r========== §a{guild.name} 日志§r ==========\n" - if guild.logs: - for log in guild.logs[-20:]: - msg += f"§7{log}\n" - else: - msg += "§7暂无普通日志记录\n" - - if guild.has_permission(player.name, "audit_log"): - msg += f"\n§r========== §a{guild.name} 审计日志§r ==========\n" - if guild.audit_logs: - for log in guild.audit_logs[-20:]: - time_str = datetime.fromtimestamp( - log.timestamp).strftime("%m-%d %H:%M") - target = f" §7| 目标: §f{log.target}" if log.target else "" - detail = f" §7| 详情: §f{log.detail}" if log.detail else "" - msg += ( - f"§7[{time_str}] §f{log.action} §7| 操作者: §e{log.actor}" - f"{target}{detail} §7| 结果: §f{log.result}\n" - ) - else: - msg += "§7暂无审计日志记录\n" - - player.show(msg) - return True - - -def _handle_announcement(self, player: Player) -> bool: - """处理公会公告""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - - # 显示当前公告 - if guild.announcement: - player.show(f"§l§a公会公告§r\n§f{guild.announcement}") - else: - player.show("§l§a公会 §d>> §r当前没有公告") - - # 检查权限 - if not guild.has_permission(player.name, "announce"): - return True - - player.show("§7输入 '设置' 来修改公告,其他任意键返回") - choice = game_utils.waitMsg(player.name, timeout=20) - - if choice == "设置": - player.show("§l§a公会 §d>> §r请输入新的公告内容:") - player.show("§7要求: 不超过200个字符,不能为空") - new_announcement = game_utils.waitMsg(player.name, timeout=60) - - # 使用新的输入验证 - is_valid, error_msg = InputValidator.validate_announcement( - new_announcement) - if not is_valid: - player.show(f"§l§a公会 §d>> §r{error_msg}") - return True - - try: - guilds = self.guild_manager.load_guilds(force_reload=True) - guild_data = guilds.get(guild.guild_id) - if not guild_data: - player.show("§l§a公会 §d>> §r公会数据异常") - return True - - guild_data.announcement = new_announcement - guild_data.add_log(f"{player.name} 更新了公告") - self.guild_manager.save_guilds(guilds) - player.show("§l§a公会 §d>> §r公告已更新") - + + for key, val in Config.EFFECTS_CONFIG.items(): + try: + # 检查配置完整性 + if "name" not in val: + fmts.print_err(f"效果配置错误: {key} 缺少 name 字段") + continue + + if "costs" not in val: + fmts.print_err(f"效果配置错误: {key} 缺少 costs 字段") + continue + + purchased_lv = guild.purchased_effects.get(key) + costs_str_list = [] + + for lv, cost in val["costs"].items(): + if purchased_lv == lv: + color = "§b" + else: + color = "§7" + costs_str_list.append(f"{color}Lv{lv}:{cost}钻§r") + + costs_str = " ".join(costs_str_list) + player.show(f"§r§f>> {val['name']} §r({costs_str})") + + except Exception as e: + fmts.print_err(f"处理效果 {key} 时出错: {e}") + continue + + player.show("§r§7>> 输入效果选择升级") + choice = game_utils.waitMsg(player.name) + + effect_key = None + for k, v in Config.EFFECTS_CONFIG.items(): + if v['name'] == choice: + effect_key = k + break + + if not effect_key: + player.show("§c无效效果") + return True + + selected = Config.EFFECTS_CONFIG[effect_key] + + # 判断钻石数量 + diamond_count = player.getItemCount("minecraft:diamond") + player.show(f"§7当前钻石数量: {diamond_count}") + + player.show("§7请输入等级 (1/2/3)") + lv_choice = game_utils.waitMsg(player.name) + if not lv_choice.isdigit() or int(lv_choice) not in selected["costs"]: + player.show("§c无效等级") + return True + + lv_choice = int(lv_choice) + cost = selected["costs"][lv_choice] + + if diamond_count < cost: + player.show(f"§c钻石不足,{cost}钻石才能购买") + return True + + # 扣除钻石 + self.game_ctrl.sendwocmd( + f"clear {player.safe_name} minecraft:diamond 0 {cost}") + + # 保存公会已购买效果 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_id = guild.guild_id + if guild_id in guilds: + guild = guilds[guild_id] + guild.purchased_effects[effect_key] = lv_choice + guild.add_log( + f"{player.name} 为公会购买了 {selected['name']} 等级{lv_choice} 效果") + self.guild_manager.save_guilds(guilds) + else: + player.show("§c保存失败,公会不存在") + return True + + # 购买后立即给在线成员补发效果,后续由低频兜底刷新,避免周期性全量刷命令。 + for member in guild.members: + if member.name in self.game_ctrl.allplayers: + self._apply_guild_effects_to_player( + member.name, + guild=guild, + force=True, + effect_names={effect_key}, + ) + + player.show(f"§a已激活效果: {selected['name']} 等级{lv_choice}") + + return True + + +def _handle_rankings(self, player: Player) -> bool: + """处理公会排行榜""" + player.show("§r========== §a公会排行榜§r ==========") + player.show("§e1. §f等级排行") + player.show("§e2. §f成员数量排行") + player.show("§e3. §f贡献度排行") + player.show("§e4. §f活跃度排行") + player.show("§7输入选项序号:") + + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "1": + rankings = self.get_guild_rankings("level") + title = "公会等级排行榜" + + def formatter(i, data): + """Format one menu item for display.""" + return ( + f"§e{i}. §r{data[0].name} §7Lv.{data[1]}\n" + f" §7会长: §f{data[0].owner} §7| 成员: §a{len(data[0].members)}\n" + ) + elif choice == "2": + rankings = self.get_guild_rankings("members") + title = "公会成员数排行榜" + + def formatter(i, data): + """Format one menu item for display.""" + return ( + f"§e{i}. §r{data[0].name} §7成员: §a{data[1]}\n" + f" §7会长: §f{data[0].owner} §7| 等级: §e{data[0].level}\n" + ) + elif choice == "3": + rankings = self.get_guild_rankings("contribution") + title = "公会贡献度排行榜" + + def formatter(i, data): + """Format one menu item for display.""" + return ( + f"§e{i}. §r{data[0].name} §7贡献: §b{data[1]}\n" + f" §7会长: §f{data[0].owner} §7| 等级: §e{data[0].level}\n" + ) + elif choice == "4": + rankings = self.get_guild_rankings("activity") + title = "公会活跃度排行榜" + + def formatter(i, data): + """Format one menu item for display.""" + return ( + f"§e{i}. §r{ + data[0].name}\n" f" §7会长: §f{ + data[0].owner} §7| 最近活跃: §a{ + self._format_time_ago( + data[1])}\n") + else: + player.show("§c无效选项") + return True + + if not rankings: + player.show("§l§a公会 §d>> §r暂无公会数据") + return True + + self._paginate_display(player, rankings, title, formatter) + return True + + +def _format_time_ago(self, timestamp: float) -> str: + """格式化时间差显示""" + if timestamp == 0: + return "从未" + + current_time = time.time() + diff = current_time - timestamp + + if diff < 60: + return "刚刚" + elif diff < 3600: + return f"{int(diff // 60)}分钟前" + elif diff < 86400: + return f"{int(diff // 3600)}小时前" + else: + return f"{int(diff // 86400)}天前" + + +def _handle_view_guild(self, player: Player) -> bool: + """查看公会详细信息""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + + level = guild.level + exp = guild.exp + required_exp = Config.GUILD_LEVEL_EXP.get(level + 1, "MAX") + + msg = f"§l§a{guild.name}§r §7(ID: {guild.guild_id[:8]}...)\n" + msg += f"§7创建时间: §f{ + datetime.fromtimestamp( + guild.create_time).strftime('%Y-%m-%d')}\n" + msg += f"§7会长: §e{guild.owner}\n" + msg += f"§7等级: §e{level} §7经验: §b{exp}/{required_exp}\n" + msg += f"§7成员: §a{len(guild.members)}/{Config.MAX_GUILD_MEMBERS}\n" + msg += f"§7仓库容量: §a{Config.VAULT_INITIAL_SLOTS} 格\n" + + if guild.announcement: + msg += f"\n§e公告: §f{guild.announcement}\n" + + if guild.base: + base = guild.base + dim_name = Config.DIMENSION_NAMES.get( + base.dimension, f"维度{base.dimension}") + msg += f"\n§7据点: §f{dim_name} ({ + base.x:.1f}, { + base.y:.1f}, { + base.z:.1f})" + + player.show(msg) + return True + + +def _handle_view_members(self, player: Player) -> bool: + """查看成员列表""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + + # 按职位和贡献度排序 + sorted_members = sorted( + guild.members, + key=lambda m: ( + ["owner", "deputy", "elder", "member"].index(m.rank.value), + -m.contribution + ) + ) + + def formatter(i, member: GuildMember): + """Format one menu item for display.""" + online = member.name in self.game_ctrl.allplayers + online_status = "§a在线" if online else "§7离线" + days_since_join = (time.time() - member.join_time) / 86400 + + return (f"§e{i}. {member.rank.display_name} §f{member.name} " + f"[{online_status}§f]\n" + f" §7贡献: §b{member.contribution} §7| " + f"加入: §f{int(days_since_join)}天前\n") + + self._paginate_display( + player, sorted_members, f"{ + guild.name} 成员列表", formatter) + return True + + +def _handle_view_logs(self, player: Player) -> bool: + """查看公会日志""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + + msg = f"§r========== §a{guild.name} 日志§r ==========\n" + if guild.logs: + for log in guild.logs[-20:]: + msg += f"§7{log}\n" + else: + msg += "§7暂无普通日志记录\n" + + if guild.has_permission(player.name, "audit_log"): + msg += f"\n§r========== §a{guild.name} 审计日志§r ==========\n" + if guild.audit_logs: + for log in guild.audit_logs[-20:]: + time_str = datetime.fromtimestamp( + log.timestamp).strftime("%m-%d %H:%M") + target = f" §7| 目标: §f{log.target}" if log.target else "" + detail = f" §7| 详情: §f{log.detail}" if log.detail else "" + msg += ( + f"§7[{time_str}] §f{log.action} §7| 操作者: §e{log.actor}" + f"{target}{detail} §7| 结果: §f{log.result}\n" + ) + else: + msg += "§7暂无审计日志记录\n" + + player.show(msg) + return True + + +def _handle_announcement(self, player: Player) -> bool: + """处理公会公告""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + + # 显示当前公告 + if guild.announcement: + player.show(f"§l§a公会公告§r\n§f{guild.announcement}") + else: + player.show("§l§a公会 §d>> §r当前没有公告") + + # 检查权限 + if not guild.has_permission(player.name, "announce"): + return True + + player.show("§7输入 '设置' 来修改公告,其他任意键返回") + choice = game_utils.waitMsg(player.name, timeout=20) + + if choice == "设置": + player.show("§l§a公会 §d>> §r请输入新的公告内容:") + player.show("§7要求: 不超过200个字符,不能为空") + new_announcement = game_utils.waitMsg(player.name, timeout=60) + + # 使用新的输入验证 + is_valid, error_msg = InputValidator.validate_announcement( + new_announcement) + if not is_valid: + player.show(f"§l§a公会 §d>> §r{error_msg}") + return True + + try: + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_data = guilds.get(guild.guild_id) + if not guild_data: + player.show("§l§a公会 §d>> §r公会数据异常") + return True + + guild_data.announcement = new_announcement + guild_data.add_log(f"{player.name} 更新了公告") + self.guild_manager.save_guilds(guilds) + player.show("§l§a公会 §d>> §r公告已更新") + # 通知在线成员 message = "§l§a公会 §d>> §r公告已更新,输入 .公会 公告 查看" for member in guild_data.members: @@ -356,580 +365,592 @@ def _handle_announcement(self, player: Player) -> bool: self.game_ctrl.sendcmd( f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' ) - except Exception as e: - player.show("§l§a公会 §d>> §r更新公告失败") - fmts.print_err(f"更新公告时出错:{e}") - - return True - - -def _handle_tasks(self, player: Player) -> bool: - """处理公会任务系统""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - while True: - latest_guild = self.guild_manager.get_guild_by_player( - player.name, force_reload=True) - if latest_guild: - guild = latest_guild - - player.show("§r========== §a公会任务§r ==========") - - # 显示活跃任务统计 - active_tasks = [t for t in guild.tasks if not t.completed] - completed_tasks = [t for t in guild.tasks if t.completed] - - player.show( - f"§7活跃任务: §e{ - len(active_tasks)} §7| 已完成: §a{ - len(completed_tasks)}") - can_manage_legacy = guild.has_permission(player.name, "task_manage") - can_create = guild.has_permission( - player.name, "task_create") or can_manage_legacy - can_delete = guild.has_permission( - player.name, "task_delete") or can_manage_legacy - can_complete = guild.has_permission( - player.name, "task_complete") or can_manage_legacy - can_manage = can_delete or can_complete - can_auto = ( - can_create - and getattr(Config, "GUILD_TASK_CONFIG", {}).get("启用自动任务模板", True) - ) - - menu_options = [ - ("查看", "查看所有任务", True), - ("参与", "参与任务", len(active_tasks) > 0), - ("创建", "创建新任务", can_create), - ("自动", "生成自动任务模板", can_auto), - ("管理", "管理任务", can_manage), - ("退出", "退出任务系统", True) - ] - - available_options = [(cmd, desc) - for cmd, desc, cond in menu_options if cond] - - for cmd, desc in available_options: - player.show(f"§e● {cmd} §7- {desc}") - - player.show("§7输入选项:") - choice = game_utils.waitMsg(player.name, timeout=30) - - if choice == "查看": - self._handle_view_tasks(player, guild) - elif choice == "参与" and len(active_tasks) > 0: - self._handle_join_task(player, guild) - elif choice == "创建" and can_create: - self._handle_create_task(player, guild) - elif choice == "自动" and can_auto: - self._handle_generate_auto_tasks(player, guild) - elif choice == "管理" and can_manage: - self._handle_manage_tasks(player, guild) - elif choice == "退出" or choice is None: - break - else: - player.show("§c无效选项") - - return True - - -def _handle_view_tasks(self, player: Player, guild: GuildData) -> bool: - """查看任务列表""" - if not guild.tasks: - player.show("§l§a公会任务 §d>> §r暂无任务") - return True - - def formatter(i, task: GuildTask): - status = "§a已完成" if task.completed else f"§e进行中 ({ - task.current_count}/{ - task.target_count})" - progress_bar = self._create_progress_bar( - task.current_count, task.target_count) - - deadline_str = "" - if task.deadline > 0: - remaining = task.deadline - time.time() - if remaining > 0: - deadline_str = f" §7| 剩余: §f{ - self._format_time_duration(remaining)}" - else: - deadline_str = " §7| §c已过期" - - return ( - f"§e{i}. §f{task.name} [{status}§f]\n" - f" §7{task.description}\n" - f" {progress_bar}{deadline_str}\n" - f" §7奖励: §b{task.reward_contribution}贡献点 " - f"§7+ §e{task.reward_exp}经验\n" - ) - - self._paginate_display(player, guild.tasks, "公会任务列表", formatter) - return True - - -def _handle_join_task(self, player: Player, guild: GuildData) -> bool: - """参与任务""" - active_tasks = [t for t in guild.tasks if not t.completed] - - if not active_tasks: - player.show("§l§a公会任务 §d>> §r暂无可参与的任务") - return True - - def formatter(i, task: GuildTask): - status = f"§e进行中 ({task.current_count}/{task.target_count})" - is_participant = player.name in task.participants - participant_status = " §a[已参与]" if is_participant else " §7[未参与]" - - return ( - f"§e{i}. §f{ - task.name} [{status}§f]{participant_status}\n" f" §7{ - task.description}\n" f" §7奖励: §b{ - task.reward_contribution}贡献点 §7+ §e{ - task.reward_exp}经验\n") - - idx = self._paginate_display( - player, active_tasks, "选择参与任务", formatter, True) - if idx is None: - return True - - task = active_tasks[idx] - - if player.name in task.participants: - player.show("§l§a公会任务 §d>> §r你已经参与了这个任务") - return True - - # 加入任务 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if guild: - for t in guild.tasks: - if t.task_id == task.task_id: - t.participants.append(player.name) - guild.add_log(f"{player.name} 参与了任务: {t.name}") - break - - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a公会任务 §d>> §r已参与任务: {task.name}") - - return True - - -def _handle_create_task(self, player: Player, guild: GuildData) -> bool: - """创建新任务""" - if not ( - guild.has_permission( - player.name, - "task_create") or guild.has_permission( - player.name, - "task_manage")): - player.show("§l§a公会任务 §d>> §r你没有创建任务权限") - return True - - task_config = getattr(Config, "GUILD_TASK_CONFIG", {}) - max_name_len = int(task_config.get("创建任务名称最大长度", 20)) - max_desc_len = int(task_config.get("创建任务描述最大长度", 100)) - max_target_count = int(task_config.get("创建任务目标数量上限", 10000)) - max_contribution_reward = int(task_config.get("创建任务贡献奖励上限", 1000)) - max_exp_reward = int(task_config.get("创建任务经验奖励上限", 1000)) - - player.show("§r========== §a创建任务§r ==========") - player.show("§7任务类型:") - player.show("§e1. §f收集任务 (收集指定物品)") - player.show("§e2. §f建造任务 (放置指定方块)") - player.show("§e3. §f贸易任务 (进行仓库交易)") - player.show("§7输入任务类型序号:") - - task_type_choice = game_utils.waitMsg(player.name, timeout=30) - - if task_type_choice == "1": - task_type = "collect" - type_name = "收集任务" - elif task_type_choice == "2": - task_type = "build" - type_name = "建造任务" - elif task_type_choice == "3": - task_type = "trade" - type_name = "贸易任务" - else: - player.show("§c无效的任务类型") - return True - - # 获取任务名称 - player.show(f"§l§a创建{type_name} §d>> §r请输入任务名称:") - task_name = game_utils.waitMsg(player.name, timeout=30) - if not task_name or len(task_name) > max_name_len: - player.show("§c任务名称无效或过长") - return True - - # 获取任务描述 - player.show("§l§a创建任务 §d>> §r请输入任务描述:") - description = game_utils.waitMsg(player.name, timeout=60) - if not description or len(description) > max_desc_len: - player.show("§c任务描述无效或过长") - return True - - # 获取目标 - if task_type == "collect": - player.show("§l§a创建任务 §d>> §r请输入目标物品名称:") - player.show("§7支持中文名称,如: 钻石、铁锭、金块等") - player.show("§7也支持英文ID,如: minecraft:diamond") - - user_input = game_utils.waitMsg(player.name, timeout=30) - if not user_input: - player.show("§c输入为空") - return True - - # 使用智能匹配查找物品ID - item_id, suggestions = self.item_matcher.validate_and_suggest( - user_input) - - if not item_id: - player.show("§c未找到匹配的物品") - if suggestions: - player.show("§7您是否想要:") - for i, suggestion in enumerate(suggestions[:3], 1): - player.show(f"§e{i}. §f{suggestion}") - return True - - target = item_id - chinese_name = self.item_matcher.get_chinese_name(item_id) - player.show(f"§l§a创建任务 §d>> §r目标物品: §f{chinese_name}") - - elif task_type == "build": - player.show("§l§a创建任务 §d>> §r请输入目标方块名称:") - player.show("§7支持中文名称,如: 石头、圆石、橡木等") - player.show("§7也支持英文ID,如: minecraft:stone") - - user_input = game_utils.waitMsg(player.name, timeout=30) - if not user_input: - player.show("§c输入为空") - return True - - # 使用智能匹配查找方块ID - block_id, suggestions = self.item_matcher.validate_and_suggest( - user_input) - - if not block_id: - player.show("§c未找到匹配的方块") - if suggestions: - player.show("§7您是否想要:") - for i, suggestion in enumerate(suggestions[:3], 1): - player.show(f"§e{i}. §f{suggestion}") - return True - - target = block_id - chinese_name = self.item_matcher.get_chinese_name(block_id) - player.show(f"§l§a创建任务 §d>> §r目标方块: §f{chinese_name}") - - else: # trade - target = "trade_count" - - # 获取目标数量 - player.show("§l§a创建任务 §d>> §r请输入目标数量:") - count_str = game_utils.waitMsg(player.name, timeout=30) - if not count_str or not count_str.isdigit(): - player.show("§c无效的数量") - return True - - target_count = int(count_str) - if target_count <= 0 or target_count > max_target_count: - player.show(f"§c数量必须在1-{max_target_count}之间") - return True - - # 获取奖励 - player.show("§l§a创建任务 §d>> §r请输入贡献点奖励:") - contrib_str = game_utils.waitMsg(player.name, timeout=30) - if not contrib_str or not contrib_str.isdigit(): - player.show("§c无效的贡献点数量") - return True - - reward_contribution = int(contrib_str) - if reward_contribution < 0 or reward_contribution > max_contribution_reward: - player.show(f"§c贡献点奖励必须在0-{max_contribution_reward}之间") - return True - - player.show("§l§a创建任务 §d>> §r请输入经验奖励:") - exp_str = game_utils.waitMsg(player.name, timeout=30) - if not exp_str or not exp_str.isdigit(): - player.show("§c无效的经验数量") - return True - - reward_exp = int(exp_str) - if reward_exp < 0 or reward_exp > max_exp_reward: - player.show(f"§c经验奖励必须在0-{max_exp_reward}之间") - return True - + except Exception as e: + player.show("§l§a公会 §d>> §r更新公告失败") + fmts.print_err(f"更新公告时出错:{e}") + + return True + + +def _handle_tasks(self, player: Player) -> bool: # skipcq: PY-R1000 + """处理公会任务系统""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + while True: + latest_guild = self.guild_manager.get_guild_by_player( + player.name, force_reload=True) + if latest_guild: + guild = latest_guild + + player.show("§r========== §a公会任务§r ==========") + + # 显示活跃任务统计 + active_tasks = [t for t in guild.tasks if not t.completed] + completed_tasks = [t for t in guild.tasks if t.completed] + + player.show( + f"§7活跃任务: §e{ + len(active_tasks)} §7| 已完成: §a{ + len(completed_tasks)}") + can_manage_legacy = guild.has_permission(player.name, "task_manage") + can_create = guild.has_permission( + player.name, "task_create") or can_manage_legacy + can_delete = guild.has_permission( + player.name, "task_delete") or can_manage_legacy + can_complete = guild.has_permission( + player.name, "task_complete") or can_manage_legacy + can_manage = can_delete or can_complete + can_auto = ( + can_create + and getattr(Config, "GUILD_TASK_CONFIG", {}).get("启用自动任务模板", True) + ) + + menu_options = [ + ("查看", "查看所有任务", True), + ("参与", "参与任务", len(active_tasks) > 0), + ("创建", "创建新任务", can_create), + ("自动", "生成自动任务模板", can_auto), + ("管理", "管理任务", can_manage), + ("退出", "退出任务系统", True) + ] + + available_options = [(cmd, desc) + for cmd, desc, cond in menu_options if cond] + + for cmd, desc in available_options: + player.show(f"§e● {cmd} §7- {desc}") + + player.show("§7输入选项:") + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "查看": + self._handle_view_tasks(player, guild) + elif choice == "参与" and len(active_tasks) > 0: + self._handle_join_task(player, guild) + elif choice == "创建" and can_create: + self._handle_create_task(player, guild) + elif choice == "自动" and can_auto: + self._handle_generate_auto_tasks(player, guild) + elif choice == "管理" and can_manage: + self._handle_manage_tasks(player, guild) + elif choice == "退出" or choice is None: + break + else: + player.show("§c无效选项") + + return True + + +def _handle_view_tasks(self, player: Player, guild: GuildData) -> bool: + """查看任务列表""" + if not guild.tasks: + player.show("§l§a公会任务 §d>> §r暂无任务") + return True + + def formatter(i, task: GuildTask): + """Format one menu item for display.""" + status = "§a已完成" if task.completed else f"§e进行中 ({ + task.current_count}/{ + task.target_count})" + progress_bar = self._create_progress_bar( + task.current_count, task.target_count) + + deadline_str = "" + if task.deadline > 0: + remaining = task.deadline - time.time() + if remaining > 0: + deadline_str = f" §7| 剩余: §f{ + self._format_time_duration(remaining)}" + else: + deadline_str = " §7| §c已过期" + + return ( + f"§e{i}. §f{task.name} [{status}§f]\n" + f" §7{task.description}\n" + f" {progress_bar}{deadline_str}\n" + f" §7奖励: §b{task.reward_contribution}贡献点 " + f"§7+ §e{task.reward_exp}经验\n" + ) + + self._paginate_display(player, guild.tasks, "公会任务列表", formatter) + return True + + +def _handle_join_task(self, player: Player, guild: GuildData) -> bool: + """参与任务""" + active_tasks = [t for t in guild.tasks if not t.completed] + + if not active_tasks: + player.show("§l§a公会任务 §d>> §r暂无可参与的任务") + return True + + def formatter(i, task: GuildTask): + """Format one menu item for display.""" + status = f"§e进行中 ({task.current_count}/{task.target_count})" + is_participant = player.name in task.participants + participant_status = " §a[已参与]" if is_participant else " §7[未参与]" + + return ( + f"§e{i}. §f{ + task.name} [{status}§f]{participant_status}\n" f" §7{ + task.description}\n" f" §7奖励: §b{ + task.reward_contribution}贡献点 §7+ §e{ + task.reward_exp}经验\n") + + idx = self._paginate_display( + player, active_tasks, "选择参与任务", formatter, True) + if idx is None: + return True + + task = active_tasks[idx] + + if player.name in task.participants: + player.show("§l§a公会任务 §d>> §r你已经参与了这个任务") + return True + + # 加入任务 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild: + for t in guild.tasks: + if t.task_id == task.task_id: + t.participants.append(player.name) + guild.add_log(f"{player.name} 参与了任务: {t.name}") + break + + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会任务 §d>> §r已参与任务: {task.name}") + + return True + + +def _handle_create_task( # skipcq: PY-R1000 + self, + player: Player, + guild: GuildData, +) -> bool: + """创建新任务""" + if not ( + guild.has_permission( + player.name, + "task_create") or guild.has_permission( + player.name, + "task_manage")): + player.show("§l§a公会任务 §d>> §r你没有创建任务权限") + return True + + task_config = getattr(Config, "GUILD_TASK_CONFIG", {}) + max_name_len = int(task_config.get("创建任务名称最大长度", 20)) + max_desc_len = int(task_config.get("创建任务描述最大长度", 100)) + max_target_count = int(task_config.get("创建任务目标数量上限", 10000)) + max_contribution_reward = int(task_config.get("创建任务贡献奖励上限", 1000)) + max_exp_reward = int(task_config.get("创建任务经验奖励上限", 1000)) + + player.show("§r========== §a创建任务§r ==========") + player.show("§7任务类型:") + player.show("§e1. §f收集任务 (收集指定物品)") + player.show("§e2. §f建造任务 (放置指定方块)") + player.show("§e3. §f贸易任务 (进行仓库交易)") + player.show("§7输入任务类型序号:") + + task_type_choice = game_utils.waitMsg(player.name, timeout=30) + + if task_type_choice == "1": + task_type = "collect" + type_name = "收集任务" + elif task_type_choice == "2": + task_type = "build" + type_name = "建造任务" + elif task_type_choice == "3": + task_type = "trade" + type_name = "贸易任务" + else: + player.show("§c无效的任务类型") + return True + + # 获取任务名称 + player.show(f"§l§a创建{type_name} §d>> §r请输入任务名称:") + task_name = game_utils.waitMsg(player.name, timeout=30) + if not task_name or len(task_name) > max_name_len: + player.show("§c任务名称无效或过长") + return True + + # 获取任务描述 + player.show("§l§a创建任务 §d>> §r请输入任务描述:") + description = game_utils.waitMsg(player.name, timeout=60) + if not description or len(description) > max_desc_len: + player.show("§c任务描述无效或过长") + return True + + # 获取目标 + if task_type == "collect": + player.show("§l§a创建任务 §d>> §r请输入目标物品名称:") + player.show("§7支持中文名称,如: 钻石、铁锭、金块等") + player.show("§7也支持英文ID,如: minecraft:diamond") + + user_input = game_utils.waitMsg(player.name, timeout=30) + if not user_input: + player.show("§c输入为空") + return True + + # 使用智能匹配查找物品ID + item_id, suggestions = self.item_matcher.validate_and_suggest( + user_input) + + if not item_id: + player.show("§c未找到匹配的物品") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + return True + + target = item_id + chinese_name = self.item_matcher.get_chinese_name(item_id) + player.show(f"§l§a创建任务 §d>> §r目标物品: §f{chinese_name}") + + elif task_type == "build": + player.show("§l§a创建任务 §d>> §r请输入目标方块名称:") + player.show("§7支持中文名称,如: 石头、圆石、橡木等") + player.show("§7也支持英文ID,如: minecraft:stone") + + user_input = game_utils.waitMsg(player.name, timeout=30) + if not user_input: + player.show("§c输入为空") + return True + + # 使用智能匹配查找方块ID + block_id, suggestions = self.item_matcher.validate_and_suggest( + user_input) + + if not block_id: + player.show("§c未找到匹配的方块") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + return True + + target = block_id + chinese_name = self.item_matcher.get_chinese_name(block_id) + player.show(f"§l§a创建任务 §d>> §r目标方块: §f{chinese_name}") + + else: # trade + target = "trade_count" + + # 获取目标数量 + player.show("§l§a创建任务 §d>> §r请输入目标数量:") + count_str = game_utils.waitMsg(player.name, timeout=30) + if not count_str or not count_str.isdigit(): + player.show("§c无效的数量") + return True + + target_count = int(count_str) + if target_count <= 0 or target_count > max_target_count: + player.show(f"§c数量必须在1-{max_target_count}之间") + return True + + # 获取奖励 + player.show("§l§a创建任务 §d>> §r请输入贡献点奖励:") + contrib_str = game_utils.waitMsg(player.name, timeout=30) + if not contrib_str or not contrib_str.isdigit(): + player.show("§c无效的贡献点数量") + return True + + reward_contribution = int(contrib_str) + if reward_contribution < 0 or reward_contribution > max_contribution_reward: + player.show(f"§c贡献点奖励必须在0-{max_contribution_reward}之间") + return True + + player.show("§l§a创建任务 §d>> §r请输入经验奖励:") + exp_str = game_utils.waitMsg(player.name, timeout=30) + if not exp_str or not exp_str.isdigit(): + player.show("§c无效的经验数量") + return True + + reward_exp = int(exp_str) + if reward_exp < 0 or reward_exp > max_exp_reward: + player.show(f"§c经验奖励必须在0-{max_exp_reward}之间") + return True + task_id = uuid.uuid4().hex[:8] - new_task = GuildTask( - task_id=task_id, - name=task_name, - description=description, - task_type=task_type, - target=target, - target_count=target_count, - reward_contribution=reward_contribution, - reward_exp=reward_exp - ) - - # 保存任务 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if guild: - guild.tasks.append(new_task) - guild.add_log(f"{player.name} 创建了任务: {task_name}") - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a公会任务 §d>> §r任务 '{task_name}' 创建成功!") - - return True - - -def _handle_generate_auto_tasks( - self, - player: Player, - guild: GuildData) -> bool: - """按配置模板生成自动任务""" - if not ( - guild.has_permission( - player.name, - "task_create") or guild.has_permission( - player.name, - "task_manage")): - player.show("§l§a公会任务 §d>> §r你没有生成任务权限") - return True - - task_config = getattr(Config, "GUILD_TASK_CONFIG", {}) - if not task_config.get("启用自动任务模板", True): - player.show("§l§a公会任务 §d>> §r自动任务模板未启用") - return True - - templates = task_config.get("自动任务模板列表", []) - if not templates: - player.show("§l§a公会任务 §d>> §r暂无自动任务模板") - return True - - guilds = self.guild_manager.load_guilds(force_reload=True) - latest_guild = guilds.get(guild.guild_id) - if not latest_guild: - player.show("§l§a公会任务 §d>> §r公会数据异常") - return True - - active_auto_tasks = [ - task for task in latest_guild.tasks - if not task.completed and task.task_id.startswith("auto-") - ] - max_active = int(task_config.get("自动任务最大同时存在数量", 6)) + new_task = GuildTask( + task_id=task_id, + name=task_name, + description=description, + task_type=task_type, + target=target, + target_count=target_count, + reward_contribution=reward_contribution, + reward_exp=reward_exp + ) + + # 保存任务 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild: + guild.tasks.append(new_task) + guild.add_log(f"{player.name} 创建了任务: {task_name}") + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会任务 §d>> §r任务 '{task_name}' 创建成功!") + + return True + + +def _handle_generate_auto_tasks( + self, + player: Player, + guild: GuildData) -> bool: + """按配置模板生成自动任务""" + if not ( + guild.has_permission( + player.name, + "task_create") or guild.has_permission( + player.name, + "task_manage")): + player.show("§l§a公会任务 §d>> §r你没有生成任务权限") + return True + + task_config = getattr(Config, "GUILD_TASK_CONFIG", {}) + if not task_config.get("启用自动任务模板", True): + player.show("§l§a公会任务 §d>> §r自动任务模板未启用") + return True + + templates = task_config.get("自动任务模板列表", []) + if not templates: + player.show("§l§a公会任务 §d>> §r暂无自动任务模板") + return True + + guilds = self.guild_manager.load_guilds(force_reload=True) + latest_guild = guilds.get(guild.guild_id) + if not latest_guild: + player.show("§l§a公会任务 §d>> §r公会数据异常") + return True + + active_auto_tasks = [ + task for task in latest_guild.tasks + if not task.completed and task.task_id.startswith("auto-") + ] + max_active = int(task_config.get("自动任务最大同时存在数量", 6)) if len(active_auto_tasks) >= max_active > 0: player.show(f"§l§a公会任务 §d>> §r自动任务数量已达上限 {max_active}") return True - - generate_count = int(task_config.get("每次生成自动任务数量", 3)) - if max_active > 0: - generate_count = min( - generate_count, - max_active - - len(active_auto_tasks)) - if generate_count <= 0: - player.show("§l§a公会任务 §d>> §r无需生成新的自动任务") - return True - - active_keys = { - (task.name, task.task_type, task.target) - for task in latest_guild.tasks - if not task.completed - } - candidates = [ - template for template in templates - if ( - template.get("name"), - template.get("task_type"), - template.get("target"), - ) not in active_keys - ] - if not candidates: - player.show("§l§a公会任务 §d>> §r所有自动任务模板都已存在") - return True - - now = time.time() - deadline_seconds = int(task_config.get("自动任务默认有效期秒", 172800)) - deadline = now + deadline_seconds if deadline_seconds > 0 else 0 - created_tasks = [] - for template in candidates[:generate_count]: - task = GuildTask( - task_id=f"auto-{uuid.uuid4().hex[: 8]} ", - name=str(template.get("name", "自动任务"))[: 20], - description=str(template.get("description", ""))[: 100], - task_type=str(template.get("task_type", "trade")), - target=str(template.get("target", "trade_count")), - target_count=max(1, int(template.get("target_count", 1))), - reward_exp=max(0, int(template.get("reward_exp", 0))), - reward_contribution=max( - 0, int(template.get("reward_contribution", 0))), - create_time=now, deadline=deadline,) - latest_guild.tasks.append(task) - created_tasks.append(task) - - latest_guild.add_log(f"{player.name} 生成了 {len(created_tasks)} 个自动任务") - latest_guild.add_audit_log( - "task_auto_generate", - player.name, - detail=",".join(task.name for task in created_tasks), - ) - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a公会任务 §d>> §r已生成 {len(created_tasks)} 个自动任务") - return True - - -def _handle_manage_tasks(self, player: Player, guild: GuildData) -> bool: - """管理任务""" - can_manage_legacy = guild.has_permission(player.name, "task_manage") - can_delete = guild.has_permission( - player.name, "task_delete") or can_manage_legacy - can_complete = guild.has_permission( - player.name, "task_complete") or can_manage_legacy - - if not can_delete and not can_complete: - player.show("§l§a公会任务 §d>> §r你没有管理任务权限") - return True - - player.show("§r========== §a任务管理§r ==========") - if can_delete: - player.show("§e1. §f删除任务") - if can_complete: - player.show("§e2. §f完成任务") - player.show("§7输入选项序号:") - - choice = game_utils.waitMsg(player.name, timeout=30) - - if choice == "1" and can_delete: - if not guild.tasks: - player.show("§l§a公会任务 §d>> §r暂无任务") - return True - # 删除任务 - - def formatter(i, task: GuildTask): - status = "§a已完成" if task.completed else "§e进行中" - return f"§e{i}. §f{ - task.name} [{status}§f]\n §7{ - task.description}\n" - - idx = self._paginate_display( - player, guild.tasks, "选择删除任务", formatter, True) - if idx is not None: - task = guild.tasks[idx] - player.show(f"§l§a任务管理 §d>> §r确认删除任务 '{task.name}'?输入 '确认' 继续") - confirm = game_utils.waitMsg(player.name, timeout=20) - - if confirm == "确认": - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if guild and idx < len(guild.tasks): - removed_task = guild.tasks.pop(idx) - guild.add_log(f"{player.name} 删除了任务: {removed_task.name}") - guild.add_audit_log("task_delete", player.name, - detail=removed_task.name) - self.guild_manager.save_guilds(guilds) - player.show( - f"§l§a任务管理 §d>> §r任务 '{ - removed_task.name}' 已删除") - - elif choice == "2" and can_complete: - # 强制完成任务 - active_tasks = [t for t in guild.tasks if not t.completed] - if not active_tasks: - player.show("§l§a任务管理 §d>> §r暂无进行中的任务") - return True - - def formatter(i, task: GuildTask): - return f"§e{i}. §f{ - task.name} §7({ - task.current_count}/{ - task.target_count})\n §7{ - task.description}\n" - - idx = self._paginate_display( - player, active_tasks, "选择完成任务", formatter, True) - if idx is not None: - task = active_tasks[idx] - player.show(f"§l§a任务管理 §d>> §r确认强制完成任务 '{task.name}'?输入 '确认' 继续") - confirm = game_utils.waitMsg(player.name, timeout=20) - - if confirm == "确认": - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if guild: - for t in guild.tasks: - if t.task_id == task.task_id: - t.completed = True - t.current_count = t.target_count - guild.add_log(f"{player.name} 强制完成了任务: {t.name}") - guild.add_audit_log("task_force_complete", - player.name, detail=t.name) - break - - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a任务管理 §d>> §r任务 '{task.name}' 已完成") - else: - player.show("§c无效选项") - - return True - - -def _handle_manage_members(self, player: Player) -> bool: - """管理公会成员""" - guild = self.guild_manager.get_guild_by_player(player.name) - member = guild.get_member(player.name) if guild else None - - if not guild or not member or not any( - guild.has_permission(player.name, permission) - for permission in ("kick", "set_rank", "transfer_owner", "join_queue") - ): - player.show("§l§a公会 §d>> §r你没有管理权限") - return True - - player.show("§r========== §a成员管理§r ==========") - options = [ - ("1", "踢出成员", guild.has_permission(player.name, "kick")), - ("2", "设置职位", guild.has_permission(player.name, "set_rank")), - ("3", "转让会长", guild.has_permission(player.name, "transfer_owner")), - ("4", "申请队列", guild.has_permission(player.name, "join_queue")), - ] - for key, label, enabled in options: - if enabled: - player.show(f"§e{key}. §f{label}") - player.show("§7输入选项序号,q 返回") - - choice = game_utils.waitMsg(player.name, timeout=30) - - if choice == "1" and guild.has_permission(player.name, "kick"): - return self._handle_kick_member(player) - elif choice == "2" and guild.has_permission(player.name, "set_rank"): - return self._handle_set_rank(player) - elif choice == "3" and guild.has_permission(player.name, "transfer_owner"): - return self._handle_transfer_ownership(player) - elif choice == "4" and guild.has_permission(player.name, "join_queue"): - return self._handle_join_request_queue(player, guild) - - return True - - -def _notify_join_request_admins( - self, - guild: GuildData, - applicant_name: str) -> None: - """通知在线的申请队列处理人。""" - if not getattr( - Config, - "GUILD_JOIN_REQUEST_CONFIG", - {}).get( - "申请提交后通知在线管理员", - True): - return - + + generate_count = int(task_config.get("每次生成自动任务数量", 3)) + if max_active > 0: + generate_count = min( + generate_count, + max_active - + len(active_auto_tasks)) + if generate_count <= 0: + player.show("§l§a公会任务 §d>> §r无需生成新的自动任务") + return True + + active_keys = { + (task.name, task.task_type, task.target) + for task in latest_guild.tasks + if not task.completed + } + candidates = [ + template for template in templates + if ( + template.get("name"), + template.get("task_type"), + template.get("target"), + ) not in active_keys + ] + if not candidates: + player.show("§l§a公会任务 §d>> §r所有自动任务模板都已存在") + return True + + now = time.time() + deadline_seconds = int(task_config.get("自动任务默认有效期秒", 172800)) + deadline = now + deadline_seconds if deadline_seconds > 0 else 0 + created_tasks = [] + for template in candidates[:generate_count]: + task = GuildTask( + task_id=f"auto-{uuid.uuid4().hex[: 8]} ", + name=str(template.get("name", "自动任务"))[: 20], + description=str(template.get("description", ""))[: 100], + task_type=str(template.get("task_type", "trade")), + target=str(template.get("target", "trade_count")), + target_count=max(1, int(template.get("target_count", 1))), + reward_exp=max(0, int(template.get("reward_exp", 0))), + reward_contribution=max( + 0, int(template.get("reward_contribution", 0))), + create_time=now, deadline=deadline,) + latest_guild.tasks.append(task) + created_tasks.append(task) + + latest_guild.add_log(f"{player.name} 生成了 {len(created_tasks)} 个自动任务") + latest_guild.add_audit_log( + "task_auto_generate", + player.name, + detail=",".join(task.name for task in created_tasks), + ) + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会任务 §d>> §r已生成 {len(created_tasks)} 个自动任务") + return True + + +def _handle_manage_tasks( # skipcq: PY-R1000 + self, + player: Player, + guild: GuildData, +) -> bool: + """管理任务""" + can_manage_legacy = guild.has_permission(player.name, "task_manage") + can_delete = guild.has_permission( + player.name, "task_delete") or can_manage_legacy + can_complete = guild.has_permission( + player.name, "task_complete") or can_manage_legacy + + if not can_delete and not can_complete: + player.show("§l§a公会任务 §d>> §r你没有管理任务权限") + return True + + player.show("§r========== §a任务管理§r ==========") + if can_delete: + player.show("§e1. §f删除任务") + if can_complete: + player.show("§e2. §f完成任务") + player.show("§7输入选项序号:") + + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "1" and can_delete: + if not guild.tasks: + player.show("§l§a公会任务 §d>> §r暂无任务") + return True + # 删除任务 + + def formatter(i, task: GuildTask): + """Format one menu item for display.""" + status = "§a已完成" if task.completed else "§e进行中" + return f"§e{i}. §f{ + task.name} [{status}§f]\n §7{ + task.description}\n" + + idx = self._paginate_display( + player, guild.tasks, "选择删除任务", formatter, True) + if idx is not None: + task = guild.tasks[idx] + player.show(f"§l§a任务管理 §d>> §r确认删除任务 '{task.name}'?输入 '确认' 继续") + confirm = game_utils.waitMsg(player.name, timeout=20) + + if confirm == "确认": + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild and idx < len(guild.tasks): + removed_task = guild.tasks.pop(idx) + guild.add_log(f"{player.name} 删除了任务: {removed_task.name}") + guild.add_audit_log("task_delete", player.name, + detail=removed_task.name) + self.guild_manager.save_guilds(guilds) + player.show( + f"§l§a任务管理 §d>> §r任务 '{ + removed_task.name}' 已删除") + + elif choice == "2" and can_complete: + # 强制完成任务 + active_tasks = [t for t in guild.tasks if not t.completed] + if not active_tasks: + player.show("§l§a任务管理 §d>> §r暂无进行中的任务") + return True + + def formatter(i, task: GuildTask): + """Format one menu item for display.""" + return f"§e{i}. §f{ + task.name} §7({ + task.current_count}/{ + task.target_count})\n §7{ + task.description}\n" + + idx = self._paginate_display( + player, active_tasks, "选择完成任务", formatter, True) + if idx is not None: + task = active_tasks[idx] + player.show(f"§l§a任务管理 §d>> §r确认强制完成任务 '{task.name}'?输入 '确认' 继续") + confirm = game_utils.waitMsg(player.name, timeout=20) + + if confirm == "确认": + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild: + for t in guild.tasks: + if t.task_id == task.task_id: + t.completed = True + t.current_count = t.target_count + guild.add_log(f"{player.name} 强制完成了任务: {t.name}") + guild.add_audit_log("task_force_complete", + player.name, detail=t.name) + break + + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a任务管理 §d>> §r任务 '{task.name}' 已完成") + else: + player.show("§c无效选项") + + return True + + +def _handle_manage_members(self, player: Player) -> bool: + """管理公会成员""" + guild = self.guild_manager.get_guild_by_player(player.name) + member = guild.get_member(player.name) if guild else None + + if not guild or not member or not any( + guild.has_permission(player.name, permission) + for permission in ("kick", "set_rank", "transfer_owner", "join_queue") + ): + player.show("§l§a公会 §d>> §r你没有管理权限") + return True + + player.show("§r========== §a成员管理§r ==========") + options = [ + ("1", "踢出成员", guild.has_permission(player.name, "kick")), + ("2", "设置职位", guild.has_permission(player.name, "set_rank")), + ("3", "转让会长", guild.has_permission(player.name, "transfer_owner")), + ("4", "申请队列", guild.has_permission(player.name, "join_queue")), + ] + for key, label, enabled in options: + if enabled: + player.show(f"§e{key}. §f{label}") + player.show("§7输入选项序号,q 返回") + + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "1" and guild.has_permission(player.name, "kick"): + return self._handle_kick_member(player) + elif choice == "2" and guild.has_permission(player.name, "set_rank"): + return self._handle_set_rank(player) + elif choice == "3" and guild.has_permission(player.name, "transfer_owner"): + return self._handle_transfer_ownership(player) + elif choice == "4" and guild.has_permission(player.name, "join_queue"): + return self._handle_join_request_queue(player, guild) + + return True + + +def _notify_join_request_admins( + self, + guild: GuildData, + applicant_name: str) -> None: + """通知在线的申请队列处理人。""" + if not getattr( + Config, + "GUILD_JOIN_REQUEST_CONFIG", + {}).get( + "申请提交后通知在线管理员", + True): + return + for member in guild.members: if ( member.name in self.game_ctrl.allplayers @@ -943,794 +964,823 @@ def _notify_join_request_admins( self.game_ctrl.sendcmd( f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' ) - - -def _handle_join_request_queue(self, player: Player, guild: GuildData) -> bool: - """处理加入申请队列""" - if not guild.has_permission(player.name, "join_queue"): - player.show("§l§a公会 §d>> §r你没有处理申请队列权限") - return True - - pending_requests = guild.pending_join_requests() - if not pending_requests: - player.show("§l§a公会 §d>> §r暂无待处理加入申请") - return True - - def formatter(i, request): - age = self._format_time_duration(time.time() - request.create_time) - reason = request.reason or "无" - return ( - f"§e{i}. §f{request.player_name}\n" - f" §7理由: §f{reason} §7| 提交: §f{age}前\n" - ) - - idx = self._paginate_display( - player, - pending_requests, - "加入申请队列", - formatter, - True) - if idx is None: - return True - - selected = pending_requests[idx] - player.show(f"§l§a公会 §d>> §r处理 §e{selected.player_name} §r的加入申请") - player.show("§e1. §f同意") - player.show("§e2. §f拒绝") - player.show("§7输入选项序号:") - choice = game_utils.waitMsg(player.name, timeout=30) - approved = choice in ("1", "同意", "批准") - rejected = choice in ("2", "拒绝") - if not approved and not rejected: - player.show("§l§a公会 §d>> §r操作已取消") - return True - - guilds = self.guild_manager.load_guilds(force_reload=True) - latest_guild = guilds.get(guild.guild_id) - if not latest_guild: - player.show("§l§a公会 §d>> §r公会数据异常") - return True - - if approved and len(latest_guild.members) >= Config.MAX_GUILD_MEMBERS: - latest_guild.resolve_join_request( - selected.player_name, - player.name, - False, - result_reason="公会已满员", - ) - self.guild_manager.save_guilds(guilds) - player.show("§l§a公会 §d>> §r公会已满员,已拒绝该申请") - return True - - if not latest_guild.resolve_join_request( - selected.player_name, - player.name, - approved, - result_reason="管理员处理", - ): - player.show("§l§a公会 §d>> §r申请已不存在或已过期") - return True - - if approved: - new_member = GuildMember( - name=selected.player_name, - rank=GuildRank.MEMBER, - join_time=time.time(), - ) - latest_guild.members.append(new_member) - latest_guild.add_log( - f"{selected.player_name} 加入公会 (审核人: {player.name})") - self.guild_manager.cache_player_guild( - selected.player_name, latest_guild.guild_id) - - self.guild_manager.save_guilds(guilds) - result_text = "已同意" if approved else "已拒绝" - player.show(f"§l§a公会 §d>> §r{result_text} {selected.player_name} 的加入申请") - - if selected.player_name in self.game_ctrl.allplayers: - message = ( - f"§l§a公会 §d>> §r你的公会申请已通过,已加入 §e{latest_guild.name}" - if approved - else f"§l§a公会 §d>> §r你加入 §e{latest_guild.name} §r的申请被拒绝" - ) - self.game_ctrl.sendcmd( - f'/tellraw {selected.player_name} {{"rawtext":[{{"text":"{message}"}}]}}' - ) - - if approved and getattr( - Config, - "GUILD_JOIN_REQUEST_CONFIG", - {}).get( - "批准后通知全体在线成员", - True): + + +def _handle_join_request_queue( # skipcq: PY-R1000 + self, + player: Player, + guild: GuildData, +) -> bool: + """处理加入申请队列""" + if not guild.has_permission(player.name, "join_queue"): + player.show("§l§a公会 §d>> §r你没有处理申请队列权限") + return True + + pending_requests = guild.pending_join_requests() + if not pending_requests: + player.show("§l§a公会 §d>> §r暂无待处理加入申请") + return True + + def formatter(i, request): + """Format one menu item for display.""" + age = self._format_time_duration(time.time() - request.create_time) + reason = request.reason or "无" + return ( + f"§e{i}. §f{request.player_name}\n" + f" §7理由: §f{reason} §7| 提交: §f{age}前\n" + ) + + idx = self._paginate_display( + player, + pending_requests, + "加入申请队列", + formatter, + True) + if idx is None: + return True + + selected = pending_requests[idx] + player.show(f"§l§a公会 §d>> §r处理 §e{selected.player_name} §r的加入申请") + player.show("§e1. §f同意") + player.show("§e2. §f拒绝") + player.show("§7输入选项序号:") + choice = game_utils.waitMsg(player.name, timeout=30) + approved = choice in ("1", "同意", "批准") + rejected = choice in ("2", "拒绝") + if not approved and not rejected: + player.show("§l§a公会 §d>> §r操作已取消") + return True + + guilds = self.guild_manager.load_guilds(force_reload=True) + latest_guild = guilds.get(guild.guild_id) + if not latest_guild: + player.show("§l§a公会 §d>> §r公会数据异常") + return True + + if approved and len(latest_guild.members) >= Config.MAX_GUILD_MEMBERS: + latest_guild.resolve_join_request( + selected.player_name, + player.name, + False, + result_reason="公会已满员", + ) + self.guild_manager.save_guilds(guilds) + player.show("§l§a公会 §d>> §r公会已满员,已拒绝该申请") + return True + + if not latest_guild.resolve_join_request( + selected.player_name, + player.name, + approved, + result_reason="管理员处理", + ): + player.show("§l§a公会 §d>> §r申请已不存在或已过期") + return True + + if approved: + new_member = GuildMember( + name=selected.player_name, + rank=GuildRank.MEMBER, + join_time=time.time(), + ) + latest_guild.members.append(new_member) + latest_guild.add_log( + f"{selected.player_name} 加入公会 (审核人: {player.name})") + self.guild_manager.cache_player_guild( + selected.player_name, latest_guild.guild_id) + + self.guild_manager.save_guilds(guilds) + result_text = "已同意" if approved else "已拒绝" + player.show(f"§l§a公会 §d>> §r{result_text} {selected.player_name} 的加入申请") + + if selected.player_name in self.game_ctrl.allplayers: + message = ( + f"§l§a公会 §d>> §r你的公会申请已通过,已加入 §e{latest_guild.name}" + if approved + else f"§l§a公会 §d>> §r你加入 §e{latest_guild.name} §r的申请被拒绝" + ) + self.game_ctrl.sendcmd( + f'/tellraw {selected.player_name} {{"rawtext":[{{"text":"{message}"}}]}}' + ) + + if approved and getattr( + Config, + "GUILD_JOIN_REQUEST_CONFIG", + {}).get( + "批准后通知全体在线成员", + True): for member in latest_guild.members: - if member.name in self.game_ctrl.allplayers and member.name != selected.player_name: + if ( + member.name in self.game_ctrl.allplayers + and member.name != selected.player_name + ): message = f"§l§a公会 §d>> §r§e{selected.player_name}§r 加入了公会" self.game_ctrl.sendcmd( f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' ) - - return True - - -def _handle_set_rank(self, player: Player) -> bool: - """设置成员职位""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - return True - if not guild.has_permission(player.name, "set_rank"): - player.show("§l§a公会 §d>> §r你没有设置成员职位权限") - return True - - # 过滤可管理的成员 - manageable_members = [ - m for m in guild.members - if guild.can_manage_member(player.name, m.name) - ] - - if not manageable_members: - player.show("§l§a公会 §d>> §r没有可管理的成员") - return True - - def formatter(i, m): return f"§e{i}. {m.rank.display_name} §f{m.name}\n" - idx = self._paginate_display( - player, - manageable_members, - "选择成员", - formatter, - True) - - if idx is None: - return True - - target_member = manageable_members[idx] - - # 选择新职位 - player.show("§r========== §a设置职位§r ==========") - player.show("§e1. §6副会长") - player.show("§e2. §e长老") - player.show("§e3. §a成员") - player.show("§7输入选项序号") - - rank_choice = game_utils.waitMsg(player.name, timeout=30) - rank_map = { - "1": GuildRank.DEPUTY, - "2": GuildRank.ELDER, - "3": GuildRank.MEMBER} - - new_rank = rank_map.get(rank_choice) + + return True + + +def _handle_set_rank(self, player: Player) -> bool: + """设置成员职位""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + return True + if not guild.has_permission(player.name, "set_rank"): + player.show("§l§a公会 §d>> §r你没有设置成员职位权限") + return True + + # 过滤可管理的成员 + manageable_members = [ + m for m in guild.members + if guild.can_manage_member(player.name, m.name) + ] + + if not manageable_members: + player.show("§l§a公会 §d>> §r没有可管理的成员") + return True + + def formatter(i, m): + """Format one menu item for display.""" + return f"§e{i}. {m.rank.display_name} §f{m.name}\n" + idx = self._paginate_display( + player, + manageable_members, + "选择成员", + formatter, + True) + + if idx is None: + return True + + target_member = manageable_members[idx] + + # 选择新职位 + player.show("§r========== §a设置职位§r ==========") + player.show("§e1. §6副会长") + player.show("§e2. §e长老") + player.show("§e3. §a成员") + player.show("§7输入选项序号") + + rank_choice = game_utils.waitMsg(player.name, timeout=30) + rank_map = { + "1": GuildRank.DEPUTY, + "2": GuildRank.ELDER, + "3": GuildRank.MEMBER} + + new_rank = rank_map.get(rank_choice) if new_rank and self.guild_manager.set_member_rank( guild, target_member.name, new_rank): player.show( f"§l§a公会 §d>> §r已将 { target_member.name} 的职位设置为 { new_rank.display_name}") - - return True - - -def _handle_donation(self, player: Player) -> bool: - """处理物品捐献""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - player.show("§l§a公会捐献§r") - player.show("§7支持捐献的物品类型:") - player.show("§e钻石 §7- 50贡献/个") - player.show("§e绿宝石 §7- 25贡献/个") - player.show("§e金锭 §7- 10贡献/个") - player.show("§e铁锭 §7- 5贡献/个") - player.show("§e下界合金锭 §7- 200贡献/个") - player.show("§e远古残骸 §7- 150贡献/个") - player.show("§7以及其他有价值的物品...") - player.show("§7请输入要捐献的物品名称 (支持中文名称):") - - user_input = game_utils.waitMsg(player.name, timeout=30) - - if not user_input or user_input.lower() == 'q': - return True - - # 使用智能匹配查找物品ID - item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) - - if not item_id: - player.show("§l§a公会捐献 §d>> §r未找到匹配的物品") - if suggestions: - player.show("§7您是否想要:") - for i, suggestion in enumerate(suggestions[:3], 1): - player.show(f"§e{i}. §f{suggestion}") - return True - - # 获取物品的贡献点价值 - contrib_per_item = guild.get_item_value(item_id) - exp_per_item = contrib_per_item * 0.5 # 经验为贡献点的一半 - - chinese_name = self.item_matcher.get_chinese_name(item_id) - player.show(f"§l§a公会捐献 §d>> §r选择物品: §f{chinese_name}") - player.show(f"§7价值: §e{contrib_per_item}贡献点/个 §7+ §b{exp_per_item}经验/个") - - player.show(f"§7你有 {player.getItemCount(item_id)} 个{chinese_name}") - player.show("§7输入要捐献的数量:") - - amount_str = game_utils.waitMsg(player.name, timeout=30) - if not amount_str or not amount_str.isdigit(): - return True - - amount = int(amount_str) - if amount <= 0 or amount > player.getItemCount(item_id): - player.show("§l§a公会 §d>> §r数量无效") - return True - - self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {amount}") - exp, contribution = self.guild_apply_reward_multipliers( - int(amount * exp_per_item), - amount * contrib_per_item, - ) - - # 重新加载公会数据并更新经验值 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild_id = guild.guild_id - - if guild_id in guilds: - # 更新贡献与公会经验 - latest_guild = guilds[guild_id] - member = latest_guild.get_member(player.name) - if member: - member.contribution += contribution - latest_guild.stats.total_contribution += contribution - latest_guild.exp += exp - latest_guild.add_log(f"{player.name} 捐献了 {amount} 个{chinese_name}") - - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a公会 §d>> §r捐献成功!获得 {contribution} 贡献度,公会获得 {exp} 经验") - else: - player.show("§l§a公会 §d>> §r捐献失败,公会数据异常") - fmts.print_err(f"捐献失败: 公会ID {guild_id} 不存在于加载的数据中") - - return True - - -def _handle_vault(self, player: Player) -> bool: - """处理公会仓库""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - if not guild.has_permission(player.name, "vault"): - player.show("§l§a公会仓库 §d>> §r你没有使用仓库权限") - return True - - while True: - latest_guild = self.guild_manager.get_guild_by_player( - player.name, force_reload=True) - if latest_guild: - guild = latest_guild - - player.show("§r========== §a公会仓库§r ==========") - max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 - player.show(f"§7容量: §a{len(guild.vault_items)}/{max_slots}") - - member = guild.get_member(player.name) - if member: - player.show(f"§7你的贡献点: §e{member.contribution}") - - can_buy = guild.has_permission(player.name, "vault_buy") - can_sell = guild.has_permission(player.name, "vault_sell") - can_cancel = ( - getattr(Config, "GUILD_VAULT_CONFIG", {}).get("启用撤回出售", True) - and ( - guild.has_permission(player.name, "vault_cancel_own") - or guild.has_permission(player.name, "vault_cancel_any") - ) - ) - can_view_logs = getattr( - Config, "GUILD_VAULT_CONFIG", {}).get( - "启用交易日志", True) - can_settings = guild.has_permission(player.name, "vault_settings") - - menu_options = [ - ("查看", "查看仓库物品", True), - ("购买", "购买仓库物品", can_buy), - ("出售", "出售物品到仓库", can_sell), - ("撤回", "撤回已上架物品", can_cancel), - ("日志", "查看仓库交易日志", can_view_logs), - ("设置", "设置仓库物品价值", can_settings), - ("退出", "退出仓库", True) - ] - - available_options = [(cmd, desc) - for cmd, desc, cond in menu_options if cond] - - for cmd, desc in available_options: - player.show(f"§e● {cmd} §7- {desc}") - - player.show("§7输入选项:") - choice = game_utils.waitMsg(player.name, timeout=30) - - if choice == "查看": - self._handle_vault_view(player, guild) - elif choice == "购买" and can_buy: - self._handle_vault_buy(player, guild) - elif choice == "出售" and can_sell: - self._handle_vault_sell(player, guild) - elif choice == "撤回" and can_cancel: - self._handle_vault_cancel(player, guild) - elif choice == "日志" and can_view_logs: - self._handle_vault_logs(player, guild) - elif choice == "设置" and can_settings: - self._handle_vault_settings(player, guild) - elif choice == "退出" or choice is None: - break - else: - player.show("§c无效选项") - - return True - - -def _handle_vault_view(self, player: Player, guild: GuildData) -> bool: - """查看仓库物品""" - if not guild.vault_items: - player.show("§l§a公会仓库 §d>> §r仓库为空") - return True - - def formatter(i, item: VaultItem): - # 获取物品显示名称 - item_name = self._get_item_display_name(item.item_id) - time_str = datetime.fromtimestamp( - item.timestamp).strftime("%m-%d %H:%M") - return (f"§e{i}. §f{item_name} §7x{item.count}\n" - f" §7价格: §e{item.price}贡献点 §7| 卖家: §a{item.seller}\n" - f" §7时间: §f{time_str}\n") - - self._paginate_display(player, guild.vault_items, "仓库物品", formatter) - return True - - -def _handle_vault_buy(self, player: Player, guild: GuildData) -> bool: - """购买仓库物品""" - if not guild.has_permission(player.name, "vault_buy"): - player.show("§l§a公会仓库 §d>> §r你没有购买仓库物品权限") - return True - - if not guild.vault_items: - player.show("§l§a公会仓库 §d>> §r仓库为空") - return True - - member = guild.get_member(player.name) - if not member: - return True - - def formatter(i, item: VaultItem): - item_name = self._get_item_display_name(item.item_id) - time_str = datetime.fromtimestamp( - item.timestamp).strftime("%m-%d %H:%M") - affordable = "§a可购买" if member.contribution >= item.price else "§c贡献点不足" - return (f"§e{i}. §f{item_name} §7x{item.count}\n" - f" §7价格: §e{item.price}贡献点 §7| {affordable}\n" - f" §7卖家: §a{item.seller} §7| 时间: §f{time_str}\n") - - idx = self._paginate_display( - player, - guild.vault_items, - "选择购买物品", - formatter, - True) - if idx is None: - return True - - item = guild.vault_items[idx] - if (item.seller == player.name and not getattr( - Config, "GUILD_VAULT_CONFIG", {}).get("允许购买自己出售的物品", False)): - player.show("§l§a公会仓库 §d>> §r不能购买自己出售的物品") - return True - - # 检查贡献点 - if member.contribution < item.price: - player.show("§l§a公会仓库 §d>> §r贡献点不足") - return True - - # 检查背包空间 - if not self._has_inventory_space(player, item.item_id, item.count): - player.show("§l§a公会仓库 §d>> §r背包空间不足") - return True - - # 确认购买 - item_name = self._get_item_display_name(item.item_id) - player.show( - f"§l§a公会仓库 §d>> §r确认购买 §f{item_name} §7x{ - item.count} §r花费 §e{ - item.price}贡献点§r?") - player.show("§7输入 '确认' 继续购买") - - confirm = game_utils.waitMsg(player.name, timeout=20) - if confirm != "确认": - player.show("§l§a公会仓库 §d>> §r购买已取消") - return True - - # 执行购买 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if not guild: - player.show("§l§a公会仓库 §d>> §r公会数据异常") - return True - - # 重新检查物品是否还存在 - if idx >= len( - guild.vault_items) or guild.vault_items[idx].item_id != item.item_id: - player.show("§l§a公会仓库 §d>> §r物品已被其他人购买") - return True - item = guild.vault_items[idx] - if (item.seller == player.name and not getattr( - Config, "GUILD_VAULT_CONFIG", {}).get("允许购买自己出售的物品", False)): - player.show("§l§a公会仓库 §d>> §r不能购买自己出售的物品") - return True - - # 扣除贡献点 - buyer_member = guild.get_member(player.name) - if not buyer_member or buyer_member.contribution < item.price: - player.show("§l§a公会仓库 §d>> §r贡献点不足") - return True - - buyer_member.contribution -= item.price - - # 给卖家贡献点(扣除税费) - tax = int(item.price * Config.VAULT_TRADE_TAX) - seller_income = item.price - tax - seller_member = guild.get_member(item.seller) - if seller_member: - seller_member.contribution += seller_income - - # 给玩家物品 - self.game_ctrl.sendwocmd(f"give {player.name} {item.item_id} {item.count}") - - guild.vault_items.pop(idx) - - guild.add_log( - f"{player.name} 购买了 {item_name} x{item.count} (花费{item.price}贡献点)") - if getattr(Config, "GUILD_VAULT_CONFIG", {}).get("启用交易日志", True): - guild.add_vault_trade_log( - "buy", - item, - player.name, - buyer=player.name, - detail=f"税费{tax},卖家收入{seller_income}", - ) - suggested_value = guild.get_item_value(item.item_id) * item.count - audit_ratio = float( - getattr( - Config, - "GUILD_VAULT_CONFIG", - {}).get( - "高价交易审计倍率", - 3.0)) - if suggested_value > 0 and item.price >= suggested_value * audit_ratio: - guild.add_audit_log( - "vault_high_price_buy", - player.name, - target=item.seller, - detail=f"{item.item_id} x{item.count} price={item.price}", - ) - - self.guild_manager.save_guilds(guilds) - - player.show(f"§l§a公会仓库 §d>> §r购买成功!花费 §e{item.price}贡献点") - - # 更新贸易任务进度 - self.check_and_complete_trade_tasks(player.name) - - # 通知卖家 - if seller_member and item.seller in self.game_ctrl.allplayers: - message = ( - f"§l§a公会仓库 §d>> §r你的 {item_name} x{item.count} " - f"被 {player.name} 购买了,获 {seller_income} 贡献点" + + return True + + +def _handle_donation(self, player: Player) -> bool: + """处理物品捐献""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + player.show("§l§a公会捐献§r") + player.show("§7支持捐献的物品类型:") + player.show("§e钻石 §7- 50贡献/个") + player.show("§e绿宝石 §7- 25贡献/个") + player.show("§e金锭 §7- 10贡献/个") + player.show("§e铁锭 §7- 5贡献/个") + player.show("§e下界合金锭 §7- 200贡献/个") + player.show("§e远古残骸 §7- 150贡献/个") + player.show("§7以及其他有价值的物品...") + player.show("§7请输入要捐献的物品名称 (支持中文名称):") + + user_input = game_utils.waitMsg(player.name, timeout=30) + + if not user_input or user_input.lower() == 'q': + return True + + # 使用智能匹配查找物品ID + item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) + + if not item_id: + player.show("§l§a公会捐献 §d>> §r未找到匹配的物品") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + return True + + # 获取物品的贡献点价值 + contrib_per_item = guild.get_item_value(item_id) + exp_per_item = contrib_per_item * 0.5 # 经验为贡献点的一半 + + chinese_name = self.item_matcher.get_chinese_name(item_id) + player.show(f"§l§a公会捐献 §d>> §r选择物品: §f{chinese_name}") + player.show(f"§7价值: §e{contrib_per_item}贡献点/个 §7+ §b{exp_per_item}经验/个") + + player.show(f"§7你有 {player.getItemCount(item_id)} 个{chinese_name}") + player.show("§7输入要捐献的数量:") + + amount_str = game_utils.waitMsg(player.name, timeout=30) + if not amount_str or not amount_str.isdigit(): + return True + + amount = int(amount_str) + if amount <= 0 or amount > player.getItemCount(item_id): + player.show("§l§a公会 §d>> §r数量无效") + return True + + self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {amount}") + exp, contribution = self.guild_apply_reward_multipliers( + int(amount * exp_per_item), + amount * contrib_per_item, + ) + + # 重新加载公会数据并更新经验值 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_id = guild.guild_id + + if guild_id in guilds: + # 更新贡献与公会经验 + latest_guild = guilds[guild_id] + member = latest_guild.get_member(player.name) + if member: + member.contribution += contribution + latest_guild.stats.total_contribution += contribution + latest_guild.exp += exp + latest_guild.add_log(f"{player.name} 捐献了 {amount} 个{chinese_name}") + + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会 §d>> §r捐献成功!获得 {contribution} 贡献度,公会获得 {exp} 经验") + else: + player.show("§l§a公会 §d>> §r捐献失败,公会数据异常") + fmts.print_err(f"捐献失败: 公会ID {guild_id} 不存在于加载的数据中") + + return True + + +def _handle_vault(self, player: Player) -> bool: # skipcq: PY-R1000 + """处理公会仓库""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + if not guild.has_permission(player.name, "vault"): + player.show("§l§a公会仓库 §d>> §r你没有使用仓库权限") + return True + + while True: + latest_guild = self.guild_manager.get_guild_by_player( + player.name, force_reload=True) + if latest_guild: + guild = latest_guild + + player.show("§r========== §a公会仓库§r ==========") + max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 + player.show(f"§7容量: §a{len(guild.vault_items)}/{max_slots}") + + member = guild.get_member(player.name) + if member: + player.show(f"§7你的贡献点: §e{member.contribution}") + + can_buy = guild.has_permission(player.name, "vault_buy") + can_sell = guild.has_permission(player.name, "vault_sell") + can_cancel = ( + getattr(Config, "GUILD_VAULT_CONFIG", {}).get("启用撤回出售", True) + and ( + guild.has_permission(player.name, "vault_cancel_own") + or guild.has_permission(player.name, "vault_cancel_any") + ) + ) + can_view_logs = getattr( + Config, "GUILD_VAULT_CONFIG", {}).get( + "启用交易日志", True) + can_settings = guild.has_permission(player.name, "vault_settings") + + menu_options = [ + ("查看", "查看仓库物品", True), + ("购买", "购买仓库物品", can_buy), + ("出售", "出售物品到仓库", can_sell), + ("撤回", "撤回已上架物品", can_cancel), + ("日志", "查看仓库交易日志", can_view_logs), + ("设置", "设置仓库物品价值", can_settings), + ("退出", "退出仓库", True) + ] + + available_options = [(cmd, desc) + for cmd, desc, cond in menu_options if cond] + + for cmd, desc in available_options: + player.show(f"§e● {cmd} §7- {desc}") + + player.show("§7输入选项:") + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "查看": + self._handle_vault_view(player, guild) + elif choice == "购买" and can_buy: + self._handle_vault_buy(player, guild) + elif choice == "出售" and can_sell: + self._handle_vault_sell(player, guild) + elif choice == "撤回" and can_cancel: + self._handle_vault_cancel(player, guild) + elif choice == "日志" and can_view_logs: + self._handle_vault_logs(player, guild) + elif choice == "设置" and can_settings: + self._handle_vault_settings(player, guild) + elif choice == "退出" or choice is None: + break + else: + player.show("§c无效选项") + + return True + + +def _handle_vault_view(self, player: Player, guild: GuildData) -> bool: + """查看仓库物品""" + if not guild.vault_items: + player.show("§l§a公会仓库 §d>> §r仓库为空") + return True + + def formatter(i, item: VaultItem): + # 获取物品显示名称 + """Format one menu item for display.""" + item_name = self._get_item_display_name(item.item_id) + time_str = datetime.fromtimestamp( + item.timestamp).strftime("%m-%d %H:%M") + return (f"§e{i}. §f{item_name} §7x{item.count}\n" + f" §7价格: §e{item.price}贡献点 §7| 卖家: §a{item.seller}\n" + f" §7时间: §f{time_str}\n") + + self._paginate_display(player, guild.vault_items, "仓库物品", formatter) + return True + + +def _handle_vault_buy( # skipcq: PY-R1000 + self, + player: Player, + guild: GuildData, +) -> bool: + """购买仓库物品""" + if not guild.has_permission(player.name, "vault_buy"): + player.show("§l§a公会仓库 §d>> §r你没有购买仓库物品权限") + return True + + if not guild.vault_items: + player.show("§l§a公会仓库 §d>> §r仓库为空") + return True + + member = guild.get_member(player.name) + if not member: + return True + + def formatter(i, item: VaultItem): + """Format one menu item for display.""" + item_name = self._get_item_display_name(item.item_id) + time_str = datetime.fromtimestamp( + item.timestamp).strftime("%m-%d %H:%M") + affordable = "§a可购买" if member.contribution >= item.price else "§c贡献点不足" + return (f"§e{i}. §f{item_name} §7x{item.count}\n" + f" §7价格: §e{item.price}贡献点 §7| {affordable}\n" + f" §7卖家: §a{item.seller} §7| 时间: §f{time_str}\n") + + idx = self._paginate_display( + player, + guild.vault_items, + "选择购买物品", + formatter, + True) + if idx is None: + return True + + item = guild.vault_items[idx] + if (item.seller == player.name and not getattr( + Config, "GUILD_VAULT_CONFIG", {}).get("允许购买自己出售的物品", False)): + player.show("§l§a公会仓库 §d>> §r不能购买自己出售的物品") + return True + + # 检查贡献点 + if member.contribution < item.price: + player.show("§l§a公会仓库 §d>> §r贡献点不足") + return True + + # 检查背包空间 + if not self._has_inventory_space(player, item.item_id, item.count): + player.show("§l§a公会仓库 §d>> §r背包空间不足") + return True + + # 确认购买 + item_name = self._get_item_display_name(item.item_id) + player.show( + f"§l§a公会仓库 §d>> §r确认购买 §f{item_name} §7x{ + item.count} §r花费 §e{ + item.price}贡献点§r?") + player.show("§7输入 '确认' 继续购买") + + confirm = game_utils.waitMsg(player.name, timeout=20) + if confirm != "确认": + player.show("§l§a公会仓库 §d>> §r购买已取消") + return True + + # 执行购买 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if not guild: + player.show("§l§a公会仓库 §d>> §r公会数据异常") + return True + + # 重新检查物品是否还存在 + if idx >= len( + guild.vault_items) or guild.vault_items[idx].item_id != item.item_id: + player.show("§l§a公会仓库 §d>> §r物品已被其他人购买") + return True + item = guild.vault_items[idx] + if (item.seller == player.name and not getattr( + Config, "GUILD_VAULT_CONFIG", {}).get("允许购买自己出售的物品", False)): + player.show("§l§a公会仓库 §d>> §r不能购买自己出售的物品") + return True + + # 扣除贡献点 + buyer_member = guild.get_member(player.name) + if not buyer_member or buyer_member.contribution < item.price: + player.show("§l§a公会仓库 §d>> §r贡献点不足") + return True + + buyer_member.contribution -= item.price + + # 给卖家贡献点(扣除税费) + tax = int(item.price * Config.VAULT_TRADE_TAX) + seller_income = item.price - tax + seller_member = guild.get_member(item.seller) + if seller_member: + seller_member.contribution += seller_income + + # 给玩家物品 + self.game_ctrl.sendwocmd(f"give {player.name} {item.item_id} {item.count}") + + guild.vault_items.pop(idx) + + guild.add_log( + f"{player.name} 购买了 {item_name} x{item.count} (花费{item.price}贡献点)") + if getattr(Config, "GUILD_VAULT_CONFIG", {}).get("启用交易日志", True): + guild.add_vault_trade_log( + "buy", + item, + player.name, + buyer=player.name, + detail=f"税费{tax},卖家收入{seller_income}", + ) + suggested_value = guild.get_item_value(item.item_id) * item.count + audit_ratio = float( + getattr( + Config, + "GUILD_VAULT_CONFIG", + {}).get( + "高价交易审计倍率", + 3.0)) + if suggested_value > 0 and item.price >= suggested_value * audit_ratio: + guild.add_audit_log( + "vault_high_price_buy", + player.name, + target=item.seller, + detail=f"{item.item_id} x{item.count} price={item.price}", + ) + + self.guild_manager.save_guilds(guilds) + + player.show(f"§l§a公会仓库 §d>> §r购买成功!花费 §e{item.price}贡献点") + + # 更新贸易任务进度 + self.check_and_complete_trade_tasks(player.name) + + # 通知卖家 + if seller_member and item.seller in self.game_ctrl.allplayers: + message = ( + f"§l§a公会仓库 §d>> §r你的 {item_name} x{item.count} " + f"被 {player.name} 购买了,获 {seller_income} 贡献点" ) self.game_ctrl.sendcmd( f'/tellraw {item.seller} {{"rawtext":[{{"text":"{message}"}}]}}' ) - - return True - - -def _handle_vault_sell(self, player: Player, guild: GuildData) -> bool: - """出售物品到仓库""" - if not guild.has_permission(player.name, "vault_sell"): - player.show("§l§a公会仓库 §d>> §r你没有出售仓库物品权限") - return True - - # 检查仓库容量 - max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 - if len(guild.vault_items) >= max_slots: - player.show("§l§a公会仓库 §d>> §r仓库已满") - return True - - player.show("§l§a公会仓库 §d>> §r请输入要出售的物品名称") - player.show("§7支持中文名称,如: 钻石、铁锭、金块等") - player.show("§7也支持英文ID,如: minecraft:diamond") - - user_input = game_utils.waitMsg(player.name, timeout=30) - - if not user_input: - player.show("§l§a公会仓库 §d>> §r输入为空") - return True - - # 使用智能匹配查找物品ID - item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) - - if not item_id: - player.show("§l§a公会仓库 §d>> §r未找到匹配的物品") - if suggestions: - player.show("§7您是否想要:") - for i, suggestion in enumerate(suggestions[:3], 1): - player.show(f"§e{i}. §f{suggestion}") - player.show("§7请重新输入准确的物品名称") - return True - - # 显示找到的物品 - chinese_name = self.item_matcher.get_chinese_name(item_id) - player.show(f"§l§a公会仓库 §d>> §r找到物品: §f{chinese_name} §7({item_id})") - - # 检查玩家是否有该物品 - item_count = player.getItemCount(item_id) - if item_count <= 0: - player.show("§l§a公会仓库 §d>> §r你没有这个物品") - return True - - player.show(f"§l§a公会仓库 §d>> §r你有 {item_count} 个该物品") - player.show("§7请输入要出售的数量:") - - count_str = game_utils.waitMsg(player.name, timeout=30) - if not count_str or not count_str.isdigit(): - player.show("§l§a公会仓库 §d>> §r无效的数量") - return True - - count = int(count_str) - if count <= 0 or count > item_count: - player.show("§l§a公会仓库 §d>> §r数量无效") - return True - max_sell_count = int( - getattr( - Config, - "GUILD_VAULT_CONFIG", - {}).get( - "单次出售最大数量", - 64)) + + return True + + +def _handle_vault_sell( # skipcq: PY-R1000 + self, + player: Player, + guild: GuildData, +) -> bool: + """出售物品到仓库""" + if not guild.has_permission(player.name, "vault_sell"): + player.show("§l§a公会仓库 §d>> §r你没有出售仓库物品权限") + return True + + # 检查仓库容量 + max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + + player.show("§l§a公会仓库 §d>> §r请输入要出售的物品名称") + player.show("§7支持中文名称,如: 钻石、铁锭、金块等") + player.show("§7也支持英文ID,如: minecraft:diamond") + + user_input = game_utils.waitMsg(player.name, timeout=30) + + if not user_input: + player.show("§l§a公会仓库 §d>> §r输入为空") + return True + + # 使用智能匹配查找物品ID + item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) + + if not item_id: + player.show("§l§a公会仓库 §d>> §r未找到匹配的物品") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + player.show("§7请重新输入准确的物品名称") + return True + + # 显示找到的物品 + chinese_name = self.item_matcher.get_chinese_name(item_id) + player.show(f"§l§a公会仓库 §d>> §r找到物品: §f{chinese_name} §7({item_id})") + + # 检查玩家是否有该物品 + item_count = player.getItemCount(item_id) + if item_count <= 0: + player.show("§l§a公会仓库 §d>> §r你没有这个物品") + return True + + player.show(f"§l§a公会仓库 §d>> §r你有 {item_count} 个该物品") + player.show("§7请输入要出售的数量:") + + count_str = game_utils.waitMsg(player.name, timeout=30) + if not count_str or not count_str.isdigit(): + player.show("§l§a公会仓库 §d>> §r无效的数量") + return True + + count = int(count_str) + if count <= 0 or count > item_count: + player.show("§l§a公会仓库 §d>> §r数量无效") + return True + max_sell_count = int( + getattr( + Config, + "GUILD_VAULT_CONFIG", + {}).get( + "单次出售最大数量", + 64)) if count > max_sell_count > 0: player.show(f"§l§a公会仓库 §d>> §r单次最多出售 {max_sell_count} 个") return True - - # 获取建议价格 - suggested_price = guild.get_item_value(item_id) * count - player.show(f"§l§a公会仓库 §d>> §r建议价格: §e{suggested_price}贡献点") - player.show("§7请输入出售价格 (贡献点,你可以自定义任意价格):") - - price_str = game_utils.waitMsg(player.name, timeout=30) - if not price_str or not price_str.isdigit(): - player.show("§l§a公会仓库 §d>> §r无效的价格") - return True - - price = int(price_str) - if price <= 0: - player.show("§l§a公会仓库 §d>> §r价格必须大于0") - return True - vault_config = getattr(Config, "GUILD_VAULT_CONFIG", {}) - min_price = int(vault_config.get("单笔价格下限", 1)) - max_price = int(vault_config.get("单笔价格上限", 100000)) - if price < min_price or price > max_price: - player.show(f"§l§a公会仓库 §d>> §r价格必须在 {min_price}-{max_price} 之间") - return True - - # 确认出售 - item_name = self._get_item_display_name(item_id) - player.show( - f"§l§a公会仓库 §d>> §r确认出售 §f{item_name} §7x{count} §r价格 §e{price}贡献点§r?") - player.show("§7输入 '确认' 继续出售") - - confirm = game_utils.waitMsg(player.name, timeout=20) - if confirm != "确认": - player.show("§l§a公会仓库 §d>> §r出售已取消") - return True - - # 执行出售 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if not guild: - player.show("§l§a公会仓库 §d>> §r公会数据异常") - return True - - # 再次检查仓库容量 - if len(guild.vault_items) >= max_slots: - player.show("§l§a公会仓库 §d>> §r仓库已满") - return True - max_listing = int(vault_config.get("单个成员最大上架数量", 18)) - if max_listing > 0: - own_listing_count = sum( - 1 for item in guild.vault_items if item.seller == player.name) - if own_listing_count >= max_listing: - player.show(f"§l§a公会仓库 §d>> §r你最多同时上架 {max_listing} 件物品") - return True - - # 扣除物品 - self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {count}") - - # 添加到仓库 - vault_item = VaultItem( - item_id=item_id, - count=count, - price=price, - seller=player.name - ) - - guild.vault_items.append(vault_item) - guild.add_log(f"{player.name} 出售了 {item_name} x{count} (价格{price}贡献点)") - if vault_config.get("启用交易日志", True): - guild.add_vault_trade_log( - "sell", vault_item, player.name, detail="上架出售") - - audit_ratio = float(vault_config.get("高价交易审计倍率", 3.0)) - if suggested_price > 0 and price >= suggested_price * audit_ratio: - guild.add_audit_log( - "vault_high_price_sell", - player.name, - detail=f"{item_id} x{count} price={price}", - ) - - # 保存数据 - self.guild_manager.save_guilds(guilds) - - player.show(f"§l§a公会仓库 §d>> §r出售成功!{item_name} x{count} 已上架") - - # 更新贸易任务进度 - self.check_and_complete_trade_tasks(player.name) - - return True - - -def _handle_vault_logs(self, player: Player, guild: GuildData) -> bool: - """查看仓库交易日志""" - if not getattr(Config, "GUILD_VAULT_CONFIG", {}).get("启用交易日志", True): - player.show("§l§a公会仓库 §d>> §r交易日志未启用") - return True - - if not guild.vault_trade_logs: - player.show("§l§a公会仓库 §d>> §r暂无仓库交易日志") - return True - - action_names = { - "sell": "上架", - "buy": "购买", - "cancel": "撤回", - } - - def formatter(i, log): - item_name = self._get_item_display_name(log.item_id) - time_str = datetime.fromtimestamp( - log.timestamp).strftime("%m-%d %H:%M") - action_name = action_names.get(log.action, log.action) - participants = [] - if log.seller: - participants.append(f"卖家:{log.seller}") - if log.buyer: - participants.append(f"买家:{log.buyer}") - participant_text = " §7| ".join( - participants) if participants else f"操作者:{log.actor}" - detail = f"\n §7说明: §f{log.detail}" if log.detail else "" - return ( - f"§e{i}. §f{action_name} §f{item_name} §7x{log.count}\n" - f" §7价格: §e{log.price}贡献点 §7| {participant_text} §7| 时间: §f{time_str}" - f"{detail}\n" - ) - - logs = list(reversed(guild.vault_trade_logs[-50:])) - self._paginate_display(player, logs, "仓库交易日志", formatter) - return True - - -def _handle_vault_cancel(self, player: Player, guild: GuildData) -> bool: - """撤回仓库上架物品""" - vault_config = getattr(Config, "GUILD_VAULT_CONFIG", {}) - if not vault_config.get("启用撤回出售", True): - player.show("§l§a公会仓库 §d>> §r撤回出售未启用") - return True - - can_cancel_own = guild.has_permission(player.name, "vault_cancel_own") - can_cancel_any = guild.has_permission(player.name, "vault_cancel_any") - only_own = vault_config.get("只允许撤回自己的物品", False) - - cancelable_items = [] - for index, item in enumerate(guild.vault_items): - is_own = item.seller == player.name - if is_own and can_cancel_own: - cancelable_items.append((index, item)) - elif not only_own and can_cancel_any: - cancelable_items.append((index, item)) - - if not cancelable_items: - player.show("§l§a公会仓库 §d>> §r没有可撤回的上架物品") - return True - - def formatter(i, data): - _index, item = data - item_name = self._get_item_display_name(item.item_id) - time_str = datetime.fromtimestamp( - item.timestamp).strftime("%m-%d %H:%M") - return ( - f"§e{i}. §f{item_name} §7x{ - item.count}\n" f" §7价格: §e{ - item.price}贡献点 §7| 卖家: §a{ - item.seller} §7| 时间: §f{time_str}\n") - - idx = self._paginate_display( - player, - cancelable_items, - "选择撤回物品", - formatter, - True) - if idx is None: - return True - - original_index, selected_item = cancelable_items[idx] - selected_name = self._get_item_display_name(selected_item.item_id) - player.show( - f"§l§a公会仓库 §d>> §r确认撤回 §f{selected_name} §7x{ - selected_item.count}§r?") - player.show("§7输入 '确认' 继续撤回") - - confirm = game_utils.waitMsg(player.name, timeout=20) - if confirm != "确认": - player.show("§l§a公会仓库 §d>> §r撤回已取消") - return True - - guilds = self.guild_manager.load_guilds(force_reload=True) - latest_guild = guilds.get(guild.guild_id) - if not latest_guild: - player.show("§l§a公会仓库 §d>> §r公会数据异常") - return True - - if original_index >= len(latest_guild.vault_items): - player.show("§l§a公会仓库 §d>> §r物品已不存在") - return True - - latest_item = latest_guild.vault_items[original_index] - if ( - latest_item.item_id != selected_item.item_id - or latest_item.count != selected_item.count - or latest_item.price != selected_item.price - or latest_item.seller != selected_item.seller - ): - player.show("§l§a公会仓库 §d>> §r物品状态已变化,请重新打开仓库") - return True - - is_own = latest_item.seller == player.name - if not ((is_own and can_cancel_own) or (not only_own and can_cancel_any)): - player.show("§l§a公会仓库 §d>> §r你没有撤回该物品的权限") - return True - - removed_item = latest_guild.cancel_vault_item(player.name, original_index) - if not removed_item: - player.show("§l§a公会仓库 §d>> §r撤回失败") - return True - - if vault_config.get("撤回后返还物品", True): - self.game_ctrl.sendwocmd( - f"give { - removed_item.seller} { - removed_item.item_id} { - removed_item.count}") - - self.guild_manager.save_guilds(guilds) - player.show( - f"§l§a公会仓库 §d>> §r已撤回 §f{selected_name} §7x{ - removed_item.count}") - if removed_item.seller != player.name and removed_item.seller in self.game_ctrl.allplayers: + + # 获取建议价格 + suggested_price = guild.get_item_value(item_id) * count + player.show(f"§l§a公会仓库 §d>> §r建议价格: §e{suggested_price}贡献点") + player.show("§7请输入出售价格 (贡献点,你可以自定义任意价格):") + + price_str = game_utils.waitMsg(player.name, timeout=30) + if not price_str or not price_str.isdigit(): + player.show("§l§a公会仓库 §d>> §r无效的价格") + return True + + price = int(price_str) + if price <= 0: + player.show("§l§a公会仓库 §d>> §r价格必须大于0") + return True + vault_config = getattr(Config, "GUILD_VAULT_CONFIG", {}) + min_price = int(vault_config.get("单笔价格下限", 1)) + max_price = int(vault_config.get("单笔价格上限", 100000)) + if price < min_price or price > max_price: + player.show(f"§l§a公会仓库 §d>> §r价格必须在 {min_price}-{max_price} 之间") + return True + + # 确认出售 + item_name = self._get_item_display_name(item_id) + player.show( + f"§l§a公会仓库 §d>> §r确认出售 §f{item_name} §7x{count} §r价格 §e{price}贡献点§r?") + player.show("§7输入 '确认' 继续出售") + + confirm = game_utils.waitMsg(player.name, timeout=20) + if confirm != "确认": + player.show("§l§a公会仓库 §d>> §r出售已取消") + return True + + # 执行出售 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if not guild: + player.show("§l§a公会仓库 §d>> §r公会数据异常") + return True + + # 再次检查仓库容量 + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + max_listing = int(vault_config.get("单个成员最大上架数量", 18)) + if max_listing > 0: + own_listing_count = sum( + 1 for item in guild.vault_items if item.seller == player.name) + if own_listing_count >= max_listing: + player.show(f"§l§a公会仓库 §d>> §r你最多同时上架 {max_listing} 件物品") + return True + + # 扣除物品 + self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {count}") + + # 添加到仓库 + vault_item = VaultItem( + item_id=item_id, + count=count, + price=price, + seller=player.name + ) + + guild.vault_items.append(vault_item) + guild.add_log(f"{player.name} 出售了 {item_name} x{count} (价格{price}贡献点)") + if vault_config.get("启用交易日志", True): + guild.add_vault_trade_log( + "sell", vault_item, player.name, detail="上架出售") + + audit_ratio = float(vault_config.get("高价交易审计倍率", 3.0)) + if suggested_price > 0 and price >= suggested_price * audit_ratio: + guild.add_audit_log( + "vault_high_price_sell", + player.name, + detail=f"{item_id} x{count} price={price}", + ) + + # 保存数据 + self.guild_manager.save_guilds(guilds) + + player.show(f"§l§a公会仓库 §d>> §r出售成功!{item_name} x{count} 已上架") + + # 更新贸易任务进度 + self.check_and_complete_trade_tasks(player.name) + + return True + + +def _handle_vault_logs(self, player: Player, guild: GuildData) -> bool: + """查看仓库交易日志""" + if not getattr(Config, "GUILD_VAULT_CONFIG", {}).get("启用交易日志", True): + player.show("§l§a公会仓库 §d>> §r交易日志未启用") + return True + + if not guild.vault_trade_logs: + player.show("§l§a公会仓库 §d>> §r暂无仓库交易日志") + return True + + action_names = { + "sell": "上架", + "buy": "购买", + "cancel": "撤回", + } + + def formatter(i, log): + """Format one menu item for display.""" + item_name = self._get_item_display_name(log.item_id) + time_str = datetime.fromtimestamp( + log.timestamp).strftime("%m-%d %H:%M") + action_name = action_names.get(log.action, log.action) + participants = [] + if log.seller: + participants.append(f"卖家:{log.seller}") + if log.buyer: + participants.append(f"买家:{log.buyer}") + participant_text = " §7| ".join( + participants) if participants else f"操作者:{log.actor}" + detail = f"\n §7说明: §f{log.detail}" if log.detail else "" + return ( + f"§e{i}. §f{action_name} §f{item_name} §7x{log.count}\n" + f" §7价格: §e{log.price}贡献点 §7| {participant_text} §7| 时间: §f{time_str}" + f"{detail}\n" + ) + + logs = list(reversed(guild.vault_trade_logs[-50:])) + self._paginate_display(player, logs, "仓库交易日志", formatter) + return True + + +def _handle_vault_cancel( # skipcq: PY-R1000 + self, + player: Player, + guild: GuildData, +) -> bool: + """撤回仓库上架物品""" + vault_config = getattr(Config, "GUILD_VAULT_CONFIG", {}) + if not vault_config.get("启用撤回出售", True): + player.show("§l§a公会仓库 §d>> §r撤回出售未启用") + return True + + can_cancel_own = guild.has_permission(player.name, "vault_cancel_own") + can_cancel_any = guild.has_permission(player.name, "vault_cancel_any") + only_own = vault_config.get("只允许撤回自己的物品", False) + + cancelable_items = [] + for index, item in enumerate(guild.vault_items): + is_own = item.seller == player.name + if is_own and can_cancel_own: + cancelable_items.append((index, item)) + elif not only_own and can_cancel_any: + cancelable_items.append((index, item)) + + if not cancelable_items: + player.show("§l§a公会仓库 §d>> §r没有可撤回的上架物品") + return True + + def formatter(i, data): + """Format one menu item for display.""" + _index, item = data + item_name = self._get_item_display_name(item.item_id) + time_str = datetime.fromtimestamp( + item.timestamp).strftime("%m-%d %H:%M") + return ( + f"§e{i}. §f{item_name} §7x{ + item.count}\n" f" §7价格: §e{ + item.price}贡献点 §7| 卖家: §a{ + item.seller} §7| 时间: §f{time_str}\n") + + idx = self._paginate_display( + player, + cancelable_items, + "选择撤回物品", + formatter, + True) + if idx is None: + return True + + original_index, selected_item = cancelable_items[idx] + selected_name = self._get_item_display_name(selected_item.item_id) + player.show( + f"§l§a公会仓库 §d>> §r确认撤回 §f{selected_name} §7x{ + selected_item.count}§r?") + player.show("§7输入 '确认' 继续撤回") + + confirm = game_utils.waitMsg(player.name, timeout=20) + if confirm != "确认": + player.show("§l§a公会仓库 §d>> §r撤回已取消") + return True + + guilds = self.guild_manager.load_guilds(force_reload=True) + latest_guild = guilds.get(guild.guild_id) + if not latest_guild: + player.show("§l§a公会仓库 §d>> §r公会数据异常") + return True + + if original_index >= len(latest_guild.vault_items): + player.show("§l§a公会仓库 §d>> §r物品已不存在") + return True + + latest_item = latest_guild.vault_items[original_index] + if ( + latest_item.item_id != selected_item.item_id + or latest_item.count != selected_item.count + or latest_item.price != selected_item.price + or latest_item.seller != selected_item.seller + ): + player.show("§l§a公会仓库 §d>> §r物品状态已变化,请重新打开仓库") + return True + + is_own = latest_item.seller == player.name + if not ((is_own and can_cancel_own) or (not only_own and can_cancel_any)): + player.show("§l§a公会仓库 §d>> §r你没有撤回该物品的权限") + return True + + removed_item = latest_guild.cancel_vault_item(player.name, original_index) + if not removed_item: + player.show("§l§a公会仓库 §d>> §r撤回失败") + return True + + if vault_config.get("撤回后返还物品", True): + self.game_ctrl.sendwocmd( + f"give { + removed_item.seller} { + removed_item.item_id} { + removed_item.count}") + + self.guild_manager.save_guilds(guilds) + player.show( + f"§l§a公会仓库 §d>> §r已撤回 §f{selected_name} §7x{ + removed_item.count}") + if ( + removed_item.seller != player.name + and removed_item.seller in self.game_ctrl.allplayers + ): message = ( f"§l§a公会仓库 §d>> §r你上架的 {selected_name} " f"x{removed_item.count} 已被 {player.name} 撤回" @@ -1738,315 +1788,324 @@ def formatter(i, data): self.game_ctrl.sendcmd( f'/tellraw {removed_item.seller} {{"rawtext":[{{"text":"{message}"}}]}}' ) - - return True - - -def _handle_vault_settings(self, player: Player, guild: GuildData) -> bool: - """设置物品价值""" - if not guild.has_permission(player.name, "vault_settings"): - player.show("§l§a公会仓库 §d>> §r你没有设置仓库物品价值权限") - return True - - player.show("§r========== §a物品价值设置§r ==========") - player.show("§e1. §f查看当前设置") - player.show("§e2. §f设置物品价值") - player.show("§e3. §f删除自定义价值") - player.show("§7输入选项序号:") - - choice = game_utils.waitMsg(player.name, timeout=30) - - if choice == "1": - # 显示当前设置 - if guild.custom_item_values: - player.show("§l§a自定义物品价值§r") - for item_id, value in guild.custom_item_values.items(): - item_name = self._get_item_display_name(item_id) - player.show(f"§f{item_name}: §e{value}贡献点") - else: - player.show("§l§a公会仓库 §d>> §r暂无自定义物品价值") - - player.show("\n§l§a默认物品价值§r") - for item_id, value in Config.DEFAULT_ITEM_VALUES.items(): - item_name = self._get_item_display_name(item_id) - player.show(f"§f{item_name}: §e{value}贡献点") - - elif choice == "2": - # 设置物品价值 - player.show("§l§a公会仓库 §d>> §r请输入物品ID (如: minecraft:diamond):") - item_id = game_utils.waitMsg(player.name, timeout=30) - - if not item_id or not item_id.startswith("minecraft:"): - player.show("§l§a公会仓库 §d>> §r无效的物品ID") - return True - - player.show("§l§a公会仓库 §d>> §r请输入贡献点价值:") - value_str = game_utils.waitMsg(player.name, timeout=30) - - if not value_str or not value_str.isdigit(): - player.show("§l§a公会仓库 §d>> §r无效的价值") - return True - - value = int(value_str) - if value <= 0: - player.show("§l§a公会仓库 §d>> §r价值必须大于0") - return True - - # 保存设置 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if guild: - guild.custom_item_values[item_id] = value - item_name = self._get_item_display_name(item_id) - guild.add_log(f"{player.name} 设置了 {item_name} 的价值为 {value} 贡献点") - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a公会仓库 §d>> §r已设置 {item_name} 的价值为 {value} 贡献点") - - elif choice == "3": - # 删除自定义价值 - if not guild.custom_item_values: - player.show("§l§a公会仓库 §d>> §r暂无自定义物品价值") - return True - - player.show("§l§a当前自定义价值§r") - items = list(guild.custom_item_values.items()) - for i, (item_id, value) in enumerate(items, 1): - item_name = self._get_item_display_name(item_id) - player.show(f"§e{i}. §f{item_name}: §e{value}贡献点") - - player.show("§7输入序号删除:") - idx_str = game_utils.waitMsg(player.name, timeout=30) - - if idx_str and idx_str.isdigit(): - idx = int(idx_str) - 1 - if 0 <= idx < len(items): - item_id, _ = items[idx] - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if guild and item_id in guild.custom_item_values: - del guild.custom_item_values[item_id] - item_name = self._get_item_display_name(item_id) - guild.add_log(f"{player.name} 删除了 {item_name} 的自定义价值") - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a公会仓库 §d>> §r已删除 {item_name} 的自定义价值") - - return True - - -def _handle_create_guild(self, player: Player, player_xuid: str) -> bool: - """处理创建公会""" - guild = self.guild_manager.get_guild_by_player(player.name) - if guild: - player.show(render_create_guild_prompt("已有公会提示词")) - return True - - score = player.getScore(Config.GUILD_SCOREBOARD) - if score < Config.GUILD_CREATION_COST: - player.show(render_create_guild_prompt("创建公会余额不足提示词", balance=score)) - return True - - player.show(render_create_guild_prompt("创建公会提示词", balance=score)) - confirm = game_utils.waitMsg(player.name, timeout=30) - if confirm is None: - player.show(render_create_guild_prompt("创建公会回复超时提示词")) - return True - if confirm.strip().lower() not in ("确认", "继续", "是", "y", "yes"): - player.show(render_create_guild_prompt("创建公会取消提示词")) - return True - - player.show(render_create_guild_prompt("创建公会输入名称提示词")) - guild_name = game_utils.waitMsg(player.name, timeout=30) - - # 使用新的输入验证 - is_valid, error_msg = InputValidator.validate_guild_name(guild_name) - if not is_valid: - player.show(render_create_guild_prompt("创建公会名称无效提示词", error=error_msg)) - return True - - score = player.getScore(Config.GUILD_SCOREBOARD) - if score < Config.GUILD_CREATION_COST: - player.show(render_create_guild_prompt("创建公会二次余额不足提示词", balance=score)) - return True - - # 扣除配置的计分板积分并创建公会 - self.game_ctrl.sendwocmd( - f"scoreboard players remove { - player.name} { - Config.GUILD_SCOREBOARD} { - Config.GUILD_CREATION_COST}") - - if self.guild_manager.create_guild(player_xuid, player.name, guild_name): - player.show(render_create_guild_prompt( - "创建公会成功提示词", guild=guild_name, player=player.name)) - # 通知所有在线玩家 - announcement = render_create_guild_prompt( - "创建公会全服公告提示词", - guild=guild_name, - player=player.name, - ) - payload = json.dumps( - {"rawtext": [{"text": announcement}]}, ensure_ascii=False) - self.game_ctrl.sendcmd(f"/tellraw @a {payload}") - else: - player.show(render_create_guild_prompt( - "创建公会名称已存在提示词", guild=guild_name, player=player.name)) - self.game_ctrl.sendwocmd( - f"scoreboard players add { - player.name} { - Config.GUILD_SCOREBOARD} { - Config.GUILD_CREATION_COST}") - - return True - - -def _handle_list_guilds(self, player: Player) -> bool: - """处理查看公会列表""" - guilds = list(self.guild_manager.load_guilds().values()) - if not guilds: - player.show(render_config_prompt("公会列表为空提示词")) - return True - - # 按等级和成员数排序 - guilds.sort(key=lambda g: (g.level, len(g.members)), reverse=True) - - def formatter(i, g): return ( - f"§e{i}. §r{g.name} §7Lv.{g.level}\n" - f" §7会长: §f{g.owner} §7| 成员: §a{len(g.members)}/{Config.MAX_GUILD_MEMBERS}\n" - ) - - page = 1 - max_page = (len(guilds) + Config.ITEMS_PER_PAGE - - 1) // Config.ITEMS_PER_PAGE - while True: - page = max(1, min(page, max_page)) - start = (page - 1) * Config.ITEMS_PER_PAGE - end = start + Config.ITEMS_PER_PAGE - - msg = ( - f"{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b公会列表§d" - f"\n{ORION_BORDER}" - ) - for i, guild in enumerate(guilds[start:end], start=1): - msg += "\n" + formatter(start + i, guild).rstrip() - msg += "\n" + format_page_footer(page, max_page, start + 1, end, False) - - player.show(msg) - - choice = game_utils.waitMsg(player.name, timeout=20) - if choice is None: - player.show(render_config_prompt("公会列表分页超时提示词")) - return True - if choice == "+": - page = min(page + 1, max_page) - elif choice == "-": - page = max(page - 1, 1) - elif choice == "q": - player.show(render_config_prompt("公会列表分页退出提示词")) - return True - - return True - - -def _handle_join_guild(self, player: Player) -> bool: - """处理加入公会申请""" - if self.guild_manager.get_guild_by_player(player.name): - player.show("§l§a公会 §d>> §r你已经加入了一个公会") - return True - - player.show("§l§a公会 §d>> §r请输入公会名字(支持模糊搜索)") - search_name = game_utils.waitMsg(player.name, timeout=30) - - if not search_name: - player.show("§l§a公会 §d>> §r公会名字不能为空") - return True - - # 搜索匹配的公会 - guilds = self.guild_manager.load_guilds() - matched_guilds = [g for g in guilds.values() if search_name.lower() - in g.name.lower()] - - if not matched_guilds: - player.show("§l§a公会 §d>> §r未找到匹配的公会") - return True - - # 选择公会 - if len(matched_guilds) == 1: - target_guild = matched_guilds[0] - else: - def formatter(i, g): return f"{i}. {g.name} (会长:{g.owner})\n" - idx = self._paginate_display( - player, matched_guilds, "选择公会", formatter, True) - if idx is None: - return True - target_guild = matched_guilds[idx] - - # 检查人数上限 - if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: - player.show("§l§a公会 §d>> §r该公会已满员") - return True - - reason = "" - join_config = getattr(Config, "GUILD_JOIN_REQUEST_CONFIG", {}) - if join_config.get("启用离线申请队列", True): - player.show("§l§a公会 §d>> §r请输入申请理由,可直接发送空白跳过") - reason_input = game_utils.waitMsg(player.name, timeout=60) - reason = reason_input or "" - - guilds = self.guild_manager.load_guilds(force_reload=True) - latest_guild = guilds.get(target_guild.guild_id) - if not latest_guild: - player.show("§l§a公会 §d>> §r公会数据异常") - return True - - if not latest_guild.add_join_request(player.name, reason): - player.show("§l§a公会 §d>> §r申请提交失败,可能已有待处理申请或队列已满") - return True - - self.guild_manager.save_guilds(guilds) - self._notify_join_request_admins(latest_guild, player.name) - player.show(f"§l§a公会 §d>> §r申请已提交至 §e{latest_guild.name} §r的申请队列") - - return True - - -def _handle_leave_guild(self, player: Player) -> bool: - """处理退出公会""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你未加入任何公会") - return True - - member = guild.get_member(player.name) - if member and member.rank == GuildRank.OWNER: - player.show("§l§a公会 §d>> §r会长不能退出公会,只能解散公会") - player.show("§7请使用 '解散' 选项来解散公会") - return True - - guild_name = self.guild_manager.remove_member(player.name) - if guild_name: - player.show(f"§l§a公会 §d>> §r已退出公会 {guild_name}") - else: - player.show("§l§a公会 §d>> §r退出失败") - return True - - -def _handle_dissolve_guild(self, player: Player, player_xuid: str) -> bool: - """处理解散公会""" - guild = self.guild_manager.get_guild_by_player(player.name) - member = guild.get_member(player.name) if guild else None - - if not guild or not member or member.rank != GuildRank.OWNER: - player.show("§l§a公会 §d>> §r你不是任何公会的会长") - return True - - player.show(f"§l§a公会 §d>> §r确定要解散公会 §e{guild.name} §r吗?") - player.show("§c此操作不可撤销!输入'确认解散'继续") - confirm = game_utils.waitMsg(player.name, timeout=30) - - if confirm != "确认解散": - player.show("§l§a公会 §d>> §r操作已取消") - return True - + + return True + + +def _handle_vault_settings( # skipcq: PY-R1000 + self, + player: Player, + guild: GuildData, +) -> bool: + """设置物品价值""" + if not guild.has_permission(player.name, "vault_settings"): + player.show("§l§a公会仓库 §d>> §r你没有设置仓库物品价值权限") + return True + + player.show("§r========== §a物品价值设置§r ==========") + player.show("§e1. §f查看当前设置") + player.show("§e2. §f设置物品价值") + player.show("§e3. §f删除自定义价值") + player.show("§7输入选项序号:") + + choice = game_utils.waitMsg(player.name, timeout=30) + + if choice == "1": + # 显示当前设置 + if guild.custom_item_values: + player.show("§l§a自定义物品价值§r") + for item_id, value in guild.custom_item_values.items(): + item_name = self._get_item_display_name(item_id) + player.show(f"§f{item_name}: §e{value}贡献点") + else: + player.show("§l§a公会仓库 §d>> §r暂无自定义物品价值") + + player.show("\n§l§a默认物品价值§r") + for item_id, value in Config.DEFAULT_ITEM_VALUES.items(): + item_name = self._get_item_display_name(item_id) + player.show(f"§f{item_name}: §e{value}贡献点") + + elif choice == "2": + # 设置物品价值 + player.show("§l§a公会仓库 §d>> §r请输入物品ID (如: minecraft:diamond):") + item_id = game_utils.waitMsg(player.name, timeout=30) + + if not item_id or not item_id.startswith("minecraft:"): + player.show("§l§a公会仓库 §d>> §r无效的物品ID") + return True + + player.show("§l§a公会仓库 §d>> §r请输入贡献点价值:") + value_str = game_utils.waitMsg(player.name, timeout=30) + + if not value_str or not value_str.isdigit(): + player.show("§l§a公会仓库 §d>> §r无效的价值") + return True + + value = int(value_str) + if value <= 0: + player.show("§l§a公会仓库 §d>> §r价值必须大于0") + return True + + # 保存设置 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild: + guild.custom_item_values[item_id] = value + item_name = self._get_item_display_name(item_id) + guild.add_log(f"{player.name} 设置了 {item_name} 的价值为 {value} 贡献点") + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会仓库 §d>> §r已设置 {item_name} 的价值为 {value} 贡献点") + + elif choice == "3": + # 删除自定义价值 + if not guild.custom_item_values: + player.show("§l§a公会仓库 §d>> §r暂无自定义物品价值") + return True + + player.show("§l§a当前自定义价值§r") + items = list(guild.custom_item_values.items()) + for i, (item_id, value) in enumerate(items, 1): + item_name = self._get_item_display_name(item_id) + player.show(f"§e{i}. §f{item_name}: §e{value}贡献点") + + player.show("§7输入序号删除:") + idx_str = game_utils.waitMsg(player.name, timeout=30) + + if idx_str and idx_str.isdigit(): + idx = int(idx_str) - 1 + if 0 <= idx < len(items): + item_id, _ = items[idx] + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if guild and item_id in guild.custom_item_values: + del guild.custom_item_values[item_id] + item_name = self._get_item_display_name(item_id) + guild.add_log(f"{player.name} 删除了 {item_name} 的自定义价值") + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会仓库 §d>> §r已删除 {item_name} 的自定义价值") + + return True + + +def _handle_create_guild(self, player: Player, player_xuid: str) -> bool: + """处理创建公会""" + guild = self.guild_manager.get_guild_by_player(player.name) + if guild: + player.show(render_create_guild_prompt("已有公会提示词")) + return True + + score = player.getScore(Config.GUILD_SCOREBOARD) + if score < Config.GUILD_CREATION_COST: + player.show(render_create_guild_prompt("创建公会余额不足提示词", balance=score)) + return True + + player.show(render_create_guild_prompt("创建公会提示词", balance=score)) + confirm = game_utils.waitMsg(player.name, timeout=30) + if confirm is None: + player.show(render_create_guild_prompt("创建公会回复超时提示词")) + return True + if confirm.strip().lower() not in ("确认", "继续", "是", "y", "yes"): + player.show(render_create_guild_prompt("创建公会取消提示词")) + return True + + player.show(render_create_guild_prompt("创建公会输入名称提示词")) + guild_name = game_utils.waitMsg(player.name, timeout=30) + + # 使用新的输入验证 + is_valid, error_msg = InputValidator.validate_guild_name(guild_name) + if not is_valid: + player.show(render_create_guild_prompt("创建公会名称无效提示词", error=error_msg)) + return True + + score = player.getScore(Config.GUILD_SCOREBOARD) + if score < Config.GUILD_CREATION_COST: + player.show(render_create_guild_prompt("创建公会二次余额不足提示词", balance=score)) + return True + + # 扣除配置的计分板积分并创建公会 + self.game_ctrl.sendwocmd( + f"scoreboard players remove { + player.name} { + Config.GUILD_SCOREBOARD} { + Config.GUILD_CREATION_COST}") + + if self.guild_manager.create_guild(player_xuid, player.name, guild_name): + player.show(render_create_guild_prompt( + "创建公会成功提示词", guild=guild_name, player=player.name)) + # 通知所有在线玩家 + announcement = render_create_guild_prompt( + "创建公会全服公告提示词", + guild=guild_name, + player=player.name, + ) + payload = json.dumps( + {"rawtext": [{"text": announcement}]}, ensure_ascii=False) + self.game_ctrl.sendcmd(f"/tellraw @a {payload}") + else: + player.show(render_create_guild_prompt( + "创建公会名称已存在提示词", guild=guild_name, player=player.name)) + self.game_ctrl.sendwocmd( + f"scoreboard players add { + player.name} { + Config.GUILD_SCOREBOARD} { + Config.GUILD_CREATION_COST}") + + return True + + +def _handle_list_guilds(self, player: Player) -> bool: + """处理查看公会列表""" + guilds = list(self.guild_manager.load_guilds().values()) + if not guilds: + player.show(render_config_prompt("公会列表为空提示词")) + return True + + # 按等级和成员数排序 + guilds.sort(key=lambda g: (g.level, len(g.members)), reverse=True) + + def formatter(i, g): + """Format one menu item for display.""" + return ( + f"§e{i}. §r{g.name} §7Lv.{g.level}\n" + f" §7会长: §f{g.owner} §7| 成员: §a{len(g.members)}/{Config.MAX_GUILD_MEMBERS}\n" + ) + + page = 1 + max_page = (len(guilds) + Config.ITEMS_PER_PAGE - + 1) // Config.ITEMS_PER_PAGE + while True: + page = max(1, min(page, max_page)) + start = (page - 1) * Config.ITEMS_PER_PAGE + end = start + Config.ITEMS_PER_PAGE + + msg = ( + f"{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b公会列表§d" + f"\n{ORION_BORDER}" + ) + for i, guild in enumerate(guilds[start:end], start=1): + msg += "\n" + formatter(start + i, guild).rstrip() + msg += "\n" + format_page_footer(page, max_page, start + 1, end, False) + + player.show(msg) + + choice = game_utils.waitMsg(player.name, timeout=20) + if choice is None: + player.show(render_config_prompt("公会列表分页超时提示词")) + return True + if choice == "+": + page = min(page + 1, max_page) + elif choice == "-": + page = max(page - 1, 1) + elif choice == "q": + player.show(render_config_prompt("公会列表分页退出提示词")) + return True + + return True + + +def _handle_join_guild(self, player: Player) -> bool: + """处理加入公会申请""" + if self.guild_manager.get_guild_by_player(player.name): + player.show("§l§a公会 §d>> §r你已经加入了一个公会") + return True + + player.show("§l§a公会 §d>> §r请输入公会名字(支持模糊搜索)") + search_name = game_utils.waitMsg(player.name, timeout=30) + + if not search_name: + player.show("§l§a公会 §d>> §r公会名字不能为空") + return True + + # 搜索匹配的公会 + guilds = self.guild_manager.load_guilds() + matched_guilds = [g for g in guilds.values() if search_name.lower() + in g.name.lower()] + + if not matched_guilds: + player.show("§l§a公会 §d>> §r未找到匹配的公会") + return True + + # 选择公会 + if len(matched_guilds) == 1: + target_guild = matched_guilds[0] + else: + def formatter(i, g): + """Format one menu item for display.""" + return f"{i}. {g.name} (会长:{g.owner})\n" + idx = self._paginate_display( + player, matched_guilds, "选择公会", formatter, True) + if idx is None: + return True + target_guild = matched_guilds[idx] + + # 检查人数上限 + if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: + player.show("§l§a公会 §d>> §r该公会已满员") + return True + + reason = "" + join_config = getattr(Config, "GUILD_JOIN_REQUEST_CONFIG", {}) + if join_config.get("启用离线申请队列", True): + player.show("§l§a公会 §d>> §r请输入申请理由,可直接发送空白跳过") + reason_input = game_utils.waitMsg(player.name, timeout=60) + reason = reason_input or "" + + guilds = self.guild_manager.load_guilds(force_reload=True) + latest_guild = guilds.get(target_guild.guild_id) + if not latest_guild: + player.show("§l§a公会 §d>> §r公会数据异常") + return True + + if not latest_guild.add_join_request(player.name, reason): + player.show("§l§a公会 §d>> §r申请提交失败,可能已有待处理申请或队列已满") + return True + + self.guild_manager.save_guilds(guilds) + self._notify_join_request_admins(latest_guild, player.name) + player.show(f"§l§a公会 §d>> §r申请已提交至 §e{latest_guild.name} §r的申请队列") + + return True + + +def _handle_leave_guild(self, player: Player) -> bool: + """处理退出公会""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你未加入任何公会") + return True + + member = guild.get_member(player.name) + if member and member.rank == GuildRank.OWNER: + player.show("§l§a公会 §d>> §r会长不能退出公会,只能解散公会") + player.show("§7请使用 '解散' 选项来解散公会") + return True + + guild_name = self.guild_manager.remove_member(player.name) + if guild_name: + player.show(f"§l§a公会 §d>> §r已退出公会 {guild_name}") + else: + player.show("§l§a公会 §d>> §r退出失败") + return True + + +def _handle_dissolve_guild(self, player: Player, player_xuid: str) -> bool: + """处理解散公会""" + _ = player_xuid + guild = self.guild_manager.get_guild_by_player(player.name) + member = guild.get_member(player.name) if guild else None + + if not guild or not member or member.rank != GuildRank.OWNER: + player.show("§l§a公会 §d>> §r你不是任何公会的会长") + return True + + player.show(f"§l§a公会 §d>> §r确定要解散公会 §e{guild.name} §r吗?") + player.show("§c此操作不可撤销!输入'确认解散'继续") + confirm = game_utils.waitMsg(player.name, timeout=30) + + if confirm != "确认解散": + player.show("§l§a公会 §d>> §r操作已取消") + return True + # 通知所有在线成员 message = f"§l§a公会 §d>> §r公会 §e{guild.name}§r 已被解散" for member in guild.members: @@ -2054,354 +2113,360 @@ def _handle_dissolve_guild(self, player: Player, player_xuid: str) -> bool: self.game_ctrl.sendcmd( f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' ) - - guilds = self.guild_manager.load_guilds(force_reload=True) - if guild.guild_id in guilds: - del guilds[guild.guild_id] - self.guild_manager.save_guilds(guilds) - player.show(f"§l§a公会 §d>> §r已解散公会 §e{guild.name}") - - return True - - -def _handle_kick_member(self, player: Player) -> bool: - """处理踢出成员""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild or not guild.has_permission(player.name, "kick"): - player.show("§l§a公会 §d>> §r你没有踢人权限") - return True - - # 使用新的权限检查逻辑过滤可踢出的成员 - kickable_members = [] - for member in guild.members: - if guild.can_manage_member(player.name, member.name): - kickable_members.append(member) - - if not kickable_members: - player.show("§l§a公会 §d>> §r没有可踢出的成员") - return True - - def formatter(i, m): return f"§e{i}. {m.rank.display_name} §f{m.name}\n" - idx = self._paginate_display( - player, - kickable_members, - "踢出成员", - formatter, - True) - - if idx is not None: - target = kickable_members[idx] - self.guild_manager.remove_member(target.name) - player.show(f"§l§a公会 §d>> §r已将 {target.name} 踢出公会") - - # 通知被踢玩家 - if target.name in self.game_ctrl.allplayers: - self.game_ctrl.sendcmd( - f'/tellraw {target.name} {{"rawtext":[{{"text":"§l§a公会 §d>> §r你已被踢出公会"}}]}}' - ) - - return True - - -def _handle_set_base(self, player: Player) -> bool: - """处理设置据点 - 增强版本""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - if not guild.has_permission(player.name, "setbase"): - player.show("§l§a公会 §d>> §r你没有设置公会据点权限") - return True - - try: - fmts.print_inf(f"开始为公会 {guild.name} 设置据点") - pos = game_utils.getPos(player.name) - - if not pos: - player.show("§l§a公会 §d>> §r获取位置失败,请稍后重试") - fmts.print_err("获取位置失败: pos为空") - return True - - dimension = pos.get("dimension", -1) - if dimension != 0: - player.show("§l§a公会 §d>> §r据点只能设置在主世界") - fmts.print_err(f"设置据点失败: 维度不是主世界 (维度={dimension})") - return True - - # 确保position字段存在 - if "position" not in pos: - player.show("§l§a公会 §d>> §r获取位置信息不完整,请稍后重试") - fmts.print_err(f"设置据点错误: 位置信息不完整 {pos}") - return True - - position = pos["position"] - x = position.get("x", 0) - y = position.get("y", 0) - z = position.get("z", 0) - - base = GuildBase( - dimension=dimension, - x=x, - y=y, - z=z - ) - - # 打印调试信息 - fmts.print_inf(f"正在设置据点: 维度={dimension}, 坐标=({x}, {y}, {z})") - - # 直接保存到当前公会对象 - guild.base = base - guild.add_log(f"{player.name} 设置了据点") - - # 重新加载公会数据以确保使用最新数据 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild_id = guild.guild_id - - # 确保公会ID存在于加载的数据中 - if guild_id not in guilds: - player.show("§l§a公会 §d>> §r公会数据加载失败,请稍后重试") - fmts.print_err(f"设置据点错误: 公会ID {guild_id} 不存在于加载的数据中") - return True - - # 更新公会数据 - guilds[guild_id].base = base - guilds[guild_id].add_log(f"{player.name} 设置了据点") - - # 保存数据 - self.guild_manager.save_guilds(guilds) - - # 再次检查是否保存成功 - check_guilds = self.guild_manager.load_guilds(force_reload=True) - if guild_id in check_guilds and check_guilds[guild_id].base: - check_base = check_guilds[guild_id].base - fmts.print_inf( - f"据点设置成功: 公会={ - guild.name}, 维度={ - check_base.dimension}, 坐标=({ - check_base.x}, { - check_base.y}, { - check_base.z})") - player.show(f"§l§a公会 §d>> §r据点已设置为 ({x:.1f}, {y:.1f}, {z:.1f})") - else: - player.show("§l§a公会 §d>> §r据点设置可能失败,请重试") - fmts.print_err(f"据点设置后验证失败: 公会={guild.name}") - - except Exception as e: - player.show("§l§a公会 §d>> §r获取位置失败,请稍后重试") - fmts.print_err(f"设置据点错误: {e}") - import traceback - fmts.print_err(traceback.format_exc()) - - return True - - -def _handle_return_base(self, player: Player) -> bool: - """处理返回据点 - 增强版本""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你没有加入任何公会") - return True - if not guild.has_permission(player.name, "return_base"): - player.show("§l§a公会 §d>> §r你没有返回公会据点权限") - return True - - # 重新加载公会数据以确保使用最新数据 - try: - guilds = self.guild_manager.load_guilds(force_reload=True) - guild_id = guild.guild_id - - # 确保公会ID存在于加载的数据中 - if guild_id not in guilds: - player.show("§l§a公会 §d>> §r公会数据加载失败,请稍后重试") - fmts.print_err(f"传送失败: 公会ID {guild_id} 不存在于加载的数据中") - return True - - # 使用最新的公会数据 - guild = guilds[guild_id] - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - if isinstance( - getattr( - guild, - "settings", - None), - dict) and guild.settings.get( - "base_locked", - False): - player.show("§l§a公会 §d>> §r公会据点已被锁定") - return True - - base = guild.base - if not base: - player.show("§l§a公会 §d>> §r公会尚未设置据点") - fmts.print_err(f"传送失败: 公会 {guild.name} 没有设置据点") - return True - - # 验证据点数据完整性 - if not all(hasattr(base, attr) - for attr in ['dimension', 'x', 'y', 'z']): - player.show("§l§a公会 §d>> §r据点数据损坏,请重新设置") - fmts.print_err(f"传送失败: 据点数据不完整 {base}") - return True - - # 输出详细的据点信息用于调试 - fmts.print_inf(f"开始传送: 玩家={player.name}, 公会={guild.name}") - fmts.print_inf( - f"据点信息: 维度={ - base.dimension}, 坐标=({ - base.x}, { - base.y}, { - base.z})") - - # 检查维度是否有效 - if base.dimension not in [0, -1, 1]: # 主世界、下界、末地 - player.show("§l§a公会 §d>> §r据点维度无效,请重新设置") - fmts.print_err(f"传送失败: 无效维度 {base.dimension}") - return True - - # 准备传送 - player.show("§l§a公会 §d>> §r准备传送到据点...") - player.show("§7请保持静止3秒...") - - # 延迟传送 - time.sleep(3) - - # 获取坐标并确保为数值类型 - x = float(base.x) - y = float(base.y) - z = float(base.z) - fmts.print_inf(f"传送玩家 {player.name} 到据点: ({x}, {y}, {z})") - - # 尝试多种传送方法,按成功率排序 - success = False - - try: - cmd = f"tp {player.name} {x} {y} {z}" - fmts.print_inf(f"方法1 - 使用sendwocmd执行: {cmd}") - self.game_ctrl.sendwocmd(cmd) - success = True - fmts.print_inf("方法1 - sendwocmd传送成功") - except Exception as e: - fmts.print_err(f"方法1 - sendwocmd失败: {e}") - - if success: - player.show(f"§l§a公会 §d>> §r已传送到公会据点 ({x:.1f}, {y:.1f}, {z:.1f})") - fmts.print_inf(f"传送成功: {player.name} -> ({x}, {y}, {z})") - else: - player.show("§l§a公会 §d>> §r传送失败,所有传送方式都无效") - fmts.print_err("所有传送指令都失败了") - - except Exception as e: - player.show("§l§a公会 §d>> §r传送过程中发生错误,请稍后重试") - fmts.print_err(f"传送到据点时发生异常: {e}") - import traceback - fmts.print_err(traceback.format_exc()) - - return True - - -def _handle_transfer_ownership(self, player: Player) -> bool: - """转让会长""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - return True - if not guild.has_permission(player.name, "transfer_owner"): - player.show("§l§a公会 §d>> §r你没有转让会长权限") - return True - - # 选择新会长 - other_members = [m for m in guild.members if m.name != player.name] - if not other_members: - player.show("§l§a公会 §d>> §r没有其他成员") - return True - - def formatter(i, m): return f"§e{i}. {m.rank.display_name} §f{m.name}\n" - idx = self._paginate_display( - player, other_members, "选择新会长", formatter, True) - - if idx is None: - return True - - new_owner = other_members[idx] - - player.show(f"§l§a公会 §d>> §r确定要将会长转让给 §e{new_owner.name}§r 吗?") - player.show("§c此操作不可撤销!输入'确认'继续") - - confirm = game_utils.waitMsg(player.name, timeout=30) - if confirm != "确认": - player.show("§l§a公会 §d>> §r操作已取消") - return True - - guilds = self.guild_manager.load_guilds(force_reload=True) - latest_guild = guilds.get(guild.guild_id) - if not latest_guild: - player.show("§l§a公会 §d>> §r公会数据异常") - return True - - old_owner = latest_guild.get_member(player.name) - latest_new_owner = latest_guild.get_member(new_owner.name) - if not old_owner or not latest_new_owner: - player.show("§l§a公会 §d>> §r成员数据异常") - return True - - latest_guild.owner = latest_new_owner.name - latest_new_owner.rank = GuildRank.OWNER - old_owner.rank = GuildRank.DEPUTY - - latest_guild.add_log(f"{player.name} 将会长转让给了 {latest_new_owner.name}") - latest_guild.add_audit_log("transfer_owner", player.name, - target=latest_new_owner.name) - self.guild_manager.save_guilds(guilds) - - player.show(f"§l§a公会 §d>> §r已将会长转让给 {latest_new_owner.name}") - - # 通知新会长 - if latest_new_owner.name in self.game_ctrl.allplayers: - self.game_ctrl.sendcmd( - f'/tellraw {latest_new_owner.name} {{"rawtext":[{{"text":"§l§a公会 §d>> §r你已成为公会会长!"}}]}}' - ) - - return True - - -handlers = { - '_handle_effect': _handle_effect, - '_handle_rankings': _handle_rankings, - '_format_time_ago': _format_time_ago, - '_handle_view_guild': _handle_view_guild, - '_handle_view_members': _handle_view_members, - '_handle_view_logs': _handle_view_logs, - '_handle_announcement': _handle_announcement, - '_handle_tasks': _handle_tasks, - '_handle_view_tasks': _handle_view_tasks, - '_handle_join_task': _handle_join_task, - '_handle_create_task': _handle_create_task, - '_handle_generate_auto_tasks': _handle_generate_auto_tasks, - '_handle_manage_tasks': _handle_manage_tasks, - '_handle_manage_members': _handle_manage_members, - '_notify_join_request_admins': _notify_join_request_admins, - '_handle_join_request_queue': _handle_join_request_queue, - '_handle_set_rank': _handle_set_rank, - '_handle_donation': _handle_donation, - '_handle_vault': _handle_vault, - '_handle_vault_view': _handle_vault_view, - '_handle_vault_buy': _handle_vault_buy, - '_handle_vault_sell': _handle_vault_sell, - '_handle_vault_logs': _handle_vault_logs, - '_handle_vault_cancel': _handle_vault_cancel, - '_handle_vault_settings': _handle_vault_settings, - '_handle_create_guild': _handle_create_guild, - '_handle_list_guilds': _handle_list_guilds, - '_handle_join_guild': _handle_join_guild, - '_handle_leave_guild': _handle_leave_guild, - '_handle_dissolve_guild': _handle_dissolve_guild, - '_handle_kick_member': _handle_kick_member, - '_handle_set_base': _handle_set_base, - '_handle_return_base': _handle_return_base, - '_handle_transfer_ownership': _handle_transfer_ownership, -} + + guilds = self.guild_manager.load_guilds(force_reload=True) + if guild.guild_id in guilds: + del guilds[guild.guild_id] + self.guild_manager.save_guilds(guilds) + player.show(f"§l§a公会 §d>> §r已解散公会 §e{guild.name}") + + return True + + +def _handle_kick_member(self, player: Player) -> bool: + """处理踢出成员""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild or not guild.has_permission(player.name, "kick"): + player.show("§l§a公会 §d>> §r你没有踢人权限") + return True + + # 使用新的权限检查逻辑过滤可踢出的成员 + kickable_members = [] + for member in guild.members: + if guild.can_manage_member(player.name, member.name): + kickable_members.append(member) + + if not kickable_members: + player.show("§l§a公会 §d>> §r没有可踢出的成员") + return True + + def formatter(i, m): + """Format one menu item for display.""" + return f"§e{i}. {m.rank.display_name} §f{m.name}\n" + idx = self._paginate_display( + player, + kickable_members, + "踢出成员", + formatter, + True) + + if idx is not None: + target = kickable_members[idx] + self.guild_manager.remove_member(target.name) + player.show(f"§l§a公会 §d>> §r已将 {target.name} 踢出公会") + + # 通知被踢玩家 + if target.name in self.game_ctrl.allplayers: + self.game_ctrl.sendcmd( + f'/tellraw {target.name} ' + '{"rawtext":[{"text":"§l§a公会 §d>> §r你已被踢出公会"}]}' + ) + + return True + + +def _handle_set_base(self, player: Player) -> bool: + """处理设置据点 - 增强版本""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + if not guild.has_permission(player.name, "setbase"): + player.show("§l§a公会 §d>> §r你没有设置公会据点权限") + return True + + try: + fmts.print_inf(f"开始为公会 {guild.name} 设置据点") + pos = game_utils.getPos(player.name) + + if not pos: + player.show("§l§a公会 §d>> §r获取位置失败,请稍后重试") + fmts.print_err("获取位置失败: pos为空") + return True + + dimension = pos.get("dimension", -1) + if dimension != 0: + player.show("§l§a公会 §d>> §r据点只能设置在主世界") + fmts.print_err(f"设置据点失败: 维度不是主世界 (维度={dimension})") + return True + + # 确保position字段存在 + if "position" not in pos: + player.show("§l§a公会 §d>> §r获取位置信息不完整,请稍后重试") + fmts.print_err(f"设置据点错误: 位置信息不完整 {pos}") + return True + + position = pos["position"] + x = position.get("x", 0) + y = position.get("y", 0) + z = position.get("z", 0) + + base = GuildBase( + dimension=dimension, + x=x, + y=y, + z=z + ) + + # 打印调试信息 + fmts.print_inf(f"正在设置据点: 维度={dimension}, 坐标=({x}, {y}, {z})") + + # 直接保存到当前公会对象 + guild.base = base + guild.add_log(f"{player.name} 设置了据点") + + # 重新加载公会数据以确保使用最新数据 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_id = guild.guild_id + + # 确保公会ID存在于加载的数据中 + if guild_id not in guilds: + player.show("§l§a公会 §d>> §r公会数据加载失败,请稍后重试") + fmts.print_err(f"设置据点错误: 公会ID {guild_id} 不存在于加载的数据中") + return True + + # 更新公会数据 + guilds[guild_id].base = base + guilds[guild_id].add_log(f"{player.name} 设置了据点") + + # 保存数据 + self.guild_manager.save_guilds(guilds) + + # 再次检查是否保存成功 + check_guilds = self.guild_manager.load_guilds(force_reload=True) + if guild_id in check_guilds and check_guilds[guild_id].base: + check_base = check_guilds[guild_id].base + fmts.print_inf( + f"据点设置成功: 公会={ + guild.name}, 维度={ + check_base.dimension}, 坐标=({ + check_base.x}, { + check_base.y}, { + check_base.z})") + player.show(f"§l§a公会 §d>> §r据点已设置为 ({x:.1f}, {y:.1f}, {z:.1f})") + else: + player.show("§l§a公会 §d>> §r据点设置可能失败,请重试") + fmts.print_err(f"据点设置后验证失败: 公会={guild.name}") + + except Exception as e: + player.show("§l§a公会 §d>> §r获取位置失败,请稍后重试") + fmts.print_err(f"设置据点错误: {e}") + import traceback + fmts.print_err(traceback.format_exc()) + + return True + + +def _handle_return_base(self, player: Player) -> bool: + """处理返回据点 - 增强版本""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你没有加入任何公会") + return True + if not guild.has_permission(player.name, "return_base"): + player.show("§l§a公会 §d>> §r你没有返回公会据点权限") + return True + + # 重新加载公会数据以确保使用最新数据 + try: + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_id = guild.guild_id + + # 确保公会ID存在于加载的数据中 + if guild_id not in guilds: + player.show("§l§a公会 §d>> §r公会数据加载失败,请稍后重试") + fmts.print_err(f"传送失败: 公会ID {guild_id} 不存在于加载的数据中") + return True + + # 使用最新的公会数据 + guild = guilds[guild_id] + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + if isinstance( + getattr( + guild, + "settings", + None), + dict) and guild.settings.get( + "base_locked", + False): + player.show("§l§a公会 §d>> §r公会据点已被锁定") + return True + + base = guild.base + if not base: + player.show("§l§a公会 §d>> §r公会尚未设置据点") + fmts.print_err(f"传送失败: 公会 {guild.name} 没有设置据点") + return True + + # 验证据点数据完整性 + if not all(hasattr(base, attr) + for attr in ['dimension', 'x', 'y', 'z']): + player.show("§l§a公会 §d>> §r据点数据损坏,请重新设置") + fmts.print_err(f"传送失败: 据点数据不完整 {base}") + return True + + # 输出详细的据点信息用于调试 + fmts.print_inf(f"开始传送: 玩家={player.name}, 公会={guild.name}") + fmts.print_inf( + f"据点信息: 维度={ + base.dimension}, 坐标=({ + base.x}, { + base.y}, { + base.z})") + + # 检查维度是否有效 + if base.dimension not in [0, -1, 1]: # 主世界、下界、末地 + player.show("§l§a公会 §d>> §r据点维度无效,请重新设置") + fmts.print_err(f"传送失败: 无效维度 {base.dimension}") + return True + + # 准备传送 + player.show("§l§a公会 §d>> §r准备传送到据点...") + player.show("§7请保持静止3秒...") + + # 延迟传送 + time.sleep(3) + + # 获取坐标并确保为数值类型 + x = float(base.x) + y = float(base.y) + z = float(base.z) + fmts.print_inf(f"传送玩家 {player.name} 到据点: ({x}, {y}, {z})") + + # 尝试多种传送方法,按成功率排序 + success = False + + try: + cmd = f"tp {player.name} {x} {y} {z}" + fmts.print_inf(f"方法1 - 使用sendwocmd执行: {cmd}") + self.game_ctrl.sendwocmd(cmd) + success = True + fmts.print_inf("方法1 - sendwocmd传送成功") + except Exception as e: + fmts.print_err(f"方法1 - sendwocmd失败: {e}") + + if success: + player.show(f"§l§a公会 §d>> §r已传送到公会据点 ({x:.1f}, {y:.1f}, {z:.1f})") + fmts.print_inf(f"传送成功: {player.name} -> ({x}, {y}, {z})") + else: + player.show("§l§a公会 §d>> §r传送失败,所有传送方式都无效") + fmts.print_err("所有传送指令都失败了") + + except Exception as e: + player.show("§l§a公会 §d>> §r传送过程中发生错误,请稍后重试") + fmts.print_err(f"传送到据点时发生异常: {e}") + import traceback + fmts.print_err(traceback.format_exc()) + + return True + + +def _handle_transfer_ownership(self, player: Player) -> bool: + """转让会长""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + return True + if not guild.has_permission(player.name, "transfer_owner"): + player.show("§l§a公会 §d>> §r你没有转让会长权限") + return True + + # 选择新会长 + other_members = [m for m in guild.members if m.name != player.name] + if not other_members: + player.show("§l§a公会 §d>> §r没有其他成员") + return True + + def formatter(i, m): + """Format one menu item for display.""" + return f"§e{i}. {m.rank.display_name} §f{m.name}\n" + idx = self._paginate_display( + player, other_members, "选择新会长", formatter, True) + + if idx is None: + return True + + new_owner = other_members[idx] + + player.show(f"§l§a公会 §d>> §r确定要将会长转让给 §e{new_owner.name}§r 吗?") + player.show("§c此操作不可撤销!输入'确认'继续") + + confirm = game_utils.waitMsg(player.name, timeout=30) + if confirm != "确认": + player.show("§l§a公会 §d>> §r操作已取消") + return True + + guilds = self.guild_manager.load_guilds(force_reload=True) + latest_guild = guilds.get(guild.guild_id) + if not latest_guild: + player.show("§l§a公会 §d>> §r公会数据异常") + return True + + old_owner = latest_guild.get_member(player.name) + latest_new_owner = latest_guild.get_member(new_owner.name) + if not old_owner or not latest_new_owner: + player.show("§l§a公会 §d>> §r成员数据异常") + return True + + latest_guild.owner = latest_new_owner.name + latest_new_owner.rank = GuildRank.OWNER + old_owner.rank = GuildRank.DEPUTY + + latest_guild.add_log(f"{player.name} 将会长转让给了 {latest_new_owner.name}") + latest_guild.add_audit_log("transfer_owner", player.name, + target=latest_new_owner.name) + self.guild_manager.save_guilds(guilds) + + player.show(f"§l§a公会 §d>> §r已将会长转让给 {latest_new_owner.name}") + + # 通知新会长 + if latest_new_owner.name in self.game_ctrl.allplayers: + self.game_ctrl.sendcmd( + f'/tellraw {latest_new_owner.name} ' + '{"rawtext":[{"text":"§l§a公会 §d>> §r你已成为公会会长!"}]}' + ) + + return True + + +handlers = { + '_handle_effect': _handle_effect, + '_handle_rankings': _handle_rankings, + '_format_time_ago': _format_time_ago, + '_handle_view_guild': _handle_view_guild, + '_handle_view_members': _handle_view_members, + '_handle_view_logs': _handle_view_logs, + '_handle_announcement': _handle_announcement, + '_handle_tasks': _handle_tasks, + '_handle_view_tasks': _handle_view_tasks, + '_handle_join_task': _handle_join_task, + '_handle_create_task': _handle_create_task, + '_handle_generate_auto_tasks': _handle_generate_auto_tasks, + '_handle_manage_tasks': _handle_manage_tasks, + '_handle_manage_members': _handle_manage_members, + '_notify_join_request_admins': _notify_join_request_admins, + '_handle_join_request_queue': _handle_join_request_queue, + '_handle_set_rank': _handle_set_rank, + '_handle_donation': _handle_donation, + '_handle_vault': _handle_vault, + '_handle_vault_view': _handle_vault_view, + '_handle_vault_buy': _handle_vault_buy, + '_handle_vault_sell': _handle_vault_sell, + '_handle_vault_logs': _handle_vault_logs, + '_handle_vault_cancel': _handle_vault_cancel, + '_handle_vault_settings': _handle_vault_settings, + '_handle_create_guild': _handle_create_guild, + '_handle_list_guilds': _handle_list_guilds, + '_handle_join_guild': _handle_join_guild, + '_handle_leave_guild': _handle_leave_guild, + '_handle_dissolve_guild': _handle_dissolve_guild, + '_handle_kick_member': _handle_kick_member, + '_handle_set_base': _handle_set_base, + '_handle_return_base': _handle_return_base, + '_handle_transfer_ownership': _handle_transfer_ownership, +} diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" index 22cf49fd..35c83301 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/handlers_quick.py" @@ -1,131 +1,133 @@ -"""Quick command handlers for common guild operations.""" - -import json - -from tooldelta import Player, game_utils, fmts -from guild_cloud_interop.models import GuildRank, VaultItem -from guild_cloud_interop.config import Config -from guild_cloud_interop.prompts import render_create_guild_prompt - - -def quick_create_guild(self, player: Player, args: tuple): - """快捷创建公会""" - player_xuid = self.xuidm.get_xuid_by_name(player.name, allow_offline=True) - - # 检查玩家是否已有公会 - guild = self.guild_manager.get_guild_by_player(player.name) - if guild: - player.show(render_create_guild_prompt("已有公会提示词")) - return True - - # 获取公会名称 - guild_name = args[0] if args and args[0] else None - - if not guild_name: - player.show(render_create_guild_prompt("快捷创建缺少名称提示词")) - return True - - if len(guild_name) < 2 or len(guild_name) > 16: - player.show(render_create_guild_prompt("快捷创建名称长度无效提示词")) - return True - - # 检查条件 - score = player.getScore(Config.GUILD_SCOREBOARD) - if score < Config.GUILD_CREATION_COST: - player.show(render_create_guild_prompt("创建公会余额不足提示词", balance=score)) - return True - - # 扣除钻石并创建公会 - self.game_ctrl.sendwocmd( - f"scoreboard players remove { - player.name} { - Config.GUILD_SCOREBOARD} { - Config.GUILD_CREATION_COST}") - - if self.guild_manager.create_guild(player_xuid, player.name, guild_name): - player.show(render_create_guild_prompt( - "创建公会成功提示词", guild=guild_name, player=player.name)) - # 通知所有在线玩家 - announcement = render_create_guild_prompt( - "创建公会全服公告提示词", - guild=guild_name, - player=player.name, - ) - payload = json.dumps( - {"rawtext": [{"text": announcement}]}, ensure_ascii=False) - self.game_ctrl.sendcmd(f"/tellraw @a {payload}") - else: - player.show(render_create_guild_prompt( - "创建公会名称已存在提示词", guild=guild_name, player=player.name)) - self.game_ctrl.sendwocmd( - f"scoreboard players add { - player.name} { - Config.GUILD_SCOREBOARD} { - Config.GUILD_CREATION_COST}") - - return True - - -def quick_join_guild(self, player: Player, args: tuple): - """快捷加入公会""" - if self.guild_manager.get_guild_by_player(player.name): - player.show("§l§a公会 §d>> §r你已经加入了一个公会") - return True - - search_name = args[0] if args and args[0] else None - - if not search_name: - player.show("§l§a公会 §d>> §r请输入公会名字,例如: 公会加入 某某公会") - return True - - # 搜索匹配的公会 - guilds = self.guild_manager.load_guilds() - matched_guilds = [g for g in guilds.values() if search_name.lower() - in g.name.lower()] - - if not matched_guilds: - player.show("§l§a公会 §d>> §r未找到匹配的公会") - return True - - # 选择公会 - if len(matched_guilds) == 1: - target_guild = matched_guilds[0] - else: - player.show(f"§l§a公会 §d>> §r找到多个匹配的公会,请选择:") - for i, guild in enumerate(matched_guilds, 1): - player.show(f"§e{i}. §f{guild.name} §7(会长: {guild.owner})") - - player.show("§7请输入序号选择公会:") - choice = game_utils.waitMsg(player.name, timeout=30) - - if not choice or not choice.isdigit() or int( - choice) < 1 or int(choice) > len(matched_guilds): - player.show("§l§a公会 §d>> §r无效的选择") - return True - - target_guild = matched_guilds[int(choice) - 1] - - if self.guild_is_frozen(target_guild): - self.show_guild_frozen(player, target_guild) - return True - - # 检查人数上限 - if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: - player.show("§l§a公会 §d>> §r该公会已满员") - return True - - # 获取有邀请权限的在线成员 - online_inviters = [] - for member in target_guild.members: - if member.name in self.game_ctrl.allplayers and target_guild.has_permission( - member.name, "invite"): - online_inviters.append(member.name) - - if not online_inviters: - player.show("§l§a公会 §d>> §r没有可以处理申请的成员在线") - return True - - # 选择一个在线的管理员发送申请 +"""Quick command handlers for common guild operations.""" + +# pylint: disable=protected-access + +import json + +from tooldelta import Player, game_utils, fmts +from guild_cloud_interop.models import GuildRank, VaultItem +from guild_cloud_interop.config import Config +from guild_cloud_interop.prompts import render_create_guild_prompt + + +def quick_create_guild(self, player: Player, args: tuple): + """快捷创建公会""" + player_xuid = self.xuidm.get_xuid_by_name(player.name, allow_offline=True) + + # 检查玩家是否已有公会 + guild = self.guild_manager.get_guild_by_player(player.name) + if guild: + player.show(render_create_guild_prompt("已有公会提示词")) + return True + + # 获取公会名称 + guild_name = args[0] if args and args[0] else None + + if not guild_name: + player.show(render_create_guild_prompt("快捷创建缺少名称提示词")) + return True + + if len(guild_name) < 2 or len(guild_name) > 16: + player.show(render_create_guild_prompt("快捷创建名称长度无效提示词")) + return True + + # 检查条件 + score = player.getScore(Config.GUILD_SCOREBOARD) + if score < Config.GUILD_CREATION_COST: + player.show(render_create_guild_prompt("创建公会余额不足提示词", balance=score)) + return True + + # 扣除钻石并创建公会 + self.game_ctrl.sendwocmd( + f"scoreboard players remove { + player.name} { + Config.GUILD_SCOREBOARD} { + Config.GUILD_CREATION_COST}") + + if self.guild_manager.create_guild(player_xuid, player.name, guild_name): + player.show(render_create_guild_prompt( + "创建公会成功提示词", guild=guild_name, player=player.name)) + # 通知所有在线玩家 + announcement = render_create_guild_prompt( + "创建公会全服公告提示词", + guild=guild_name, + player=player.name, + ) + payload = json.dumps( + {"rawtext": [{"text": announcement}]}, ensure_ascii=False) + self.game_ctrl.sendcmd(f"/tellraw @a {payload}") + else: + player.show(render_create_guild_prompt( + "创建公会名称已存在提示词", guild=guild_name, player=player.name)) + self.game_ctrl.sendwocmd( + f"scoreboard players add { + player.name} { + Config.GUILD_SCOREBOARD} { + Config.GUILD_CREATION_COST}") + + return True + + +def quick_join_guild(self, player: Player, args: tuple): # skipcq: PY-R1000 + """快捷加入公会""" + if self.guild_manager.get_guild_by_player(player.name): + player.show("§l§a公会 §d>> §r你已经加入了一个公会") + return True + + search_name = args[0] if args and args[0] else None + + if not search_name: + player.show("§l§a公会 §d>> §r请输入公会名字,例如: 公会加入 某某公会") + return True + + # 搜索匹配的公会 + guilds = self.guild_manager.load_guilds() + matched_guilds = [g for g in guilds.values() if search_name.lower() + in g.name.lower()] + + if not matched_guilds: + player.show("§l§a公会 §d>> §r未找到匹配的公会") + return True + + # 选择公会 + if len(matched_guilds) == 1: + target_guild = matched_guilds[0] + else: + player.show(f"§l§a公会 §d>> §r找到多个匹配的公会,请选择:") + for i, guild in enumerate(matched_guilds, 1): + player.show(f"§e{i}. §f{guild.name} §7(会长: {guild.owner})") + + player.show("§7请输入序号选择公会:") + choice = game_utils.waitMsg(player.name, timeout=30) + + if not choice or not choice.isdigit() or int( + choice) < 1 or int(choice) > len(matched_guilds): + player.show("§l§a公会 §d>> §r无效的选择") + return True + + target_guild = matched_guilds[int(choice) - 1] + + if self.guild_is_frozen(target_guild): + self.show_guild_frozen(player, target_guild) + return True + + # 检查人数上限 + if len(target_guild.members) >= Config.MAX_GUILD_MEMBERS: + player.show("§l§a公会 §d>> §r该公会已满员") + return True + + # 获取有邀请权限的在线成员 + online_inviters = [] + for member in target_guild.members: + if member.name in self.game_ctrl.allplayers and target_guild.has_permission( + member.name, "invite"): + online_inviters.append(member.name) + + if not online_inviters: + player.show("§l§a公会 §d>> §r没有可以处理申请的成员在线") + return True + + # 选择一个在线的管理员发送申请 inviter = online_inviters[0] # 可以改进为选择职位最高的 # 发送申请 @@ -137,216 +139,222 @@ def quick_join_guild(self, player: Player, args: tuple): self.game_ctrl.sendcmd( f'/tellraw {inviter} {{"rawtext":[{{"text":"{message}"}}]}}' ) - player.show(f"已向 {inviter} 发送加入申请") - - reply = game_utils.waitMsg(inviter, timeout=60) - if reply == "同意": - if self.guild_manager.add_member( - target_guild.name, player.name, inviter): - player.show(f"§l§a公会 §d>> §r你已加入公会 {target_guild.name}") - self.game_ctrl.sendcmd( - f'/tellraw {inviter} {{"rawtext":[{{"text":"§l§a公会 §d>> §r已同意申请"}}]}}' - ) + player.show(f"已向 {inviter} 发送加入申请") + + reply = game_utils.waitMsg(inviter, timeout=60) + if reply == "同意": + if self.guild_manager.add_member( + target_guild.name, player.name, inviter): + player.show(f"§l§a公会 §d>> §r你已加入公会 {target_guild.name}") + self.game_ctrl.sendcmd( + f'/tellraw {inviter} {{"rawtext":[{{"text":"§l§a公会 §d>> §r已同意申请"}}]}}' + ) # 通知其他在线成员 for member in target_guild.members: - if member.name in self.game_ctrl.allplayers and member.name != player.name: + if ( + member.name in self.game_ctrl.allplayers + and member.name != player.name + ): message = f"§l§a公会 §d>> §r§e{player.name}§r 加入了公会" self.game_ctrl.sendcmd( f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' ) - elif reply == "拒绝": - player.show("§l§a公会 §d>> §r你的申请被拒绝了") - else: - player.show("§l§a公会 §d>> §r申请超时") - - return True - - -def quick_view_guild(self, player: Player, args: tuple): - """快捷查看公会信息""" - self._handle_view_guild(player) - return True - - -def quick_view_members(self, player: Player, args: tuple): - """快捷查看成员列表""" - self._handle_view_members(player) - return True - - -def quick_base_action(self, player: Player, args: tuple): - """快捷公会据点操作""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - action = args[0] if args and args[0] else None - - if action == "tp": - if not guild.base: - player.show("§l§a公会 §d>> §r公会尚未设置据点,请先设置") - return True - return self._handle_return_base(player) - elif action == "set": - # 检查权限 - 只有会长可以设置据点 - member = guild.get_member(player.name) - if not member or member.rank != GuildRank.OWNER: - player.show("§l§a公会 §d>> §r只有会长才能设置公会据点") - player.show("§7当前权限: §f" + - (member.rank.display_name if member else "无")) - return True - return self._handle_set_base(player) - else: - # 显示据点信息 - member = guild.get_member(player.name) - is_owner = member and member.rank == GuildRank.OWNER - - if guild.base: - base = guild.base - dim_name = Config.DIMENSION_NAMES.get( - base.dimension, f"维度{base.dimension}") - player.show( - f"§l§a公会据点§r\n§7位置: §f{dim_name} ({ - base.x:.1f}, { - base.y:.1f}, { - base.z:.1f})") - player.show("§7使用 §f.公会据点 tp §7传送到据点") - if is_owner: - player.show("§7使用 §f.公会据点 set §7重新设置据点") - else: - player.show("§l§a公会 §d>> §r公会尚未设置据点") - if is_owner: - player.show("§7使用 §f公会据点 set §7设置据点") - else: - player.show("§7只有会长才能设置据点") - - return True - - -def quick_donate(self, player: Player, args: tuple): - """快捷捐献物品""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - item_name = args[0] if args and args[0] else None - amount = args[1] if args and args[1] else 1 - - if not item_name: - player.show("§l§a公会 §d>> §r请输入物品名称 (如: 钻石, 绿宝石, 金锭, 铁锭)") - return True - - if amount <= 0: - player.show("§l§a公会 §d>> §r数量必须大于0") - return True - - item_map = { - "钻石": ("minecraft:diamond", 10, 5), - "绿宝石": ("minecraft:emerald", 5, 3), - "金锭": ("minecraft:gold_ingot", 2, 1), - "铁锭": ("minecraft:iron_ingot", 1, 0.5) - } - - item_info = item_map.get(item_name.lower()) - if not item_info: - player.show("§l§a公会 §d>> §r不支持的捐献物品") - return True - - item_id, contrib_per_item, exp_per_item = item_info - - if player.getItemCount(item_id) < amount: - player.show( - f"§l§a公会 §d>> §r你只有 { - player.getItemCount(item_id)} 个{item_name}") - return True - - self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {amount}") - - exp, contribution = self.guild_apply_reward_multipliers( - int(amount * exp_per_item), - amount * contrib_per_item, - ) - - # 重新加载公会数据并更新经验值 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild_id = guild.guild_id - - if guild_id in guilds: - # 更新贡献与公会经验 - guild = guilds[guild_id] - member = guild.get_member(player.name) - if member: - member.contribution += contribution - guild.stats.total_contribution += contribution - guild.exp += exp - - # 等级提升逻辑 - while True: - next_level = guild.level + 1 - next_exp_needed = Config.GUILD_LEVEL_EXP.get(next_level) - if not next_exp_needed: - break # 已到最大等级 - if guild.exp >= next_exp_needed: - guild.level += 1 - guild.exp -= next_exp_needed - guild.add_log(f"{guild.name} 公会升级到 Lv{guild.level}!") - else: - break - - # 保存公会数据 - self.guild_manager.save_guilds(guilds) - - player.show(f"§l§a公会 §d>> §r捐献成功!获得 {contribution} 贡献度,公会获得 {exp} 经验") - else: - player.show("§l§a公会 §d>> §r捐献失败,公会数据异常") - fmts.print_err(f"捐献失败: 公会ID {guild_id} 不存在于加载的数据中") - - return True - - -def quick_announcement(self, player: Player, args: tuple): - """快捷查看/设置公告""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - - # 显示当前公告 - if guild.announcement: - player.show(f"§l§a公会公告§r\n§f{guild.announcement}") - else: - player.show("§l§a公会 §d>> §r当前没有公告") - - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - # 检查权限 - if not guild.has_permission(player.name, "announce"): - player.show("§l§a公会 §d>> §r你没有设置公告的权限") - return True - - player.show("§7输入 '设置' 来修改公告,其他任意键返回") - choice = game_utils.waitMsg(player.name, timeout=20) - - if choice == "设置": - player.show("§l§a公会 §d>> §r请输入新的公告内容 (最多100字):") - new_announcement = game_utils.waitMsg(player.name, timeout=60) - - if new_announcement and len(new_announcement) <= 100: - guilds = self.guild_manager.load_guilds(force_reload=True) - guild.announcement = new_announcement - guild.add_log(f"{player.name} 更新了公告") - self.guild_manager.save_guilds(guilds) - player.show("§l§a公会 §d>> §r公告已更新") + elif reply == "拒绝": + player.show("§l§a公会 §d>> §r你的申请被拒绝了") + else: + player.show("§l§a公会 §d>> §r申请超时") + + return True + + +def quick_view_guild(self, player: Player, args: tuple): + """快捷查看公会信息""" + _ = args + self._handle_view_guild(player) + return True + + +def quick_view_members(self, player: Player, args: tuple): + """快捷查看成员列表""" + _ = args + self._handle_view_members(player) + return True + + +def quick_base_action(self, player: Player, args: tuple): + """快捷公会据点操作""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + action = args[0] if args and args[0] else None + + if action == "tp": + if not guild.base: + player.show("§l§a公会 §d>> §r公会尚未设置据点,请先设置") + return True + return self._handle_return_base(player) + elif action == "set": + # 检查权限 - 只有会长可以设置据点 + member = guild.get_member(player.name) + if not member or member.rank != GuildRank.OWNER: + player.show("§l§a公会 §d>> §r只有会长才能设置公会据点") + player.show("§7当前权限: §f" + + (member.rank.display_name if member else "无")) + return True + return self._handle_set_base(player) + else: + # 显示据点信息 + member = guild.get_member(player.name) + is_owner = member and member.rank == GuildRank.OWNER + + if guild.base: + base = guild.base + dim_name = Config.DIMENSION_NAMES.get( + base.dimension, f"维度{base.dimension}") + player.show( + f"§l§a公会据点§r\n§7位置: §f{dim_name} ({ + base.x:.1f}, { + base.y:.1f}, { + base.z:.1f})") + player.show("§7使用 §f.公会据点 tp §7传送到据点") + if is_owner: + player.show("§7使用 §f.公会据点 set §7重新设置据点") + else: + player.show("§l§a公会 §d>> §r公会尚未设置据点") + if is_owner: + player.show("§7使用 §f公会据点 set §7设置据点") + else: + player.show("§7只有会长才能设置据点") + + return True + + +def quick_donate(self, player: Player, args: tuple): + """快捷捐献物品""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + item_name = args[0] if args and args[0] else None + amount = args[1] if args and args[1] else 1 + + if not item_name: + player.show("§l§a公会 §d>> §r请输入物品名称 (如: 钻石, 绿宝石, 金锭, 铁锭)") + return True + + if amount <= 0: + player.show("§l§a公会 §d>> §r数量必须大于0") + return True + + item_map = { + "钻石": ("minecraft:diamond", 10, 5), + "绿宝石": ("minecraft:emerald", 5, 3), + "金锭": ("minecraft:gold_ingot", 2, 1), + "铁锭": ("minecraft:iron_ingot", 1, 0.5) + } + + item_info = item_map.get(item_name.lower()) + if not item_info: + player.show("§l§a公会 §d>> §r不支持的捐献物品") + return True + + item_id, contrib_per_item, exp_per_item = item_info + + if player.getItemCount(item_id) < amount: + player.show( + f"§l§a公会 §d>> §r你只有 { + player.getItemCount(item_id)} 个{item_name}") + return True + + self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {amount}") + + exp, contribution = self.guild_apply_reward_multipliers( + int(amount * exp_per_item), + amount * contrib_per_item, + ) + + # 重新加载公会数据并更新经验值 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild_id = guild.guild_id + + if guild_id in guilds: + # 更新贡献与公会经验 + guild = guilds[guild_id] + member = guild.get_member(player.name) + if member: + member.contribution += contribution + guild.stats.total_contribution += contribution + guild.exp += exp + + # 等级提升逻辑 + while True: + next_level = guild.level + 1 + next_exp_needed = Config.GUILD_LEVEL_EXP.get(next_level) + if not next_exp_needed: + break # 已到最大等级 + if guild.exp >= next_exp_needed: + guild.level += 1 + guild.exp -= next_exp_needed + guild.add_log(f"{guild.name} 公会升级到 Lv{guild.level}!") + else: + break + + # 保存公会数据 + self.guild_manager.save_guilds(guilds) + + player.show(f"§l§a公会 §d>> §r捐献成功!获得 {contribution} 贡献度,公会获得 {exp} 经验") + else: + player.show("§l§a公会 §d>> §r捐献失败,公会数据异常") + fmts.print_err(f"捐献失败: 公会ID {guild_id} 不存在于加载的数据中") + + return True + + +def quick_announcement(self, player: Player, args: tuple): + """快捷查看/设置公告""" + _ = args + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + + # 显示当前公告 + if guild.announcement: + player.show(f"§l§a公会公告§r\n§f{guild.announcement}") + else: + player.show("§l§a公会 §d>> §r当前没有公告") + + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + # 检查权限 + if not guild.has_permission(player.name, "announce"): + player.show("§l§a公会 §d>> §r你没有设置公告的权限") + return True + + player.show("§7输入 '设置' 来修改公告,其他任意键返回") + choice = game_utils.waitMsg(player.name, timeout=20) + + if choice == "设置": + player.show("§l§a公会 §d>> §r请输入新的公告内容 (最多100字):") + new_announcement = game_utils.waitMsg(player.name, timeout=60) + + if new_announcement and len(new_announcement) <= 100: + guilds = self.guild_manager.load_guilds(force_reload=True) + guild.announcement = new_announcement + guild.add_log(f"{player.name} 更新了公告") + self.guild_manager.save_guilds(guilds) + player.show("§l§a公会 §d>> §r公告已更新") # 通知在线成员 message = "§l§a公会 §d>> §r公告已更新,输入 公会 公告 查看" @@ -355,132 +363,133 @@ def quick_announcement(self, player: Player, args: tuple): self.game_ctrl.sendcmd( f'/tellraw {member.name} {{"rawtext":[{{"text":"{message}"}}]}}' ) - else: - player.show("§l§a公会 §d>> §r公告内容无效或过长") - - return True - - -def quick_vault_menu(self, player: Player, args: tuple): - """快捷仓库菜单""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - return self._handle_vault(player) - - -def quick_vault_sell(self, player: Player, args: tuple): - """快速出售物品到仓库""" - guild = self.guild_manager.get_guild_by_player(player.name) - if not guild: - player.show("§l§a公会 §d>> §r你尚未加入任何公会") - return True - if self.guild_is_frozen(guild): - self.show_guild_frozen(player, guild) - return True - - # 所有成员都可以使用仓库 - - user_input = args[0] if args and args[0] else None - count = args[1] if len(args) > 1 and args[1] else 1 - price = args[2] if len(args) > 2 and args[2] else 0 - - if not user_input: - player.show("§l§a公会仓库 §d>> §r请输入物品名称,例如: 出售 钻石 10 500") - player.show("§7支持中文名称: 出售 钻石 10 500") - player.show("§7也支持英文ID: 出售 minecraft:diamond 10 500") - return True - - # 使用智能匹配查找物品ID - item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) - - if not item_id: - player.show("§l§a公会仓库 §d>> §r未找到匹配的物品") - if suggestions: - player.show("§7您是否想要:") - for i, suggestion in enumerate(suggestions[:3], 1): - player.show(f"§e{i}. §f{suggestion}") - return True - - # 显示找到的物品 - chinese_name = self.item_matcher.get_chinese_name(item_id) - player.show(f"§l§a公会仓库 §d>> §r找到物品: §f{chinese_name}") - - # 检查仓库容量 - max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 - if len(guild.vault_items) >= max_slots: - player.show("§l§a公会仓库 §d>> §r仓库已满") - return True - - # 检查玩家是否有该物品 - item_count = player.getItemCount(item_id) - if item_count < count: - player.show(f"§l§a公会仓库 §d>> §r你只有 {item_count} 个该物品,无法出售 {count} 个") - return True - - # 如果没有指定价格,使用建议价格 - if price <= 0: - price = guild.get_item_value(item_id) * count - player.show(f"§l§a公会仓库 §d>> §r使用建议价格: {price} 贡献点") - - # 确认出售 - item_name = self._get_item_display_name(item_id) - player.show( - f"§l§a公会仓库 §d>> §r确认出售 §f{item_name} §7x{count} §r价格 §e{price}贡献点§r?") - player.show("§7输入 '确认' 继续出售,其他任意键取消") - - confirm = game_utils.waitMsg(player.name, timeout=20) - if confirm != "确认": - player.show("§l§a公会仓库 §d>> §r出售已取消") - return True - - # 执行出售 - guilds = self.guild_manager.load_guilds(force_reload=True) - guild = guilds.get(guild.guild_id) - if not guild: - player.show("§l§a公会仓库 §d>> §r公会数据异常") - return True - - # 再次检查仓库容量 - if len(guild.vault_items) >= max_slots: - player.show("§l§a公会仓库 §d>> §r仓库已满") - return True - - # 扣除物品 - self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {count}") - - # 添加到仓库 - vault_item = VaultItem( - item_id=item_id, - count=count, - price=price, - seller=player.name - ) - - guild.vault_items.append(vault_item) - guild.add_log(f"{player.name} 出售了 {item_name} x{count} (价格{price}贡献点)") - - # 保存数据 - self.guild_manager.save_guilds(guilds) - - player.show( - f"§l§a公会仓库 §d>> §r出售成功!{item_name} x{count} 已上架,价格 {price} 贡献点") - return True - - -handlers_quick = { - "quick_create_guild": quick_create_guild, - "quick_join_guild": quick_join_guild, - "quick_view_guild": quick_view_guild, - "quick_view_members": quick_view_members, - "quick_base_action": quick_base_action, - "quick_donate": quick_donate, - "quick_announcement": quick_announcement, - "quick_vault_menu": quick_vault_menu, - "quick_vault_sell": quick_vault_sell, -} + else: + player.show("§l§a公会 §d>> §r公告内容无效或过长") + + return True + + +def quick_vault_menu(self, player: Player, args: tuple): + """快捷仓库菜单""" + _ = args + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + return self._handle_vault(player) + + +def quick_vault_sell(self, player: Player, args: tuple): # skipcq: PY-R1000 + """快速出售物品到仓库""" + guild = self.guild_manager.get_guild_by_player(player.name) + if not guild: + player.show("§l§a公会 §d>> §r你尚未加入任何公会") + return True + if self.guild_is_frozen(guild): + self.show_guild_frozen(player, guild) + return True + + # 所有成员都可以使用仓库 + + user_input = args[0] if args and args[0] else None + count = args[1] if len(args) > 1 and args[1] else 1 + price = args[2] if len(args) > 2 and args[2] else 0 + + if not user_input: + player.show("§l§a公会仓库 §d>> §r请输入物品名称,例如: 出售 钻石 10 500") + player.show("§7支持中文名称: 出售 钻石 10 500") + player.show("§7也支持英文ID: 出售 minecraft:diamond 10 500") + return True + + # 使用智能匹配查找物品ID + item_id, suggestions = self.item_matcher.validate_and_suggest(user_input) + + if not item_id: + player.show("§l§a公会仓库 §d>> §r未找到匹配的物品") + if suggestions: + player.show("§7您是否想要:") + for i, suggestion in enumerate(suggestions[:3], 1): + player.show(f"§e{i}. §f{suggestion}") + return True + + # 显示找到的物品 + chinese_name = self.item_matcher.get_chinese_name(item_id) + player.show(f"§l§a公会仓库 §d>> §r找到物品: §f{chinese_name}") + + # 检查仓库容量 + max_slots = Config.VAULT_INITIAL_SLOTS # 固定10000格 + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + + # 检查玩家是否有该物品 + item_count = player.getItemCount(item_id) + if item_count < count: + player.show(f"§l§a公会仓库 §d>> §r你只有 {item_count} 个该物品,无法出售 {count} 个") + return True + + # 如果没有指定价格,使用建议价格 + if price <= 0: + price = guild.get_item_value(item_id) * count + player.show(f"§l§a公会仓库 §d>> §r使用建议价格: {price} 贡献点") + + # 确认出售 + item_name = self._get_item_display_name(item_id) + player.show( + f"§l§a公会仓库 §d>> §r确认出售 §f{item_name} §7x{count} §r价格 §e{price}贡献点§r?") + player.show("§7输入 '确认' 继续出售,其他任意键取消") + + confirm = game_utils.waitMsg(player.name, timeout=20) + if confirm != "确认": + player.show("§l§a公会仓库 §d>> §r出售已取消") + return True + + # 执行出售 + guilds = self.guild_manager.load_guilds(force_reload=True) + guild = guilds.get(guild.guild_id) + if not guild: + player.show("§l§a公会仓库 §d>> §r公会数据异常") + return True + + # 再次检查仓库容量 + if len(guild.vault_items) >= max_slots: + player.show("§l§a公会仓库 §d>> §r仓库已满") + return True + + # 扣除物品 + self.game_ctrl.sendwocmd(f"clear {player.name} {item_id} 0 {count}") + + # 添加到仓库 + vault_item = VaultItem( + item_id=item_id, + count=count, + price=price, + seller=player.name + ) + + guild.vault_items.append(vault_item) + guild.add_log(f"{player.name} 出售了 {item_name} x{count} (价格{price}贡献点)") + + # 保存数据 + self.guild_manager.save_guilds(guilds) + + player.show( + f"§l§a公会仓库 §d>> §r出售成功!{item_name} x{count} 已上架,价格 {price} 贡献点") + return True + + +handlers_quick = { + "quick_create_guild": quick_create_guild, + "quick_join_guild": quick_join_guild, + "quick_view_guild": quick_view_guild, + "quick_view_members": quick_view_members, + "quick_base_action": quick_base_action, + "quick_donate": quick_donate, + "quick_announcement": quick_announcement, + "quick_vault_menu": quick_vault_menu, + "quick_vault_sell": quick_vault_sell, +} diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" index 381cd779..3ee8a52f 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/logic.py" @@ -1,5 +1,7 @@ """Shared guild runtime and gameplay logic.""" +# pylint: disable=protected-access + import os import time from typing import List, Optional, Tuple, Any @@ -152,6 +154,7 @@ def _show_menu( def guild_update_data(self, args: list[str]): """更新过去的数据,确保所有公会都有 guild_id,且与外层 id 一致""" + _ = args updated = False # 使用统一接口加载所有公会数据 @@ -176,6 +179,7 @@ def guild_update_data(self, args: list[str]): def guild_menu_cb(self, player: Player, args: tuple): """公会菜单回调函数 - 增强版本""" + _ = args player_xuid = self.xuidm.get_xuid_by_name(player.name, allow_offline=True) # 强制刷新缓存以确保数据最新 @@ -221,7 +225,9 @@ def guild_menu_cb(self, player: Player, args: tuple): # 基础菜单项(总是存在) handlers = { - _menu_item_name("基础功能", "创建", "创建"): lambda: self._handle_create_guild(player, player_xuid), + _menu_item_name("基础功能", "创建", "创建"): ( + lambda: self._handle_create_guild(player, player_xuid) + ), _menu_item_name("基础功能", "列表", "列表"): lambda: self._handle_list_guilds(player), _menu_item_name("基础功能", "查看", "查看"): lambda: self._handle_view_guild(player), _menu_item_name("基础功能", "成员", "成员"): lambda: self._handle_view_members(player), @@ -229,8 +235,12 @@ def guild_menu_cb(self, player: Player, args: tuple): _menu_item_name("基础功能", "公告", "公告"): lambda: self._handle_announcement(player), _menu_item_name("基础功能", "加入", "加入"): lambda: self._handle_join_guild(player), _menu_item_name("基础功能", "退出", "退出"): lambda: self._handle_leave_guild(player), - _menu_item_name("基础功能", "管理", "管理"): lambda: self._handle_manage_members(player), - _menu_item_name("基础功能", "解散", "解散"): lambda: self._handle_dissolve_guild(player, player_xuid), + _menu_item_name("基础功能", "管理", "管理"): ( + lambda: self._handle_manage_members(player) + ), + _menu_item_name("基础功能", "解散", "解散"): ( + lambda: self._handle_dissolve_guild(player, player_xuid) + ), } # 追加可选功能项 @@ -290,6 +300,7 @@ def _has_inventory_space( item_id: str, count: int) -> bool: """检查玩家背包是否有足够空间""" + _ = (player, item_id, count) return True @@ -420,7 +431,7 @@ def _wait_or_stopped(self, seconds: float) -> bool: return stop_event.wait(seconds) -def _apply_guild_effects_to_player( +def _apply_guild_effects_to_player( # skipcq: PY-R1000 self, player_name: str, guild: Optional[GuildData] = None, @@ -602,6 +613,7 @@ def update_online_task(self): def on_player_action(self, packet): """监听玩家行为,用于任务进度跟踪""" + _ = packet try: # TODO 等待具体的数据包格式 pass @@ -700,7 +712,10 @@ def get_guild_rankings( def get_member_rankings( - self, guild_id: str, sort_by: str = "contribution") -> List[Tuple[GuildMember, Any]]: + self, + guild_id: str, + sort_by: str = "contribution", +) -> List[Tuple[GuildMember, Any]]: """获取公会成员排行榜""" guilds = self.guild_manager.load_guilds() guild = guilds.get(guild_id) @@ -774,7 +789,7 @@ def _paginate_display( player.show(render_config_prompt("通用分页无效选择提示词")) -def custom_vault_sell(self, player: Player, args: tuple): +def custom_vault_sell(self, player: Player, args: tuple): # skipcq: PY-R1000 """自定义价格出售物品到仓库""" guild = self.guild_manager.get_guild_by_player(player.name) if not guild: @@ -887,6 +902,7 @@ def custom_vault_sell(self, player: Player, args: tuple): def show_item_list(self, player: Player, args: tuple): """显示支持的物品名称列表""" + _ = args player.show("§r========== §a支持的物品名称§r ==========") player.show("§7以下是系统支持的物品名称,您可以在各种功能中使用:") player.show("") @@ -984,6 +1000,7 @@ def admin_clear_guild_data(self, player: Player, args: tuple): def debug_guild_menu(self, player: Player, args: tuple): """调试公会菜单显示问题""" + _ = args player.show("§l§c公会菜单调试信息§r") player.show("=" * 40) @@ -1048,6 +1065,7 @@ def debug_guild_menu(self, player: Player, args: tuple): def debug_base_function(self, player: Player, args: tuple): """调试据点功能问题""" + _ = args player.show("§l§c据点功能调试信息§r") player.show("=" * 50) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" index 5a01df4d..722c34d9 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/models.py" @@ -23,6 +23,7 @@ class GuildRank(Enum): @property def display_name(self): + """Return the display name.""" return { "owner": "§c会长", "deputy": "§6副会长", @@ -32,6 +33,7 @@ def display_name(self): @property def config_key(self): + """Return the config key.""" return { "owner": "会长", "deputy": "副会长", @@ -427,7 +429,10 @@ def pending_join_requests( request for request in self.join_requests if request.status == "pending" - and (expire_seconds <= 0 or current_time - request.create_time <= expire_seconds) + and ( + expire_seconds <= 0 + or current_time - request.create_time <= expire_seconds + ) ] def add_join_request( @@ -656,7 +661,7 @@ def to_dict(self): } @classmethod - def from_dict(cls, data, outer_key=None): + def from_dict(cls, data, outer_key=None): # skipcq: PY-R1000 """Implement the from dict operation.""" base = None try: diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/service.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/service.py" index 58b6ecf5..2cb5783c 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/service.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/service.py" @@ -26,6 +26,7 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): + _ = exc_tb if exc_type is not None or not self.success: self.rollback() fmts.print_err(f"事务回滚:{exc_val if exc_val else '操作失败'}") diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" index 71842f8c..94d1c28b 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/ui.py" @@ -91,7 +91,7 @@ def normalize_inline(text: str) -> str: return text.strip() -def classify_prefix(text: str) -> str: +def classify_prefix(text: str) -> str: # skipcq: PY-R1000 """Implement the classify prefix operation.""" if "错误" in text or "失败" in text or "无效" in text or "不足" in text or "权限不足" in text: return ERROR_PREFIX @@ -130,10 +130,14 @@ def format_title(title: str) -> str: """Implement the format title operation.""" title = normalize_inline(title) title = title.replace("§a", "§6").replace("§c", "§c") - return f"{ORION_BORDER}\n{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b{title}§d\n{ORION_BORDER}" + return ( + f"{ORION_BORDER}\n" + f"{TITLE_PREFIX} 『§6公会系统 §d云链联动版§f』 §b{title}§d\n" + f"{ORION_BORDER}" + ) -def format_message(text: str) -> str: +def format_message(text: str) -> str: # skipcq: PY-R1000 """Implement the format message operation.""" lines = str(text).splitlines() rendered: list[str] = [] diff --git "a/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index 451e9ef5..b73004dd 100644 --- "a/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\347\231\275\345\220\215\345\215\225&\347\256\241\347\220\206\345\221\230\346\243\200\346\265\213\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -499,6 +499,7 @@ def get_runtime_status(self) -> dict[str, int | float | bool]: def enforce_whitelist(self, player_name: str, player_xuid: str): """对白名单未命中的玩家执行踢出。""" + _ = player_name if player_xuid in self._cfg["白名单"]["白名单玩家"]: return self.game_ctrl.sendwocmd( diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" index 8e379a46..e71400ea 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/binding_mixin.py" @@ -471,10 +471,12 @@ def _binding_cfg(self) -> dict[str, Any]: def _binding_enabled(self, group_id: int) -> bool: """Implement the binding enabled operation.""" + _ = group_id return bool(self._binding_cfg().get("是否开启QQ号与游戏ID绑定功能", False)) def _binding_text(self, group_id: int, key: str, fallback: str) -> str: """Implement the binding text operation.""" + _ = group_id value = self._binding_cfg().get(key, fallback) text = str(value).strip() return text or fallback @@ -489,6 +491,7 @@ def _binding_reject_text(self, group_id: int) -> str: def _binding_timeout_minutes(self, group_id: int) -> int: """Implement the binding timeout minutes operation.""" + _ = group_id return self._normalize_positive_int( self._binding_cfg().get( "绑定超时时间(单位:分钟)", @@ -516,6 +519,7 @@ def _qq_has_bound_xuid(self, qqid: int) -> bool: def get_group_binding_triggers(self, group_id: int) -> list[str]: """Return group binding triggers data.""" + _ = group_id raw = self._binding_cfg().get("绑定触发词", ["绑定"]) return self.normalize_string_triggers(raw, ["绑定"]) diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" index dfbdcf54..20682e29 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_editor_mixin.py" @@ -195,7 +195,7 @@ def _config_file_select_menu(self, ctx: dict[str, Any]): if result is self.CONFIG_EXIT: return self.CONFIG_EXIT - def _edit_config_file_whole( + def _edit_config_file_whole( # skipcq: PY-R1000 self, ctx: dict[str, Any], item: dict[str, str]): """Implement the edit config file whole operation.""" try: @@ -511,8 +511,10 @@ def _is_safe_backup_path(self, path: str) -> bool: backup_root = os.path.abspath(self._config_backup_root()) target = os.path.abspath(path) try: - return os.path.commonpath( - [backup_root, target]) == backup_root and target.lower().endswith(".json") + return ( + os.path.commonpath([backup_root, target]) == backup_root + and target.lower().endswith(".json") + ) except ValueError: return False @@ -524,7 +526,7 @@ def _extract_config_items(full_config: dict[str, Any]) -> dict[str, Any]: return config_items return full_config - def _apply_runtime_config_file( + def _apply_runtime_config_file( # skipcq: PY-R1000 self, item: dict[str, str], full_config: dict[str, Any]) -> str: """Implement the apply runtime config file operation.""" config_name = item["name"] diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" index 0a6bdc5a..6f214723 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/config_mixin.py" @@ -136,7 +136,9 @@ def group_default(group_id: int = 194838530): "替换花里胡哨的昵称": True, "替换花里胡哨的消息": True, }, - QQLinkerConfigMixin.PERMISSION_SETTINGS_KEY: QQLinkerConfigMixin.permission_default(), + QQLinkerConfigMixin.PERMISSION_SETTINGS_KEY: ( + QQLinkerConfigMixin.permission_default() + ), "指令设置": { "发送指令前缀": "/", "帮助菜单唤醒词": ["help", "帮助"], @@ -799,7 +801,7 @@ def delete_migrated_legacy_group_admin_file(self, path: str) -> bool: return False return True - def migrate_config(self, raw_cfg: Any): + def migrate_config(self, raw_cfg: Any): # skipcq: PY-R1000 """把整个插件配置迁到最新版本。 历史上这个插件经历过“单群结构”和“多群结构”两个阶段, @@ -823,7 +825,9 @@ def migrate_config(self, raw_cfg: Any): ) ) default_interval = dynamic_settings[self.DYNAMIC_LOAD_INTERVAL_KEY] - dynamic_settings[self.DYNAMIC_LOAD_INTERVAL_KEY] = self._normalize_positive_int( + dynamic_settings[ + self.DYNAMIC_LOAD_INTERVAL_KEY + ] = self._normalize_positive_int( dynamic_load_cfg.get( self.DYNAMIC_LOAD_INTERVAL_KEY, default_interval, diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/orion_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/orion_mixin.py" index 3ee48ff9..e9deb01a 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/orion_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/orion_mixin.py" @@ -1,940 +1,945 @@ -"""Orion System integration helpers for Ultra.""" - -import os -import re - -from tooldelta import fmts, utils - - -# 和 Orion_System 的联动全收在这里,主入口不用再关心封禁细节。 -class QQLinkerOrionMixin: - """负责 Orion_System 菜单、封禁与解封逻辑。""" - - @staticmethod - def console_menu_header(title: str) -> str: - """生成控制台使用的 Orion 风格标题栏。""" - return ( - "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓" - "§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" - f"§l§d❐§f 『§6群服互通云链版Ultra版§f』 §b{title}" - ) - - @staticmethod - def console_menu_footer(page_label: str, body: str) -> str: - """生成控制台使用的 Orion 风格页脚。""" - return ( - "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓 " - f"§r§7[ §b{page_label} §7] " - "§l§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" - f"§r{body}" - ) - - def prompt_console_input( - self, - title: str, - page_label: str, - body_lines: list[str], - prompt: str, - ) -> str: - """按 Orion 卡片样式展示控制台交互步骤,并读取输入。""" - self.print_console_card(title, page_label, body_lines, level="info") - return input(fmts.fmt_info(f"§a❀ §b{prompt}")).strip() - - @staticmethod - def print_console_info(text: str): - """按统一 UI 风格输出控制台普通信息。""" - fmts.print_inf(f"§a❀ §b{text}") - - @staticmethod - def print_console_success(text: str): - """按统一 UI 风格输出控制台成功信息。""" - fmts.print_suc(f"§a❀ §b{text}") - - @staticmethod - def print_console_warn(text: str): - """按统一 UI 风格输出控制台警告信息。""" - fmts.print_war(f"§6❀ §e{text}") - - @staticmethod - def print_console_error(text: str): - """按统一 UI 风格输出控制台错误信息。""" - fmts.print_err(f"§c❀ §e{text}") - - def print_console_card( - self, - title: str, - page_label: str, - body_lines: list[str], - level: str = "info", - ): - """按 Orion 风格打印一张控制台信息卡片。""" - card = ( - self.console_menu_header(title) + - "\n" + - self.console_menu_footer( - page_label, - "\n".join( - i if i.startswith("§") else f"§a❀ §b{i}" for i in body_lines), - )) - { - "info": fmts.print_inf, - "success": fmts.print_suc, - "warn": fmts.print_war, - "error": fmts.print_err, - }.get(level, fmts.print_inf)(card) - - def require_orion(self): - """返回可用的 Orion 实例,不可用时抛出明确异常。""" - if self.orion is None: - raise RuntimeError("相关插件未安装:Orion_System") - enabled = self._extract_plugin_enabled_flag(self.orion) - if not enabled and enabled is not None: - raise RuntimeError("相关插件未启用") - return self.orion - - def reply_to_qq(self, group_id: int, qqid: int, text: str): - """向指定群成员回复一条文本消息。""" - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {text}", - do_remove_cq_code=False, - ) - - def reply_result(self, group_id: int, qqid: int, ok: bool, msg: str): - """把统一格式的成功/失败结果回发到群里。""" - prefix = "😄" if ok else "😭" - self.reply_to_qq(group_id, qqid, f"{prefix} {msg}") - - def on_qq_orion_ban(self, group_id: int, sender: int, args: list[str]): - """群聊侧的 Orion 封禁入口。 - - 支持两种调用方式: - - 直接给参数:适合熟悉命令的管理人员 - - 不给参数:转到交互式菜单 - """ - if not self._can_use_group_permission(group_id, sender, "封禁/解封玩家权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if self.orion is None: - self.reply_to_qq(group_id, sender, "相关插件未安装:Orion_System") - return - if not self.ensure_linked_plugin_enabled(self.orion, group_id, sender): - return - if args == []: - self.qq_orion_ban_menu(group_id, sender) - return - target = args[0] - ban_time_raw = args[1] - reason = " ".join(args[2:]).strip() or "群聊管理员封禁" - ok, msg = self.orion_ban_player(target, ban_time_raw, reason) - self.reply_result(group_id, sender, ok, msg) - - def on_qq_orion_unban(self, group_id: int, sender: int, args: list[str]): - """群聊侧的 Orion 解封入口。""" - if not self._can_use_group_permission(group_id, sender, "封禁/解封玩家权限"): - self._reply_menu_permission_denied(group_id, sender) - return - if self.orion is None: - self.reply_to_qq(group_id, sender, "相关插件未安装:Orion_System") - return - if not self.ensure_linked_plugin_enabled(self.orion, group_id, sender): - return - if args == []: - self.qq_orion_unban_menu(group_id, sender) - return - ok, msg = self.orion_unban_player(args[0]) - self.reply_result(group_id, sender, ok, msg) - - def qq_prompt( - self, - group_id: int, - qqid: int, - text: str, - timeout: int = 60): - """发送一段提示文本,并等待同群同 QQ 的下一条回复。""" - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {text}", - do_remove_cq_code=False) - resp = self.waitMsg(qqid, timeout=timeout, group_id=group_id) - if isinstance(resp, str): - return resp.strip() - return None - - @staticmethod - def orion_ui_border(): - """返回 Orion 菜单统一使用的装饰边框。""" - return "✧✦〓〓〓〓〓〓〓〓〓〓〓✦✧" - - def orion_ui_menu( - self, - subtitle: str, - options: list[str], - hints: list[str], - group_id: int | None = None, - ): - """生成 Orion 风格的普通菜单文本。""" - hints = self.normalize_menu_control_hints(hints, group_id) - parts = [self.orion_ui_border(), f"❐ 『Orion System 猎户座』 {subtitle}"] - parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(options)]) - parts.append(self.orion_ui_border()) - parts.extend([f"❀ {hint}" for hint in hints]) - return "\n".join(parts) - - def plugin_ui_menu( - self, - system_name: str, - subtitle: str, - options: list[str], - hints: list[str], - group_id: int | None = None, - ): - """生成插件内部复用的菜单文本。""" - hints = self.normalize_menu_control_hints(hints, group_id) - parts = [self.orion_ui_border(), f"❐ 『{system_name}』 {subtitle}"] - parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(options)]) - parts.append(self.orion_ui_border()) - parts.extend([f"❀ {hint}" for hint in hints]) - return "\n".join(parts) - - def orion_ui_list( - self, - subtitle: str, - items: list[str], - page: int, - total_pages: int, - select_hint: str, - search_hint: str | None = None, - group_id: int | None = None, - ): - """生成带分页和搜索提示的列表菜单文本。""" - parts = [self.orion_ui_border(), f"❐ 『Orion System 猎户座』 {subtitle}"] - parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(items)]) - parts.append(self.orion_ui_border()) - parts.append(f"❀ 当前第 {page}/{total_pages} 页") - parts.append(f"❀ 输入 {select_hint}") - if search_hint: - parts.append(f"❀ 输入 {search_hint}") - parts.append("❀ 输入 - 转到上一页") - parts.append("❀ 输入 + 转到下一页") - parts.append("❀ 输入 正整数+页 转到对应页") - parts.append(f"❀ {self.menu_exit_hint(group_id)}") - return "\n".join(parts) - - def get_orion_items_per_page(self, group_id: int): - """读取 Orion 菜单每页条数配置。""" - group_cfg = self.group_cfgs.get(group_id) - if group_cfg is not None: - try: - return max( - 1, - int(group_cfg["指令设置"]["QQ群封禁/解封菜单每页显示个数"]), - ) - except (KeyError, TypeError, ValueError): - return 10 - return 10 - - def orion_xuid_status_text(self, xuid: str): - """读取某个 xuid 在 Orion 中的封禁状态摘要。""" - orion = self.require_orion() - path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" - if not os.path.exists(path): - return "未封禁" - try: - data = orion.utils.disk_read_need_exists(path) - except Exception: - return "状态异常" - return f"封禁至: {data.get('ban_end_real_time', '未知')}" - - def orion_device_status_text(self, device_id: str): - """读取某个设备号在 Orion 中的封禁状态摘要。""" - orion = self.require_orion() - path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" - if not os.path.exists(path): - return "未封禁" - try: - data = orion.utils.disk_read_need_exists(path) - except Exception: - return "状态异常" - return f"封禁至: {data.get('ban_end_real_time', '未知')}" - - @staticmethod - def format_device_history(player_data: dict[str, list[str]]): - """把设备号关联历史压缩成适合列表展示的单行文本。""" - outputs: list[str] = [] - for xuid, names in list(player_data.items())[:3]: - if isinstance(names, list) and names: - outputs.append(f"{xuid}:{'/'.join(names[-2:])}") - else: - outputs.append(f"{xuid}:[]") - if len(player_data) > 3: - outputs.append("...") - return "; ".join(outputs) - - def build_online_xuid_data(self): - """构建当前在线玩家的 xuid 映射。""" - orion = self.require_orion() - result: dict[str, str] = {} - for player_name in self.game_ctrl.allplayers.copy(): - try: - xuid = orion.xuid_getter.get_xuid_by_name(player_name) - except Exception: - continue - result[xuid] = player_name - return result - - def build_historical_xuid_data(self): - """读取历史玩家名称到 xuid 的映射数据。""" - path = os.path.join("插件数据文件", "前置-玩家XUID获取", "xuids.json") - try: - return self.orion.utils.disk_read_need_exists( - path) if self.orion else {} - except Exception: - return {} - - def build_device_history_data(self): - """读取 Orion 设备号与玩家历史的映射数据。""" - orion = self.require_orion() - path = f"{orion.data_path}/{orion.config_mgr.player_data_file}" - try: - data = orion.utils.disk_read_need_exists(path) - except Exception: - return {} - return data if isinstance(data, dict) else {} - - def _show_paginated_orion_menu( - self, - group_id: int, - qqid: int, - title: str, - matched_items: list[dict[str, object]], - page: int, - per_page: int, - select_hint: str, - search_hint: str | None, - ): - """渲染一页列表菜单,并返回用户输入与分页边界。""" - total_pages, start_index, end_index = self.orion.utils.paginate( - len(matched_items), - per_page, - page, - ) - output_lines = [ - item["display"] - for item in matched_items[start_index - 1: end_index] - ] - displayed_count = len(output_lines) - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] " - + self.orion_ui_list( - title, - output_lines, - page, - total_pages, - select_hint.format( - start_index=start_index, - end_index=end_index, - displayed_count=displayed_count, - ), - search_hint, - group_id, - ), - do_remove_cq_code=False, - ) - user_input = self.waitMsg(qqid, timeout=60, group_id=group_id) - return user_input, total_pages, start_index, end_index - - def _handle_paginated_orion_input( - self, - group_id: int, - qqid: int, - user_input: str | None, - page: int, - total_pages: int, - allow_search: bool, - search: str, - ): - """统一处理分页菜单中的翻页、退出、搜索和选择输入。""" - if user_input is None: - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 回复超时! 已退出菜单", - do_remove_cq_code=False, - ) - return "exit", None - - user_input = user_input.strip() - if self.is_menu_exit_input(user_input, group_id): - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 已退出菜单", - do_remove_cq_code=False, - ) - return "exit", None - - if user_input == "+": - if page < total_pages: - return "page", page + 1 - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 已经是最后一页啦~", - do_remove_cq_code=False, - ) - return "retry", None - - if user_input == "-": - if page > 1: - return "page", page - 1 - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 已经是第一页啦~", - do_remove_cq_code=False, - ) - return "retry", None - - if match := re.fullmatch(r"^([1-9]\d*)页$", user_input): - page_num = int(match.group(1)) - if 1 <= page_num <= total_pages: - return "page", page_num - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 不存在第 {page_num} 页!请重新输入!", - do_remove_cq_code=False, - ) - return "retry", None - - choice = utils.try_int(user_input) - if choice is not None: - return "choice", choice - - if allow_search: - return "search", user_input.replace("\\", "") - - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 您的输入有误", - do_remove_cq_code=False, - ) - return "retry", search - - def _select_paginated_orion_items( - self, - group_id: int, - qqid: int, - title: str, - build_matches, - empty_message: str, - select_hint: str, - search_hint: str | None, - allow_search: bool = True, - ): - """执行一套通用的分页选择流程。""" - search = "" - page = 1 - per_page = self.get_orion_items_per_page(group_id) - while True: - matched_items = build_matches(search) - if not matched_items: - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] {empty_message}", - do_remove_cq_code=False, - ) - return None - - user_input, total_pages, start_index, end_index = ( - self._show_paginated_orion_menu( - group_id, - qqid, - title, - matched_items, - page, - per_page, - select_hint, - search_hint, - ) - ) - action, value = self._handle_paginated_orion_input( - group_id, - qqid, - user_input, - page, - total_pages, - allow_search, - search, - ) - if action == "exit": - return None - if action == "page": - page = value - continue - if action == "search": - search = value - page = 1 - continue - if action == "retry": - continue - displayed_count = end_index - start_index + 1 - if action == "choice" and value in range(1, displayed_count + 1): - return matched_items[start_index + value - 2] - self.sendmsg( - group_id, - f"[CQ:at,qq={qqid}] ❀ 您的输入有误", - do_remove_cq_code=False, - ) - - def qq_select_orion_xuid( - self, - group_id: int, - qqid: int, - title: str, - xuid_data: dict[str, str], - allow_search: bool = True, - ): - """从 xuid 列表里分页选择目标玩家。 - - 返回 `(xuid, 玩家名)`,失败或退出时返回 `(None, None)`。 - """ - selected = self._select_paginated_orion_items( - group_id, - qqid, - title, - lambda search: [ - { - "value": (xuid, name), - "display": ( - f"{xuid} - {name}" - f" - {self.orion_xuid_status_text(xuid)}" - ), - } - for xuid, name in xuid_data.items() - if search == "" or search in xuid or search in name - ], - "找不到您输入的 xuid 或玩家名称", - "[1-{displayed_count}] 之间的数字以选择 对应玩家", - "xuid、玩家名称或玩家部分名称 可尝试搜索", - allow_search=allow_search, - ) - if selected is None: - return None, None - return selected["value"] - - def qq_select_orion_device( - self, - group_id: int, - qqid: int, - title: str, - device_data: dict[str, dict[str, list[str]]], - ): - """从设备号列表里分页选择目标设备。""" - selected = self._select_paginated_orion_items( - group_id, - qqid, - title, - lambda search: [ - { - "value": (device_id, player_info), - "display": ( - f"{device_id} - {self.format_device_history(player_info)}" - f" - {self.orion_device_status_text(device_id)}" - ), - } - for device_id, player_info in device_data.items() - if search == "" - or search in device_id - or search in self.format_device_history(player_info) - ], - "找不到您输入的设备号或玩家名称", - "[1-{displayed_count}] 之间的数字以选择 对应设备号", - "设备号、玩家名称或玩家部分名称 可尝试搜索", - ) - if selected is None: - return None, None - return selected["value"] - - def qq_get_orion_ban_time(self, group_id: int, qqid: int): - """统一处理群里输入的封禁时长。""" - prompt = self.orion_ui_menu( - "封禁时间输入", - [], - [ - "输入 -1 表示永久封禁", - "输入 正整数 表示封禁秒数", - "输入 0年0月5日6时7分8秒 表示对应时长", - "输入 . 退出", - ], - group_id, - ) - user_input = self.qq_prompt(group_id, qqid, prompt, timeout=120) - if user_input is None: - self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - if self.is_menu_exit_input(user_input, group_id): - self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - ban_time = self.orion.utils.ban_time_format( - user_input) if self.orion else 0 - if ban_time == 0: - self.reply_to_qq(group_id, qqid, "❀ 您输入的封禁时间有误") - return None - return ban_time - - def qq_get_orion_reason(self, group_id: int, qqid: int): - """获取封禁原因,允许直接回车走默认文案。""" - user_input = self.qq_prompt( - group_id, - qqid, - self.orion_ui_menu( - "封禁原因输入", - [], - [ - "请输入封禁原因", - "直接回车使用默认原因“群聊管理员封禁”", - "输入 . 退出", - ], - group_id, - ), - timeout=120, - ) - if user_input is None: - self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return None - if self.is_menu_exit_input(user_input, group_id): - self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return None - return user_input or "群聊管理员封禁" - - def _select_orion_ban_target(self, group_id: int, qqid: int, choice: str): - """根据封禁模式选择具体目标对象。""" - if choice == "1": - xuid, player_name = self.qq_select_orion_xuid( - group_id, - qqid, - "在线玩家封禁", - self.build_online_xuid_data(), - ) - if not xuid or not player_name: - return None - return { - "kind": "xuid", - "payload": (xuid, player_name), - "online_only": True, - } - if choice == "2": - xuid, player_name = self.qq_select_orion_xuid( - group_id, - qqid, - "历史玩家封禁", - self.build_historical_xuid_data(), - ) - if not xuid or not player_name: - return None - return { - "kind": "xuid", - "payload": (xuid, player_name), - "online_only": False, - } - if choice == "3": - device_id, player_info = self.qq_select_orion_device( - group_id, - qqid, - "设备号封禁", - self.build_device_history_data(), - ) - if not device_id or not player_info: - return None - return { - "kind": "device", - "payload": (device_id, player_info), - } - self.reply_to_qq(group_id, qqid, "❀ 您的输入有误") - return None - - def _apply_selected_orion_ban( - self, - group_id: int, - qqid: int, - ban_target: dict): - """把封禁时间和封禁原因的通用交互流程复用到所有封禁模式。""" - ban_time = self.qq_get_orion_ban_time(group_id, qqid) - if ban_time is None: - return - reason = self.qq_get_orion_reason(group_id, qqid) - if reason is None: - return - - if ban_target["kind"] == "xuid": - xuid, player_name = ban_target["payload"] - ok, msg = self.apply_orion_xuid_ban( - xuid, - player_name, - ban_time, - reason, - online_only=ban_target.get("online_only", False), - ) - else: - device_id, player_info = ban_target["payload"] - ok, msg = self.apply_orion_device_ban( - device_id, - player_info, - ban_time, - reason, - ) - self.reply_result(group_id, qqid, ok, msg) - - def qq_orion_ban_menu(self, group_id: int, qqid: int): - """交互式 Orion 封禁菜单。""" - if not self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - if self.orion is None: - self.reply_to_qq(group_id, qqid, "相关插件未安装:Orion_System") - return - if not self.ensure_linked_plugin_enabled(self.orion, group_id, qqid): - return - choice = self.qq_prompt( - group_id, - qqid, - self.orion_ui_menu( - "封禁管理系统", - ["根据在线玩家名称和xuid封禁", "根据历史玩家名称和xuid封禁", "根据设备号封禁"], - ["输入 [1-3] 之间的数字以选择 封禁模式", "输入 . 退出"], - group_id, - ), - timeout=120, - ) - if choice is None: - self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self.is_menu_exit_input(choice, group_id): - self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - ban_target = self._select_orion_ban_target(group_id, qqid, choice) - if ban_target is None: - return - self._apply_selected_orion_ban(group_id, qqid, ban_target) - - def qq_orion_unban_menu(self, group_id: int, qqid: int): - """交互式 Orion 解封菜单。""" - if not self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): - self._reply_menu_permission_denied(group_id, qqid) - return - if self.orion is None: - self.reply_to_qq(group_id, qqid, "相关插件未安装:Orion_System") - return - if not self.ensure_linked_plugin_enabled(self.orion, group_id, qqid): - return - choice = self.qq_prompt( - group_id, - qqid, - self.orion_ui_menu( - "解封管理系统", - ["根据玩家名称和xuid解封", "根据设备号解封"], - ["输入 [1-2] 之间的数字以选择 解封模式", "输入 . 退出"], - group_id, - ), - timeout=120, - ) - if choice is None: - self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") - return - if self.is_menu_exit_input(choice, group_id): - self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") - return - if choice == "1": - xuid_dir = f"{self.orion.data_path}/{self.orion.config_mgr.xuid_dir}" - xuid_data: dict[str, str] = {} - if os.path.isdir(xuid_dir): - for xuid_json in os.listdir(xuid_dir): - xuid = xuid_json.replace(".json", "") - try: - xuid_data[xuid] = self.orion.xuid_getter.get_name_by_xuid( - xuid, True, ) - except Exception: - xuid_data[xuid] = xuid - xuid, player_name = self.qq_select_orion_xuid( - group_id, - qqid, - "xuid 解封", - xuid_data, - allow_search=True, - ) - if not xuid or not player_name: - return - ok, msg = self.apply_orion_xuid_unban(xuid, player_name) - self.reply_result(group_id, qqid, ok, msg) - return - if choice == "2": - device_dir = f"{ - self.orion.data_path}/{ - self.orion.config_mgr.device_id_dir}" - player_data = self.build_device_history_data() - device_data: dict[str, dict[str, list[str]]] = {} - if os.path.isdir(device_dir): - for device_json in os.listdir(device_dir): - device_id = device_json.replace(".json", "") - device_data[device_id] = player_data.get(device_id, {}) - device_id, player_info = self.qq_select_orion_device( - group_id, - qqid, - "设备号解封", - device_data, - ) - if not device_id or player_info is None: - return - ok, msg = self.apply_orion_device_unban(device_id, player_info) - self.reply_result(group_id, qqid, ok, msg) - return - self.reply_to_qq(group_id, qqid, "❀ 您的输入有误") - - def resolve_orion_target(self, target: str): - """把群里的输入尽量解析成稳定的 `(xuid, 玩家名)` 组合。""" - if self.orion is None: - return None, None, "相关插件未安装:Orion_System" - if not hasattr(self.orion, "xuid_getter"): - return None, None, "Orion 插件尚未完成初始化" - - # 先按玩家名解析,失败再把输入当 xuid 处理,兼容群里两种用法。 - try: - xuid = self.orion.xuid_getter.get_xuid_by_name(target, True) - try: - player_name = self.orion.xuid_getter.get_name_by_xuid( - xuid, True) - except Exception: - player_name = target - except Exception: - xuid = target - try: - player_name = self.orion.xuid_getter.get_name_by_xuid( - xuid, True) - except Exception: - player_name = target - - if not isinstance(xuid, str) or not xuid: - return None, None, "无法解析玩家名称或 xuid" - return xuid, player_name, None - - def orion_ban_player(self, target: str, ban_time_raw: str, reason: str): - """非交互式封禁入口,给命令行式调用使用。""" - xuid, player_name, error = self.resolve_orion_target(target) - if error: - return False, error - orion = self.require_orion() - ban_time = orion.utils.ban_time_format(ban_time_raw) - return self.apply_orion_xuid_ban(xuid, player_name, ban_time, reason) - - def orion_unban_player(self, target: str): - """非交互式解封入口,供命令式调用复用。""" - xuid, player_name, error = self.resolve_orion_target(target) - if error: - return False, error - return self.apply_orion_xuid_unban(xuid, player_name) - - def apply_orion_xuid_ban( - self, - xuid: str, - player_name: str, - ban_time: int | str, - reason: str, - online_only: bool = False, - ): - """写入 xuid 封禁记录,并在需要时踢出对应在线玩家。""" - orion = self.require_orion() - # 实际封禁前会先过一遍在线状态和 Orion 自己的白名单检查。 - if online_only and player_name not in self.game_ctrl.allplayers: - return False, f"玩家 {player_name} 当前不在线" - if orion.utils.in_whitelist(player_name): - return False, f"玩家 {player_name} 位于 Orion 反制白名单内" - timestamp_now, date_now = orion.utils.now() - path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" - with orion.lock_ban_xuid: - ban_data = orion.utils.disk_read(path) - timestamp_end, date_end = orion.utils.calculate_ban_end_time( - ban_data, - ban_time, - timestamp_now, - ) - if not timestamp_end and timestamp_end is not None: - return False, f"玩家 {player_name} 已经是永久封禁" - orion.utils.disk_write( - path, - { - "xuid": xuid, - "name": player_name, - "ban_start_real_time": date_now, - "ban_start_timestamp": timestamp_now, - "ban_end_real_time": date_end, - "ban_end_timestamp": timestamp_end, - "ban_reason": reason, - }, - ) - if player_name in self.game_ctrl.allplayers: - orion.utils.kick(player_name, f"由于{reason},您被系统封禁至:{date_end}") - return True, f"已通过 Orion 封禁 {player_name} (xuid:{xuid}) 至 {date_end}" - - def apply_orion_device_ban( - self, - device_id: str, - player_info: dict[str, list[str]], - ban_time: int | str, - reason: str, - ): - """写入设备号封禁记录,并处理命中该设备号的在线玩家。""" - orion = self.require_orion() - # 设备号封禁会顺手踢掉当前在线、且命中过这个设备号的玩家。 - timestamp_now, date_now = orion.utils.now() - path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" - with orion.lock_ban_device_id: - ban_data = orion.utils.disk_read(path) - timestamp_end, date_end = orion.utils.calculate_ban_end_time( - ban_data, - ban_time, - timestamp_now, - ) - if not timestamp_end and timestamp_end is not None: - return False, f"设备号 {device_id} 已经是永久封禁" - orion.utils.disk_write( - path, - { - "device_id": device_id, - "xuid_and_player": player_info, - "ban_start_real_time": date_now, - "ban_start_timestamp": timestamp_now, - "ban_end_real_time": date_end, - "ban_end_timestamp": timestamp_end, - "ban_reason": reason, - }, - ) - for _xuid, names in player_info.items(): - kick_name = None - if isinstance(names, list): - for name in reversed(names): - if name in self.game_ctrl.allplayers: - kick_name = name - break - if kick_name: - orion.utils.kick(kick_name, f"由于{reason},您被系统封禁至:{date_end}") - return True, f"已通过 Orion 封禁设备号 {device_id} 至 {date_end}" - - def apply_orion_xuid_unban(self, xuid: str, player_name: str): - """删除 Orion 中某个 xuid 的封禁记录。""" - orion = self.require_orion() - path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" - if not os.path.exists(path): - return False, f"玩家 {player_name} 当前不在 Orion 的 xuid 封禁列表中" - os.remove(path) - return True, f"已通过 Orion 解封 {player_name} (xuid:{xuid})" - - def apply_orion_device_unban( - self, - device_id: str, - player_info: dict[str, list[str]], - ): - """删除 Orion 中某个设备号的封禁记录。""" - orion = self.require_orion() - path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" - if not os.path.exists(path): - return False, f"设备号 {device_id} 当前不在 Orion 的设备号封禁列表中" - os.remove(path) - return True, f"已通过 Orion 解封设备号 {device_id}" +"""Orion System integration helpers for Ultra.""" + +import os +import re + +from tooldelta import fmts, utils + + +# 和 Orion_System 的联动全收在这里,主入口不用再关心封禁细节。 +class QQLinkerOrionMixin: + """负责 Orion_System 菜单、封禁与解封逻辑。""" + + @staticmethod + def console_menu_header(title: str) -> str: + """生成控制台使用的 Orion 风格标题栏。""" + return ( + "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓§1〓〓〓〓〓〓" + "§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" + f"§l§d❐§f 『§6群服互通云链版Ultra版§f』 §b{title}" + ) + + @staticmethod + def console_menu_footer(page_label: str, body: str) -> str: + """生成控制台使用的 Orion 风格页脚。""" + return ( + "§d✧✦§f〓〓§b〓〓〓§9〓〓〓〓 " + f"§r§7[ §b{page_label} §7] " + "§l§9〓〓〓〓§b〓〓〓§f〓〓§d✦✧\n" + f"§r{body}" + ) + + def prompt_console_input( + self, + title: str, + page_label: str, + body_lines: list[str], + prompt: str, + ) -> str: + """按 Orion 卡片样式展示控制台交互步骤,并读取输入。""" + self.print_console_card(title, page_label, body_lines, level="info") + return input(fmts.fmt_info(f"§a❀ §b{prompt}")).strip() + + @staticmethod + def print_console_info(text: str): + """按统一 UI 风格输出控制台普通信息。""" + fmts.print_inf(f"§a❀ §b{text}") + + @staticmethod + def print_console_success(text: str): + """按统一 UI 风格输出控制台成功信息。""" + fmts.print_suc(f"§a❀ §b{text}") + + @staticmethod + def print_console_warn(text: str): + """按统一 UI 风格输出控制台警告信息。""" + fmts.print_war(f"§6❀ §e{text}") + + @staticmethod + def print_console_error(text: str): + """按统一 UI 风格输出控制台错误信息。""" + fmts.print_err(f"§c❀ §e{text}") + + def print_console_card( + self, + title: str, + page_label: str, + body_lines: list[str], + level: str = "info", + ): + """按 Orion 风格打印一张控制台信息卡片。""" + card = ( + self.console_menu_header(title) + + "\n" + + self.console_menu_footer( + page_label, + "\n".join( + i if i.startswith("§") else f"§a❀ §b{i}" for i in body_lines), + )) + { + "info": fmts.print_inf, + "success": fmts.print_suc, + "warn": fmts.print_war, + "error": fmts.print_err, + }.get(level, fmts.print_inf)(card) + + def require_orion(self): + """返回可用的 Orion 实例,不可用时抛出明确异常。""" + if self.orion is None: + raise RuntimeError("相关插件未安装:Orion_System") + enabled = self._extract_plugin_enabled_flag(self.orion) + if not enabled and enabled is not None: + raise RuntimeError("相关插件未启用") + return self.orion + + def reply_to_qq(self, group_id: int, qqid: int, text: str): + """向指定群成员回复一条文本消息。""" + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False, + ) + + def reply_result(self, group_id: int, qqid: int, ok: bool, msg: str): + """把统一格式的成功/失败结果回发到群里。""" + prefix = "😄" if ok else "😭" + self.reply_to_qq(group_id, qqid, f"{prefix} {msg}") + + def on_qq_orion_ban(self, group_id: int, sender: int, args: list[str]): + """群聊侧的 Orion 封禁入口。 + + 支持两种调用方式: + - 直接给参数:适合熟悉命令的管理人员 + - 不给参数:转到交互式菜单 + """ + if not self._can_use_group_permission(group_id, sender, "封禁/解封玩家权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if self.orion is None: + self.reply_to_qq(group_id, sender, "相关插件未安装:Orion_System") + return + if not self.ensure_linked_plugin_enabled(self.orion, group_id, sender): + return + if args == []: + self.qq_orion_ban_menu(group_id, sender) + return + target = args[0] + ban_time_raw = args[1] + reason = " ".join(args[2:]).strip() or "群聊管理员封禁" + ok, msg = self.orion_ban_player(target, ban_time_raw, reason) + self.reply_result(group_id, sender, ok, msg) + + def on_qq_orion_unban(self, group_id: int, sender: int, args: list[str]): + """群聊侧的 Orion 解封入口。""" + if not self._can_use_group_permission(group_id, sender, "封禁/解封玩家权限"): + self._reply_menu_permission_denied(group_id, sender) + return + if self.orion is None: + self.reply_to_qq(group_id, sender, "相关插件未安装:Orion_System") + return + if not self.ensure_linked_plugin_enabled(self.orion, group_id, sender): + return + if args == []: + self.qq_orion_unban_menu(group_id, sender) + return + ok, msg = self.orion_unban_player(args[0]) + self.reply_result(group_id, sender, ok, msg) + + def qq_prompt( + self, + group_id: int, + qqid: int, + text: str, + timeout: int = 60): + """发送一段提示文本,并等待同群同 QQ 的下一条回复。""" + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {text}", + do_remove_cq_code=False) + resp = self.waitMsg(qqid, timeout=timeout, group_id=group_id) + if isinstance(resp, str): + return resp.strip() + return None + + @staticmethod + def orion_ui_border(): + """返回 Orion 菜单统一使用的装饰边框。""" + return "✧✦〓〓〓〓〓〓〓〓〓〓〓✦✧" + + def orion_ui_menu( + self, + subtitle: str, + options: list[str], + hints: list[str], + group_id: int | None = None, + ): + """生成 Orion 风格的普通菜单文本。""" + hints = self.normalize_menu_control_hints(hints, group_id) + parts = [self.orion_ui_border(), f"❐ 『Orion System 猎户座』 {subtitle}"] + parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(options)]) + parts.append(self.orion_ui_border()) + parts.extend([f"❀ {hint}" for hint in hints]) + return "\n".join(parts) + + def plugin_ui_menu( + self, + system_name: str, + subtitle: str, + options: list[str], + hints: list[str], + group_id: int | None = None, + ): + """生成插件内部复用的菜单文本。""" + hints = self.normalize_menu_control_hints(hints, group_id) + parts = [self.orion_ui_border(), f"❐ 『{system_name}』 {subtitle}"] + parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(options)]) + parts.append(self.orion_ui_border()) + parts.extend([f"❀ {hint}" for hint in hints]) + return "\n".join(parts) + + def orion_ui_list( + self, + subtitle: str, + items: list[str], + page: int, + total_pages: int, + select_hint: str, + search_hint: str | None = None, + group_id: int | None = None, + ): + """生成带分页和搜索提示的列表菜单文本。""" + parts = [self.orion_ui_border(), f"❐ 『Orion System 猎户座』 {subtitle}"] + parts.extend([f"[ {i + 1} ] {text}" for i, text in enumerate(items)]) + parts.append(self.orion_ui_border()) + parts.append(f"❀ 当前第 {page}/{total_pages} 页") + parts.append(f"❀ 输入 {select_hint}") + if search_hint: + parts.append(f"❀ 输入 {search_hint}") + parts.append("❀ 输入 - 转到上一页") + parts.append("❀ 输入 + 转到下一页") + parts.append("❀ 输入 正整数+页 转到对应页") + parts.append(f"❀ {self.menu_exit_hint(group_id)}") + return "\n".join(parts) + + def get_orion_items_per_page(self, group_id: int): + """读取 Orion 菜单每页条数配置。""" + group_cfg = self.group_cfgs.get(group_id) + if group_cfg is not None: + try: + return max( + 1, + int(group_cfg["指令设置"]["QQ群封禁/解封菜单每页显示个数"]), + ) + except (KeyError, TypeError, ValueError): + return 10 + return 10 + + def orion_xuid_status_text(self, xuid: str): + """读取某个 xuid 在 Orion 中的封禁状态摘要。""" + orion = self.require_orion() + path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" + if not os.path.exists(path): + return "未封禁" + try: + data = orion.utils.disk_read_need_exists(path) + except Exception: + return "状态异常" + return f"封禁至: {data.get('ban_end_real_time', '未知')}" + + def orion_device_status_text(self, device_id: str): + """读取某个设备号在 Orion 中的封禁状态摘要。""" + orion = self.require_orion() + path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" + if not os.path.exists(path): + return "未封禁" + try: + data = orion.utils.disk_read_need_exists(path) + except Exception: + return "状态异常" + return f"封禁至: {data.get('ban_end_real_time', '未知')}" + + @staticmethod + def format_device_history(player_data: dict[str, list[str]]): + """把设备号关联历史压缩成适合列表展示的单行文本。""" + outputs: list[str] = [] + for xuid, names in list(player_data.items())[:3]: + if isinstance(names, list) and names: + outputs.append(f"{xuid}:{'/'.join(names[-2:])}") + else: + outputs.append(f"{xuid}:[]") + if len(player_data) > 3: + outputs.append("...") + return "; ".join(outputs) + + def build_online_xuid_data(self): + """构建当前在线玩家的 xuid 映射。""" + orion = self.require_orion() + result: dict[str, str] = {} + for player_name in self.game_ctrl.allplayers.copy(): + try: + xuid = orion.xuid_getter.get_xuid_by_name(player_name) + except Exception: + continue + result[xuid] = player_name + return result + + def build_historical_xuid_data(self): + """读取历史玩家名称到 xuid 的映射数据。""" + path = os.path.join("插件数据文件", "前置-玩家XUID获取", "xuids.json") + try: + return self.orion.utils.disk_read_need_exists( + path) if self.orion else {} + except Exception: + return {} + + def build_device_history_data(self): + """读取 Orion 设备号与玩家历史的映射数据。""" + orion = self.require_orion() + path = f"{orion.data_path}/{orion.config_mgr.player_data_file}" + try: + data = orion.utils.disk_read_need_exists(path) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + def _show_paginated_orion_menu( + self, + group_id: int, + qqid: int, + title: str, + matched_items: list[dict[str, object]], + page: int, + per_page: int, + select_hint: str, + search_hint: str | None, + ): + """渲染一页列表菜单,并返回用户输入与分页边界。""" + total_pages, start_index, end_index = self.orion.utils.paginate( + len(matched_items), + per_page, + page, + ) + output_lines = [ + item["display"] + for item in matched_items[start_index - 1: end_index] + ] + displayed_count = len(output_lines) + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] " + + self.orion_ui_list( + title, + output_lines, + page, + total_pages, + select_hint.format( + start_index=start_index, + end_index=end_index, + displayed_count=displayed_count, + ), + search_hint, + group_id, + ), + do_remove_cq_code=False, + ) + user_input = self.waitMsg(qqid, timeout=60, group_id=group_id) + return user_input, total_pages, start_index, end_index + + def _handle_paginated_orion_input( + self, + group_id: int, + qqid: int, + user_input: str | None, + page: int, + total_pages: int, + allow_search: bool, + search: str, + ): + """统一处理分页菜单中的翻页、退出、搜索和选择输入。""" + if user_input is None: + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 回复超时! 已退出菜单", + do_remove_cq_code=False, + ) + return "exit", None + + user_input = user_input.strip() + if self.is_menu_exit_input(user_input, group_id): + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 已退出菜单", + do_remove_cq_code=False, + ) + return "exit", None + + if user_input == "+": + if page < total_pages: + return "page", page + 1 + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 已经是最后一页啦~", + do_remove_cq_code=False, + ) + return "retry", None + + if user_input == "-": + if page > 1: + return "page", page - 1 + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 已经是第一页啦~", + do_remove_cq_code=False, + ) + return "retry", None + + if match := re.fullmatch(r"^([1-9]\d*)页$", user_input): + page_num = int(match.group(1)) + if 1 <= page_num <= total_pages: + return "page", page_num + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 不存在第 {page_num} 页!请重新输入!", + do_remove_cq_code=False, + ) + return "retry", None + + choice = utils.try_int(user_input) + if choice is not None: + return "choice", choice + + if allow_search: + return "search", user_input.replace("\\", "") + + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 您的输入有误", + do_remove_cq_code=False, + ) + return "retry", search + + def _select_paginated_orion_items( + self, + group_id: int, + qqid: int, + title: str, + build_matches, + empty_message: str, + select_hint: str, + search_hint: str | None, + allow_search: bool = True, + ): + """执行一套通用的分页选择流程。""" + search = "" + page = 1 + per_page = self.get_orion_items_per_page(group_id) + while True: + matched_items = build_matches(search) + if not matched_items: + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] {empty_message}", + do_remove_cq_code=False, + ) + return None + + user_input, total_pages, start_index, end_index = ( + self._show_paginated_orion_menu( + group_id, + qqid, + title, + matched_items, + page, + per_page, + select_hint, + search_hint, + ) + ) + action, value = self._handle_paginated_orion_input( + group_id, + qqid, + user_input, + page, + total_pages, + allow_search, + search, + ) + if action == "exit": + return None + if action == "page": + page = value + continue + if action == "search": + search = value + page = 1 + continue + if action == "retry": + continue + displayed_count = end_index - start_index + 1 + if action == "choice" and value in range(1, displayed_count + 1): + return matched_items[start_index + value - 2] + self.sendmsg( + group_id, + f"[CQ:at,qq={qqid}] ❀ 您的输入有误", + do_remove_cq_code=False, + ) + + def qq_select_orion_xuid( + self, + group_id: int, + qqid: int, + title: str, + xuid_data: dict[str, str], + allow_search: bool = True, + ): + """从 xuid 列表里分页选择目标玩家。 + + 返回 `(xuid, 玩家名)`,失败或退出时返回 `(None, None)`。 + """ + selected = self._select_paginated_orion_items( + group_id, + qqid, + title, + lambda search: [ + { + "value": (xuid, name), + "display": ( + f"{xuid} - {name}" + f" - {self.orion_xuid_status_text(xuid)}" + ), + } + for xuid, name in xuid_data.items() + if search == "" or search in xuid or search in name + ], + "找不到您输入的 xuid 或玩家名称", + "[1-{displayed_count}] 之间的数字以选择 对应玩家", + "xuid、玩家名称或玩家部分名称 可尝试搜索", + allow_search=allow_search, + ) + if selected is None: + return None, None + return selected["value"] + + def qq_select_orion_device( + self, + group_id: int, + qqid: int, + title: str, + device_data: dict[str, dict[str, list[str]]], + ): + """从设备号列表里分页选择目标设备。""" + selected = self._select_paginated_orion_items( + group_id, + qqid, + title, + lambda search: [ + { + "value": (device_id, player_info), + "display": ( + f"{device_id} - {self.format_device_history(player_info)}" + f" - {self.orion_device_status_text(device_id)}" + ), + } + for device_id, player_info in device_data.items() + if search == "" + or search in device_id + or search in self.format_device_history(player_info) + ], + "找不到您输入的设备号或玩家名称", + "[1-{displayed_count}] 之间的数字以选择 对应设备号", + "设备号、玩家名称或玩家部分名称 可尝试搜索", + ) + if selected is None: + return None, None + return selected["value"] + + def qq_get_orion_ban_time(self, group_id: int, qqid: int): + """统一处理群里输入的封禁时长。""" + prompt = self.orion_ui_menu( + "封禁时间输入", + [], + [ + "输入 -1 表示永久封禁", + "输入 正整数 表示封禁秒数", + "输入 0年0月5日6时7分8秒 表示对应时长", + "输入 . 退出", + ], + group_id, + ) + user_input = self.qq_prompt(group_id, qqid, prompt, timeout=120) + if user_input is None: + self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + if self.is_menu_exit_input(user_input, group_id): + self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + ban_time = self.orion.utils.ban_time_format( + user_input) if self.orion else 0 + if ban_time == 0: + self.reply_to_qq(group_id, qqid, "❀ 您输入的封禁时间有误") + return None + return ban_time + + def qq_get_orion_reason(self, group_id: int, qqid: int): + """获取封禁原因,允许直接回车走默认文案。""" + user_input = self.qq_prompt( + group_id, + qqid, + self.orion_ui_menu( + "封禁原因输入", + [], + [ + "请输入封禁原因", + "直接回车使用默认原因“群聊管理员封禁”", + "输入 . 退出", + ], + group_id, + ), + timeout=120, + ) + if user_input is None: + self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return None + if self.is_menu_exit_input(user_input, group_id): + self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return None + return user_input or "群聊管理员封禁" + + def _select_orion_ban_target(self, group_id: int, qqid: int, choice: str): + """根据封禁模式选择具体目标对象。""" + if choice == "1": + xuid, player_name = self.qq_select_orion_xuid( + group_id, + qqid, + "在线玩家封禁", + self.build_online_xuid_data(), + ) + if not xuid or not player_name: + return None + return { + "kind": "xuid", + "payload": (xuid, player_name), + "online_only": True, + } + if choice == "2": + xuid, player_name = self.qq_select_orion_xuid( + group_id, + qqid, + "历史玩家封禁", + self.build_historical_xuid_data(), + ) + if not xuid or not player_name: + return None + return { + "kind": "xuid", + "payload": (xuid, player_name), + "online_only": False, + } + if choice == "3": + device_id, player_info = self.qq_select_orion_device( + group_id, + qqid, + "设备号封禁", + self.build_device_history_data(), + ) + if not device_id or not player_info: + return None + return { + "kind": "device", + "payload": (device_id, player_info), + } + self.reply_to_qq(group_id, qqid, "❀ 您的输入有误") + return None + + def _apply_selected_orion_ban( + self, + group_id: int, + qqid: int, + ban_target: dict): + """把封禁时间和封禁原因的通用交互流程复用到所有封禁模式。""" + ban_time = self.qq_get_orion_ban_time(group_id, qqid) + if ban_time is None: + return + reason = self.qq_get_orion_reason(group_id, qqid) + if reason is None: + return + + if ban_target["kind"] == "xuid": + xuid, player_name = ban_target["payload"] + ok, msg = self.apply_orion_xuid_ban( + xuid, + player_name, + ban_time, + reason, + online_only=ban_target.get("online_only", False), + ) + else: + device_id, player_info = ban_target["payload"] + ok, msg = self.apply_orion_device_ban( + device_id, + player_info, + ban_time, + reason, + ) + self.reply_result(group_id, qqid, ok, msg) + + def qq_orion_ban_menu(self, group_id: int, qqid: int): + """交互式 Orion 封禁菜单。""" + if not self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if self.orion is None: + self.reply_to_qq(group_id, qqid, "相关插件未安装:Orion_System") + return + if not self.ensure_linked_plugin_enabled(self.orion, group_id, qqid): + return + choice = self.qq_prompt( + group_id, + qqid, + self.orion_ui_menu( + "封禁管理系统", + ["根据在线玩家名称和xuid封禁", "根据历史玩家名称和xuid封禁", "根据设备号封禁"], + ["输入 [1-3] 之间的数字以选择 封禁模式", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if choice is None: + self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self.is_menu_exit_input(choice, group_id): + self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + ban_target = self._select_orion_ban_target(group_id, qqid, choice) + if ban_target is None: + return + self._apply_selected_orion_ban(group_id, qqid, ban_target) + + def qq_orion_unban_menu( # skipcq: PY-R1000 + self, + group_id: int, + qqid: int, + ): + """交互式 Orion 解封菜单。""" + if not self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + self._reply_menu_permission_denied(group_id, qqid) + return + if self.orion is None: + self.reply_to_qq(group_id, qqid, "相关插件未安装:Orion_System") + return + if not self.ensure_linked_plugin_enabled(self.orion, group_id, qqid): + return + choice = self.qq_prompt( + group_id, + qqid, + self.orion_ui_menu( + "解封管理系统", + ["根据玩家名称和xuid解封", "根据设备号解封"], + ["输入 [1-2] 之间的数字以选择 解封模式", "输入 . 退出"], + group_id, + ), + timeout=120, + ) + if choice is None: + self.reply_to_qq(group_id, qqid, "❀ 回复超时! 已退出菜单") + return + if self.is_menu_exit_input(choice, group_id): + self.reply_to_qq(group_id, qqid, "❀ 已退出菜单") + return + if choice == "1": + xuid_dir = f"{self.orion.data_path}/{self.orion.config_mgr.xuid_dir}" + xuid_data: dict[str, str] = {} + if os.path.isdir(xuid_dir): + for xuid_json in os.listdir(xuid_dir): + xuid = xuid_json.replace(".json", "") + try: + xuid_data[xuid] = self.orion.xuid_getter.get_name_by_xuid( + xuid, True, ) + except Exception: + xuid_data[xuid] = xuid + xuid, player_name = self.qq_select_orion_xuid( + group_id, + qqid, + "xuid 解封", + xuid_data, + allow_search=True, + ) + if not xuid or not player_name: + return + ok, msg = self.apply_orion_xuid_unban(xuid, player_name) + self.reply_result(group_id, qqid, ok, msg) + return + if choice == "2": + device_dir = f"{ + self.orion.data_path}/{ + self.orion.config_mgr.device_id_dir}" + player_data = self.build_device_history_data() + device_data: dict[str, dict[str, list[str]]] = {} + if os.path.isdir(device_dir): + for device_json in os.listdir(device_dir): + device_id = device_json.replace(".json", "") + device_data[device_id] = player_data.get(device_id, {}) + device_id, player_info = self.qq_select_orion_device( + group_id, + qqid, + "设备号解封", + device_data, + ) + if not device_id or player_info is None: + return + ok, msg = self.apply_orion_device_unban(device_id, player_info) + self.reply_result(group_id, qqid, ok, msg) + return + self.reply_to_qq(group_id, qqid, "❀ 您的输入有误") + + def resolve_orion_target(self, target: str): + """把群里的输入尽量解析成稳定的 `(xuid, 玩家名)` 组合。""" + if self.orion is None: + return None, None, "相关插件未安装:Orion_System" + if not hasattr(self.orion, "xuid_getter"): + return None, None, "Orion 插件尚未完成初始化" + + # 先按玩家名解析,失败再把输入当 xuid 处理,兼容群里两种用法。 + try: + xuid = self.orion.xuid_getter.get_xuid_by_name(target, True) + try: + player_name = self.orion.xuid_getter.get_name_by_xuid( + xuid, True) + except Exception: + player_name = target + except Exception: + xuid = target + try: + player_name = self.orion.xuid_getter.get_name_by_xuid( + xuid, True) + except Exception: + player_name = target + + if not isinstance(xuid, str) or not xuid: + return None, None, "无法解析玩家名称或 xuid" + return xuid, player_name, None + + def orion_ban_player(self, target: str, ban_time_raw: str, reason: str): + """非交互式封禁入口,给命令行式调用使用。""" + xuid, player_name, error = self.resolve_orion_target(target) + if error: + return False, error + orion = self.require_orion() + ban_time = orion.utils.ban_time_format(ban_time_raw) + return self.apply_orion_xuid_ban(xuid, player_name, ban_time, reason) + + def orion_unban_player(self, target: str): + """非交互式解封入口,供命令式调用复用。""" + xuid, player_name, error = self.resolve_orion_target(target) + if error: + return False, error + return self.apply_orion_xuid_unban(xuid, player_name) + + def apply_orion_xuid_ban( + self, + xuid: str, + player_name: str, + ban_time: int | str, + reason: str, + online_only: bool = False, + ): + """写入 xuid 封禁记录,并在需要时踢出对应在线玩家。""" + orion = self.require_orion() + # 实际封禁前会先过一遍在线状态和 Orion 自己的白名单检查。 + if online_only and player_name not in self.game_ctrl.allplayers: + return False, f"玩家 {player_name} 当前不在线" + if orion.utils.in_whitelist(player_name): + return False, f"玩家 {player_name} 位于 Orion 反制白名单内" + timestamp_now, date_now = orion.utils.now() + path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" + with orion.lock_ban_xuid: + ban_data = orion.utils.disk_read(path) + timestamp_end, date_end = orion.utils.calculate_ban_end_time( + ban_data, + ban_time, + timestamp_now, + ) + if not timestamp_end and timestamp_end is not None: + return False, f"玩家 {player_name} 已经是永久封禁" + orion.utils.disk_write( + path, + { + "xuid": xuid, + "name": player_name, + "ban_start_real_time": date_now, + "ban_start_timestamp": timestamp_now, + "ban_end_real_time": date_end, + "ban_end_timestamp": timestamp_end, + "ban_reason": reason, + }, + ) + if player_name in self.game_ctrl.allplayers: + orion.utils.kick(player_name, f"由于{reason},您被系统封禁至:{date_end}") + return True, f"已通过 Orion 封禁 {player_name} (xuid:{xuid}) 至 {date_end}" + + def apply_orion_device_ban( + self, + device_id: str, + player_info: dict[str, list[str]], + ban_time: int | str, + reason: str, + ): + """写入设备号封禁记录,并处理命中该设备号的在线玩家。""" + orion = self.require_orion() + # 设备号封禁会顺手踢掉当前在线、且命中过这个设备号的玩家。 + timestamp_now, date_now = orion.utils.now() + path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" + with orion.lock_ban_device_id: + ban_data = orion.utils.disk_read(path) + timestamp_end, date_end = orion.utils.calculate_ban_end_time( + ban_data, + ban_time, + timestamp_now, + ) + if not timestamp_end and timestamp_end is not None: + return False, f"设备号 {device_id} 已经是永久封禁" + orion.utils.disk_write( + path, + { + "device_id": device_id, + "xuid_and_player": player_info, + "ban_start_real_time": date_now, + "ban_start_timestamp": timestamp_now, + "ban_end_real_time": date_end, + "ban_end_timestamp": timestamp_end, + "ban_reason": reason, + }, + ) + for _xuid, names in player_info.items(): + kick_name = None + if isinstance(names, list): + for name in reversed(names): + if name in self.game_ctrl.allplayers: + kick_name = name + break + if kick_name: + orion.utils.kick(kick_name, f"由于{reason},您被系统封禁至:{date_end}") + return True, f"已通过 Orion 封禁设备号 {device_id} 至 {date_end}" + + def apply_orion_xuid_unban(self, xuid: str, player_name: str): + """删除 Orion 中某个 xuid 的封禁记录。""" + orion = self.require_orion() + path = f"{orion.data_path}/{orion.config_mgr.xuid_dir}/{xuid}.json" + if not os.path.exists(path): + return False, f"玩家 {player_name} 当前不在 Orion 的 xuid 封禁列表中" + os.remove(path) + return True, f"已通过 Orion 解封 {player_name} (xuid:{xuid})" + + def apply_orion_device_unban( + self, + device_id: str, + player_info: dict[str, list[str]], + ): + """删除 Orion 中某个设备号的封禁记录。""" + _ = player_info + orion = self.require_orion() + path = f"{orion.data_path}/{orion.config_mgr.device_id_dir}/{device_id}.json" + if not os.path.exists(path): + return False, f"设备号 {device_id} 当前不在 Orion 的设备号封禁列表中" + os.remove(path) + return True, f"已通过 Orion 解封设备号 {device_id}" diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" index 7c400f24..b43be832 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/qq_mixin.py" @@ -116,7 +116,7 @@ def _has_help_admin_actions(self, group_id: int, qqid: int) -> bool: ) @staticmethod - def _extract_plugin_enabled_flag(plugin: Any): + def _extract_plugin_enabled_flag(plugin: Any): # skipcq: PY-R1000 """读取联动插件明确暴露的整体启用状态;缺少该项时返回 None。""" for method_name in ("_plugin_enabled", "is_plugin_enabled"): method = getattr(plugin, method_name, None) @@ -607,7 +607,11 @@ def qq_help_show_all_reference(self, group_id: int, qqid: int): continue self._reply_to_qq(group_id, qqid, "❀ 您的输入有误") - def qq_help_build_all_reference_lines(self, group_id: int, qqid: int): + def qq_help_build_all_reference_lines( # skipcq: PY-R1000 + self, + group_id: int, + qqid: int, + ): """按运行时触发分发入口整理全部命令触发词说明。""" cmd_prefix = self.get_group_cmd_prefix(group_id) lines = [ @@ -618,13 +622,19 @@ def qq_help_build_all_reference_lines(self, group_id: int, qqid: int): ): lines.append( f"{' / '.join(self.get_group_player_list_triggers(group_id))} - 查看玩家列表" - ) + ) lines.append( - f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统菜单(普通成员需绑定游戏账号;管理员进入管理菜单)" + ( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " + "公会系统菜单(普通成员需绑定游戏账号;管理员进入管理菜单)" + ) ) if self._can_use_group_permission(group_id, qqid, "查询背包权限"): lines.append( - f"{' / '.join(self.get_group_inventory_menu_triggers(group_id))} - 查询在线玩家背包" + ( + f"{' / '.join(self.get_group_inventory_menu_triggers(group_id))} - " + "查询在线玩家背包" + ) ) if self._can_use_group_permission(group_id, qqid, "发送指令权限"): lines.append(f"{cmd_prefix}[指令] - 向租赁服发送指令") @@ -634,7 +644,10 @@ def qq_help_build_all_reference_lines(self, group_id: int, qqid: int): ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), ): lines.append( - f"{' / '.join(self.get_group_admin_menu_triggers(group_id))} - QQ群管理员管理菜单" + ( + f"{' / '.join(self.get_group_admin_menu_triggers(group_id))} - " + "QQ群管理员管理菜单" + ) ) if self._can_use_group_permission(group_id, qqid, "配置配置文件权限"): lines.append( @@ -655,7 +668,10 @@ def qq_help_build_all_reference_lines(self, group_id: int, qqid: int): ) if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): lines.append( - f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - 白名单&管理员检测管理菜单" + ( + f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - " + "白名单&管理员检测管理菜单" + ) ) if self.task_system is not None and self._can_use_group_permission( group_id, @@ -667,11 +683,17 @@ def qq_help_build_all_reference_lines(self, group_id: int, qqid: int): ) if self._can_use_group_permission(group_id, qqid, "领地系统权限"): lines.append( - f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - 领地系统云链联动版管理菜单" + ( + f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - " + "领地系统云链联动版管理菜单" + ) ) if self._can_use_group_permission(group_id, qqid, "公会系统权限"): lines.append( - f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统管理菜单(管理员)" + ( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " + "公会系统管理菜单(管理员)" + ) ) for trigger in self.triggers: if trigger.op_only and not self.is_group_admin(group_id, qqid): @@ -761,9 +783,12 @@ def qq_help_show_basic_reference(self, group_id: int, qqid: int): ): options.append( f"{' / '.join(self.get_group_player_list_triggers(group_id))} - 查看玩家列表" - ) + ) options.append( - f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统菜单(需绑定游戏账号)" + ( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " + "公会系统菜单(需绑定游戏账号)" + ) ) self._reply_to_qq( group_id, @@ -789,7 +814,10 @@ def qq_help_show_admin_reference(self, group_id: int, qqid: int): ("QQ普通管理员菜单权限", "QQ超级管理员菜单权限"), ): options.append( - f"{' / '.join(self.get_group_admin_menu_triggers(group_id))} - QQ群管理员管理菜单" + ( + f"{' / '.join(self.get_group_admin_menu_triggers(group_id))} - " + "QQ群管理员管理菜单" + ) ) if self._can_use_group_permission(group_id, qqid, "配置配置文件权限"): options.append( @@ -797,18 +825,30 @@ def qq_help_show_admin_reference(self, group_id: int, qqid: int): ) if self._can_use_group_permission(group_id, qqid, "查询背包权限"): options.append( - f"{' / '.join(self.get_group_inventory_menu_triggers(group_id))} - 查询在线玩家背包" + ( + f"{' / '.join(self.get_group_inventory_menu_triggers(group_id))} - " + "查询在线玩家背包" + ) ) if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + orion_ban_triggers = " / ".join( + self.get_group_orion_ban_triggers(group_id) + ) + orion_unban_triggers = " / ".join( + self.get_group_orion_unban_triggers(group_id) + ) options.extend( [ - f"{' / '.join(self.get_group_orion_ban_triggers(group_id))} - Orion QQ 封禁菜单", - f"{' / '.join(self.get_group_orion_unban_triggers(group_id))} - Orion QQ 解封菜单", + f"{orion_ban_triggers} - Orion QQ 封禁菜单", + f"{orion_unban_triggers} - Orion QQ 解封菜单", ] ) if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): options.append( - f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - 白名单&管理员检测管理菜单" + ( + f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - " + "白名单&管理员检测管理菜单" + ) ) if self.task_system is not None and self._can_use_group_permission( group_id, @@ -820,11 +860,17 @@ def qq_help_show_admin_reference(self, group_id: int, qqid: int): ) if self._can_use_group_permission(group_id, qqid, "领地系统权限"): options.append( - f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - 领地系统云链联动版管理菜单" + ( + f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - " + "领地系统云链联动版管理菜单" + ) ) if self._can_use_group_permission(group_id, qqid, "公会系统权限"): options.append( - f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统管理菜单(管理员)" + ( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " + "公会系统管理菜单(管理员)" + ) ) if not options: self._reply_to_qq(group_id, qqid, "当前没有可展示的管理功能触发词") @@ -845,15 +891,24 @@ def qq_help_show_integration_reference(self, group_id: int, qqid: int): """Handle the qq help show integration reference QQ menu operation.""" options = [] if self._can_use_group_permission(group_id, qqid, "封禁/解封玩家权限"): + orion_ban_triggers = " / ".join( + self.get_group_orion_ban_triggers(group_id) + ) + orion_unban_triggers = " / ".join( + self.get_group_orion_unban_triggers(group_id) + ) options.extend( [ - f"{' / '.join(self.get_group_orion_ban_triggers(group_id))} - Orion QQ 封禁菜单", - f"{' / '.join(self.get_group_orion_unban_triggers(group_id))} - Orion QQ 解封菜单", + f"{orion_ban_triggers} - Orion QQ 封禁菜单", + f"{orion_unban_triggers} - Orion QQ 解封菜单", ] ) if self._can_use_group_permission(group_id, qqid, "白名单&管理员检测权限"): options.append( - f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - 白名单&管理员检测管理菜单" + ( + f"{' / '.join(self.get_group_checker_menu_triggers(group_id))} - " + "白名单&管理员检测管理菜单" + ) ) if self.task_system is not None and self._can_use_group_permission( group_id, @@ -865,11 +920,17 @@ def qq_help_show_integration_reference(self, group_id: int, qqid: int): ) if self._can_use_group_permission(group_id, qqid, "领地系统权限"): options.append( - f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - 领地系统云链联动版管理菜单" + ( + f"{' / '.join(self.get_group_land_menu_triggers(group_id))} - " + "领地系统云链联动版管理菜单" + ) ) if self._can_use_group_permission(group_id, qqid, "公会系统权限"): options.append( - f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - 公会系统菜单(普通成员需绑定;管理员进入管理菜单)" + ( + f"{' / '.join(self.get_group_guild_menu_triggers(group_id))} - " + "公会系统菜单(普通成员需绑定;管理员进入管理菜单)" + ) ) if not options: self._reply_to_qq(group_id, qqid, "当前没有可展示的联动系统触发词") @@ -1594,7 +1655,10 @@ def _format_guild_summary(self, guild: dict[str, Any], group_id: int): f"据点锁定:{'是' if guild.get('base_locked') else '否'}", f"冻结:{'是' if guild.get('frozen') else '否'}", f"冻结原因:{guild.get('frozen_reason') or '无'}", - f"活跃/完成任务:{guild.get('active_tasks', 0)} / {guild.get('completed_tasks', 0)}", + ( + f"活跃/完成任务:{guild.get('active_tasks', 0)} / " + f"{guild.get('completed_tasks', 0)}" + ), f"总贡献:{guild.get('total_contribution', 0)}", f"效果:{effect_text}", f"公告:{guild.get('announcement') or '无'}", @@ -1811,11 +1875,13 @@ def qq_guild_player_menu(self, group_id: int, qqid: int): subtitle = f"玩家菜单 - {player_name} | 未加入公会" options = ["查看公会列表", "申请加入公会", "查看公会排行", "查看贡献排行"] actions = [ - lambda: self.qq_guild_list( - group_id, qqid), lambda: self.qq_guild_player_request_join( - group_id, qqid, player_name), lambda: self.qq_guild_show_rankings( - group_id, qqid), lambda: self.qq_guild_show_donation_rankings( - group_id, qqid), ] + lambda: self.qq_guild_list(group_id, qqid), + lambda: self.qq_guild_player_request_join( + group_id, qqid, player_name + ), + lambda: self.qq_guild_show_rankings(group_id, qqid), + lambda: self.qq_guild_show_donation_rankings(group_id, qqid), + ] result = self._qq_guild_run_menu( group_id, qqid, subtitle, options, actions) if result == "back": @@ -2006,7 +2072,10 @@ def qq_guild_player_join_task( self._reply_to_qq(group_id, qqid, "暂无可参与的任务") return options = [ - f"{task.get('name', '<未知任务>')} ({task.get('current_count', 0)}/{task.get('target_count', 0)})" + ( + f"{task.get('name', '<未知任务>')} " + f"({task.get('current_count', 0)}/{task.get('target_count', 0)})" + ) for task in active_tasks[:20] ] choice = self._qq_guild_prompt( diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" index e1e2fcdf..6a1c6ef5 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/runtime_mixin.py" @@ -131,11 +131,23 @@ def connect_to_websocket(self): level="info", ) session_id = self._start_ws_session() + + def _on_message(ws_obj, message, sid=session_id): + return self.on_ws_message(ws_obj, message, sid) and None + + def _on_error(ws_obj, error, sid=session_id): + return self.on_ws_error(ws_obj, error, sid) + + def _on_close(ws_obj, code, reason, sid=session_id): + return self.on_ws_close(ws_obj, code, reason, sid) + ws_app = websocket.WebSocketApp( - target, header, on_message=lambda a, b, sid=session_id: self.on_ws_message( - a, b, sid) and None, on_error=lambda a, b, sid=session_id: self.on_ws_error( - a, b, sid), on_close=lambda a, b, c, sid=session_id: self.on_ws_close( - a, b, c, sid), ) + target, + header, + on_message=_on_message, + on_error=_on_error, + on_close=_on_close, + ) ws_app.on_open = lambda ws_obj, sid=session_id: self.on_ws_open( ws_obj, sid) self.ws = ws_app diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_abnf.cpython-312.pyc" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_abnf.cpython-312.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..c19ca0f05f1aa0d93c363f7ecc916f8168087ae6 GIT binary patch literal 20201 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 ZADAxxuR0EPa(~^qwZC5XH+34s{}+G@vI+nI literal 0 HcmV?d00001 diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_app.cpython-312.pyc" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_app.cpython-312.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..33f185687e69a4e3a92f5de338e7ec975e0e1f02 GIT binary patch literal 31267 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 literal 0 HcmV?d00001 diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_core.cpython-312.pyc" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_core.cpython-312.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..a447cf6313e0f28750b93220c25a4b3ae1af8d4c GIT binary patch literal 26549 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 J7(~)>{0~c{Cy4+6 literal 0 HcmV?d00001 diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_http.cpython-312.pyc" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_http.cpython-312.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..628bc8d6ba448e9bb28e078a7d7fd4e8b2406ba5 GIT binary patch literal 15380 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 L(CdCfYassr=vJ{x literal 0 HcmV?d00001 diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_socket.cpython-312.pyc" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_socket.cpython-312.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..c9647114c124cd702a5fdcaf33406f8449dc328a GIT binary patch literal 7797 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 literal 0 HcmV?d00001 diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_url.cpython-312.pyc" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/__pycache__/_url.cpython-312.pyc" new file mode 100644 index 0000000000000000000000000000000000000000..915c4be6b57aff3c44d77a947310caf21673f3da GIT binary patch literal 7222 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 V7ULR2+kX8Ayt&P%|G None: - """Update the default reconnect interval used by ``WebSocketApp``.""" - RECONNECT_SETTINGS["interval"] = reconnectInterval - - -class DispatcherBase: - """Base dispatcher that coordinates socket reads and reconnects.""" - - def __init__(self, - app: Any, - ping_timeout: Union[float, - int, - None]) -> None: - """Store the owning app and ping timeout used by the dispatcher.""" - self.app = app - self.ping_timeout = ping_timeout - - @staticmethod - def timeout(seconds: Union[float, int, None], callback: Callable) -> None: - """Sleep for ``seconds`` and then invoke ``callback``.""" - time.sleep(seconds) - callback() - - @staticmethod - def reconnect(seconds: int, reconnector: Callable) -> None: - """Wait for ``seconds`` and then invoke the reconnect callback.""" - _logging.info( - "reconnect() - retrying in %s seconds [%s frames in stack]", - seconds, - len(inspect.stack()), - ) - time.sleep(seconds) - reconnector(reconnecting=True) - - -class Dispatcher(DispatcherBase): - """Dispatcher for plain sockets using ``selectors``.""" - - def read( - self, - sock: socket.socket, - read_callback: Callable, - check_callback: Callable, - ) -> None: - """Poll the socket and invoke callbacks while the app keeps running.""" - sel = selectors.DefaultSelector() - sel.register(self.app.sock.sock, selectors.EVENT_READ) - try: - while self.app.keep_running: - if sel.select(self.ping_timeout) and not read_callback(): - break - check_callback() - finally: - sel.close() - - -class SSLDispatcher(DispatcherBase): - """Dispatcher for SSL sockets that may already have pending bytes.""" - - def read( - self, - sock: socket.socket, - read_callback: Callable, - check_callback: Callable, - ) -> None: - """Read SSL socket events while also handling pending decrypted bytes.""" - sock = self.app.sock.sock - sel = selectors.DefaultSelector() - sel.register(sock, selectors.EVENT_READ) - try: - while self.app.keep_running: - if self.select(sock, sel) and not read_callback(): - break - check_callback() - finally: - sel.close() - - def select(self, sock, sel: selectors.DefaultSelector): - """Return ready events from the SSL socket, if any are available.""" - sock = self.app.sock.sock - if sock.pending(): - return [ - sock, - ] - - r = sel.select(self.ping_timeout) - - if len(r) > 0: - return r[0][0] - return None - - -class WrappedDispatcher: - """Adapter for custom dispatcher implementations.""" - - def __init__(self, - app, - ping_timeout: Union[float, - int, - None], - dispatcher) -> None: - """Wrap a custom dispatcher and register its abort signal handler.""" - self.app = app - self.ping_timeout = ping_timeout - self.dispatcher = dispatcher - dispatcher.signal(2, dispatcher.abort) # keyboard interrupt - - def read( - self, - sock: socket.socket, - read_callback: Callable, - check_callback: Callable, - ) -> None: - """Delegate reads to the custom dispatcher and apply timeout checks.""" - self.dispatcher.read(sock, read_callback) - if self.ping_timeout: - self.timeout(self.ping_timeout, check_callback) - - def timeout(self, seconds: float, callback: Callable) -> None: - """Delegate timeout handling to the wrapped dispatcher.""" - self.dispatcher.timeout(seconds, callback) - - def reconnect(self, seconds: int, reconnector: Callable) -> None: - """Delegate reconnect scheduling to the wrapped dispatcher.""" - self.timeout(seconds, reconnector) - - -class WebSocketApp: - """Higher-level WebSocket API similar to the JavaScript WebSocket object.""" - - def __init__( - self, - url: str, - header: Union[list, dict, Callable, None] = None, - on_open: Optional[Callable[[WebSocket], None]] = None, - on_reconnect: Optional[Callable[[WebSocket], None]] = None, - on_message: Optional[Callable[[WebSocket, Any], None]] = None, - on_error: Optional[Callable[[WebSocket, Any], None]] = None, - on_close: Optional[Callable[[WebSocket, Any, Any], None]] = None, - on_ping: Optional[Callable] = None, - on_pong: Optional[Callable] = None, - on_cont_message: Optional[Callable] = None, - keep_running: bool = True, - get_mask_key: Optional[Callable] = None, - cookie: Optional[str] = None, - subprotocols: Optional[list] = None, - on_data: Optional[Callable] = None, - prepared_socket: Optional[socket.socket] = None, - ) -> None: - """ - WebSocketApp initialization - - Parameters - ---------- - url: str - Websocket url. - header: list or dict or Callable - Custom header for websocket handshake. - If the parameter is a callable object, it is called just before - the connection attempt. - The returned dict or list is used as custom header value. - This could be useful in order to properly setup timestamp dependent headers. - on_open: function - Callback object which is called at opening websocket. - on_open has one argument. - The 1st argument is this class object. - on_reconnect: function - Callback object which is called at reconnecting websocket. - on_reconnect has one argument. - The 1st argument is this class object. - on_message: function - Callback object which is called when received data. - on_message has 2 arguments. - The 1st argument is this class object. - The 2nd argument is utf-8 data received from the server. - on_error: function - Callback object which is called when we get error. - on_error has 2 arguments. - The 1st argument is this class object. - The 2nd argument is exception object. - on_close: function - Callback object which is called when connection is closed. - on_close has 3 arguments. - The 1st argument is this class object. - The 2nd argument is close_status_code. - The 3rd argument is close_msg. - on_cont_message: function - Callback object which is called when a continuation - frame is received. - on_cont_message has 3 arguments. - The 1st argument is this class object. - The 2nd argument is utf-8 string which we get from the server. - The 3rd argument is continue flag. if 0, the data continue - to next frame data - on_data: function - Callback object which is called when a message received. - This is called before on_message or on_cont_message, - and then on_message or on_cont_message is called. - on_data has 4 argument. - The 1st argument is this class object. - The 2nd argument is utf-8 string which we get from the server. - The 3rd argument is data type. - ABNF.OPCODE_TEXT or ABNF.OPCODE_BINARY will be came. - The 4th argument is continue flag. If 0, the data continue - keep_running: bool - This parameter is obsolete and ignored. - get_mask_key: function - A callable function to get new mask keys, see the - WebSocket.set_mask_key's docstring for more information. - cookie: str - Cookie value. - subprotocols: list - List of available sub protocols. Default is None. - prepared_socket: socket - Pre-initialized stream socket. - """ - self.url = url - self.header = header if header is not None else [] - self.cookie = cookie - - self.on_open = on_open - self.on_reconnect = on_reconnect - self.on_message = on_message - self.on_data = on_data - self.on_error = on_error - self.on_close = on_close - self.on_ping = on_ping - self.on_pong = on_pong - self.on_cont_message = on_cont_message - self.keep_running = False - self.get_mask_key = get_mask_key - self.sock: Optional[WebSocket] = None - self.last_ping_tm = float(0) - self.last_pong_tm = float(0) - self.ping_thread: Optional[threading.Thread] = None - self.stop_ping: Optional[threading.Event] = None - self.ping_interval = float(0) - self.ping_timeout: Union[float, int, None] = None - self.ping_payload = "" - self.subprotocols = subprotocols - self.prepared_socket = prepared_socket - self.has_errored = False - self.has_done_teardown = False - self.has_done_teardown_lock = threading.Lock() - - def send(self, data: Union[bytes, str], - opcode: int = ABNF.OPCODE_TEXT) -> None: - """Send a message using the supplied opcode.""" - if not self.sock or self.sock.send(data, opcode) == 0: - raise WebSocketConnectionClosedException( - "Connection is already closed.") - - def send_text(self, text_data: str) -> None: - """Send UTF-8 encoded text data.""" - if not self.sock or self.sock.send(text_data, ABNF.OPCODE_TEXT) == 0: - raise WebSocketConnectionClosedException( - "Connection is already closed.") - - def send_bytes(self, data: Union[bytes, bytearray]) -> None: - """Send binary data.""" - if not self.sock or self.sock.send(data, ABNF.OPCODE_BINARY) == 0: - raise WebSocketConnectionClosedException( - "Connection is already closed.") - - def close(self, **kwargs) -> None: - """Close the websocket connection.""" - self.keep_running = False - if self.sock: - self.sock.close(**kwargs) - self.sock = None - - def _start_ping_thread(self) -> None: - """Start the background ping thread used by ``run_forever``.""" - self.last_ping_tm = self.last_pong_tm = float(0) - self.stop_ping = threading.Event() - self.ping_thread = threading.Thread(target=self._send_ping) - self.ping_thread.daemon = True - self.ping_thread.start() - - def _stop_ping_thread(self) -> None: - """Stop the background ping thread and reset ping timestamps.""" - if self.stop_ping: - self.stop_ping.set() - if self.ping_thread and self.ping_thread.is_alive(): - self.ping_thread.join(3) - self.last_ping_tm = self.last_pong_tm = float(0) - - def _send_ping(self) -> None: - """Periodically send ping frames while the connection stays open.""" - if self.stop_ping.wait( - self.ping_interval) or not self.keep_running: - return - while not self.stop_ping.wait( - self.ping_interval) and self.keep_running: - if self.sock: - self.last_ping_tm = time.time() - try: - _logging.debug("Sending ping") - self.sock.ping(self.ping_payload) - except Exception as e: - _logging.debug("Failed to send ping: %s", e) - - def run_forever( # skipcq: PY-R1000 - self, - sockopt: tuple = None, - sslopt: dict = None, - ping_interval: Union[float, int] = 0, - ping_timeout: Union[float, int, None] = None, - ping_payload: str = "", - http_proxy_host: str = None, - http_proxy_port: Union[int, str] = None, - http_no_proxy: list = None, - http_proxy_auth: tuple = None, - http_proxy_timeout: Optional[float] = None, - skip_utf8_validation: bool = False, - host: str = None, - origin: str = None, - dispatcher=None, - suppress_origin: bool = False, - proxy_type: str = None, - reconnect: int = None, - ) -> bool: - """ - Run event loop for WebSocket framework. - - This loop is an infinite loop and is alive while websocket is available. - - Parameters - ---------- - sockopt: tuple - Values for socket.setsockopt. - sockopt must be tuple - and each element is argument of sock.setsockopt. - sslopt: dict - Optional dict object for ssl socket option. - ping_interval: int or float - Automatically send "ping" command - every specified period (in seconds). - If set to 0, no ping is sent periodically. - ping_timeout: int or float - Timeout (in seconds) if the pong message is not received. - ping_payload: str - Payload message to send with each ping. - http_proxy_host: str - HTTP proxy host name. - http_proxy_port: int or str - HTTP proxy port. If not set, set to 80. - http_no_proxy: list - Whitelisted host names that don't use the proxy. - http_proxy_timeout: int or float - HTTP proxy timeout, default is 60 sec as per python-socks. - http_proxy_auth: tuple - HTTP proxy auth information. - Tuple of username and password. Default is None. - skip_utf8_validation: bool - skip utf8 validation. - host: str - update host header. - origin: str - update origin header. - dispatcher: Dispatcher object - customize reading data from socket. - suppress_origin: bool - suppress outputting origin header. - proxy_type: str - type of proxy from: http, socks4, socks4a, socks5, socks5h - reconnect: int - delay interval when reconnecting - - Returns - ------- - teardown: bool - False if the `WebSocketApp` is closed or caught KeyboardInterrupt, - True if any other exception was raised during a loop. - """ - if reconnect is None: - reconnect = RECONNECT_SETTINGS["interval"] - - if ping_timeout is not None and ping_timeout <= 0: - raise WebSocketException("Ensure ping_timeout > 0") - if ping_interval is not None and ping_interval < 0: - raise WebSocketException("Ensure ping_interval >= 0") - if ping_timeout and ping_interval and ping_interval <= ping_timeout: - raise WebSocketException("Ensure ping_interval > ping_timeout") - if not sockopt: - sockopt = () - if not sslopt: - sslopt = {} - if self.sock: - raise WebSocketException("socket is already opened") - - self.ping_interval = ping_interval - self.ping_timeout = ping_timeout - self.ping_payload = ping_payload - self.has_done_teardown = False - self.keep_running = True - - def read() -> bool: - """Read one frame from the socket and dispatch the matching callbacks.""" - if not self.keep_running: - return teardown() - - try: - op_code, frame = self.sock.recv_data_frame(True) - except ( - WebSocketConnectionClosedException, - KeyboardInterrupt, - SSLEOFError, - ) as e: - if custom_dispatcher: - return handleDisconnect(e, bool(reconnect)) - raise - - if op_code == ABNF.OPCODE_CLOSE: - return teardown(frame) - if op_code == ABNF.OPCODE_PING: - self._callback(self.on_ping, frame.data) - if op_code == ABNF.OPCODE_PONG: - self.last_pong_tm = time.time() - self._callback(self.on_pong, frame.data) - elif op_code == ABNF.OPCODE_CONT and self.on_cont_message: - self._callback( - self.on_data, - frame.data, - frame.opcode, - frame.fin) - self._callback(self.on_cont_message, frame.data, frame.fin) - else: - data = frame.data - if op_code == ABNF.OPCODE_TEXT and not skip_utf8_validation: - data = data.decode("utf-8") - self._callback(self.on_data, data, frame.opcode, True) - self._callback(self.on_message, data) - - return True - - def check() -> bool: - """Check whether the connection exceeded the configured ping timeout.""" - if self.ping_timeout: - has_timeout_expired = ( - time.time() - self.last_ping_tm > self.ping_timeout - ) - has_pong_not_arrived_after_last_ping = ( - self.last_pong_tm - self.last_ping_tm < 0 - ) - has_pong_arrived_too_late = ( - self.last_pong_tm - self.last_ping_tm > self.ping_timeout - ) - - if ( - self.last_ping_tm - and has_timeout_expired - and ( - has_pong_not_arrived_after_last_ping - or has_pong_arrived_too_late - ) - ): - raise WebSocketTimeoutException("ping/pong timed out") - return True - - def handleDisconnect( - e: Union[ - WebSocketConnectionClosedException, - ConnectionRefusedError, - KeyboardInterrupt, - SystemExit, - Exception, - ], - reconnecting: bool = False, - ) -> bool: - """Handle disconnects, teardown, and optional reconnect scheduling.""" - self.has_errored = True - self._stop_ping_thread() - if not reconnecting: - self._callback(self.on_error, e) - - if isinstance(e, (KeyboardInterrupt, SystemExit)): - teardown() - # 这里的 e 来自外层回调参数,不处在 except 语句块里, - # 不能用 bare raise;按原异常类型重建后继续向上传播。 - if isinstance(e, KeyboardInterrupt): - raise KeyboardInterrupt(*e.args) from e - raise SystemExit(*e.args) from e - - if reconnect: - _logging.info("%s - reconnect", e) - if custom_dispatcher: - _logging.debug( - "Calling custom dispatcher reconnect [%s frames in stack]", len( - inspect.stack()),) - dispatcher.reconnect(reconnect, setSock) - else: - _logging.error("%s - goodbye", e) - teardown() - - def teardown(close_frame: ABNF = None): - """ - Tears down the connection. - - Parameters - ---------- - close_frame: ABNF frame - If close_frame is set, the on_close handler is invoked - with the statusCode and reason from the provided frame. - """ - # teardown() is called in many code paths to ensure resources are - # cleaned up and on_close is fired. The flag and lock ensure that - # cleanup still runs only once. - with self.has_done_teardown_lock: - if self.has_done_teardown: - return - self.has_done_teardown = True - - self._stop_ping_thread() - self.keep_running = False - if self.sock: - self.sock.close() - close_status_code, close_reason = self._get_close_args( - close_frame if close_frame else None - ) - self.sock = None - - # Finally call the callback AFTER all teardown is complete - self._callback(self.on_close, close_status_code, close_reason) - - def setSock(reconnecting: bool = False) -> None: - """Create the socket, perform the handshake, and start reading.""" - if reconnecting and self.sock: - self.sock.shutdown() - - self.sock = WebSocket( - self.get_mask_key, - sockopt=sockopt, - sslopt=sslopt, - fire_cont_frame=self.on_cont_message is not None, - skip_utf8_validation=skip_utf8_validation, - enable_multithread=True, - ) - - self.sock.settimeout(getdefaulttimeout()) - try: - header = self.header() if callable(self.header) else self.header - - self.sock.connect( - self.url, - header=header, - cookie=self.cookie, - http_proxy_host=http_proxy_host, - http_proxy_port=http_proxy_port, - http_no_proxy=http_no_proxy, - http_proxy_auth=http_proxy_auth, - http_proxy_timeout=http_proxy_timeout, - subprotocols=self.subprotocols, - host=host, - origin=origin, - suppress_origin=suppress_origin, - proxy_type=proxy_type, - socket=self.prepared_socket, - ) - - _logging.info("Websocket connected") - - if self.ping_interval: - self._start_ping_thread() - - if reconnecting and self.on_reconnect: - self._callback(self.on_reconnect) - else: - self._callback(self.on_open) - - dispatcher.read(self.sock.sock, read, check) - except ( - WebSocketConnectionClosedException, - ConnectionRefusedError, - KeyboardInterrupt, - SystemExit, - ) as e: - handleDisconnect(e, reconnecting) - except Exception as e: - handleDisconnect(e, reconnecting) - - custom_dispatcher = bool(dispatcher) - dispatcher = self.create_dispatcher( - ping_timeout, dispatcher, parse_url(self.url)[3] - ) - - try: - setSock() - if not custom_dispatcher and reconnect: - while self.keep_running: - _logging.debug( - "Calling dispatcher reconnect [%s frames in stack]", - len(inspect.stack()), - ) - dispatcher.reconnect(reconnect, setSock) - except (KeyboardInterrupt, Exception) as e: - _logging.info("tearing down on exception %s", e) - teardown() - finally: - if not custom_dispatcher: - # Ensure teardown was called before returning from run_forever - teardown() - - return self.has_errored - - def create_dispatcher( - self, - ping_timeout: Union[float, int, None], - dispatcher: Optional[DispatcherBase] = None, - is_ssl: bool = False, - ) -> Union[Dispatcher, SSLDispatcher, WrappedDispatcher]: - """Create the dispatcher object used by ``run_forever``.""" - if dispatcher: # If custom dispatcher is set, use WrappedDispatcher - return WrappedDispatcher(self, ping_timeout, dispatcher) - timeout = ping_timeout or 10 - if is_ssl: - return SSLDispatcher(self, timeout) - return Dispatcher(self, timeout) - - def _get_close_args(self, close_frame: ABNF) -> list: - """Extract the close code and reason from a close frame if present.""" - # Need to catch the case where close_frame is None - # Otherwise the following if statement causes an error - if not self.on_close or not close_frame: - return [None, None] - - # Extract close frame status code - if close_frame.data and len(close_frame.data) >= 2: - close_status_code = 256 * int(close_frame.data[0]) + int( - close_frame.data[1] - ) - reason = close_frame.data[2:] - if isinstance(reason, bytes): - reason = reason.decode("utf-8") - return [close_status_code, reason] - # Most likely reached this because len(close_frame_data.data) < 2 - return [None, None] - - def _callback(self, callback, *args) -> None: - """Invoke a callback and forward callback failures to ``on_error``.""" - if callback: - try: - callback(self, *args) - - except Exception as e: - _logging.error("error from callback %s: %s", callback, e) - if self.on_error: - self.on_error(self, e) +""" +_app.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import inspect +import selectors +import socket +import threading +import time +from typing import Any, Callable, Optional, Union + +from . import _logging +from ._abnf import ABNF +from ._core import WebSocket, getdefaulttimeout +from ._exceptions import ( + WebSocketConnectionClosedException, + WebSocketException, + WebSocketTimeoutException, +) +from ._ssl_compat import SSLEOFError +from ._url import parse_url + +__all__ = ["WebSocketApp"] + +RECONNECT_SETTINGS = {"interval": 0} + + +def setReconnect(reconnectInterval: int) -> None: + """Update the default reconnect interval used by ``WebSocketApp``.""" + RECONNECT_SETTINGS["interval"] = reconnectInterval + + +class DispatcherBase: + """Base dispatcher that coordinates socket reads and reconnects.""" + + def __init__(self, + app: Any, + ping_timeout: Union[float, + int, + None]) -> None: + """Store the owning app and ping timeout used by the dispatcher.""" + self.app = app + self.ping_timeout = ping_timeout + + @staticmethod + def timeout(seconds: Union[float, int, None], callback: Callable) -> None: + """Sleep for ``seconds`` and then invoke ``callback``.""" + time.sleep(seconds) + callback() + + @staticmethod + def reconnect(seconds: int, reconnector: Callable) -> None: + """Wait for ``seconds`` and then invoke the reconnect callback.""" + _logging.info( + "reconnect() - retrying in %s seconds [%s frames in stack]", + seconds, + len(inspect.stack()), + ) + time.sleep(seconds) + reconnector(reconnecting=True) + + +class Dispatcher(DispatcherBase): + """Dispatcher for plain sockets using ``selectors``.""" + + def read( + self, + sock: socket.socket, + read_callback: Callable, + check_callback: Callable, + ) -> None: + """Poll the socket and invoke callbacks while the app keeps running.""" + _ = sock + sel = selectors.DefaultSelector() + sel.register(self.app.sock.sock, selectors.EVENT_READ) + try: + while self.app.keep_running: + if sel.select(self.ping_timeout) and not read_callback(): + break + check_callback() + finally: + sel.close() + + +class SSLDispatcher(DispatcherBase): + """Dispatcher for SSL sockets that may already have pending bytes.""" + + def read( + self, + sock: socket.socket, + read_callback: Callable, + check_callback: Callable, + ) -> None: + """Read SSL socket events while also handling pending decrypted bytes.""" + sock = self.app.sock.sock + sel = selectors.DefaultSelector() + sel.register(sock, selectors.EVENT_READ) + try: + while self.app.keep_running: + if self.select(sock, sel) and not read_callback(): + break + check_callback() + finally: + sel.close() + + def select(self, sock, sel: selectors.DefaultSelector): + """Return ready events from the SSL socket, if any are available.""" + sock = self.app.sock.sock + if sock.pending(): + return [ + sock, + ] + + r = sel.select(self.ping_timeout) + + if len(r) > 0: + return r[0][0] + return None + + +class WrappedDispatcher: + """Adapter for custom dispatcher implementations.""" + + def __init__(self, + app, + ping_timeout: Union[float, + int, + None], + dispatcher) -> None: + """Wrap a custom dispatcher and register its abort signal handler.""" + self.app = app + self.ping_timeout = ping_timeout + self.dispatcher = dispatcher + dispatcher.signal(2, dispatcher.abort) # keyboard interrupt + + def read( + self, + sock: socket.socket, + read_callback: Callable, + check_callback: Callable, + ) -> None: + """Delegate reads to the custom dispatcher and apply timeout checks.""" + self.dispatcher.read(sock, read_callback) + if self.ping_timeout: + self.timeout(self.ping_timeout, check_callback) + + def timeout(self, seconds: float, callback: Callable) -> None: + """Delegate timeout handling to the wrapped dispatcher.""" + self.dispatcher.timeout(seconds, callback) + + def reconnect(self, seconds: int, reconnector: Callable) -> None: + """Delegate reconnect scheduling to the wrapped dispatcher.""" + self.timeout(seconds, reconnector) + + +class WebSocketApp: + """Higher-level WebSocket API similar to the JavaScript WebSocket object.""" + + def __init__( + self, + url: str, + header: Union[list, dict, Callable, None] = None, + on_open: Optional[Callable[[WebSocket], None]] = None, + on_reconnect: Optional[Callable[[WebSocket], None]] = None, + on_message: Optional[Callable[[WebSocket, Any], None]] = None, + on_error: Optional[Callable[[WebSocket, Any], None]] = None, + on_close: Optional[Callable[[WebSocket, Any, Any], None]] = None, + on_ping: Optional[Callable] = None, + on_pong: Optional[Callable] = None, + on_cont_message: Optional[Callable] = None, + keep_running: bool = True, + get_mask_key: Optional[Callable] = None, + cookie: Optional[str] = None, + subprotocols: Optional[list] = None, + on_data: Optional[Callable] = None, + prepared_socket: Optional[socket.socket] = None, + ) -> None: + """ + WebSocketApp initialization + + Parameters + ---------- + url: str + Websocket url. + header: list or dict or Callable + Custom header for websocket handshake. + If the parameter is a callable object, it is called just before + the connection attempt. + The returned dict or list is used as custom header value. + This could be useful in order to properly setup timestamp dependent headers. + on_open: function + Callback object which is called at opening websocket. + on_open has one argument. + The 1st argument is this class object. + on_reconnect: function + Callback object which is called at reconnecting websocket. + on_reconnect has one argument. + The 1st argument is this class object. + on_message: function + Callback object which is called when received data. + on_message has 2 arguments. + The 1st argument is this class object. + The 2nd argument is utf-8 data received from the server. + on_error: function + Callback object which is called when we get error. + on_error has 2 arguments. + The 1st argument is this class object. + The 2nd argument is exception object. + on_close: function + Callback object which is called when connection is closed. + on_close has 3 arguments. + The 1st argument is this class object. + The 2nd argument is close_status_code. + The 3rd argument is close_msg. + on_cont_message: function + Callback object which is called when a continuation + frame is received. + on_cont_message has 3 arguments. + The 1st argument is this class object. + The 2nd argument is utf-8 string which we get from the server. + The 3rd argument is continue flag. if 0, the data continue + to next frame data + on_data: function + Callback object which is called when a message received. + This is called before on_message or on_cont_message, + and then on_message or on_cont_message is called. + on_data has 4 argument. + The 1st argument is this class object. + The 2nd argument is utf-8 string which we get from the server. + The 3rd argument is data type. + ABNF.OPCODE_TEXT or ABNF.OPCODE_BINARY will be came. + The 4th argument is continue flag. If 0, the data continue + keep_running: bool + This parameter is obsolete and ignored. + get_mask_key: function + A callable function to get new mask keys, see the + WebSocket.set_mask_key's docstring for more information. + cookie: str + Cookie value. + subprotocols: list + List of available sub protocols. Default is None. + prepared_socket: socket + Pre-initialized stream socket. + """ + _ = keep_running + self.url = url + self.header = header if header is not None else [] + self.cookie = cookie + + self.on_open = on_open + self.on_reconnect = on_reconnect + self.on_message = on_message + self.on_data = on_data + self.on_error = on_error + self.on_close = on_close + self.on_ping = on_ping + self.on_pong = on_pong + self.on_cont_message = on_cont_message + self.keep_running = False + self.get_mask_key = get_mask_key + self.sock: Optional[WebSocket] = None + self.last_ping_tm = float(0) + self.last_pong_tm = float(0) + self.ping_thread: Optional[threading.Thread] = None + self.stop_ping: Optional[threading.Event] = None + self.ping_interval = float(0) + self.ping_timeout: Union[float, int, None] = None + self.ping_payload = "" + self.subprotocols = subprotocols + self.prepared_socket = prepared_socket + self.has_errored = False + self.has_done_teardown = False + self.has_done_teardown_lock = threading.Lock() + + def send(self, data: Union[bytes, str], + opcode: int = ABNF.OPCODE_TEXT) -> None: + """Send a message using the supplied opcode.""" + if not self.sock or self.sock.send(data, opcode) == 0: + raise WebSocketConnectionClosedException( + "Connection is already closed.") + + def send_text(self, text_data: str) -> None: + """Send UTF-8 encoded text data.""" + if not self.sock or self.sock.send(text_data, ABNF.OPCODE_TEXT) == 0: + raise WebSocketConnectionClosedException( + "Connection is already closed.") + + def send_bytes(self, data: Union[bytes, bytearray]) -> None: + """Send binary data.""" + if not self.sock or self.sock.send(data, ABNF.OPCODE_BINARY) == 0: + raise WebSocketConnectionClosedException( + "Connection is already closed.") + + def close(self, **kwargs) -> None: + """Close the websocket connection.""" + self.keep_running = False + if self.sock: + self.sock.close(**kwargs) + self.sock = None + + def _start_ping_thread(self) -> None: + """Start the background ping thread used by ``run_forever``.""" + self.last_ping_tm = self.last_pong_tm = float(0) + self.stop_ping = threading.Event() + self.ping_thread = threading.Thread(target=self._send_ping) + self.ping_thread.daemon = True + self.ping_thread.start() + + def _stop_ping_thread(self) -> None: + """Stop the background ping thread and reset ping timestamps.""" + if self.stop_ping: + self.stop_ping.set() + if self.ping_thread and self.ping_thread.is_alive(): + self.ping_thread.join(3) + self.last_ping_tm = self.last_pong_tm = float(0) + + def _send_ping(self) -> None: + """Periodically send ping frames while the connection stays open.""" + if self.stop_ping.wait( + self.ping_interval) or not self.keep_running: + return + while not self.stop_ping.wait( + self.ping_interval) and self.keep_running: + if self.sock: + self.last_ping_tm = time.time() + try: + _logging.debug("Sending ping") + self.sock.ping(self.ping_payload) + except Exception as e: + _logging.debug("Failed to send ping: %s", e) + + def run_forever( # skipcq: PY-R1000 + self, + sockopt: tuple = None, + sslopt: dict = None, + ping_interval: Union[float, int] = 0, + ping_timeout: Union[float, int, None] = None, + ping_payload: str = "", + http_proxy_host: str = None, + http_proxy_port: Union[int, str] = None, + http_no_proxy: list = None, + http_proxy_auth: tuple = None, + http_proxy_timeout: Optional[float] = None, + skip_utf8_validation: bool = False, + host: str = None, + origin: str = None, + dispatcher=None, + suppress_origin: bool = False, + proxy_type: str = None, + reconnect: int = None, + ) -> bool: + """ + Run event loop for WebSocket framework. + + This loop is an infinite loop and is alive while websocket is available. + + Parameters + ---------- + sockopt: tuple + Values for socket.setsockopt. + sockopt must be tuple + and each element is argument of sock.setsockopt. + sslopt: dict + Optional dict object for ssl socket option. + ping_interval: int or float + Automatically send "ping" command + every specified period (in seconds). + If set to 0, no ping is sent periodically. + ping_timeout: int or float + Timeout (in seconds) if the pong message is not received. + ping_payload: str + Payload message to send with each ping. + http_proxy_host: str + HTTP proxy host name. + http_proxy_port: int or str + HTTP proxy port. If not set, set to 80. + http_no_proxy: list + Whitelisted host names that don't use the proxy. + http_proxy_timeout: int or float + HTTP proxy timeout, default is 60 sec as per python-socks. + http_proxy_auth: tuple + HTTP proxy auth information. + Tuple of username and password. Default is None. + skip_utf8_validation: bool + skip utf8 validation. + host: str + update host header. + origin: str + update origin header. + dispatcher: Dispatcher object + customize reading data from socket. + suppress_origin: bool + suppress outputting origin header. + proxy_type: str + type of proxy from: http, socks4, socks4a, socks5, socks5h + reconnect: int + delay interval when reconnecting + + Returns + ------- + teardown: bool + False if the `WebSocketApp` is closed or caught KeyboardInterrupt, + True if any other exception was raised during a loop. + """ + if reconnect is None: + reconnect = RECONNECT_SETTINGS["interval"] + + if ping_timeout is not None and ping_timeout <= 0: + raise WebSocketException("Ensure ping_timeout > 0") + if ping_interval is not None and ping_interval < 0: + raise WebSocketException("Ensure ping_interval >= 0") + if ping_timeout and ping_interval and ping_interval <= ping_timeout: + raise WebSocketException("Ensure ping_interval > ping_timeout") + if not sockopt: + sockopt = () + if not sslopt: + sslopt = {} + if self.sock: + raise WebSocketException("socket is already opened") + + self.ping_interval = ping_interval + self.ping_timeout = ping_timeout + self.ping_payload = ping_payload + self.has_done_teardown = False + self.keep_running = True + + def read() -> bool: + """Read one frame from the socket and dispatch the matching callbacks.""" + if not self.keep_running: + return teardown() + + try: + op_code, frame = self.sock.recv_data_frame(True) + except ( + WebSocketConnectionClosedException, + KeyboardInterrupt, + SSLEOFError, + ) as e: + if custom_dispatcher: + return handleDisconnect(e, bool(reconnect)) + raise + + if op_code == ABNF.OPCODE_CLOSE: + return teardown(frame) + if op_code == ABNF.OPCODE_PING: + self._callback(self.on_ping, frame.data) + if op_code == ABNF.OPCODE_PONG: + self.last_pong_tm = time.time() + self._callback(self.on_pong, frame.data) + elif op_code == ABNF.OPCODE_CONT and self.on_cont_message: + self._callback( + self.on_data, + frame.data, + frame.opcode, + frame.fin) + self._callback(self.on_cont_message, frame.data, frame.fin) + else: + data = frame.data + if op_code == ABNF.OPCODE_TEXT and not skip_utf8_validation: + data = data.decode("utf-8") + self._callback(self.on_data, data, frame.opcode, True) + self._callback(self.on_message, data) + + return True + + def check() -> bool: + """Check whether the connection exceeded the configured ping timeout.""" + if self.ping_timeout: + has_timeout_expired = ( + time.time() - self.last_ping_tm > self.ping_timeout + ) + has_pong_not_arrived_after_last_ping = ( + self.last_pong_tm - self.last_ping_tm < 0 + ) + has_pong_arrived_too_late = ( + self.last_pong_tm - self.last_ping_tm > self.ping_timeout + ) + + if ( + self.last_ping_tm + and has_timeout_expired + and ( + has_pong_not_arrived_after_last_ping + or has_pong_arrived_too_late + ) + ): + raise WebSocketTimeoutException("ping/pong timed out") + return True + + def handleDisconnect( + e: Union[ + WebSocketConnectionClosedException, + ConnectionRefusedError, + KeyboardInterrupt, + SystemExit, + Exception, + ], + reconnecting: bool = False, + ) -> bool: + """Handle disconnects, teardown, and optional reconnect scheduling.""" + self.has_errored = True + self._stop_ping_thread() + if not reconnecting: + self._callback(self.on_error, e) + + if isinstance(e, (KeyboardInterrupt, SystemExit)): + teardown() + # 这里的 e 来自外层回调参数,不处在 except 语句块里, + # 不能用 bare raise;按原异常类型重建后继续向上传播。 + if isinstance(e, KeyboardInterrupt): + raise KeyboardInterrupt(*e.args) from e + raise SystemExit(*e.args) from e + + if reconnect: + _logging.info("%s - reconnect", e) + if custom_dispatcher: + _logging.debug( + "Calling custom dispatcher reconnect [%s frames in stack]", len( + inspect.stack()),) + dispatcher.reconnect(reconnect, setSock) + else: + _logging.error("%s - goodbye", e) + teardown() + + def teardown(close_frame: ABNF = None): + """ + Tears down the connection. + + Parameters + ---------- + close_frame: ABNF frame + If close_frame is set, the on_close handler is invoked + with the statusCode and reason from the provided frame. + """ + # teardown() is called in many code paths to ensure resources are + # cleaned up and on_close is fired. The flag and lock ensure that + # cleanup still runs only once. + with self.has_done_teardown_lock: + if self.has_done_teardown: + return + self.has_done_teardown = True + + self._stop_ping_thread() + self.keep_running = False + if self.sock: + self.sock.close() + close_status_code, close_reason = self._get_close_args( + close_frame if close_frame else None + ) + self.sock = None + + # Finally call the callback AFTER all teardown is complete + self._callback(self.on_close, close_status_code, close_reason) + + def setSock(reconnecting: bool = False) -> None: + """Create the socket, perform the handshake, and start reading.""" + if reconnecting and self.sock: + self.sock.shutdown() + + self.sock = WebSocket( + self.get_mask_key, + sockopt=sockopt, + sslopt=sslopt, + fire_cont_frame=self.on_cont_message is not None, + skip_utf8_validation=skip_utf8_validation, + enable_multithread=True, + ) + + self.sock.settimeout(getdefaulttimeout()) + try: + header = self.header() if callable(self.header) else self.header + + self.sock.connect( + self.url, + header=header, + cookie=self.cookie, + http_proxy_host=http_proxy_host, + http_proxy_port=http_proxy_port, + http_no_proxy=http_no_proxy, + http_proxy_auth=http_proxy_auth, + http_proxy_timeout=http_proxy_timeout, + subprotocols=self.subprotocols, + host=host, + origin=origin, + suppress_origin=suppress_origin, + proxy_type=proxy_type, + socket=self.prepared_socket, + ) + + _logging.info("Websocket connected") + + if self.ping_interval: + self._start_ping_thread() + + if reconnecting and self.on_reconnect: + self._callback(self.on_reconnect) + else: + self._callback(self.on_open) + + dispatcher.read(self.sock.sock, read, check) + except ( + WebSocketConnectionClosedException, + ConnectionRefusedError, + KeyboardInterrupt, + SystemExit, + ) as e: + handleDisconnect(e, reconnecting) + except Exception as e: + handleDisconnect(e, reconnecting) + + custom_dispatcher = bool(dispatcher) + dispatcher = self.create_dispatcher( + ping_timeout, dispatcher, parse_url(self.url)[3] + ) + + try: + setSock() + if not custom_dispatcher and reconnect: + while self.keep_running: + _logging.debug( + "Calling dispatcher reconnect [%s frames in stack]", + len(inspect.stack()), + ) + dispatcher.reconnect(reconnect, setSock) + except (KeyboardInterrupt, Exception) as e: + _logging.info("tearing down on exception %s", e) + teardown() + finally: + if not custom_dispatcher: + # Ensure teardown was called before returning from run_forever + teardown() + + return self.has_errored + + def create_dispatcher( + self, + ping_timeout: Union[float, int, None], + dispatcher: Optional[DispatcherBase] = None, + is_ssl: bool = False, + ) -> Union[Dispatcher, SSLDispatcher, WrappedDispatcher]: + """Create the dispatcher object used by ``run_forever``.""" + if dispatcher: # If custom dispatcher is set, use WrappedDispatcher + return WrappedDispatcher(self, ping_timeout, dispatcher) + timeout = ping_timeout or 10 + if is_ssl: + return SSLDispatcher(self, timeout) + return Dispatcher(self, timeout) + + def _get_close_args(self, close_frame: ABNF) -> list: + """Extract the close code and reason from a close frame if present.""" + # Need to catch the case where close_frame is None + # Otherwise the following if statement causes an error + if not self.on_close or not close_frame: + return [None, None] + + # Extract close frame status code + if close_frame.data and len(close_frame.data) >= 2: + close_status_code = 256 * int(close_frame.data[0]) + int( + close_frame.data[1] + ) + reason = close_frame.data[2:] + if isinstance(reason, bytes): + reason = reason.decode("utf-8") + return [close_status_code, reason] + # Most likely reached this because len(close_frame_data.data) < 2 + return [None, None] + + def _callback(self, callback, *args) -> None: + """Invoke a callback and forward callback failures to ``on_error``.""" + if callback: + try: + callback(self, *args) + + except Exception as e: + _logging.error("error from callback %s: %s", callback, e) + if self.on_error: + self.on_error(self, e) diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_utils.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_utils.py" index c17053a9..6ceff5c3 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_utils.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_utils.py" @@ -1,471 +1,472 @@ -""" -_utils.py -websocket - WebSocket client library for Python - -Copyright 2024 engn33r - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" - -from typing import Union -__all__ = [ - "NoLock", - "validate_utf8", - "extract_err_message", - "extract_error_code"] - - -class NoLock: - """Context manager that performs no locking.""" - - def __enter__(self) -> None: - """Enter the no-op context manager.""" - return None - - def __exit__(self, exc_type, exc_value, traceback) -> None: - """Exit the no-op context manager.""" - return None - - -try: - # If wsaccel is available we use compiled routines to validate UTF-8 - # strings. - from wsaccel.utf8validator import Utf8Validator - - def _validate_utf8(utfbytes: Union[str, bytes]) -> bool: - """Validate UTF-8 bytes using the optional wsaccel accelerator.""" - result: bool = Utf8Validator().validate(utfbytes)[0] - return result - -except ImportError: - # UTF-8 validator - # python implementation of http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ - - _UTF8_ACCEPT = 0 - _UTF8_REJECT = 12 - - _UTF8D = [ - # The first part of the table maps bytes to character classes that - # to reduce the size of the transition table and create bitmasks. - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 9, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 7, - 8, - 8, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 10, - 3, - 3, - 3, - 3, - 3, - 3, - 3, - 3, - 3, - 3, - 3, - 3, - 4, - 3, - 3, - 11, - 6, - 6, - 6, - 5, - 8, - 8, - 8, - 8, - 8, - 8, - 8, - 8, - 8, - 8, - 8, - # The second part is a transition table that maps a combination - # of a state of the automaton and a character class to a state. - 0, - 12, - 24, - 36, - 60, - 96, - 84, - 12, - 12, - 12, - 48, - 72, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 0, - 12, - 12, - 12, - 12, - 12, - 0, - 12, - 0, - 12, - 12, - 12, - 24, - 12, - 12, - 12, - 12, - 12, - 24, - 12, - 24, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 24, - 12, - 12, - 12, - 12, - 12, - 24, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 24, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 36, - 12, - 36, - 12, - 12, - 12, - 36, - 12, - 12, - 12, - 12, - 12, - 36, - 12, - 36, - 12, - 12, - 12, - 36, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - 12, - ] - - def _decode(state: int, codep: int, ch: int) -> tuple: - """Advance the UTF-8 DFA by one byte.""" - tp = _UTF8D[ch] - - codep = ( - (ch & 0x3F) | ( - codep << 6) if ( - state != _UTF8_ACCEPT) else ( - 0xFF >> tp) & ch) - state = _UTF8D[256 + state + tp] - - return state, codep - - def _validate_utf8(utfbytes: Union[str, bytes]) -> bool: - """Validate UTF-8 bytes using the bundled DFA implementation.""" - state = _UTF8_ACCEPT - codep = 0 - for i in utfbytes: - state, codep = _decode(state, codep, int(i)) - if state == _UTF8_REJECT: - return False - - return True - - -def validate_utf8(utfbytes: Union[str, bytes]) -> bool: - """Return whether ``utfbytes`` contains a valid UTF-8 byte sequence.""" - return _validate_utf8(utfbytes) - - -def extract_err_message(exception: Exception) -> Union[str, None]: - """Return the first positional argument from ``exception`` if present.""" - if exception.args: - exception_message: str = exception.args[0] - return exception_message - return None - - -def extract_error_code(exception: Exception) -> Union[int, None]: - """Return the integer error code stored in ``exception.args`` if present.""" - if exception.args and len(exception.args) > 1: - return exception.args[0] if isinstance( - exception.args[0], int) else None - return None +""" +_utils.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from typing import Union +__all__ = [ + "NoLock", + "validate_utf8", + "extract_err_message", + "extract_error_code"] + + +class NoLock: + """Context manager that performs no locking.""" + + def __enter__(self) -> None: + """Enter the no-op context manager.""" + return None + + def __exit__(self, exc_type, exc_value, traceback) -> None: + """Exit the no-op context manager.""" + _ = (exc_type, exc_value, traceback) + return None + + +try: + # If wsaccel is available we use compiled routines to validate UTF-8 + # strings. + from wsaccel.utf8validator import Utf8Validator + + def _validate_utf8(utfbytes: Union[str, bytes]) -> bool: + """Validate UTF-8 bytes using the optional wsaccel accelerator.""" + result: bool = Utf8Validator().validate(utfbytes)[0] + return result + +except ImportError: + # UTF-8 validator + # python implementation of http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ + + _UTF8_ACCEPT = 0 + _UTF8_REJECT = 12 + + _UTF8D = [ + # The first part of the table maps bytes to character classes that + # to reduce the size of the transition table and create bitmasks. + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 8, + 8, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 10, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 4, + 3, + 3, + 11, + 6, + 6, + 6, + 5, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + # The second part is a transition table that maps a combination + # of a state of the automaton and a character class to a state. + 0, + 12, + 24, + 36, + 60, + 96, + 84, + 12, + 12, + 12, + 48, + 72, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 0, + 12, + 12, + 12, + 12, + 12, + 0, + 12, + 0, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 36, + 12, + 36, + 12, + 12, + 12, + 36, + 12, + 12, + 12, + 12, + 12, + 36, + 12, + 36, + 12, + 12, + 12, + 36, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + ] + + def _decode(state: int, codep: int, ch: int) -> tuple: + """Advance the UTF-8 DFA by one byte.""" + tp = _UTF8D[ch] + + codep = ( + (ch & 0x3F) | ( + codep << 6) if ( + state != _UTF8_ACCEPT) else ( + 0xFF >> tp) & ch) + state = _UTF8D[256 + state + tp] + + return state, codep + + def _validate_utf8(utfbytes: Union[str, bytes]) -> bool: + """Validate UTF-8 bytes using the bundled DFA implementation.""" + state = _UTF8_ACCEPT + codep = 0 + for i in utfbytes: + state, codep = _decode(state, codep, int(i)) + if state == _UTF8_REJECT: + return False + + return True + + +def validate_utf8(utfbytes: Union[str, bytes]) -> bool: + """Return whether ``utfbytes`` contains a valid UTF-8 byte sequence.""" + return _validate_utf8(utfbytes) + + +def extract_err_message(exception: Exception) -> Union[str, None]: + """Return the first positional argument from ``exception`` if present.""" + if exception.args: + exception_message: str = exception.args[0] + return exception_message + return None + + +def extract_error_code(exception: Exception) -> Union[int, None]: + """Return the integer error code stored in ``exception.args`` if present.""" + if exception.args and len(exception.args) > 1: + return exception.args[0] if isinstance( + exception.args[0], int) else None + return None diff --git "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_wsdump.py" "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_wsdump.py" index bb5e3a2c..95f3b770 100644 --- "a/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_wsdump.py" +++ "b/\347\276\244\346\234\215\344\272\222\351\200\232\344\272\221\351\223\276\347\211\210Ultra\347\211\210/websocket/_wsdump.py" @@ -1,274 +1,275 @@ -#!/usr/bin/env python3 - -""" -wsdump.py -websocket - WebSocket client library for Python - -Copyright 2024 engn33r - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" - -import argparse -import code -import gzip -import ssl -import sys -import threading -import time -import zlib -from urllib.parse import urlparse - -import websocket - -try: - import readline -except ImportError: - readline = None - - -def get_encoding() -> str: - """Return the normalized stdin encoding used by the console helpers.""" - encoding = getattr(sys.stdin, "encoding", "") - if not encoding: - return "utf-8" - return encoding.lower() - - -OPCODE_DATA = (websocket.ABNF.OPCODE_TEXT, websocket.ABNF.OPCODE_BINARY) -ENCODING = get_encoding() - - -class VAction(argparse.Action): - """Argparse action that counts repeated ``-v`` occurrences.""" - - def __call__( - self, - parser: argparse.Namespace, - args: tuple, - values: str, - option_string: str = None, - ) -> None: - """Normalize verbose values from integers or repeated ``v`` flags.""" - if values is None: - values = "1" - try: - values = int(values) - except ValueError: - values = values.count("v") + 1 - setattr(args, self.dest, values) - - -def parse_args() -> argparse.Namespace: - """Parse command-line arguments for the websocket dump utility.""" - parser = argparse.ArgumentParser(description="WebSocket Simple Dump Tool") - parser.add_argument( - "url", - metavar="ws_url", - help="websocket url. ex. ws://echo.websocket.events/") - parser.add_argument( - "-p", - "--proxy", - help="proxy url. ex. http://127.0.0.1:8080") - parser.add_argument( - "-v", - "--verbose", - default=0, - nargs="?", - action=VAction, - dest="verbose", - help="set verbose mode. If set to 1, show opcode. " - "If set to 2, enable to trace websocket module", - ) - parser.add_argument( - "-n", "--nocert", action="store_true", help="Ignore invalid SSL cert" - ) - parser.add_argument("-r", "--raw", action="store_true", help="raw output") - parser.add_argument( - "-s", - "--subprotocols", - nargs="*", - help="Set subprotocols") - parser.add_argument("-o", "--origin", help="Set origin") - parser.add_argument( - "--eof-wait", - default=0, - type=int, - help="wait time(second) after 'EOF' received.", - ) - parser.add_argument("-t", "--text", help="Send initial text") - parser.add_argument( - "--timings", action="store_true", help="Print timings in seconds" - ) - parser.add_argument( - "--headers", - help="Set custom headers. Use ',' as separator") - - return parser.parse_args() - - -class RawInput: - """Compatibility wrapper around ``input`` that normalizes encoding.""" - - @staticmethod - def raw_input(prompt: str = "") -> str: - """Read a line and normalize it to UTF-8 encoded bytes when needed.""" - line = input(prompt) - - if ENCODING and ENCODING != "utf-8" and not isinstance(line, str): - line = line.decode(ENCODING).encode("utf-8") - elif isinstance(line, str): - line = line.encode("utf-8") - - return line - - -class InteractiveConsole(RawInput, code.InteractiveConsole): - """Interactive console that renders inbound messages with terminal color.""" - - @staticmethod - def write(data: str) -> None: - """Render received data above the current prompt.""" - sys.stdout.write("\033[2K\033[E") - sys.stdout.write("\033[34m< " + data + "\033[39m") - sys.stdout.write("\n> ") - sys.stdout.flush() - - def read(self) -> str: - """Read the next outbound message from stdin.""" - return self.raw_input("> ") - - -class NonInteractive(RawInput): - """Console facade for non-interactive stdout output.""" - - @staticmethod - def write(data: str) -> None: - """Write received data directly to stdout.""" - sys.stdout.write(data) - sys.stdout.write("\n") - sys.stdout.flush() - - def read(self) -> str: - """Read the next outbound message without a visible prompt.""" - return self.raw_input("") - - -def main() -> None: # skipcq: PY-R1000 - """Run the standalone websocket dump client.""" - start_time = time.time() - args = parse_args() - if args.verbose > 1: - websocket.enableTrace(True) - options = {} - if args.proxy: - p = urlparse(args.proxy) - options["http_proxy_host"] = p.hostname - options["http_proxy_port"] = p.port - if args.origin: - options["origin"] = args.origin - if args.subprotocols: - options["subprotocols"] = args.subprotocols - opts = {} - if args.nocert: - opts = {"cert_reqs": ssl.CERT_NONE, "check_hostname": False} - if args.headers: - options["header"] = list(map(str.strip, args.headers.split(","))) - ws = websocket.create_connection(args.url, sslopt=opts, **options) - if args.raw: - console = NonInteractive() - else: - console = InteractiveConsole() - print("Press Ctrl+C to quit") - - def recv() -> tuple: - """Receive one websocket frame and normalize control opcodes.""" - try: - frame = ws.recv_frame() - except websocket.WebSocketException: - return websocket.ABNF.OPCODE_CLOSE, "" - if not frame: - raise websocket.WebSocketException(f"Not a valid frame {frame}") - if frame.opcode in OPCODE_DATA: - return frame.opcode, frame.data - if frame.opcode == websocket.ABNF.OPCODE_CLOSE: - ws.send_close() - return frame.opcode, "" - if frame.opcode == websocket.ABNF.OPCODE_PING: - ws.pong(frame.data) - return frame.opcode, frame.data - - return frame.opcode, frame.data - - def recv_ws() -> None: - """Continuously read websocket messages and print them to the console.""" - while True: - opcode, data = recv() - msg = None - if opcode == websocket.ABNF.OPCODE_TEXT and isinstance( - data, bytes): - data = str(data, "utf-8") - if (isinstance(data, bytes) and len(data) > - 2 and data[:2] == b"\037\213"): # gzip magick - try: - data = "[gzip] " + str(gzip.decompress(data), "utf-8") - except Exception: - pass - elif isinstance(data, bytes): - try: - data = "[zlib] " + str( - zlib.decompress(data, -zlib.MAX_WBITS), "utf-8" - ) - except Exception: - pass - - if isinstance(data, bytes): - data = repr(data) - - if args.verbose: - msg = f"{websocket.ABNF.OPCODE_MAP.get(opcode)}: {data}" - else: - msg = data - - if msg is not None: - if args.timings: - console.write(f"{time.time() - start_time}: {msg}") - else: - console.write(msg) - - if opcode == websocket.ABNF.OPCODE_CLOSE: - break - - thread = threading.Thread(target=recv_ws) - thread.daemon = True - thread.start() - - if args.text: - ws.send(args.text) - - while True: - try: - message = console.read() - ws.send(message) - except KeyboardInterrupt: - return - except EOFError: - time.sleep(args.eof_wait) - return - - -if __name__ == "__main__": - try: - main() - except Exception as e: - print(e) +#!/usr/bin/env python3 + +""" +wsdump.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import argparse +import code +import gzip +import ssl +import sys +import threading +import time +import zlib +from urllib.parse import urlparse + +import websocket + +try: + import readline +except ImportError: + readline = None + + +def get_encoding() -> str: + """Return the normalized stdin encoding used by the console helpers.""" + encoding = getattr(sys.stdin, "encoding", "") + if not encoding: + return "utf-8" + return encoding.lower() + + +OPCODE_DATA = (websocket.ABNF.OPCODE_TEXT, websocket.ABNF.OPCODE_BINARY) +ENCODING = get_encoding() + + +class VAction(argparse.Action): + """Argparse action that counts repeated ``-v`` occurrences.""" + + def __call__( + self, + parser: argparse.Namespace, + args: tuple, + values: str, + option_string: str = None, + ) -> None: + """Normalize verbose values from integers or repeated ``v`` flags.""" + _ = (parser, option_string) + if values is None: + values = "1" + try: + values = int(values) + except ValueError: + values = values.count("v") + 1 + setattr(args, self.dest, values) + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments for the websocket dump utility.""" + parser = argparse.ArgumentParser(description="WebSocket Simple Dump Tool") + parser.add_argument( + "url", + metavar="ws_url", + help="websocket url. ex. ws://echo.websocket.events/") + parser.add_argument( + "-p", + "--proxy", + help="proxy url. ex. http://127.0.0.1:8080") + parser.add_argument( + "-v", + "--verbose", + default=0, + nargs="?", + action=VAction, + dest="verbose", + help="set verbose mode. If set to 1, show opcode. " + "If set to 2, enable to trace websocket module", + ) + parser.add_argument( + "-n", "--nocert", action="store_true", help="Ignore invalid SSL cert" + ) + parser.add_argument("-r", "--raw", action="store_true", help="raw output") + parser.add_argument( + "-s", + "--subprotocols", + nargs="*", + help="Set subprotocols") + parser.add_argument("-o", "--origin", help="Set origin") + parser.add_argument( + "--eof-wait", + default=0, + type=int, + help="wait time(second) after 'EOF' received.", + ) + parser.add_argument("-t", "--text", help="Send initial text") + parser.add_argument( + "--timings", action="store_true", help="Print timings in seconds" + ) + parser.add_argument( + "--headers", + help="Set custom headers. Use ',' as separator") + + return parser.parse_args() + + +class RawInput: + """Compatibility wrapper around ``input`` that normalizes encoding.""" + + @staticmethod + def raw_input(prompt: str = "") -> str: + """Read a line and normalize it to UTF-8 encoded bytes when needed.""" + line = input(prompt) + + if ENCODING and ENCODING != "utf-8" and not isinstance(line, str): + line = line.decode(ENCODING).encode("utf-8") + elif isinstance(line, str): + line = line.encode("utf-8") + + return line + + +class InteractiveConsole(RawInput, code.InteractiveConsole): + """Interactive console that renders inbound messages with terminal color.""" + + @staticmethod + def write(data: str) -> None: + """Render received data above the current prompt.""" + sys.stdout.write("\033[2K\033[E") + sys.stdout.write("\033[34m< " + data + "\033[39m") + sys.stdout.write("\n> ") + sys.stdout.flush() + + def read(self) -> str: + """Read the next outbound message from stdin.""" + return self.raw_input("> ") + + +class NonInteractive(RawInput): + """Console facade for non-interactive stdout output.""" + + @staticmethod + def write(data: str) -> None: + """Write received data directly to stdout.""" + sys.stdout.write(data) + sys.stdout.write("\n") + sys.stdout.flush() + + def read(self) -> str: + """Read the next outbound message without a visible prompt.""" + return self.raw_input("") + + +def main() -> None: # skipcq: PY-R1000 + """Run the standalone websocket dump client.""" + start_time = time.time() + args = parse_args() + if args.verbose > 1: + websocket.enableTrace(True) + options = {} + if args.proxy: + p = urlparse(args.proxy) + options["http_proxy_host"] = p.hostname + options["http_proxy_port"] = p.port + if args.origin: + options["origin"] = args.origin + if args.subprotocols: + options["subprotocols"] = args.subprotocols + opts = {} + if args.nocert: + opts = {"cert_reqs": ssl.CERT_NONE, "check_hostname": False} + if args.headers: + options["header"] = list(map(str.strip, args.headers.split(","))) + ws = websocket.create_connection(args.url, sslopt=opts, **options) + if args.raw: + console = NonInteractive() + else: + console = InteractiveConsole() + print("Press Ctrl+C to quit") + + def recv() -> tuple: + """Receive one websocket frame and normalize control opcodes.""" + try: + frame = ws.recv_frame() + except websocket.WebSocketException: + return websocket.ABNF.OPCODE_CLOSE, "" + if not frame: + raise websocket.WebSocketException(f"Not a valid frame {frame}") + if frame.opcode in OPCODE_DATA: + return frame.opcode, frame.data + if frame.opcode == websocket.ABNF.OPCODE_CLOSE: + ws.send_close() + return frame.opcode, "" + if frame.opcode == websocket.ABNF.OPCODE_PING: + ws.pong(frame.data) + return frame.opcode, frame.data + + return frame.opcode, frame.data + + def recv_ws() -> None: # skipcq: PY-R1000 + """Continuously read websocket messages and print them to the console.""" + while True: + opcode, data = recv() + msg = None + if opcode == websocket.ABNF.OPCODE_TEXT and isinstance( + data, bytes): + data = str(data, "utf-8") + if (isinstance(data, bytes) and len(data) > + 2 and data[:2] == b"\037\213"): # gzip magick + try: + data = "[gzip] " + str(gzip.decompress(data), "utf-8") + except Exception: + pass + elif isinstance(data, bytes): + try: + data = "[zlib] " + str( + zlib.decompress(data, -zlib.MAX_WBITS), "utf-8" + ) + except Exception: + pass + + if isinstance(data, bytes): + data = repr(data) + + if args.verbose: + msg = f"{websocket.ABNF.OPCODE_MAP.get(opcode)}: {data}" + else: + msg = data + + if msg is not None: + if args.timings: + console.write(f"{time.time() - start_time}: {msg}") + else: + console.write(msg) + + if opcode == websocket.ABNF.OPCODE_CLOSE: + break + + thread = threading.Thread(target=recv_ws) + thread.daemon = True + thread.start() + + if args.text: + ws.send(args.text) + + while True: + try: + message = console.read() + ws.send(message) + except KeyboardInterrupt: + return + except EOFError: + time.sleep(args.eof_wait) + return + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(e) diff --git "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index 0bb728e0..085f468e 100644 --- "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -775,7 +775,10 @@ def _select_land( choice = self._select_menu( player, title, - [f"{land.name} §7- 领主: {land.owner}, {land.range_text()}" for land in lands], + [ + f"{land.name} §7- 领主: {land.owner}, {land.range_text()}" + for land in lands + ], ) if choice is None: return None @@ -1236,7 +1239,7 @@ def _menu_tp(self, player: Player): return self._tp(player, [land.name]) - def _create(self, player: Player, args: List[str]): + def _create(self, player: Player, args: List[str]): # skipcq: PY-R1000 """Implement the create operation.""" if len(args) < 2: player.show(self._error( @@ -1573,19 +1576,25 @@ def _test(self, player: Player): if self.lands: lines.append( - "所有领地:" + "、".join(f"{land.name}({land.owner})" for land in self.lands.values())) + "所有领地:" + + "、".join( + f"{land.name}({land.owner})" for land in self.lands.values() + ) + ) else: lines.append("所有领地:无") player.show(self._ui_card("测试信息", lines)) def on_player_join(self, player: Player): """Implement the on player join operation.""" + _ = player if not self.enabled: return pass def on_player_leave(self, player: Player): """Implement the on player leave operation.""" + _ = player if not self.enabled: return pass @@ -1604,7 +1613,7 @@ def api_get_land( return False, f"领地 '{land_query}' 不存在", None return True, "查询成功", self._land_summary(land) - def api_add_land( + def api_add_land( # skipcq: PY-R1000 self, owner: str, name: str, @@ -1785,7 +1794,10 @@ def api_remove_member(self, land.name}' 移除 {player_name}", self._land_summary(land) def api_transfer_owner( - self, land_query: str, owner: str) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + self, + land_query: str, + owner: str, + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: """Expose the api transfer owner API operation.""" land = self._find_land_by_name_or_id(land_query) if land is None: @@ -1952,7 +1964,10 @@ def _console_prompt_pos(self, prompt: str) -> Optional[List[float]]: return None def _console_select_land( - self, title: str, lands: Optional[List[LandData]] = None) -> Optional[LandData]: + self, + title: str, + lands: Optional[List[LandData]] = None, + ) -> Optional[LandData]: """Implement the console select land operation.""" lands = list(self.lands.values()) if lands is None else lands if not lands: @@ -2035,8 +2050,11 @@ def _console_delete_no_create_region(self): choice = self._console_select( "删除不可创建区域", [ - f"{region.get('名称', f'区域{i}')} - {region.get('类型', '未知') - } - {'启用' if region.get('启用', True) else '禁用'}" + ( + f"{region.get('名称', f'区域{i}')} - " + f"{region.get('类型', '未知')} - " + f"{'启用' if region.get('启用', True) else '禁用'}" + ) for i, region in enumerate(self.no_create_regions_raw, 1) ], ) diff --git "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/models.py" "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/models.py" index 768eb839..80409fba 100644 --- "a/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/models.py" +++ "b/\351\242\206\345\234\260\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/models.py" @@ -15,6 +15,7 @@ class LandRank(Enum): @property def display_name(self): + """Return the display name.""" return { "owner": "§c领主", "admin": "§6管理员", From 6bda16c74908b27d0871d050c77396faba0844ee Mon Sep 17 00:00:00 2001 From: ljxbx Date: Sat, 13 Jun 2026 10:14:16 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8D=AB=E7=94=9F?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__init__.py" | 131 +++++++++--------- .../__init__.py" | 35 +++-- .../__pycache__/api.cpython-312.pyc" | Bin 94985 -> 95946 bytes .../__pycache__/control.cpython-312.pyc" | Bin 21074 -> 20922 bytes .../__pycache__/handlers.cpython-312.pyc" | Bin 117095 -> 117135 bytes .../handlers_quick.cpython-312.pyc" | Bin 22178 -> 22178 bytes .../__pycache__/logic.cpython-312.pyc" | Bin 57564 -> 57460 bytes .../__pycache__/matchers.cpython-312.pyc" | Bin 6402 -> 6430 bytes .../__pycache__/models.cpython-312.pyc" | Bin 32438 -> 32454 bytes .../guild_cloud_interop/api.py" | 28 +++- .../guild_cloud_interop/control.py" | 3 +- .../guild_cloud_interop/handlers.py" | 52 +++---- .../guild_cloud_interop/handlers_quick.py" | 48 +++---- .../guild_cloud_interop/logic.py" | 74 +++++----- .../guild_cloud_interop/matchers.py" | 4 + .../guild_cloud_interop/models.py" | 17 ++- .../__init__.py" | 1 - .../binding_mixin.py" | 4 + .../config_editor_mixin.py" | 35 ++--- .../config_mixin.py" | 1 - .../qq_mixin.py" | 14 +- .../runtime_mixin.py" | 10 +- .../__init__.py" | 25 ++-- 23 files changed, 274 insertions(+), 208 deletions(-) diff --git "a/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index 01ef06f5..9966fc31 100644 --- "a/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\344\273\273\345\212\241\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -5,7 +5,7 @@ import time import threading from dataclasses import dataclass -from typing import Any +from typing import Any, TYPE_CHECKING as PY_TYPE_CHECKING from tooldelta import ( cfg as config, utils, @@ -17,6 +17,11 @@ plugin_entry, ) +if PY_TYPE_CHECKING: + from ZBasic_Lang_中文编程 import ToolDelta_ZBasic + from 前置_聊天栏菜单 import ChatbarMenu + from 前置_Cb2Bot通信 import TellrawCb2Bot + CONFIG_FILE_DIR = "插件配置文件" DYNAMIC_LOAD_SETTINGS_KEY = "动态载入设置" @@ -67,12 +72,17 @@ def __init__(self, frame): super().__init__(frame) self._config_reload_stop = threading.Event() self._config_file_state = None + self._quest_config_state = None self.QUEST_PATH = os.path.join(self.data_path, "任务") self.QUEST_DATA_PATH = os.path.join(self.data_path, "任务数据") self.tmpjson = utils.tempjson self.quest_data_paths: set[str] = set() self.in_plot_running = {} self.quests: dict[str, Quest] = {} + self.interper = None + self.chatbar = None + self.cb2bot = None + self.cmp_scripts = {} for ipath in [self.QUEST_PATH, self.QUEST_DATA_PATH]: os.makedirs(ipath, exist_ok=True) CFG_STD = { @@ -554,7 +564,7 @@ def can_add_quest(self, player: Player, quest: Quest) -> tuple[bool, str]: quests = self.read_quests(player) if quest in quests: return False, "当前任务正在进行中,无法重复领取" - quest_time = self.read_quests_finished(player).get(quest, None) + quest_time = self.read_quests_finished(player).get(quest) quest_mode = quest.cooldown if quest_mode == -1 and quest_time is not None: return False, "你已经完成该任务" @@ -802,30 +812,30 @@ def on_def(self): self.chatbar = self.GetPluginAPI("聊天栏菜单") self.cb2bot = self.GetPluginAPI("Cb2Bot通信") if TYPE_CHECKING: - from ZBasic_Lang_中文编程 import ToolDelta_ZBasic - from 前置_聊天栏菜单 import ChatbarMenu - from 前置_Cb2Bot通信 import TellrawCb2Bot - - self.interper: ToolDelta_ZBasic - self.chatbar: ChatbarMenu - self.cb2bot: TellrawCb2Bot + self.interper = self.get_typecheck_plugin_api(ToolDelta_ZBasic) + self.chatbar = self.get_typecheck_plugin_api(ChatbarMenu) + self.cb2bot = self.get_typecheck_plugin_api(TellrawCb2Bot) self.cb2bot.regist_message_cb("quest.ok", self.on_quest_ok) self.cb2bot.regist_message_cb("quest.start", self.on_quest_start) def show_succ(self, player: Player, msg): """Implement the show succ operation.""" + _ = self player.show(f"§7<§a§o√§r§7> §a{msg}") def show_warn(self, player: Player, msg): """Implement the show warn operation.""" + _ = self player.show(f"§7<§6§o!§r§7> §6{msg}") def show_fail(self, player: Player, msg): """Implement the show fail operation.""" + _ = self player.show(f"§7<§c§o!§r§7> §c{msg}") def show_inf(self, player: Player, msg): """Implement the show inf operation.""" + _ = self player.show(f"§7<§f§o!§r§7> §f{msg}") @utils.thread_func("任务的游戏初始化") @@ -890,6 +900,7 @@ def init_player(self, player: Player): def init_quest_file(self): """Implement the init quest file operation.""" + _ = self return {"in_quests": [], "quests_ok": {}} def get_player_quest_data_path(self, player: Player) -> str: @@ -949,8 +960,6 @@ def read_quests_finished(self, player: Player) -> dict[Quest, int]: @utils.thread_func("管理员向玩家添加任务") def force_add_quest_menu(self, player: Player, args: tuple): - # with utils.ChatbarLock(player, lambda _: - # print(utils.chatbar_lock_list)): """Implement the force add quest menu operation.""" (quest_tagname,) = args if (quest := self.get_quest(quest_tagname)) is None: @@ -976,70 +985,68 @@ def force_add_quest_menu(self, player: Player, args: tuple): @utils.thread_func("列出任务列表") def list_player_quests(self, player: Player): - # with utils.ChatbarLock(player): """Implement the list player quests operation.""" player_quests = self.read_quests(player) if not player_quests: self.show_fail(player, "你没有正在进行的任务") return - else: - player.show(self.cfg["任务设置"]["任务列表显示格式"][0]) - for i, quest_data in enumerate(player_quests): - if quest_data is None: - player.show( - utils.simple_fmt( - { - "[任务显示名]": "§c<任务失效>§f", - "[任务描述]": "--", - "[i]": i + 1, - }, - self.cfg["任务设置"]["任务列表显示格式"][1], - ), - ) - else: - player.show( - utils.simple_fmt( - { - "[任务显示名]": quest_data.show_name, - "[任务描述]": quest_data.description, - "[i]": i + 1, - }, - self.cfg["任务设置"]["任务列表显示格式"][1], - ), - ) - resp = player.input( - utils.simple_fmt( - {"[任务数量]": len(player_quests)}, - self.cfg["任务设置"]["任务列表显示格式"][2], - ) - ) - if resp is None: - return - resp = utils.try_int(resp.strip("[]")) - if resp is None: - player.show("§c序号不合法") - return - if resp not in range(1, len(player_quests) + 1): - self.show_fail(player, "序号超出范围") - return - getting_quest = player_quests[resp - 1] - if getting_quest is None: - self.show_fail(player, "无法完成失效的任务") - return - ok, reason = self.detect_quest(player, getting_quest) - if not ok: + player.show(self.cfg["任务设置"]["任务列表显示格式"][0]) + for i, quest_data in enumerate(player_quests): + if quest_data is None: player.show( utils.simple_fmt( - {"[玩家名]": player.name, "[原因]": reason}, - self.cfg["任务设置"]["任务无法提交的显示"]["格式"], + { + "[任务显示名]": "§c<任务失效>§f", + "[任务描述]": "--", + "[i]": i + 1, + }, + self.cfg["任务设置"]["任务列表显示格式"][1], ), ) - return else: - self.finish_quest(player, getting_quest) + player.show( + utils.simple_fmt( + { + "[任务显示名]": quest_data.show_name, + "[任务描述]": quest_data.description, + "[i]": i + 1, + }, + self.cfg["任务设置"]["任务列表显示格式"][1], + ), + ) + resp = player.input( + utils.simple_fmt( + {"[任务数量]": len(player_quests)}, + self.cfg["任务设置"]["任务列表显示格式"][2], + ) + ) + if resp is None: + return + resp = utils.try_int(resp.strip("[]")) + if resp is None: + player.show("§c序号不合法") + return + if resp not in range(1, len(player_quests) + 1): + self.show_fail(player, "序号超出范围") + return + getting_quest = player_quests[resp - 1] + if getting_quest is None: + self.show_fail(player, "无法完成失效的任务") + return + ok, reason = self.detect_quest(player, getting_quest) + if not ok: + player.show( + utils.simple_fmt( + {"[玩家名]": player.name, "[原因]": reason}, + self.cfg["任务设置"]["任务无法提交的显示"]["格式"], + ), + ) + return + self.finish_quest(player, getting_quest) def sec_to_timer(self, timesec: int, fmt: str): """Implement the sec to timer operation.""" + _ = self days, left = divmod(timesec, 86400) hrs, left = divmod(left, 3600) mins, secs = divmod(left, 60) diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" index aa0bf4ff..5fa5f195 100644 --- "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" +++ "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/__init__.py" @@ -1,7 +1,7 @@ """Guild cloud interop ToolDelta plugin entrypoint.""" from threading import Event -from typing import Dict +from typing import Dict, TYPE_CHECKING as PY_TYPE_CHECKING from tooldelta import ( FrameExit, @@ -28,6 +28,10 @@ ) from guild_cloud_interop.ui import wrap_player +if PY_TYPE_CHECKING: + from 前置_聊天栏菜单 import ChatbarMenu + from 前置_玩家XUID获取 import XUIDGetter + def _normalize_chatbar_trigger(trigger: object, fallback: str = "公会") -> str: """Return the trigger token expected by 聊天栏菜单.add_new_trigger.""" @@ -59,6 +63,9 @@ def __init__(self, frame: ToolDelta): self._guild_menu_callback = None self._guild_menu_chatbar_entry = None self._guild_runtime_events = {} + self._config_file_state = None + self.chatbar = None + self.xuidm = None self.item_matcher = ItemNameMatcher() self.ListenPreload(self.on_def) self.ListenActive(self.on_inject) @@ -105,14 +112,14 @@ def on_def(self): self.xuidm = self.GetPluginAPI("XUID获取") if TYPE_CHECKING: - from 前置_聊天栏菜单 import ChatbarMenu - from 前置_玩家XUID获取 import XUIDGetter - - self.chatbar: ChatbarMenu - self.xuidm: XUIDGetter + self.chatbar = self.get_typecheck_plugin_api(ChatbarMenu) + self.xuidm = self.get_typecheck_plugin_api(XUIDGetter) def ui_callback(self, callback): """Implement the ui callback operation.""" + if self is None: + return callback + def wrapped(player, args): """Implement the wrapped operation.""" return callback(wrap_player(player), args) @@ -121,6 +128,8 @@ def wrapped(player, args): def _guild_menu_commands(self) -> list[str]: """Implement the guild menu commands operation.""" + if self is None: + return ["公会"] raw_triggers = getattr(Config, "GUILD_MENU_TRIGGER", ["公会"]) if isinstance(raw_triggers, str): raw_triggers = [raw_triggers] @@ -136,9 +145,9 @@ def _guild_menu_commands(self) -> list[str]: def _find_guild_menu_chatbar_entry(self): """Implement the find guild menu chatbar entry operation.""" - entry = getattr(self, "_guild_menu_chatbar_entry", None) - if entry is not None: - return entry + chatbar_entry = getattr(self, "_guild_menu_chatbar_entry", None) + if chatbar_entry is not None: + return chatbar_entry chatbar = getattr(self, "chatbar", None) chatbar_triggers = getattr(chatbar, "chatbar_triggers", None) @@ -158,16 +167,16 @@ def _find_guild_menu_chatbar_entry(self): def sync_runtime_config_bindings(self): """Apply hot-reloaded config values that are registered outside Config.""" - entry = self._find_guild_menu_chatbar_entry() - if entry is None: + chatbar_entry = self._find_guild_menu_chatbar_entry() + if chatbar_entry is None: return commands = self._guild_menu_commands() - current_commands = list(getattr(entry, "triggers", [])) + current_commands = list(getattr(chatbar_entry, "triggers", [])) if current_commands == commands: return - entry.triggers = commands + chatbar_entry.triggers = commands def on_inject(self): """Implement the on inject operation.""" diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/api.cpython-312.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/api.cpython-312.pyc" index 9ca9871a48856757c73fe04333b2e0db6e969fac..9ee3741f313ad388457375db9d1382846f259e58 100644 GIT binary patch delta 10504 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 vbzec>k_K?J}9c;BbZkSF{fw#rZA delta 9486 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 diff --git "a/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/control.cpython-312.pyc" "b/\345\205\254\344\274\232\347\263\273\347\273\237\344\272\221\351\223\276\350\201\224\345\212\250\347\211\210/guild_cloud_interop/__pycache__/control.cpython-312.pyc" index 1136fd559efdff4053e64bdd393dee1bab9703c5..7f597de5b87d67e0833a5cd4dc381aec80e62c36 100644 GIT binary patch delta 1970 zcmY+EZBSHY6oBv9d-nt6!qT#Q2(AmW1j{!r3L*hw;DWFYIBI6PuDc5?!ou8LG;-0X zv2@aLSGK1MJaNO%$j2qTB6kEG)eF?Q@?Ig4#y1(F#(t7q+pFP&nXb`E;2#D{*p|`#FFO}@S;~N)ti`X$5GL3 zo@Tke$lW(^V)9RBdG^`eh* z)aMO^BxxP3t)?};D-_T&ZKt&z_)Tsd9FeV0cI#lfyet0@kInTqG+L}Y|9%pt73D!b zmM2@VqsW$7z_zH5gEe7zUbJ}by{81rQ!HS{@uEUVmZysn0Vhjb)#-|{D;V`fqmnd_ z*-Bdp7D6wfkI+vDGbln&e<&;s(0daWl&*|3vCVzaP+t%SO4G8b2F?Vn8+1rTjQBc( z(IKzDKinDWl7{fz(r%D2xh&T(!}O6Uxuz@tma!+s(~hKlm*Jera86Y0O}J^Q!c*m& z=DSV8l`^Ya)J=(T?kfFMYP#E{pK|HlRhYf#2l!k5chN$~*}yzCN!=7%kkp=8nXFz7 zX(vgwSXZ$E{*hm{@Xe zcB?rL(uA|$9ohf$VrKpJh#6d|4hNIrD!BB62&5CfN2LkeB;~!ucBeQe( z(aPfN!z5DR|J|FZ$A;5GSr%T=&Y0X?jXYQxo5kK>uv z%