diff --git a/.gitignore b/.gitignore index 44e4ed65b..2d250de2d 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ plugins/.venv/ *.iml uv.lock /astrBot/ +plugins/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f7eff81c1..1469021aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ omit = [ "*/tests/*", "*/__pycache__/*", "*/_legacy_api.py", + "*/plugins/*" ] [tool.coverage.report] diff --git a/src-new/astrbot_sdk/cli.py b/src-new/astrbot_sdk/cli.py index 74ac2524a..59426f645 100644 --- a/src-new/astrbot_sdk/cli.py +++ b/src-new/astrbot_sdk/cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import re import sys import typing @@ -45,6 +46,9 @@ BUILD_EXCLUDED_DIRS = { BUILD_EXCLUDED_FILES = { ".astrbot-worker-state.json", } +INIT_DEFAULT_AUTHOR = "" +INIT_DEFAULT_PYTHON_VERSION = "3.12" +INIT_DEFAULT_VERSION = "1.0.0" class _CliPluginValidationError(RuntimeError): @@ -262,12 +266,6 @@ def _handle_dev_meta_command(command: str, state: dict[str, Any]) -> bool: return True return False - -def _slugify_plugin_name(value: str) -> str: - slug = re.sub(r"[^a-zA-Z0-9]+", "_", value).strip("_").lower() - return slug or "my_plugin" - - def _class_name_for_plugin(value: str) -> str: parts = [part for part in re.split(r"[^a-zA-Z0-9]+", value) if part] if not parts: @@ -280,18 +278,62 @@ def _sanitize_build_part(value: str) -> str: return sanitized or "artifact" -def _render_init_plugin_yaml(*, plugin_name: str, display_name: str) -> str: - python_version = f"{sys.version_info.major}.{sys.version_info.minor}" +def _yaml_string(value: str) -> str: + return json.dumps(value, ensure_ascii=False) + + +def _normalize_init_plugin_name(value: str) -> str: + normalized = re.sub(r"[\s-]+", "_", value.strip()) + normalized = re.sub(r"[^a-zA-Z0-9_]+", "_", normalized) + normalized = re.sub(r"_+", "_", normalized).strip("_").lower() + if not normalized: + normalized = "my_plugin" + + prefix = "astrbot_plugin_" + if normalized == "astrbot_plugin": + return f"{prefix}my_plugin" + if normalized.startswith(prefix): + suffix = normalized.removeprefix(prefix).strip("_") or "my_plugin" + return f"{prefix}{suffix}" + return f"{prefix}{normalized}" + + +def _prompt_required_init_name() -> str: + while True: + value = click.prompt("插件名字", default="", show_default=False).strip() + if value: + return value + click.echo("插件名字不能为空") + + +def _collect_init_inputs(name: str | None) -> tuple[str, str, str]: + if name is not None: + return name, INIT_DEFAULT_AUTHOR, INIT_DEFAULT_VERSION + + plugin_name = _prompt_required_init_name() + author = click.prompt("作者名字", default="", show_default=False).strip() + version = click.prompt("版本", default=INIT_DEFAULT_VERSION).strip() + return plugin_name, author, version or INIT_DEFAULT_VERSION + + +def _render_init_plugin_yaml( + *, + plugin_name: str, + display_name: str, + author: str, + version: str, + python_version: str, +) -> str: class_name = _class_name_for_plugin(plugin_name) return dedent( f"""\ name: {plugin_name} - display_name: {display_name} - desc: 使用 AstrBot SDK 创建的插件 - author: your-name - version: 0.1.0 + display_name: {_yaml_string(display_name)} + desc: {_yaml_string("使用 AstrBot SDK 创建的插件")} + author: {_yaml_string(author)} + version: {_yaml_string(version)} runtime: - python: "{python_version}" + python: {_yaml_string(python_version)} components: - class: main:{class_name} """ @@ -394,19 +436,24 @@ def _iter_build_files(plugin_dir: Path, output_dir: Path) -> list[Path]: return files -def _init_plugin(name: str) -> None: - target_dir = Path(name) +def _init_plugin(name: str | None) -> None: + raw_name, author, version = _collect_init_inputs(name) + normalized_name = _normalize_init_plugin_name(raw_name) + target_dir = Path(normalized_name) if target_dir.exists(): raise _CliPluginValidationError(f"目标目录已存在:{target_dir}") - plugin_name = _slugify_plugin_name(target_dir.name) - display_name = target_dir.name + plugin_name = normalized_name + display_name = raw_name target_dir.mkdir(parents=True, exist_ok=False) (target_dir / "tests").mkdir() (target_dir / "plugin.yaml").write_text( _render_init_plugin_yaml( plugin_name=plugin_name, display_name=display_name, + author=author, + version=version, + python_version=INIT_DEFAULT_PYTHON_VERSION, ), encoding="utf-8", ) @@ -419,7 +466,7 @@ def _init_plugin(name: str) -> None: _render_init_test_py(plugin_name=plugin_name), encoding="utf-8", ) - click.echo(f"已创建插件骨架:{target_dir}") + click.echo(f"已创建插件骨架:{target_dir.resolve()}") click.echo("后续命令:") click.echo(f" astrbot-sdk validate --plugin-dir {target_dir}") click.echo( @@ -485,13 +532,15 @@ def run(plugins_dir: Path) -> None: @cli.command() -@click.argument("name", type=str) -def init(name: str) -> None: - """Create a new plugin skeleton in the target directory.""" +@click.argument("name", required=False, type=str) +def init(name: str | None) -> None: + """Create a new plugin skeleton; omit name to enter interactive mode.""" _run_sync_entrypoint( lambda: _init_plugin(name), - log_message=f"创建插件骨架:{name}", - context={"target": Path(name)}, + log_message=( + f"创建插件骨架:{name}" if name is not None else "创建插件骨架:交互模式" + ), + context={"target": name or ""}, ) diff --git a/src-new/astrbot_sdk/runtime/peer.py b/src-new/astrbot_sdk/runtime/peer.py index 3501fbf26..2f9bae325 100644 --- a/src-new/astrbot_sdk/runtime/peer.py +++ b/src-new/astrbot_sdk/runtime/peer.py @@ -344,6 +344,7 @@ class Peer: asyncio.get_running_loop().create_future() ) self._pending_results[request_id] = future + # FIXME: 这里会输出乱七八糟的各种东西 await self._send( InitializeMessage( id=request_id, @@ -354,6 +355,7 @@ class Peer: metadata=handshake_metadata, ) ) + # FIXME: 👆会输出各种乱七八糟的东西 result = await future if result.kind != "initialize_result": raise AstrBotError.protocol_error("initialize 必须收到 initialize_result") diff --git a/src-new/astrbot_sdk/runtime/supervisor.py b/src-new/astrbot_sdk/runtime/supervisor.py index f9f82331a..0f3f145e0 100644 --- a/src-new/astrbot_sdk/runtime/supervisor.py +++ b/src-new/astrbot_sdk/runtime/supervisor.py @@ -537,6 +537,7 @@ class SupervisorRuntime: discovery = discover_plugins(self.plugins_dir) self.skipped_plugins = dict(discovery.skipped_plugins) plan_result = self.env_manager.plan(discovery.plugins) + logger.info(f"发现 {len(discovery.plugins)} 个插件,{len(plan_result.groups)} 个环境组") self.skipped_plugins.update(plan_result.skipped_plugins) try: planned_sessions: list[WorkerSession] = [] @@ -598,7 +599,7 @@ class SupervisorRuntime: aggregated_handlers = list(self.handler_to_worker.keys()) logger.info( - "Loaded plugins: {}", ", ".join(sorted(self.loaded_plugins)) or "none" + "Loaded plugins: \n{}", "\n ".join(sorted(self.loaded_plugins)) or "none" ) await self.peer.start() diff --git a/tests_v4/test_top_level_modules.py b/tests_v4/test_top_level_modules.py index 0d465d86d..d3ba112a8 100644 --- a/tests_v4/test_top_level_modules.py +++ b/tests_v4/test_top_level_modules.py @@ -304,7 +304,7 @@ class TestCliModule: with runner.isolated_filesystem(): result = runner.invoke(cli, ["init", "demo-plugin"]) - plugin_dir = Path("demo-plugin") + plugin_dir = Path("astrbot_plugin_demo_plugin") manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8") main_file = (plugin_dir / "main.py").read_text(encoding="utf-8") test_file = (plugin_dir / "tests" / "test_plugin.py").read_text( @@ -313,14 +313,89 @@ class TestCliModule: assert result.exit_code == 0 assert "已创建插件骨架" in result.output - assert "name: demo_plugin" in manifest - assert ( - f'python: "{sys.version_info.major}.{sys.version_info.minor}"' in manifest - ) - assert "class DemoPlugin(Star):" in main_file + assert "name: astrbot_plugin_demo_plugin" in manifest + assert 'display_name: "demo-plugin"' in manifest + assert 'author: ""' in manifest + assert 'version: "1.0.0"' in manifest + assert 'python: "3.12"' in manifest + assert "class AstrbotPluginDemoPlugin(Star):" in main_file assert "MockContext" in test_file assert "MockMessageEvent" in test_file + def test_init_command_normalizes_spaces_to_underscores(self): + """init should normalize spaces in the generated directory and manifest name.""" + runner = CliRunner() + + with runner.isolated_filesystem(): + result = runner.invoke(cli, ["init", "demo plugin"]) + + plugin_dir = Path("astrbot_plugin_demo_plugin") + manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8") + assert plugin_dir.is_dir() + + assert result.exit_code == 0 + assert "name: astrbot_plugin_demo_plugin" in manifest + assert 'display_name: "demo plugin"' in manifest + + def test_init_command_converts_legacy_prefix_to_underscore_prefix(self): + """init should translate the legacy astrbot-plugin prefix to astrbot_plugin.""" + runner = CliRunner() + + with runner.isolated_filesystem(): + result = runner.invoke(cli, ["init", "astrbot-plugin-demo"]) + + plugin_dir = Path("astrbot_plugin_demo") + manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8") + assert plugin_dir.is_dir() + + assert result.exit_code == 0 + assert "name: astrbot_plugin_demo" in manifest + + def test_init_command_enters_interactive_mode_without_name(self): + """init without a name should prompt for plugin metadata interactively.""" + runner = CliRunner() + + with runner.isolated_filesystem(): + result = runner.invoke( + cli, + ["init"], + input="hello world\nalice\n2.3.4\n", + ) + + plugin_dir = Path("astrbot_plugin_hello_world") + manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8") + assert plugin_dir.is_dir() + + assert result.exit_code == 0 + assert "插件名字" in result.output + assert "作者名字" in result.output + assert "版本" in result.output + assert "name: astrbot_plugin_hello_world" in manifest + assert 'display_name: "hello world"' in manifest + assert 'author: "alice"' in manifest + assert 'version: "2.3.4"' in manifest + assert 'python: "3.12"' in manifest + + def test_init_command_reprompts_for_empty_interactive_name(self): + """init interactive mode should reject an empty plugin name and keep prompting.""" + runner = CliRunner() + + with runner.isolated_filesystem(): + result = runner.invoke( + cli, + ["init"], + input="\nhello world\n\n\n", + ) + + plugin_dir = Path("astrbot_plugin_hello_world") + manifest = (plugin_dir / "plugin.yaml").read_text(encoding="utf-8") + assert plugin_dir.is_dir() + + assert result.exit_code == 0 + assert "插件名字不能为空" in result.output + assert 'author: ""' in manifest + assert 'version: "1.0.0"' in manifest + def test_validate_command_checks_real_plugin_fixture(self): """validate should reuse loader-based discovery against a real v4 fixture.""" runner = CliRunner() @@ -379,11 +454,11 @@ class TestCliModule: [ "build", "--plugin-dir", - "buildable-plugin", + "astrbot_plugin_buildable_plugin", ], ) - artifact_dir = Path("buildable-plugin") / "dist" + artifact_dir = Path("astrbot_plugin_buildable_plugin") / "dist" artifacts = sorted(artifact_dir.glob("*.zip")) assert len(artifacts) == 1 with zipfile.ZipFile(artifacts[0]) as archive: