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..b52f4ddc --- /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,1063 @@ +"""Cloud-linked quest system plugin.""" + +import copy +import os +import time +import threading +from dataclasses import dataclass +from typing import Any, TYPE_CHECKING as PY_TYPE_CHECKING +from tooldelta import ( + cfg as config, + utils, + fmts, + game_utils, + Plugin, + Player, + TYPE_CHECKING, + 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 = "动态载入设置" +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_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 = { + 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): + """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): # skipcq: PY-R1000 + """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) + 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( # skipcq: PY-R1000 + 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: + 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("任务的游戏初始化") + 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.""" + _ = self + 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): + """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): + """Implement the list player quests operation.""" + player_quests = self.read_quests(player) + if not player_quests: + self.show_fail(player, "你没有正在进行的任务") + return + 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 + 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) + 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..855f2387 --- /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": "SuperScript & 小六神", + "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..5e00ad98 --- /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,290 @@ +"""Guild cloud interop ToolDelta plugin entrypoint.""" + +from threading import Event +from typing import Dict, TYPE_CHECKING as PY_TYPE_CHECKING + +from tooldelta import ( + FrameExit, + Player, + Plugin, + ToolDelta, + TYPE_CHECKING, + plugin_entry, + utils, +) +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 + +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.""" + 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._config_file_state = None + self.chatbar = None + self.xuidm = None + 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: + 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) + + return wrapped + + 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] + 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.""" + 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) + if not isinstance(chatbar_triggers, list): + return None + triggers = list(chatbar_triggers) + + callback = getattr(self, "_guild_menu_callback", None) + for candidate in 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.""" + chatbar_entry = self._find_guild_menu_chatbar_entry() + if chatbar_entry is None: + return + + commands = self._guild_menu_commands() + current_commands = list(getattr(chatbar_entry, "triggers", [])) + if current_commands == commands: + return + + chatbar_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/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..21ac1952 --- /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/__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 00000000..9ee3741f Binary files /dev/null and "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" differ 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 00000000..6877d05d Binary files /dev/null and "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" differ 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 00000000..8ad9b02b Binary files /dev/null and "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" differ 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-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__/config.cpython-313.pyc" new file mode 100644 index 00000000..0faae8d7 Binary files /dev/null and "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-313.pyc" differ 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_watcher.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_watcher.cpython-312.pyc" new file mode 100644 index 00000000..7afbd837 Binary files /dev/null and "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_watcher.cpython-312.pyc" differ 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_watcher.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__/config_watcher.cpython-313.pyc" new file mode 100644 index 00000000..fcf9c80f Binary files /dev/null and "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_watcher.cpython-313.pyc" differ 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 00000000..7f597de5 Binary files /dev/null and "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" differ 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-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__/control.cpython-313.pyc" new file mode 100644 index 00000000..3322d0b6 Binary files /dev/null and "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-313.pyc" differ 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 00000000..5ad844da Binary files /dev/null and "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" differ 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-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.cpython-313.pyc" new file mode 100644 index 00000000..f0f8e1d8 Binary files /dev/null and "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-313.pyc" differ 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 00000000..252b3674 Binary files /dev/null and "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" differ 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 00000000..1bda484d Binary files /dev/null and "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" differ 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 00000000..7bb6acfa Binary files /dev/null and "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" differ 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-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__/logic.cpython-313.pyc" new file mode 100644 index 00000000..c8955eb2 Binary files /dev/null and "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-313.pyc" differ 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 00000000..1d59d4c3 Binary files /dev/null and "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" differ 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 00000000..1d7f9d9c Binary files /dev/null and "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" differ 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" new file mode 100644 index 00000000..2460e10c Binary files /dev/null and "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" differ 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 00000000..60d8081c Binary files /dev/null and "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" differ 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 00000000..b22159d0 Binary files /dev/null and "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" differ 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 00000000..97f2d327 Binary files /dev/null and "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" differ 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 00000000..4670dffc Binary files /dev/null and "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" differ 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 00000000..3f725f12 Binary files /dev/null and "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" differ 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-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__/ui.cpython-312.pyc" new file mode 100644 index 00000000..ecf44b29 Binary files /dev/null and "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-312.pyc" differ 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 00000000..9430589f Binary files /dev/null and "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" differ 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-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__/validators.cpython-312.pyc" new file mode 100644 index 00000000..8a6c0287 Binary files /dev/null and "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-312.pyc" differ 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 00000000..50e8ec16 Binary files /dev/null and "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" differ 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..27da16d7 --- /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,1907 @@ +"""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" new file mode 100644 index 00000000..f3dfb6c0 --- /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,1727 @@ +"""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个字符之间", +''' + 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 ''' + 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( # skipcq: PY-R1000 + 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" new file mode 100644 index 00000000..a1cf8483 --- /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,77 @@ +"""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 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: + return None + return stat.st_mtime_ns, stat.st_size + + +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) + 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: + """Implement the config reload task operation.""" + 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..977c876d --- /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,429 @@ +"""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" new file mode 100644 index 00000000..c1775324 --- /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,2439 @@ +"""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" + 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" + 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}贡献点 " + f"§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} " + f"§7({task.current_count}/{task.target_count})\n" + f" §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} " + f"§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} " + 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( + "创建公会成功提示词", 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} " + f"{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}, " + 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据点设置可能失败,请重试") + 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" new file mode 100644 index 00000000..72d8d66e --- /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,488 @@ +"""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} " + 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( + "创建公会成功提示词", 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} " + f"{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} " + 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重新设置据点") + 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" new file mode 100644 index 00000000..f1634172 --- /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,1164 @@ +"""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}, " + 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 = [ + ("创建", "创建自己的公会", 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__}, " + f"{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/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..a051af2a --- /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,151 @@ +"""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 self is None: + return "" + 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 self is None: + return 0.0 + 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..5d119573 --- /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,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, + ) 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..e576504f --- /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,68 @@ +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: + """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 + 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..2cb5783c --- /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,48 @@ +"""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): + _ = 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..4254baad --- /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,241 @@ +"""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()} " + 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()} " + f"§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/\345\205\254\344\274\232\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..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" @@ -1,19 +1,33 @@ -import time +"""Whitelist and operator-status checker plugin.""" + +import copy +import os +import threading from typing import Any from tooldelta import Player, Plugin, cfg, fmts, game_utils, plugin_entry, utils -from tooldelta.internal.launch_cli import FrameNeOmgAccessPoint + + +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, 2) + 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, @@ -28,6 +42,10 @@ class WhitelistAndOpCheck(Plugin): } STD_CFG = { + DYNAMIC_LOAD_SETTINGS_KEY: { + DYNAMIC_LOAD_ENABLED_KEY: bool, + DYNAMIC_LOAD_INTERVAL_KEY: cfg.PInt, + }, "检查时间(秒)": float, "白名单": {"开启状态": bool, "踢出提示词": str, "白名单玩家": {}}, "管理员检测": {"开启状态": bool, "提示词": str, "管理员列表": {}}, @@ -37,30 +55,175 @@ def __init__(self, frame): """初始化运行时状态并注册插件生命周期回调。""" super().__init__(frame) self.get_xuid = None - self.neomega = 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(None, value) + 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 in result: - result[key] = cls.merge_with_default(value, result[key]) - else: - result[key] = value + if key not in result: + result[key] = copy.deepcopy(value) return result - return raw if raw is not None else default + 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]: """读取配置文件并做结构校验,失败时退回默认值。""" @@ -71,17 +234,93 @@ def load_config(self) -> dict[str, Any]: self.DEFAULT_CFG, self.version, ) - merged_cfg = self.merge_with_default(raw_cfg, self.DEFAULT_CFG) + 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.merge_with_default({}, self.DEFAULT_CFG) + 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 查询前置插件。""" @@ -89,27 +328,41 @@ def on_preload(self): def on_active(self): """在插件激活后挂载控制台入口并启动周期检测。""" - self.neomega = self.require_neomega() - self.bot_name = self.neomega.get_bot_basic_info().BotName + self.bot_name = self.resolve_bot_name() 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_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): """玩家进服时按当前配置执行白名单和管理员状态检查。""" @@ -245,6 +498,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( @@ -269,7 +523,7 @@ def enforce_admin_state(self, player_name: str, player_xuid: str): def console_manage_whitelist(self, _args: list[str]): """打开控制台白名单管理菜单。""" - self.console_manage_player_mapping( + self.console_manage_whitelist_mapping( title="白名单", add_action=self.add_whitelist_player, remove_action=self.remove_whitelist_player, @@ -277,17 +531,7 @@ def console_manage_whitelist(self, _args: list[str]): 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( + def console_manage_whitelist_mapping( self, title: str, add_action, @@ -295,7 +539,7 @@ def console_manage_player_mapping( add_prompt: str, remove_prompt: str, ): - """复用同一套控制台交互来管理白名单和管理员列表。""" + """控制台白名单增删交互。""" option_add = f"添加{title}" option_remove = f"移除{title}" while True: @@ -330,8 +574,7 @@ def print_console_result(ok: bool, message: str): @utils.thread_func("循环检测白名单和管理员") def start_periodic_check(self): """按配置周期轮询在线玩家,补做白名单和管理员状态校验。""" - while True: - time.sleep(float(self._cfg["检查时间(秒)"])) + 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 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..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,8 +1,10 @@ { - "author": "猫七街", - "version": "1.1.2", + "author": "猫七街 & 小六神", + "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..e67ebdcf --- /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,717 @@ +"""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.""" + if self is None: + return False + 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.""" + _ = 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 + + 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.""" + _ = group_id + 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.""" + if self is None: + return str(text) + 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.""" + _ = group_id + 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, + ), + group_id=group_id, + ) + 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, + group_id: int | None = None): + """向指定 QQ 发送私信。 + + 当传入 group_id 时,按“群临时会话”下发私信。这样即使机器人没有 + 把对方加为好友,也能把消息送达(OneBot 通过共同所在的群发起临时会话)。 + 不传 group_id 时退回普通好友私信,行为与旧版本一致。 + """ + if self.ws is None: + raise RuntimeError("WebSocket 尚未初始化") + if not self.available: + self._print_cloud_status( + "群服互通 云链连接", + "忽略发送", + ["当前未连接云链", f"已忽略发送到 QQ {qqid} 的私信"], + 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": params, + } + 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" new file mode 100644 index 00000000..5dedeb29 --- /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,646 @@ +"""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.""" + _ = self + 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 self.CONFIG_BACK + 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: + config_dir = self.CONFIG_FILE_DIR + self._config_error( + ctx, f"未找到 {config_dir}/*.json 配置文件") + 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( + 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 self.CONFIG_BACK + 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( # 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: + content = file.read() + except Exception as err: + self._config_error(ctx, f"读取配置文件失败: {err}") + return self.CONFIG_BACK + 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 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 self.CONFIG_BACK + + if not isinstance(parsed, dict): + self._config_error(ctx, "配置文件根节点必须是 JSON 对象,未替换配置文件") + return self.CONFIG_BACK + if not self._config_file_shape_matches(original_config, parsed): + self._config_error(ctx, "请发送完整配置文件,不能只发送配置项内容") + return self.CONFIG_BACK + + try: + if not self._is_safe_config_path(item_path): + self._config_error(ctx, "配置文件路径不在允许的插件配置目录内") + return self.CONFIG_BACK + 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 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, + 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 self.CONFIG_BACK + 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 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 self.CONFIG_BACK + + 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( # 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"] + 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 cb3740a8..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" @@ -8,6 +8,7 @@ import inspect import json import os +from copy import deepcopy from typing import Any from collections.abc import Callable @@ -20,6 +21,100 @@ 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): """返回单个群聊的默认配置骨架。""" @@ -36,14 +131,25 @@ def group_default(group_id: int = 194838530): "群到游戏": { "是否启用": True, "转发格式": "群 <[昵称]> [消息]", + "仅转发以下符号开头的消息(列表为空则全部转发)": [], "屏蔽的QQ号": [], "替换花里胡哨的昵称": True, "替换花里胡哨的消息": True, }, + QQLinkerConfigMixin.PERMISSION_SETTINGS_KEY: ( + QQLinkerConfigMixin.permission_default() + ), "指令设置": { "发送指令前缀": "/", "帮助菜单唤醒词": ["help", "帮助"], + "帮助菜单非管理功能每页显示数量": 10, + "帮助菜单管理功能每页显示数量": 10, + "命令触发词帮助菜单每页显示数量": 10, + "配置文件整文件修改模式每页显示数量": 10, "管理员菜单唤醒词": ["管理员菜单"], + "配置中心唤醒词": ["配置中心", "配置菜单", "群服配置"], + "退出整个菜单触发词": [".", "。", "q"], + "返回上一级菜单触发词": ["!", "!"], "是否允许查看玩家列表": True, "查看玩家人数的唤醒词": ["list", "玩家列表"], "查询背包菜单唤醒词": ["查询背包"], @@ -51,6 +157,12 @@ def group_default(group_id: int = 194838530): "QQ群封禁唤醒词": ["orban", "orion ban", "猎户封禁"], "QQ群解封唤醒词": ["orunban", "orion unban", "猎户解封"], "QQ群白名单&管理员检测唤醒词": ["白名单&管理员检测", "检测管理"], + "任务系统菜单唤醒词": ["任务系统"], + "任务系统每页显示玩家数量": 10, + "任务系统每页显示任务数量": 10, + "领地系统菜单唤醒词": ["领地系统云链联动版", "领地系统", "领地管理"], + "领地系统每页显示领地数量": 10, + "公会系统管理菜单唤醒词": ["公会系统"], "QQ群封禁/解封菜单每页显示个数": 10, }, } @@ -59,13 +171,56 @@ def group_default(group_id: int = 194838530): def cfg_default(cls): """返回插件级默认配置。""" return { - "云链设置": {"地址": "ws://127.0.0.1:3001", "校验码": ""}, - "群聊设置": [cls.group_default()], + 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, "游戏到群": { @@ -78,14 +233,23 @@ def cfg_std(cls): "群到游戏": { "是否启用": 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), @@ -93,11 +257,22 @@ def cfg_std(cls): "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), } @@ -116,6 +291,27 @@ def normalize_int_list(values: Any) -> list[int]: 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): """递归合并旧配置和默认值。 @@ -130,7 +326,8 @@ def merge_with_default(cls, raw: Any, default: Any): if isinstance(raw, dict): for key, value in raw.items(): if key in result: - result[key] = cls.merge_with_default(value, result[key]) + result[key] = cls.merge_with_default( + value, result[key]) else: result[key] = value return result @@ -156,18 +353,96 @@ def migrate_group_config(self, raw_group: Any): # 老版本配置是按几个子区块散开的,这里逐段合并到统一结构里。 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_game_to_group_cfg(self, group_cfg: dict[str, Any], old_g2q: Any): + 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["是否启用"] = 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( @@ -175,6 +450,7 @@ def _merge_game_to_group_cfg(self, group_cfg: dict[str, Any], old_g2q: Any): game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"], ), game_to_group["仅转发以下符号开头的消息(列表为空则全部转发)"], + allow_empty=True, ) game_to_group["屏蔽以下字符串开头的消息"] = self._clean_string_list( old_g2q.get( @@ -187,14 +463,25 @@ def _merge_game_to_group_cfg(self, group_cfg: dict[str, Any], old_g2q: Any): old_g2q.get("转发玩家进退提示", game_to_group["转发玩家进退提示"]) ) - def _merge_group_to_game_cfg(self, group_cfg: dict[str, Any], old_q2g: Any): + 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["是否启用"] = 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["仅转发以下符号开头的消息(列表为空则全部转发)"] = 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["替换花里胡哨的昵称"]) ) @@ -202,6 +489,69 @@ def _merge_group_to_game_cfg(self, group_cfg: dict[str, Any], old_q2g: Any): 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): @@ -219,6 +569,46 @@ def _merge_command_cfg(self, group_cfg: dict[str, Any], old_cmd: Any): 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["是否允许查看玩家列表"]) ) @@ -252,6 +642,42 @@ def _merge_command_cfg(self, group_cfg: dict[str, Any], old_cmd: Any): ), 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群封禁/解封菜单每页显示个数", @@ -261,11 +687,14 @@ def _merge_command_cfg(self, group_cfg: dict[str, Any], old_cmd: Any): ) @staticmethod - def _clean_string_list(raw: Any, fallback: list[str]): + 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: + if cleaned or allow_empty: return cleaned return fallback @@ -277,7 +706,102 @@ def _normalize_positive_int(value: Any, fallback: int): except (TypeError, ValueError): return fallback - def migrate_config(self, raw_cfg: Any): + 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): # skipcq: PY-R1000 """把整个插件配置迁到最新版本。 历史上这个插件经历过“单群结构”和“多群结构”两个阶段, @@ -285,9 +809,32 @@ 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], + ) + ) + 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, + ) + cloud_cfg = raw_cfg.get("云链设置", {}) if isinstance(cloud_cfg, dict): new_cfg["云链设置"]["地址"] = str( @@ -298,9 +845,18 @@ def migrate_config(self, raw_cfg: Any): "" 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) @@ -333,6 +889,7 @@ def migrate_config(self, raw_cfg: Any): migrated_group["指令设置"]["是否允许查看玩家列表"], ) ) + self._merge_permission_cfg(migrated_group, {}) group_cfgs.append(migrated_group) if group_cfgs: @@ -341,6 +898,7 @@ def migrate_config(self, raw_cfg: Any): 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): @@ -360,65 +918,218 @@ def reload_group_configs(self): 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 group_state_path(self, group_id: int): - """返回某个群的权限状态文件路径。""" - return os.path.join(self.group_state_dir, f"{group_id}.json") + 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): - """读取单个群的管理员状态。 - - 就算文件损坏,也会兜底回空状态,避免因为单个群配置异常拖垮整个插件。 - """ - path = self.group_state_path(group_id) - if not os.path.isfile(path): + """从主配置读取单个群的管理员状态。""" + permission_cfg = self._permission_cfg_for_group(group_id) + if permission_cfg is None: 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", [])), - } + "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]]): - """保存并顺手归一化群权限状态。""" - path = self.group_state_path(group_id) + """把群权限状态保存到主配置文件。""" + permission_cfg = self._permission_cfg_for_group(group_id) + if permission_cfg is None: + return 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) + "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): - """确保群权限文件一定存在,而且结构可读。""" - 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) + """确保群权限配置结构可读,并移除重复/非法管理员项。""" + permission_cfg = self._permission_cfg_for_group(group_id) + if permission_cfg is None: return - self.save_group_state(group_id, {"admins": [], "super_admins": []}) + 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 是否是指定群的超级管理员。""" + """判断某个 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: @@ -427,6 +1138,8 @@ def is_qq_op(self, qqid: int, group_id: int | None = None): 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"]: @@ -446,6 +1159,8 @@ def add_group_role(self, group_id: int, qqid: int, is_super: bool): 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"]: @@ -459,6 +1174,175 @@ def remove_group_role(self, group_id: int, qqid: int, is_super: bool): 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] @@ -484,6 +1368,99 @@ def get_group_inventory_items_per_page(self, group_id: int): 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] @@ -496,6 +1473,77 @@ def get_group_admin_menu_triggers(self, group_id: int): 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] @@ -509,7 +1557,8 @@ def get_group_orion_ban_triggers(self, group_id: int): "QQ群封禁唤醒词", ["orban", "orion ban", "猎户封禁"], ) - return self.normalize_string_triggers(raw, ["orban", "orion ban", "猎户封禁"]) + return self.normalize_string_triggers( + raw, ["orban", "orion ban", "猎户封禁"]) def get_group_orion_unban_triggers(self, group_id: int): """读取某个群的 Orion 解封触发词。""" @@ -518,7 +1567,8 @@ def get_group_orion_unban_triggers(self, group_id: int): "QQ群解封唤醒词", ["orunban", "orion unban", "猎户解封"], ) - return self.normalize_string_triggers(raw, ["orunban", "orion unban", "猎户解封"]) + return self.normalize_string_triggers( + raw, ["orunban", "orion unban", "猎户解封"]) def get_group_checker_menu_triggers(self, group_id: int): """读取某个群的白名单联动菜单触发词。""" @@ -529,6 +1579,25 @@ def get_group_checker_menu_triggers(self, group_id: int): ) 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]): """把触发词列表清洗成无空值、无重复的稳定序列。""" @@ -556,8 +1625,13 @@ def add_trigger( 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) - ) + QQMsgTrigger( + triggers, + argument_hint, + usage, + func, + args_pd, + op_only)) def set_manual_launch(self, port: int): """切换到“本地启动器负责拉起云链”的模式。""" 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..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,998 +1,943 @@ -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( # 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 a5aee918..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" @@ -1,565 +1,4040 @@ -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( - group_id, - qqid, - "管理系统", - [ - "添加玩家到白名单", - "从白名单中移除玩家", - "添加服务器管理员", - "移除服务器管理员", - "开启/关闭 白名单检测", - "开启/关闭 管理员检测", - "设置检测周期", - "查看当前状态", - ], - ["输入 [1-8] 之间的数字以选择 对应功能", "输入 . 退出"], - ) - 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, "❀ 您的输入有误") +"""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 "未设置" + 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): + """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): + 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', '<未知任务>')} " + 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( + 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', '<未知>')} " + 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): + """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', '<未知>')} " + 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): + """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)} " + 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( + 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/\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..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" @@ -1,5 +1,8 @@ +"""Runtime websocket and message forwarding logic for Ultra.""" + import json import time +from copy import deepcopy from typing import Any try: @@ -60,10 +63,12 @@ def execute_cmd_and_get_zhcn_cb(self, cmd: str): return f'😅 未知的 MC 指令, 可能是指令格式有误: "{cmd}"' if translate is not None: output_text = "\n".join( - translate(i.Message, i.Parameters) for i in result.OutputMessages - ) + translate( + i.Message, + i.Parameters) for i in result.OutputMessages) else: - output_text = "\n".join(i.Message for i in result.OutputMessages) + output_text = "\n".join( + i.Message for i in result.OutputMessages) if result.SuccessCount: return "😄 指令执行成功,执行结果:\n" + output_text return "😭 指令执行失败,原因:\n" + output_text @@ -90,7 +95,7 @@ def should_forward_game_message(msg: str, group_cfg: dict[str, Any]): if trans_chars: for prefix in trans_chars: if msg.startswith(prefix): - return True, msg[len(prefix) :] + return True, msg[len(prefix):] return False, msg if block_prefixs: for prefix in block_prefixs: @@ -126,21 +131,28 @@ def connect_to_websocket(self): level="info", ) 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( 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 - ), + 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 - ) + ws_obj, sid) self.ws = ws_app self.available = False ws_app.run_forever() @@ -159,6 +171,212 @@ def _get_websocket_target(self): 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): """把原始群消息广播给主动注册的其他插件。""" @@ -193,7 +411,8 @@ def on_ws_message(self, _ws, message, session_id: int): 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): + if self._stop_when_group_broadcast_handled( + group_id, user_id, nickname, msg): return if self.execute_triggers(group_id, user_id, msg): return @@ -204,7 +423,10 @@ 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": + 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 @@ -234,14 +456,20 @@ def _extract_text_message(msg: Any) -> str: raise ValueError(f"键 'message' 值不是字符串类型, 而是 {msg}") return msg - def _consume_waiting_reply(self, group_id: int, user_id: int, msg: str) -> bool: + 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) + cb = self.waitmsg_cbs.pop(wait_key, None) + if cb is not None: + cb(msg) return True - if user_id in self.waitmsg_cbs: - self.waitmsg_cbs[user_id](msg) + cb = self.waitmsg_cbs.pop(user_id, None) + if cb is not None: + cb(msg) return True return False @@ -273,6 +501,16 @@ def _forward_group_message_to_game( 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) @@ -293,7 +531,8 @@ def on_ws_error(self, _ws, error, session_id: int): if not isinstance(error, Exception): # 某些 WebSocket 实现会在连接仍然可用时回调空字符串/None。 # 这类“空错误”没有实际诊断价值,也不代表连接真的断开。 - if error is None or (isinstance(error, str) and error.strip() == ""): + if error is None or (isinstance(error, str) + and error.strip() == ""): return self._print_cloud_status( "群服互通 云链连接", @@ -313,17 +552,48 @@ def on_ws_error(self, _ws, error, session_id: int): level="error", ) - def waitMsg(self, qqid: int, timeout=60, group_id: int | None = None) -> str | None: + 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) + 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 + 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): """连接关闭时按当前状态决定是否自动重连。""" @@ -361,12 +631,16 @@ def on_player_leave(self, playerf: 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 + 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) + can_send, filtered_msg = self.should_forward_game_message( + msg, group_cfg) if not can_send: continue self.sendmsg( @@ -376,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): """对一条群消息做内置命令和外挂命令的统一分发。""" @@ -396,29 +671,71 @@ def _reply_to_qq(self, group_id: int, qqid: int, text: str): do_remove_cq_code=False, ) - def _handle_exact_trigger(self, group_id: int, qqid: int, clean_msg: str) -> bool: + 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_admin_only_action( + 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_admin_only_action( + 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( @@ -433,8 +750,8 @@ def _handle_prefixed_command( 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, "你没有权限执行此指令") + 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}[指令]") @@ -485,17 +802,22 @@ def _handle_orion_trigger( 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, "你没有权限执行此指令") + 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}") + 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: + def _handle_external_trigger( + self, + group_id: int, + qqid: int, + msg: str) -> bool: """处理外部插件注册进来的自定义触发词。""" for trigger in self.triggers: matched = trigger.match(msg) @@ -542,6 +864,42 @@ def _reply_trigger_arg_error( 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): @@ -590,7 +948,7 @@ def sendmsg(self, group: int, msg: str, do_remove_cq_code=True): cq_end = msg.find("]") if cq_end != -1: head = msg[: cq_end + 1] - tail = msg[cq_end + 1 :].lstrip() + tail = msg[cq_end + 1:].lstrip() msg = head if tail == "" else head + "\n" + tail if do_remove_cq_code: msg = remove_cq_code(msg) @@ -599,3 +957,70 @@ def sendmsg(self, group: int, msg: str, do_remove_cq_code=True): "params": {"group_id": group, "message": msg}, } self.ws.send(json.dumps(payload)) + + def api_send_group_msg( + self, + group_id: int | str, + message: str, + strip_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(strip_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}", + strip_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 00000000..59760d65 Binary files /dev/null and "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" differ 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 00000000..c19ca0f0 Binary files /dev/null and "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" differ 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 00000000..1740ee8a Binary files /dev/null and "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" differ 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 00000000..33f18568 Binary files /dev/null and "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" differ 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 00000000..2b81ce0a Binary files /dev/null and "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" differ 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 00000000..eac85c60 Binary files /dev/null and "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" differ 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 00000000..a447cf63 Binary files /dev/null and "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" differ 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-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__/_core.cpython-313.pyc" new file mode 100644 index 00000000..af1cd357 Binary files /dev/null and "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-313.pyc" differ 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 00000000..4d65430c Binary files /dev/null and "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" differ 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-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__/_handshake.cpython-312.pyc" new file mode 100644 index 00000000..58c50ba0 Binary files /dev/null and "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-312.pyc" differ 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 00000000..53feeba1 Binary files /dev/null and "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" differ 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 00000000..628bc8d6 Binary files /dev/null and "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" differ 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-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__/_http.cpython-313.pyc" new file mode 100644 index 00000000..86be68c9 Binary files /dev/null and "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-313.pyc" differ 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 00000000..319ac5e7 Binary files /dev/null and "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" differ 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 00000000..c9647114 Binary files /dev/null and "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" differ 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 00000000..df7d97cc Binary files /dev/null and "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" differ 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__/_ssl_compat.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__/_ssl_compat.cpython-313.pyc" new file mode 100644 index 00000000..f560b624 Binary files /dev/null and "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__/_ssl_compat.cpython-313.pyc" differ 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 00000000..915c4be6 Binary files /dev/null and "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" differ 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-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__/_url.cpython-313.pyc" new file mode 100644 index 00000000..457a1618 Binary files /dev/null and "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-313.pyc" differ 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__/_utils.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__/_utils.cpython-312.pyc" new file mode 100644 index 00000000..35a8036c Binary files /dev/null and "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__/_utils.cpython-312.pyc" differ 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__/_utils.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__/_utils.cpython-313.pyc" new file mode 100644 index 00000000..14ecd8eb Binary files /dev/null and "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__/_utils.cpython-313.pyc" differ 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-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__/_wsdump.cpython-312.pyc" new file mode 100644 index 00000000..87398067 Binary files /dev/null and "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-312.pyc" differ 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 00000000..8d234382 Binary files /dev/null and "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" differ 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..5b823b0e 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" @@ -48,7 +48,11 @@ def setReconnect(reconnectInterval: int) -> None: class DispatcherBase: """Base dispatcher that coordinates socket reads and reconnects.""" - def __init__(self, app: Any, ping_timeout: Union[float, int, None]) -> None: + 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 @@ -81,6 +85,7 @@ def read( 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: @@ -131,7 +136,12 @@ def select(self, sock, sel: selectors.DefaultSelector): class WrappedDispatcher: """Adapter for custom dispatcher implementations.""" - def __init__(self, app, ping_timeout: Union[float, int, None], dispatcher) -> None: + 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 @@ -247,6 +257,7 @@ def __init__( prepared_socket: socket Pre-initialized stream socket. """ + _ = keep_running self.url = url self.header = header if header is not None else [] self.cookie = cookie @@ -276,20 +287,24 @@ def __init__( 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: + 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.") + 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.") + 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.") + raise WebSocketConnectionClosedException( + "Connection is already closed.") def close(self, **kwargs) -> None: """Close the websocket connection.""" @@ -316,9 +331,11 @@ def _stop_ping_thread(self) -> None: 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: + 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 is True: + while not self.stop_ping.wait( + self.ping_interval) and self.keep_running: if self.sock: self.last_ping_tm = time.time() try: @@ -446,7 +463,11 @@ def read() -> bool: 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_data, + frame.data, + frame.opcode, + frame.fin) self._callback(self.on_cont_message, frame.data, frame.fin) else: data = frame.data @@ -509,9 +530,8 @@ def handleDisconnect( _logging.info("%s - reconnect", e) if custom_dispatcher: _logging.debug( - "Calling custom dispatcher reconnect [%s frames in stack]", - len(inspect.stack()), - ) + "Calling custom dispatcher reconnect [%s frames in stack]", len( + inspect.stack()),) dispatcher.reconnect(reconnect, setSock) else: _logging.error("%s - goodbye", 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..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" @@ -18,7 +18,11 @@ """ from typing import Union -__all__ = ["NoLock", "validate_utf8", "extract_err_message", "extract_error_code"] +__all__ = [ + "NoLock", + "validate_utf8", + "extract_err_message", + "extract_error_code"] class NoLock: @@ -30,6 +34,7 @@ def __enter__(self) -> None: def __exit__(self, exc_type, exc_value, traceback) -> None: """Exit the no-op context manager.""" + _ = (exc_type, exc_value, traceback) return None @@ -426,8 +431,10 @@ def _decode(state: int, codep: int, ch: int) -> tuple: tp = _UTF8D[ch] codep = ( - (ch & 0x3F) | (codep << 6) if (state != _UTF8_ACCEPT) else (0xFF >> tp) & ch - ) + (ch & 0x3F) | ( + codep << 6) if ( + state != _UTF8_ACCEPT) else ( + 0xFF >> tp) & ch) state = _UTF8D[256 + state + tp] return state, codep @@ -460,5 +467,6 @@ def extract_err_message(exception: Exception) -> Union[str, 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 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..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" @@ -60,6 +60,7 @@ def __call__( option_string: str = None, ) -> None: """Normalize verbose values from integers or repeated ``v`` flags.""" + _ = (parser, option_string) if values is None: values = "1" try: @@ -73,9 +74,13 @@ 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") + "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", @@ -90,7 +95,11 @@ def parse_args() -> argparse.Namespace: "-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( + "-s", + "--subprotocols", + nargs="*", + help="Set subprotocols") parser.add_argument("-o", "--origin", help="Set origin") parser.add_argument( "--eof-wait", @@ -102,7 +111,9 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--timings", action="store_true", help="Print timings in seconds" ) - parser.add_argument("--headers", help="Set custom headers. Use ',' as separator") + parser.add_argument( + "--headers", + help="Set custom headers. Use ',' as separator") return parser.parse_args() @@ -200,16 +211,16 @@ def recv() -> tuple: return frame.opcode, frame.data - def recv_ws() -> None: + 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): + 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 + 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: 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..918b8561 --- /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,2492 @@ +"""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} " + f"{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} " + f"{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" new file mode 100644 index 00000000..5d1f4071 --- /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,58 @@ +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]: + """Implement the default config operation.""" + 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]]: + """Implement the default no create regions operation.""" + 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..ec0d10a4 --- /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..6253fdf7 --- /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,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: + """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" new file mode 100644 index 00000000..80409fba --- /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,208 @@ +"""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 the display name.""" + 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