Files
AstrBot/src-new/astrbot_sdk/cli.py

624 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AstrBot SDK 的命令行入口。"""
from __future__ import annotations
import asyncio
import re
import sys
import typing
import zipfile
from collections.abc import Coroutine
from pathlib import Path
from textwrap import dedent
from typing import Any
import click
from loguru import logger
from .errors import AstrBotError
from .runtime.bootstrap import run_plugin_worker, run_supervisor, run_websocket_server
from .runtime.loader import load_plugin, load_plugin_spec, validate_plugin_spec
from .testing import (
LocalRuntimeConfig,
PluginHarness,
StdoutPlatformSink,
_PluginExecutionError,
_PluginLoadError,
)
EXIT_OK = 0
EXIT_UNEXPECTED = 1
EXIT_USAGE = 2
EXIT_PLUGIN_LOAD = 3
EXIT_RUNTIME = 4
EXIT_PLUGIN_EXECUTION = 5
BUILD_EXCLUDED_DIRS = {
".git",
".idea",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".venv",
"__pycache__",
"dist",
}
BUILD_EXCLUDED_FILES = {
".astrbot-worker-state.json",
}
class _CliPluginValidationError(RuntimeError):
"""CLI 侧的插件结构或打包校验失败。"""
def setup_logger(verbose: bool = False) -> None:
"""初始化 CLI 使用的日志配置。"""
logger.remove()
logger.add(
sys.stderr,
format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <level>{message}</level>",
level="DEBUG" if verbose else "INFO",
colorize=True,
)
def _run_async_entrypoint(
entrypoint: Coroutine[Any, Any, object],
*,
log_message: str,
log_level: str = "info",
context: dict[str, Any] | None = None,
) -> None:
log_method = getattr(logger, log_level)
log_method(log_message)
try:
asyncio.run(entrypoint)
except Exception as exc:
exit_code, error_code, hint = _classify_cli_exception(exc)
_render_cli_error(
error_code=error_code,
message=str(exc),
hint=hint,
context=context,
)
if exit_code == EXIT_UNEXPECTED:
logger.exception("CLI 异常退出")
raise SystemExit(exit_code) from exc
def _run_sync_entrypoint(
entrypoint: typing.Callable[[], object],
*,
log_message: str,
log_level: str = "info",
context: dict[str, Any] | None = None,
) -> None:
log_method = getattr(logger, log_level)
log_method(log_message)
try:
entrypoint()
except Exception as exc:
exit_code, error_code, hint = _classify_cli_exception(exc)
_render_cli_error(
error_code=error_code,
message=str(exc),
hint=hint,
context=context,
)
if exit_code == EXIT_UNEXPECTED:
logger.exception("CLI 异常退出")
raise SystemExit(exit_code) from exc
def _classify_cli_exception(exc: Exception) -> tuple[int, str, str]:
if isinstance(exc, AstrBotError):
return (
EXIT_RUNTIME,
exc.code,
exc.hint or "请检查本地 mock core 与插件调用参数",
)
if isinstance(
exc,
(
_CliPluginValidationError,
_PluginLoadError,
FileNotFoundError,
ImportError,
ModuleNotFoundError,
),
):
return (
EXIT_PLUGIN_LOAD,
"plugin_load_error",
"请检查插件目录、plugin.yaml、requirements.txt 和导入路径",
)
if isinstance(exc, LookupError):
return (
EXIT_RUNTIME,
"dispatch_error",
"请检查 handler 或 capability 是否已正确注册",
)
if isinstance(exc, _PluginExecutionError):
return (
EXIT_PLUGIN_EXECUTION,
"plugin_execution_error",
"请检查插件生命周期、handler 或 capability 的实现",
)
return (
EXIT_UNEXPECTED,
"unexpected_error",
"请查看详细日志,必要时使用 --verbose 重试",
)
def _render_cli_error(
*,
error_code: str,
message: str,
hint: str = "",
context: dict[str, Any] | None = None,
) -> None:
click.echo(f"Error[{error_code}]: {message}", err=True)
if hint:
click.echo(f"Suggestion: {hint}", err=True)
if not context:
return
for key, value in context.items():
click.echo(f"{key}: {value}", err=True)
async def _run_local_dev(
*,
plugin_dir: Path,
event_text: str | None,
interactive: bool,
session_id: str,
user_id: str,
platform: str,
group_id: str | None,
event_type: str,
) -> None:
sink = StdoutPlatformSink(stream=sys.stdout)
harness = PluginHarness(
LocalRuntimeConfig(
plugin_dir=plugin_dir,
session_id=session_id,
user_id=user_id,
platform=platform,
group_id=group_id,
event_type=event_type,
),
platform_sink=sink,
)
state = {
"session_id": session_id,
"user_id": user_id,
"platform": platform,
"group_id": group_id,
"event_type": event_type,
}
async with harness:
if interactive:
click.echo(
"本地交互模式已启动。可用命令:/session <id> /user <id> /platform <name> /group <id> /private /event <type> /exit"
)
while True:
line = await asyncio.to_thread(sys.stdin.readline)
if not line:
break
text = line.strip()
if not text:
continue
if _handle_dev_meta_command(text, state):
if text in {"/exit", "/quit"}:
break
continue
await harness.dispatch_text(
text,
session_id=str(state["session_id"]),
user_id=str(state["user_id"]),
platform=str(state["platform"]),
group_id=typing.cast(str | None, state["group_id"]),
event_type=str(state["event_type"]),
)
return
assert event_text is not None
await harness.dispatch_text(
event_text,
session_id=session_id,
user_id=user_id,
platform=platform,
group_id=group_id,
event_type=event_type,
)
def _handle_dev_meta_command(command: str, state: dict[str, Any]) -> bool:
if command in {"/exit", "/quit"}:
return True
if command.startswith("/session "):
state["session_id"] = command.split(" ", 1)[1].strip()
click.echo(f"切换 session_id -> {state['session_id']}")
return True
if command.startswith("/user "):
state["user_id"] = command.split(" ", 1)[1].strip()
click.echo(f"切换 user_id -> {state['user_id']}")
return True
if command.startswith("/platform "):
state["platform"] = command.split(" ", 1)[1].strip()
click.echo(f"切换 platform -> {state['platform']}")
return True
if command.startswith("/group "):
state["group_id"] = command.split(" ", 1)[1].strip()
click.echo(f"切换 group_id -> {state['group_id']}")
return True
if command == "/private":
state["group_id"] = None
click.echo("已切换为私聊上下文")
return True
if command.startswith("/event "):
state["event_type"] = command.split(" ", 1)[1].strip()
click.echo(f"切换 event_type -> {state['event_type']}")
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:
return "MyPlugin"
return "".join(part[:1].upper() + part[1:] for part in parts)
def _sanitize_build_part(value: str) -> str:
sanitized = re.sub(r"[^a-zA-Z0-9._-]+", "_", value).strip("._-")
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}"
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
runtime:
python: "{python_version}"
components:
- class: main:{class_name}
"""
)
def _render_init_main_py(*, plugin_name: str) -> str:
class_name = _class_name_for_plugin(plugin_name)
return dedent(
f"""\
from astrbot_sdk import Context, MessageEvent, Star, on_command
class {class_name}(Star):
@on_command("hello")
async def hello(self, event: MessageEvent, ctx: Context) -> None:
await event.reply("Hello, World!")
"""
)
def _render_init_test_py(*, plugin_name: str) -> str:
class_name = _class_name_for_plugin(plugin_name)
return dedent(
f'''\
import pytest
from astrbot_sdk.testing import MockContext, MockMessageEvent
from main import {class_name}
@pytest.mark.asyncio
async def test_hello_handler():
plugin = {class_name}()
ctx = MockContext(plugin_id="{plugin_name}")
event = MockMessageEvent(text="/hello", context=ctx)
await plugin.hello(event, ctx)
assert event.replies == ["Hello, World!"]
ctx.platform.assert_sent("Hello, World!")
'''
)
def _ensure_plugin_dir_exists(plugin_dir: Path) -> Path:
resolved = plugin_dir.resolve()
if not resolved.exists() or not resolved.is_dir():
raise _CliPluginValidationError(f"插件目录不存在:{plugin_dir}")
return resolved
def _load_validated_plugin(plugin_dir: Path) -> tuple[Any, Any]:
resolved_dir = _ensure_plugin_dir_exists(plugin_dir)
plugin = load_plugin_spec(resolved_dir)
try:
validate_plugin_spec(plugin)
except ValueError as exc:
raise _CliPluginValidationError(str(exc)) from exc
loaded = load_plugin(plugin)
if not loaded.instances:
raise _CliPluginValidationError(
"未找到可加载的组件,请检查 plugin.yaml 中的 components"
)
return plugin, loaded
def _build_kind(plugin: Any) -> str:
return (
"legacy-main"
if bool(plugin.manifest_data.get("__legacy_main__"))
else "plugin-yaml"
)
def _path_is_within(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
except ValueError:
return False
return True
def _iter_build_files(plugin_dir: Path, output_dir: Path) -> list[Path]:
files: list[Path] = []
for path in sorted(plugin_dir.rglob("*")):
if path.is_dir():
continue
if _path_is_within(path, output_dir):
continue
relative = path.relative_to(plugin_dir)
if any(part in BUILD_EXCLUDED_DIRS for part in relative.parts[:-1]):
continue
if relative.name in BUILD_EXCLUDED_FILES:
continue
if path.suffix in {".pyc", ".pyo"}:
continue
files.append(path)
return files
def _init_plugin(name: str) -> None:
target_dir = Path(name)
if target_dir.exists():
raise _CliPluginValidationError(f"目标目录已存在:{target_dir}")
plugin_name = _slugify_plugin_name(target_dir.name)
display_name = target_dir.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,
),
encoding="utf-8",
)
(target_dir / "requirements.txt").write_text("", encoding="utf-8")
(target_dir / "main.py").write_text(
_render_init_main_py(plugin_name=plugin_name),
encoding="utf-8",
)
(target_dir / "tests" / "test_plugin.py").write_text(
_render_init_test_py(plugin_name=plugin_name),
encoding="utf-8",
)
click.echo(f"已创建插件骨架:{target_dir}")
click.echo("后续命令:")
click.echo(f" astrbot-sdk validate --plugin-dir {target_dir}")
click.echo(
f" astrbot-sdk dev --local --plugin-dir {target_dir} --event-text hello"
)
def _validate_plugin(plugin_dir: Path) -> None:
plugin, loaded = _load_validated_plugin(plugin_dir)
click.echo(f"校验通过:{plugin.name}")
click.echo(f"kind: {_build_kind(plugin)}")
click.echo(f"plugin_dir: {plugin.plugin_dir}")
click.echo(f"handlers: {len(loaded.handlers)}")
click.echo(f"capabilities: {len(loaded.capabilities)}")
click.echo(f"instances: {len(loaded.instances)}")
def _build_plugin(plugin_dir: Path, output_dir: Path | None) -> None:
plugin, _ = _load_validated_plugin(plugin_dir)
build_dir = (output_dir or (plugin.plugin_dir / "dist")).resolve()
build_dir.mkdir(parents=True, exist_ok=True)
version = _sanitize_build_part(str(plugin.manifest_data.get("version") or "0.0.0"))
archive_name = f"{_sanitize_build_part(plugin.name)}-{version}.zip"
archive_path = build_dir / archive_name
with zipfile.ZipFile(
archive_path,
mode="w",
compression=zipfile.ZIP_DEFLATED,
) as archive:
for path in _iter_build_files(plugin.plugin_dir, build_dir):
archive.write(path, arcname=path.relative_to(plugin.plugin_dir))
click.echo(f"构建完成:{archive_path}")
click.echo(f"artifact: {archive_path}")
@click.group()
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
@click.pass_context
def cli(ctx, verbose: bool) -> None:
"""AstrBot SDK CLI。"""
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
setup_logger(verbose)
@cli.command()
@click.option(
"--plugins-dir",
default="plugins",
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
help="Directory containing plugin folders",
)
def run(plugins_dir: Path) -> None:
"""Start the plugin supervisor over stdio."""
_run_async_entrypoint(
run_supervisor(plugins_dir=plugins_dir),
log_message=f"启动插件主管进程,插件目录:{plugins_dir}",
context={"plugins_dir": plugins_dir},
)
@cli.command()
@click.argument("name", type=str)
def init(name: str) -> None:
"""Create a new plugin skeleton in the target directory."""
_run_sync_entrypoint(
lambda: _init_plugin(name),
log_message=f"创建插件骨架:{name}",
context={"target": Path(name)},
)
@cli.command()
@click.option(
"--plugin-dir",
default=".",
show_default=True,
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
help="Plugin directory to validate",
)
def validate(plugin_dir: Path) -> None:
"""Validate plugin manifest, imports and handler discovery."""
_run_sync_entrypoint(
lambda: _validate_plugin(plugin_dir),
log_message=f"校验插件目录:{plugin_dir}",
context={"plugin_dir": plugin_dir},
)
@cli.command()
@click.option(
"--plugin-dir",
default=".",
show_default=True,
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
help="Plugin directory to package",
)
@click.option(
"--output-dir",
default=None,
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
help="Directory for the build artifact, defaults to <plugin-dir>/dist",
)
def build(plugin_dir: Path, output_dir: Path | None) -> None:
"""Validate and package a plugin into a zip artifact."""
_run_sync_entrypoint(
lambda: _build_plugin(plugin_dir, output_dir),
log_message=f"构建插件包:{plugin_dir}",
context={"plugin_dir": plugin_dir, "output_dir": output_dir},
)
@cli.command()
@click.option(
"--plugin-dir",
required=True,
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
help="Plugin directory to run locally",
)
@click.option("--local", "local_mode", is_flag=True, help="Run against local mock core")
@click.option(
"--standalone",
"standalone_mode",
is_flag=True,
help="Alias of --local for compatibility",
)
@click.option("--event-text", type=str, help="Single message text to dispatch")
@click.option("--interactive", is_flag=True, help="Read follow-up messages from stdin")
@click.option("--session-id", default="local-session", show_default=True)
@click.option("--user-id", default="local-user", show_default=True)
@click.option("--platform", "platform_name", default="test", show_default=True)
@click.option("--group-id", default=None)
@click.option("--event-type", default="message", show_default=True)
def dev(
plugin_dir: Path,
local_mode: bool,
standalone_mode: bool,
event_text: str | None,
interactive: bool,
session_id: str,
user_id: str,
platform_name: str,
group_id: str | None,
event_type: str,
) -> None:
"""Run a plugin against the local mock core for development."""
if not (local_mode or standalone_mode):
raise click.BadParameter("当前 dev 只支持 --local/--standalone 模式")
if interactive and event_text:
raise click.BadParameter("--interactive 与 --event-text 不能同时使用")
if not interactive and not event_text:
raise click.BadParameter("请提供 --event-text或改用 --interactive")
_run_async_entrypoint(
_run_local_dev(
plugin_dir=plugin_dir,
event_text=event_text,
interactive=interactive,
session_id=session_id,
user_id=user_id,
platform=platform_name,
group_id=group_id,
event_type=event_type,
),
log_message=f"启动本地开发模式:{plugin_dir}",
context={
"plugin_dir": plugin_dir,
"session_id": session_id,
"platform": platform_name,
"event_type": event_type,
},
)
@cli.command(hidden=True)
@click.option(
"--plugin-dir",
required=True,
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
)
def worker(plugin_dir: Path) -> None:
"""Internal command used by the supervisor to start a worker."""
_run_async_entrypoint(
run_plugin_worker(plugin_dir=plugin_dir),
log_message=f"启动插件工作进程:{plugin_dir}",
log_level="debug",
context={"plugin_dir": plugin_dir},
)
@cli.command(hidden=True)
@click.option("--port", default=8765, type=int, help="WebSocket server port")
def websocket(port: int) -> None:
"""Legacy websocket runtime entrypoint."""
_run_async_entrypoint(
run_websocket_server(port=port),
log_message=f"启动 WebSocket 服务器,端口:{port}",
context={"port": port},
)