mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
feat: restore independent plugin workers
This commit is contained in:
5
src/astrbot_sdk/__main__.py
Normal file
5
src/astrbot_sdk/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .cli.main import cli
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
import click
|
||||
from loguru import logger
|
||||
from ..runtime.serve import run_websocket_server
|
||||
|
||||
from ..runtime.serve import run_plugin_worker, run_supervisor, run_websocket_server
|
||||
|
||||
|
||||
def setup_logger(verbose: bool = False):
|
||||
@@ -38,10 +40,34 @@ def cli(ctx, verbose):
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--port", default=8765, help="WebSocket server port", type=int)
|
||||
@click.option(
|
||||
"--plugins-dir",
|
||||
default="plugins",
|
||||
type=click.Path(file_okay=False, dir_okay=True, path_type=str),
|
||||
help="Directory containing plugin folders",
|
||||
)
|
||||
@click.pass_context
|
||||
def run(ctx, port: int):
|
||||
"""Start the WebSocket server"""
|
||||
def run(ctx, plugins_dir: str):
|
||||
"""Start the plugin supervisor over stdio."""
|
||||
logger.info(f"Starting plugin supervisor with plugins dir: {plugins_dir}")
|
||||
asyncio.run(run_supervisor(plugins_dir=plugins_dir))
|
||||
|
||||
|
||||
@cli.command(hidden=True)
|
||||
@click.option(
|
||||
"--plugin-dir",
|
||||
required=True,
|
||||
type=click.Path(file_okay=False, dir_okay=True, path_type=str),
|
||||
)
|
||||
def worker(plugin_dir: str):
|
||||
"""Internal command used by the supervisor to start a worker."""
|
||||
asyncio.run(run_plugin_worker(plugin_dir=plugin_dir))
|
||||
|
||||
|
||||
@cli.command(hidden=True)
|
||||
@click.option("--port", default=8765, help="WebSocket server port", type=int)
|
||||
def websocket(port: int):
|
||||
"""Legacy websocket runtime entrypoint."""
|
||||
logger.info(f"Starting WebSocket server on port {port}...")
|
||||
asyncio.run(run_websocket_server(port=port))
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from typing import IO, Any
|
||||
|
||||
@@ -23,6 +24,7 @@ class StdioClient(JSONRPCClient):
|
||||
self,
|
||||
command: list[str],
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the STDIO client.
|
||||
|
||||
@@ -33,6 +35,7 @@ class StdioClient(JSONRPCClient):
|
||||
super().__init__()
|
||||
self._command = command
|
||||
self._cwd = cwd
|
||||
self._env = env or os.environ.copy()
|
||||
self._process: subprocess.Popen | None = None
|
||||
self._stdin: IO[Any] | None = None
|
||||
self._stdout: IO[Any] | None = None
|
||||
@@ -64,6 +67,7 @@ class StdioClient(JSONRPCClient):
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=self._cwd,
|
||||
env=self._env,
|
||||
text=True,
|
||||
bufsize=1, # Line buffered
|
||||
)
|
||||
@@ -116,6 +120,11 @@ class StdioClient(JSONRPCClient):
|
||||
|
||||
# Terminate subprocess if running
|
||||
if self._process:
|
||||
if self._stdout:
|
||||
try:
|
||||
self._stdout.close()
|
||||
except Exception:
|
||||
logger.debug("Failed to close subprocess stdin cleanly")
|
||||
logger.info("Terminating subprocess...")
|
||||
self._process.terminate()
|
||||
try:
|
||||
|
||||
@@ -38,6 +38,7 @@ class StdioServer(JSONRPCServer):
|
||||
self._stdout = stdout or sys.stdout
|
||||
self._read_task: asyncio.Task | None = None
|
||||
self._write_lock = asyncio.Lock()
|
||||
self._closed_event = asyncio.Event()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the server and begin reading from stdin."""
|
||||
@@ -55,6 +56,7 @@ class StdioServer(JSONRPCServer):
|
||||
return
|
||||
|
||||
self._running = False
|
||||
self._closed_event.set()
|
||||
|
||||
# Cancel read task
|
||||
if self._read_task:
|
||||
@@ -101,6 +103,8 @@ class StdioServer(JSONRPCServer):
|
||||
if not line:
|
||||
# EOF reached
|
||||
logger.info("EOF reached on stdin")
|
||||
self._running = False
|
||||
self._closed_event.set()
|
||||
break
|
||||
|
||||
line = line.strip()
|
||||
@@ -120,8 +124,12 @@ class StdioServer(JSONRPCServer):
|
||||
except Exception as e:
|
||||
logger.error(f"Error in read loop: {e}")
|
||||
finally:
|
||||
self._closed_event.set()
|
||||
logger.debug("Stopped reading from stdin")
|
||||
|
||||
async def wait_closed(self) -> None:
|
||||
await self._closed_event.wait()
|
||||
|
||||
def _parse_message(self, line: str) -> JSONRPCMessage:
|
||||
"""Parse a JSON-RPC message from a string.
|
||||
|
||||
|
||||
@@ -1,11 +1,56 @@
|
||||
import asyncio
|
||||
import signal
|
||||
from .rpc.server import WebSocketServer, StdioServer
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import IO, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from .api.context import Context
|
||||
from .rpc.server import StdioServer, WebSocketServer
|
||||
from .star_runner import StarRunner
|
||||
from .stars.star_manager import StarManager
|
||||
from .api.context import Context
|
||||
from loguru import logger
|
||||
from typing import IO, Any
|
||||
from .supervisor import SupervisorRuntime
|
||||
|
||||
|
||||
def _install_signal_handlers(stop_event: asyncio.Event) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
try:
|
||||
loop.add_signal_handler(sig, stop_event.set)
|
||||
except NotImplementedError:
|
||||
logger.debug(f"Signal handlers are not supported for {sig}")
|
||||
|
||||
|
||||
def _prepare_stdio_transport(
|
||||
stdin: IO[Any] | None,
|
||||
stdout: IO[Any] | None,
|
||||
) -> tuple[IO[Any], IO[Any], IO[Any] | None]:
|
||||
if stdin is not None and stdout is not None:
|
||||
return stdin, stdout, None
|
||||
|
||||
transport_stdin = stdin or sys.stdin
|
||||
transport_stdout = stdout or sys.stdout
|
||||
original_stdout = sys.stdout
|
||||
sys.stdout = sys.stderr
|
||||
return transport_stdin, transport_stdout, original_stdout
|
||||
|
||||
|
||||
async def _wait_for_stdio_shutdown(
|
||||
server: StdioServer, stop_event: asyncio.Event
|
||||
) -> None:
|
||||
stop_waiter = asyncio.create_task(stop_event.wait())
|
||||
stdio_waiter = asyncio.create_task(server.wait_closed())
|
||||
done, pending = await asyncio.wait(
|
||||
{stop_waiter, stdio_waiter},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
for task in done:
|
||||
if task.cancelled():
|
||||
continue
|
||||
task.result()
|
||||
|
||||
|
||||
async def run_websocket_server(
|
||||
@@ -13,6 +58,7 @@ async def run_websocket_server(
|
||||
port: int = 8765,
|
||||
path: str = "/",
|
||||
heartbeat_interval: int = 30,
|
||||
plugin_dir: str | Path | None = None,
|
||||
):
|
||||
server = WebSocketServer(
|
||||
port=port, host=host, path=path, heartbeat=heartbeat_interval
|
||||
@@ -20,13 +66,11 @@ async def run_websocket_server(
|
||||
runner = StarRunner(server)
|
||||
context = Context.default_context(runner=runner)
|
||||
star_manager = StarManager(context=context)
|
||||
star_manager.discover_star()
|
||||
star_manager.discover_star(Path(plugin_dir) if plugin_dir else None)
|
||||
await runner.run()
|
||||
|
||||
stop_event = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, stop_event.set)
|
||||
_install_signal_handlers(stop_event)
|
||||
|
||||
logger.info("Server is running. Press Ctrl+C to stop.")
|
||||
|
||||
@@ -37,26 +81,55 @@ async def run_websocket_server(
|
||||
await server.stop()
|
||||
|
||||
|
||||
async def start_stdio_server(
|
||||
stdin: IO[Any] | None = None, stdout: IO[Any] | None = None
|
||||
):
|
||||
"""Start a JSON-RPC server over stdio."""
|
||||
server = StdioServer(stdin=stdin, stdout=stdout)
|
||||
async def run_supervisor(
|
||||
plugins_dir: str | Path = "plugins",
|
||||
stdin: IO[Any] | None = None,
|
||||
stdout: IO[Any] | None = None,
|
||||
) -> None:
|
||||
transport_stdin, transport_stdout, original_stdout = _prepare_stdio_transport(
|
||||
stdin, stdout
|
||||
)
|
||||
server = StdioServer(stdin=transport_stdin, stdout=transport_stdout)
|
||||
supervisor = SupervisorRuntime(
|
||||
server=server,
|
||||
plugins_dir=Path(plugins_dir),
|
||||
)
|
||||
|
||||
try:
|
||||
await supervisor.start()
|
||||
stop_event = asyncio.Event()
|
||||
_install_signal_handlers(stop_event)
|
||||
logger.info(f"Plugin supervisor is running with plugins dir: {plugins_dir}")
|
||||
await _wait_for_stdio_shutdown(server, stop_event)
|
||||
finally:
|
||||
logger.info("Shutting down plugin supervisor...")
|
||||
await supervisor.stop()
|
||||
if original_stdout is not None:
|
||||
sys.stdout = original_stdout
|
||||
|
||||
|
||||
async def run_plugin_worker(
|
||||
plugin_dir: str | Path,
|
||||
stdin: IO[Any] | None = None,
|
||||
stdout: IO[Any] | None = None,
|
||||
) -> None:
|
||||
transport_stdin, transport_stdout, original_stdout = _prepare_stdio_transport(
|
||||
stdin, stdout
|
||||
)
|
||||
server = StdioServer(stdin=transport_stdin, stdout=transport_stdout)
|
||||
runner = StarRunner(server)
|
||||
context = Context.default_context(runner=runner)
|
||||
star_manager = StarManager(context=context)
|
||||
star_manager.discover_star()
|
||||
await runner.run()
|
||||
|
||||
stop_event = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, stop_event.set)
|
||||
|
||||
logger.info("Stdio server is running. Press Ctrl+C to stop.")
|
||||
star_manager.discover_star(Path(plugin_dir))
|
||||
|
||||
try:
|
||||
await stop_event.wait()
|
||||
await runner.run()
|
||||
stop_event = asyncio.Event()
|
||||
_install_signal_handlers(stop_event)
|
||||
logger.info(f"Plugin worker is running for: {plugin_dir}")
|
||||
await _wait_for_stdio_shutdown(server, stop_event)
|
||||
finally:
|
||||
logger.info("Shutting down...")
|
||||
await server.stop()
|
||||
logger.info("Shutting down plugin worker...")
|
||||
await runner.stop()
|
||||
if original_stdout is not None:
|
||||
sys.stdout = original_stdout
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import os
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
from loguru import logger
|
||||
@@ -177,7 +178,7 @@ class NewStdioStar(NewStar):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_dir: str,
|
||||
plugins_dir: str,
|
||||
python_executable: str = "python",
|
||||
context: Any = None,
|
||||
**kwargs: Any,
|
||||
@@ -185,18 +186,33 @@ class NewStdioStar(NewStar):
|
||||
"""Initialize a STDIO-based NewStar.
|
||||
|
||||
Args:
|
||||
plugin_dir: Path to the plugin directory
|
||||
plugins_dir: Path to the plugins directory
|
||||
python_executable: Python executable to use (defaults to 'python')
|
||||
context: Context instance for managing managers and their functions
|
||||
"""
|
||||
# Construct the command to start the plugin
|
||||
if not os.path.exists(plugin_dir):
|
||||
raise FileNotFoundError(f"Plugin directory not found: {plugin_dir}")
|
||||
if not os.path.exists(plugins_dir):
|
||||
raise FileNotFoundError(f"Plugins directory not found: {plugins_dir}")
|
||||
|
||||
command = [python_executable, "-m", "astrbot_sdk", "run", "--stdio"]
|
||||
repo_src_dir = str(Path(__file__).resolve().parents[3])
|
||||
env = os.environ.copy()
|
||||
existing_pythonpath = env.get("PYTHONPATH")
|
||||
env["PYTHONPATH"] = (
|
||||
f"{repo_src_dir}{os.pathsep}{existing_pythonpath}"
|
||||
if existing_pythonpath
|
||||
else repo_src_dir
|
||||
)
|
||||
|
||||
command = [
|
||||
python_executable,
|
||||
"-m",
|
||||
"astrbot_sdk",
|
||||
"run",
|
||||
"--plugins-dir",
|
||||
plugins_dir,
|
||||
]
|
||||
|
||||
# Create StdioClient with subprocess management
|
||||
client = StdioClient(command=command, cwd=plugin_dir)
|
||||
client = StdioClient(command=command, cwd=plugins_dir, env=env)
|
||||
super().__init__(client, context=context)
|
||||
|
||||
|
||||
|
||||
558
src/astrbot_sdk/runtime/supervisor.py
Normal file
558
src/astrbot_sdk/runtime/supervisor.py
Normal file
@@ -0,0 +1,558 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import yaml
|
||||
from loguru import logger
|
||||
from .rpc.client.stdio import StdioClient
|
||||
from .rpc.jsonrpc import (
|
||||
JSONRPCErrorData,
|
||||
JSONRPCErrorResponse,
|
||||
JSONRPCMessage,
|
||||
JSONRPCRequest,
|
||||
JSONRPCSuccessResponse,
|
||||
)
|
||||
from .rpc.request_helper import RPCRequestHelper
|
||||
from .rpc.server.base import JSONRPCServer
|
||||
from .stars.registry import EventType, StarHandlerMetadata
|
||||
from .types import CallHandlerRequest, HandshakeRequest
|
||||
|
||||
STATE_FILE_NAME = ".astrbot-worker-state.json"
|
||||
|
||||
|
||||
def _venv_python_path(venv_dir: Path) -> Path:
|
||||
if os.name == "nt":
|
||||
return venv_dir / "Scripts" / "python.exe"
|
||||
return venv_dir / "bin" / "python"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PluginSpec:
|
||||
name: str
|
||||
plugin_dir: Path
|
||||
manifest_path: Path
|
||||
requirements_path: Path
|
||||
python_version: str
|
||||
manifest_data: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PluginDiscoveryResult:
|
||||
plugins: list[PluginSpec]
|
||||
skipped_plugins: dict[str, str]
|
||||
|
||||
|
||||
def discover_plugins(plugins_dir: Path) -> PluginDiscoveryResult:
|
||||
plugins_root = plugins_dir.resolve()
|
||||
skipped_plugins: dict[str, str] = {}
|
||||
plugins: list[PluginSpec] = []
|
||||
seen_names: set[str] = set()
|
||||
|
||||
if not plugins_root.exists():
|
||||
logger.warning(f"Plugins directory does not exist: {plugins_root}")
|
||||
return PluginDiscoveryResult([], {})
|
||||
|
||||
for entry in sorted(plugins_root.iterdir()):
|
||||
if not entry.is_dir() or entry.name.startswith("."):
|
||||
continue
|
||||
|
||||
manifest_path = entry / "plugin.yaml"
|
||||
requirements_path = entry / "requirements.txt"
|
||||
if not manifest_path.exists():
|
||||
logger.warning(f"Skipping {entry}: missing plugin.yaml")
|
||||
continue
|
||||
if not requirements_path.exists():
|
||||
logger.warning(f"Skipping {entry}: missing requirements.txt")
|
||||
skipped_plugins[entry.name] = "missing requirements.txt"
|
||||
continue
|
||||
|
||||
try:
|
||||
manifest_data = (
|
||||
yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {}
|
||||
)
|
||||
except Exception as exc:
|
||||
skipped_plugins[entry.name] = f"failed to parse plugin.yaml: {exc}"
|
||||
continue
|
||||
|
||||
plugin_name = manifest_data.get("name")
|
||||
components = manifest_data.get("components")
|
||||
runtime = manifest_data.get("runtime") or {}
|
||||
python_version = runtime.get("python")
|
||||
|
||||
if not isinstance(plugin_name, str) or not plugin_name:
|
||||
skipped_plugins[entry.name] = "plugin name is required"
|
||||
continue
|
||||
if plugin_name in seen_names:
|
||||
skipped_plugins[plugin_name] = "duplicate plugin name"
|
||||
continue
|
||||
if not isinstance(components, list) or not components:
|
||||
skipped_plugins[plugin_name] = "components must be a non-empty list"
|
||||
continue
|
||||
if not isinstance(python_version, str) or not python_version:
|
||||
skipped_plugins[plugin_name] = "runtime.python is required"
|
||||
continue
|
||||
|
||||
seen_names.add(plugin_name)
|
||||
plugins.append(
|
||||
PluginSpec(
|
||||
name=plugin_name,
|
||||
plugin_dir=entry.resolve(),
|
||||
manifest_path=manifest_path.resolve(),
|
||||
requirements_path=requirements_path.resolve(),
|
||||
python_version=python_version,
|
||||
manifest_data=manifest_data,
|
||||
)
|
||||
)
|
||||
|
||||
return PluginDiscoveryResult(plugins=plugins, skipped_plugins=skipped_plugins)
|
||||
|
||||
|
||||
class PluginEnvironmentManager:
|
||||
def __init__(self, repo_root: Path, uv_binary: str | None = None) -> None:
|
||||
self.repo_root = repo_root.resolve()
|
||||
self.uv_binary = uv_binary or shutil.which("uv")
|
||||
self.cache_dir = self.repo_root / ".uv-cache"
|
||||
|
||||
def prepare_environment(self, plugin: PluginSpec) -> Path:
|
||||
if not self.uv_binary:
|
||||
raise RuntimeError("uv executable not found")
|
||||
|
||||
state_path = plugin.plugin_dir / STATE_FILE_NAME
|
||||
venv_dir = plugin.plugin_dir / ".venv"
|
||||
python_path = _venv_python_path(venv_dir)
|
||||
fingerprint = self._fingerprint(plugin)
|
||||
state = self._load_state(state_path)
|
||||
|
||||
if (
|
||||
not python_path.exists()
|
||||
or not self._matches_python_version(venv_dir, plugin.python_version)
|
||||
or state.get("fingerprint") != fingerprint
|
||||
):
|
||||
self._rebuild(plugin, venv_dir, python_path)
|
||||
self._write_state(state_path, plugin, fingerprint)
|
||||
|
||||
return python_path
|
||||
|
||||
def _rebuild(self, plugin: PluginSpec, venv_dir: Path, python_path: Path) -> None:
|
||||
if venv_dir.exists():
|
||||
shutil.rmtree(venv_dir)
|
||||
|
||||
venv_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._run_command(
|
||||
[
|
||||
self.uv_binary,
|
||||
"venv",
|
||||
"--python",
|
||||
plugin.python_version,
|
||||
"--system-site-packages",
|
||||
"--no-python-downloads",
|
||||
"--no-managed-python",
|
||||
str(venv_dir),
|
||||
],
|
||||
cwd=self.repo_root,
|
||||
command_name=f"create venv for {plugin.name}",
|
||||
)
|
||||
|
||||
requirements_text = plugin.requirements_path.read_text(encoding="utf-8").strip()
|
||||
if not requirements_text:
|
||||
return
|
||||
|
||||
self._run_command(
|
||||
[
|
||||
self.uv_binary,
|
||||
"pip",
|
||||
"install",
|
||||
"--python",
|
||||
str(python_path),
|
||||
"-r",
|
||||
str(plugin.requirements_path),
|
||||
],
|
||||
cwd=plugin.plugin_dir,
|
||||
command_name=f"install requirements for {plugin.name}",
|
||||
)
|
||||
|
||||
def _run_command(
|
||||
self,
|
||||
command: list[str],
|
||||
*,
|
||||
cwd: Path,
|
||||
command_name: str,
|
||||
) -> None:
|
||||
logger.info(f"{command_name}: {' '.join(command)}")
|
||||
process = subprocess.run(
|
||||
command,
|
||||
cwd=str(cwd),
|
||||
env={**os.environ, "UV_CACHE_DIR": str(self.cache_dir)},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"{command_name} failed with exit code {process.returncode}: "
|
||||
f"{process.stderr.strip() or process.stdout.strip()}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _fingerprint(plugin: PluginSpec) -> str:
|
||||
requirements = plugin.requirements_path.read_text(encoding="utf-8")
|
||||
payload = {
|
||||
"python_version": plugin.python_version,
|
||||
"requirements": requirements,
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=True, sort_keys=True)
|
||||
|
||||
@staticmethod
|
||||
def _load_state(state_path: Path) -> dict[str, Any]:
|
||||
if not state_path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _write_state(state_path: Path, plugin: PluginSpec, fingerprint: str) -> None:
|
||||
state_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"plugin": plugin.name,
|
||||
"python_version": plugin.python_version,
|
||||
"fingerprint": fingerprint,
|
||||
},
|
||||
ensure_ascii=True,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _matches_python_version(venv_dir: Path, version: str) -> bool:
|
||||
pyvenv_cfg = venv_dir / "pyvenv.cfg"
|
||||
if not pyvenv_cfg.exists():
|
||||
return False
|
||||
try:
|
||||
content = pyvenv_cfg.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return False
|
||||
match = re.search(r"version\s*=\s*(\d+\.\d+)\.\d+", content, re.IGNORECASE)
|
||||
return match is not None and match.group(1) == version
|
||||
|
||||
|
||||
class WorkerRuntime:
|
||||
def __init__(
|
||||
self,
|
||||
plugin: PluginSpec,
|
||||
server: JSONRPCServer,
|
||||
repo_root: Path,
|
||||
env_manager: PluginEnvironmentManager,
|
||||
) -> None:
|
||||
self.plugin = plugin
|
||||
self.server = server
|
||||
self.repo_root = repo_root.resolve()
|
||||
self.env_manager = env_manager
|
||||
self.rpc_helper = RPCRequestHelper()
|
||||
self.client: StdioClient | None = None
|
||||
self.raw_handshake: dict[str, Any] = {}
|
||||
self.handlers: list[StarHandlerMetadata] = []
|
||||
self._context_requests: dict[str, str] = {}
|
||||
self._forwarded_call_ids: set[str] = set()
|
||||
|
||||
async def start(self) -> None:
|
||||
python_path = self.env_manager.prepare_environment(self.plugin)
|
||||
repo_src_dir = str(self.repo_root / "src")
|
||||
env = os.environ.copy()
|
||||
existing_pythonpath = env.get("PYTHONPATH")
|
||||
env["PYTHONPATH"] = (
|
||||
f"{repo_src_dir}{os.pathsep}{existing_pythonpath}"
|
||||
if existing_pythonpath
|
||||
else repo_src_dir
|
||||
)
|
||||
|
||||
self.client = StdioClient(
|
||||
command=[
|
||||
str(python_path),
|
||||
"-m",
|
||||
"astrbot_sdk",
|
||||
"worker",
|
||||
"--plugin-dir",
|
||||
str(self.plugin.plugin_dir),
|
||||
],
|
||||
cwd=str(self.plugin.plugin_dir),
|
||||
env=env,
|
||||
)
|
||||
self.client.set_message_handler(self._handle_message)
|
||||
await self.client.start()
|
||||
|
||||
response = await asyncio.wait_for(
|
||||
self.rpc_helper.call_rpc(
|
||||
self.client,
|
||||
HandshakeRequest(
|
||||
jsonrpc="2.0",
|
||||
id=self.rpc_helper._generate_request_id(),
|
||||
method="handshake",
|
||||
),
|
||||
),
|
||||
timeout=15.0,
|
||||
)
|
||||
if not isinstance(response, JSONRPCSuccessResponse):
|
||||
raise RuntimeError(f"Handshake failed for plugin {self.plugin.name}")
|
||||
|
||||
result = response.result
|
||||
if not isinstance(result, dict):
|
||||
raise RuntimeError(f"Invalid handshake payload for plugin {self.plugin.name}")
|
||||
|
||||
self.raw_handshake = result
|
||||
self.handlers = self._parse_handlers(result)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self.client is not None:
|
||||
await self.client.stop()
|
||||
|
||||
async def forward_call_handler(self, request: JSONRPCRequest) -> None:
|
||||
if self.client is None:
|
||||
raise RuntimeError(f"Worker for {self.plugin.name} is not running")
|
||||
if request.id is not None:
|
||||
self._forwarded_call_ids.add(str(request.id))
|
||||
await self.client.send_message(request)
|
||||
|
||||
async def handle_context_response(
|
||||
self, message: JSONRPCSuccessResponse | JSONRPCErrorResponse
|
||||
) -> bool:
|
||||
message_id = str(message.id)
|
||||
worker_request_id = self._context_requests.pop(message_id, None)
|
||||
if worker_request_id is None:
|
||||
return False
|
||||
if self.client is None:
|
||||
return True
|
||||
|
||||
if isinstance(message, JSONRPCSuccessResponse):
|
||||
await self.client.send_message(
|
||||
JSONRPCSuccessResponse(
|
||||
jsonrpc="2.0",
|
||||
id=worker_request_id,
|
||||
result=message.result,
|
||||
)
|
||||
)
|
||||
else:
|
||||
await self.client.send_message(
|
||||
JSONRPCErrorResponse(
|
||||
jsonrpc="2.0",
|
||||
id=worker_request_id,
|
||||
error=message.error,
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
async def _handle_message(self, message: JSONRPCMessage) -> None:
|
||||
if isinstance(message, (JSONRPCSuccessResponse, JSONRPCErrorResponse)):
|
||||
if message.id in self.rpc_helper.pending_requests:
|
||||
self.rpc_helper.resolve_pending_request(message)
|
||||
return
|
||||
|
||||
if message.id is not None and str(message.id) in self._forwarded_call_ids:
|
||||
self._forwarded_call_ids.discard(str(message.id))
|
||||
await self.server.send_message(message)
|
||||
return
|
||||
|
||||
if not isinstance(message, JSONRPCRequest):
|
||||
return
|
||||
|
||||
if message.method in [
|
||||
"handler_stream_start",
|
||||
"handler_stream_update",
|
||||
"handler_stream_end",
|
||||
]:
|
||||
await self.server.send_message(message)
|
||||
return
|
||||
|
||||
if message.method != "call_context_function":
|
||||
logger.warning(
|
||||
f"Worker {self.plugin.name} sent unknown request: {message.method}"
|
||||
)
|
||||
return
|
||||
|
||||
supervisor_request_id = (
|
||||
f"ctx:{self.plugin.name}:{message.id}"
|
||||
if message.id is not None
|
||||
else f"ctx:{self.plugin.name}:none"
|
||||
)
|
||||
self._context_requests[supervisor_request_id] = str(message.id)
|
||||
await self.server.send_message(
|
||||
JSONRPCRequest(
|
||||
jsonrpc="2.0",
|
||||
id=supervisor_request_id,
|
||||
method=message.method,
|
||||
params=message.params,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_handlers(handshake_payload: dict[str, Any]) -> list[StarHandlerMetadata]:
|
||||
handlers: list[StarHandlerMetadata] = []
|
||||
|
||||
def _placeholder_handler(*args, **kwargs):
|
||||
raise NotImplementedError("Worker supervisor does not execute handlers")
|
||||
|
||||
for star_info in handshake_payload.values():
|
||||
handlers_data = star_info.get("handlers") or []
|
||||
for handler_data in handlers_data:
|
||||
handlers.append(
|
||||
StarHandlerMetadata(
|
||||
event_type=EventType(handler_data["event_type"]),
|
||||
handler_full_name=handler_data["handler_full_name"],
|
||||
handler_name=handler_data["handler_name"],
|
||||
handler_module_path=handler_data["handler_module_path"],
|
||||
handler=_placeholder_handler,
|
||||
event_filters=[],
|
||||
desc=handler_data.get("desc", ""),
|
||||
extras_configs=handler_data.get("extras_configs", {}),
|
||||
)
|
||||
)
|
||||
return handlers
|
||||
|
||||
|
||||
class SupervisorRuntime:
|
||||
def __init__(
|
||||
self,
|
||||
server: JSONRPCServer,
|
||||
plugins_dir: Path,
|
||||
*,
|
||||
env_manager: PluginEnvironmentManager | None = None,
|
||||
worker_factory: Callable[
|
||||
[PluginSpec, JSONRPCServer, Path, PluginEnvironmentManager], WorkerRuntime
|
||||
]
|
||||
| None = None,
|
||||
) -> None:
|
||||
self.server = server
|
||||
self.plugins_dir = plugins_dir.resolve()
|
||||
self.repo_root = Path(__file__).resolve().parents[3]
|
||||
self.env_manager = env_manager or PluginEnvironmentManager(self.repo_root)
|
||||
self.worker_factory = worker_factory or WorkerRuntime
|
||||
self.loaded_plugins: list[str] = []
|
||||
self.skipped_plugins: dict[str, str] = {}
|
||||
self._workers_by_name: dict[str, WorkerRuntime] = {}
|
||||
self._handler_to_worker: dict[str, WorkerRuntime] = {}
|
||||
|
||||
async def start(self) -> None:
|
||||
discovery = discover_plugins(self.plugins_dir)
|
||||
self.skipped_plugins = dict(discovery.skipped_plugins)
|
||||
|
||||
for plugin in discovery.plugins:
|
||||
worker = self.worker_factory(
|
||||
plugin,
|
||||
self.server,
|
||||
self.repo_root,
|
||||
self.env_manager,
|
||||
)
|
||||
try:
|
||||
await worker.start()
|
||||
except Exception as exc:
|
||||
self.skipped_plugins[plugin.name] = str(exc)
|
||||
logger.error(f"Failed to start worker for {plugin.name}: {exc}")
|
||||
await worker.stop()
|
||||
continue
|
||||
|
||||
duplicate_handlers = [
|
||||
handler.handler_full_name
|
||||
for handler in worker.handlers
|
||||
if handler.handler_full_name in self._handler_to_worker
|
||||
]
|
||||
if duplicate_handlers:
|
||||
self.skipped_plugins[plugin.name] = (
|
||||
f"duplicate handlers: {', '.join(sorted(duplicate_handlers))}"
|
||||
)
|
||||
await worker.stop()
|
||||
continue
|
||||
|
||||
self._workers_by_name[plugin.name] = worker
|
||||
self.loaded_plugins.append(plugin.name)
|
||||
for handler in worker.handlers:
|
||||
self._handler_to_worker[handler.handler_full_name] = worker
|
||||
|
||||
self.loaded_plugins.sort()
|
||||
self.server.set_message_handler(self._handle_message)
|
||||
await self.server.start()
|
||||
self._log_startup_summary()
|
||||
|
||||
async def stop(self) -> None:
|
||||
for worker in list(self._workers_by_name.values()):
|
||||
await worker.stop()
|
||||
await self.server.stop()
|
||||
|
||||
async def _handle_message(self, message: JSONRPCMessage) -> None:
|
||||
if isinstance(message, JSONRPCRequest):
|
||||
if message.method == "handshake":
|
||||
await self.server.send_message(self._build_handshake_response(message.id))
|
||||
return
|
||||
if message.method == "call_handler":
|
||||
await self._route_call_handler(message)
|
||||
return
|
||||
logger.warning(f"Unknown method from core: {message.method}")
|
||||
return
|
||||
|
||||
for worker in self._workers_by_name.values():
|
||||
if await worker.handle_context_response(message):
|
||||
return
|
||||
|
||||
logger.warning(f"Received response for unknown request id: {message.id}")
|
||||
|
||||
def _build_handshake_response(
|
||||
self, request_id: str | None
|
||||
) -> JSONRPCSuccessResponse:
|
||||
payload: dict[str, Any] = {}
|
||||
for worker in self._workers_by_name.values():
|
||||
payload.update(worker.raw_handshake)
|
||||
return JSONRPCSuccessResponse(
|
||||
jsonrpc="2.0",
|
||||
id=request_id,
|
||||
result=payload,
|
||||
)
|
||||
|
||||
async def _route_call_handler(self, message: JSONRPCRequest) -> None:
|
||||
try:
|
||||
params = CallHandlerRequest.Params.model_validate(message.params)
|
||||
except Exception as exc:
|
||||
await self.server.send_message(
|
||||
JSONRPCErrorResponse(
|
||||
jsonrpc="2.0",
|
||||
id=message.id,
|
||||
error=JSONRPCErrorData(code=-32602, message=f"Invalid params: {exc}"),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
worker = self._handler_to_worker.get(params.handler_full_name)
|
||||
if worker is None:
|
||||
await self.server.send_message(
|
||||
JSONRPCErrorResponse(
|
||||
jsonrpc="2.0",
|
||||
id=message.id,
|
||||
error=JSONRPCErrorData(
|
||||
code=-32601,
|
||||
message=f"Handler not found: {params.handler_full_name}",
|
||||
),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
await worker.forward_call_handler(message)
|
||||
|
||||
def _log_startup_summary(self) -> None:
|
||||
loaded = ", ".join(self.loaded_plugins) if self.loaded_plugins else "none"
|
||||
logger.info(f"Loaded plugins: {loaded}")
|
||||
if not self.skipped_plugins:
|
||||
logger.info("Skipped plugins: none")
|
||||
return
|
||||
for plugin_name, reason in sorted(self.skipped_plugins.items()):
|
||||
logger.warning(f"Skipped plugin {plugin_name}: {reason}")
|
||||
323
src/astrbot_sdk/tests/test_supervisor.py
Normal file
323
src/astrbot_sdk/tests/test_supervisor.py
Normal file
@@ -0,0 +1,323 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from astrbot_sdk.runtime.rpc.jsonrpc import (
|
||||
JSONRPCRequest,
|
||||
JSONRPCSuccessResponse,
|
||||
)
|
||||
from astrbot_sdk.runtime.stars.registry import EventType, StarHandlerMetadata
|
||||
from astrbot_sdk.runtime.supervisor import (
|
||||
PluginEnvironmentManager,
|
||||
PluginSpec,
|
||||
SupervisorRuntime,
|
||||
WorkerRuntime,
|
||||
discover_plugins,
|
||||
)
|
||||
from astrbot_sdk.runtime.types import CallHandlerRequest
|
||||
|
||||
|
||||
def write_plugin(
|
||||
root: Path,
|
||||
folder_name: str,
|
||||
*,
|
||||
plugin_name: str | None = None,
|
||||
python_version: str | None = "3.12",
|
||||
include_requirements: bool = True,
|
||||
) -> Path:
|
||||
plugin_dir = root / folder_name
|
||||
commands_dir = plugin_dir / "commands"
|
||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||
(commands_dir / "__init__.py").write_text("", encoding="utf-8")
|
||||
|
||||
manifest: dict[str, Any] = {
|
||||
"_schema_version": 2,
|
||||
"name": plugin_name or folder_name,
|
||||
"display_name": folder_name,
|
||||
"desc": "test plugin",
|
||||
"author": "tester",
|
||||
"version": "0.1.0",
|
||||
"components": [
|
||||
{
|
||||
"class": "commands.sample:SampleCommand",
|
||||
"type": "command",
|
||||
"name": "hello",
|
||||
"description": "hello",
|
||||
}
|
||||
],
|
||||
}
|
||||
if python_version is not None:
|
||||
manifest["runtime"] = {"python": python_version}
|
||||
|
||||
(plugin_dir / "plugin.yaml").write_text(
|
||||
yaml.safe_dump(manifest, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
if include_requirements:
|
||||
(plugin_dir / "requirements.txt").write_text("", encoding="utf-8")
|
||||
return plugin_dir
|
||||
|
||||
|
||||
class FakeServer:
|
||||
def __init__(self) -> None:
|
||||
self.handler = None
|
||||
self.sent_messages: list[Any] = []
|
||||
|
||||
def set_message_handler(self, handler) -> None:
|
||||
self.handler = handler
|
||||
|
||||
async def start(self) -> None:
|
||||
return None
|
||||
|
||||
async def stop(self) -> None:
|
||||
return None
|
||||
|
||||
async def send_message(self, message) -> None:
|
||||
self.sent_messages.append(message)
|
||||
|
||||
|
||||
class FakeEnvManager(PluginEnvironmentManager):
|
||||
def __init__(self) -> None:
|
||||
self.prepared: list[str] = []
|
||||
|
||||
def prepare_environment(self, plugin: PluginSpec) -> Path:
|
||||
self.prepared.append(plugin.name)
|
||||
return Path("/tmp/fake-python")
|
||||
|
||||
|
||||
class FakeWorkerRuntime(WorkerRuntime):
|
||||
def __init__(
|
||||
self,
|
||||
plugin: PluginSpec,
|
||||
server,
|
||||
repo_root: Path,
|
||||
env_manager: PluginEnvironmentManager,
|
||||
) -> None:
|
||||
self.plugin = plugin
|
||||
self.server = server
|
||||
self.repo_root = repo_root
|
||||
self.env_manager = env_manager
|
||||
self.raw_handshake: dict[str, Any] = {}
|
||||
self.handlers: list[StarHandlerMetadata] = []
|
||||
self.forwarded_requests: list[JSONRPCRequest] = []
|
||||
self.received_context_responses: list[Any] = []
|
||||
self.stopped = False
|
||||
|
||||
async def start(self) -> None:
|
||||
handler_full_name = (
|
||||
f"commands.{self.plugin.name}:SampleCommand.handle_{self.plugin.name}"
|
||||
)
|
||||
self.raw_handshake = {
|
||||
f"{self.plugin.name}.main": {
|
||||
"name": self.plugin.name,
|
||||
"author": "tester",
|
||||
"desc": "test plugin",
|
||||
"version": "0.1.0",
|
||||
"repo": None,
|
||||
"module_path": f"{self.plugin.name}.main",
|
||||
"root_dir_name": self.plugin.plugin_dir.name,
|
||||
"reserved": False,
|
||||
"activated": True,
|
||||
"config": None,
|
||||
"star_handler_full_names": [handler_full_name],
|
||||
"display_name": self.plugin.name,
|
||||
"logo_path": None,
|
||||
"handlers": [
|
||||
{
|
||||
"event_type": EventType.AdapterMessageEvent.value,
|
||||
"handler_full_name": handler_full_name,
|
||||
"handler_name": f"handle_{self.plugin.name}",
|
||||
"handler_module_path": f"commands.{self.plugin.name}",
|
||||
"desc": "",
|
||||
"extras_configs": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
self.handlers = [
|
||||
StarHandlerMetadata(
|
||||
event_type=EventType.AdapterMessageEvent,
|
||||
handler_full_name=handler_full_name,
|
||||
handler_name=f"handle_{self.plugin.name}",
|
||||
handler_module_path=f"commands.{self.plugin.name}",
|
||||
handler=lambda *args, **kwargs: None,
|
||||
event_filters=[],
|
||||
)
|
||||
]
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.stopped = True
|
||||
|
||||
async def forward_call_handler(self, request: JSONRPCRequest) -> None:
|
||||
self.forwarded_requests.append(request)
|
||||
handler_full_name = self.handlers[0].handler_full_name
|
||||
await self.server.send_message(
|
||||
JSONRPCRequest(
|
||||
jsonrpc="2.0",
|
||||
method="handler_stream_start",
|
||||
params={
|
||||
"id": request.id,
|
||||
"handler_full_name": handler_full_name,
|
||||
},
|
||||
)
|
||||
)
|
||||
await self.server.send_message(
|
||||
JSONRPCRequest(
|
||||
jsonrpc="2.0",
|
||||
method="handler_stream_update",
|
||||
params={
|
||||
"id": request.id,
|
||||
"handler_full_name": handler_full_name,
|
||||
"data": {"plugin": self.plugin.name},
|
||||
},
|
||||
)
|
||||
)
|
||||
await self.server.send_message(
|
||||
JSONRPCRequest(
|
||||
jsonrpc="2.0",
|
||||
method="handler_stream_end",
|
||||
params={
|
||||
"id": request.id,
|
||||
"handler_full_name": handler_full_name,
|
||||
},
|
||||
)
|
||||
)
|
||||
await self.server.send_message(
|
||||
JSONRPCSuccessResponse(
|
||||
jsonrpc="2.0",
|
||||
id=request.id,
|
||||
result={"handled_by": self.plugin.name},
|
||||
)
|
||||
)
|
||||
|
||||
async def handle_context_response(self, message) -> bool:
|
||||
if message.id != f"ctx:{self.plugin.name}:1":
|
||||
return False
|
||||
self.received_context_responses.append(message)
|
||||
return True
|
||||
|
||||
|
||||
class DiscoverPluginsTest(unittest.TestCase):
|
||||
def test_discover_plugins_requires_runtime_python(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
write_plugin(root, "plugin_one", plugin_name="plugin_one")
|
||||
write_plugin(
|
||||
root,
|
||||
"plugin_two",
|
||||
plugin_name="plugin_two",
|
||||
python_version=None,
|
||||
)
|
||||
write_plugin(
|
||||
root,
|
||||
"plugin_three",
|
||||
plugin_name="plugin_three",
|
||||
include_requirements=False,
|
||||
)
|
||||
|
||||
discovery = discover_plugins(root)
|
||||
|
||||
self.assertEqual([plugin.name for plugin in discovery.plugins], ["plugin_one"])
|
||||
self.assertIn("plugin_two", discovery.skipped_plugins)
|
||||
self.assertIn("plugin_three", discovery.skipped_plugins)
|
||||
|
||||
|
||||
class SupervisorRuntimeTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.plugins_dir = Path(self.temp_dir.name)
|
||||
write_plugin(self.plugins_dir, "plugin_one", plugin_name="plugin_one")
|
||||
write_plugin(self.plugins_dir, "plugin_two", plugin_name="plugin_two")
|
||||
self.server = FakeServer()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
async def test_handshake_aggregates_workers_and_routes_call_handler(self) -> None:
|
||||
runtime = SupervisorRuntime(
|
||||
server=self.server,
|
||||
plugins_dir=self.plugins_dir,
|
||||
env_manager=FakeEnvManager(),
|
||||
worker_factory=FakeWorkerRuntime,
|
||||
)
|
||||
await runtime.start()
|
||||
|
||||
await self.server.handler(
|
||||
JSONRPCRequest(jsonrpc="2.0", id="handshake-1", method="handshake")
|
||||
)
|
||||
handshake_response = self.server.sent_messages[-1]
|
||||
self.assertIsInstance(handshake_response, JSONRPCSuccessResponse)
|
||||
self.assertEqual(
|
||||
sorted(handshake_response.result.keys()),
|
||||
["plugin_one.main", "plugin_two.main"],
|
||||
)
|
||||
|
||||
handler_full_name = (
|
||||
"commands.plugin_two:SampleCommand.handle_plugin_two"
|
||||
)
|
||||
await self.server.handler(
|
||||
CallHandlerRequest(
|
||||
jsonrpc="2.0",
|
||||
id="call-1",
|
||||
method="call_handler",
|
||||
params=CallHandlerRequest.Params(
|
||||
handler_full_name=handler_full_name,
|
||||
event={
|
||||
"message_str": "hello",
|
||||
"message_obj": {
|
||||
"type": "FriendMessage",
|
||||
"self_id": "bot",
|
||||
"session_id": "session",
|
||||
"message_id": "message-id",
|
||||
"sender": {"user_id": "user-1", "nickname": "User 1"},
|
||||
"message": [],
|
||||
"message_str": "hello",
|
||||
"raw_message": {},
|
||||
"timestamp": 0,
|
||||
},
|
||||
"platform_meta": {
|
||||
"name": "fake",
|
||||
"description": "fake",
|
||||
"id": "fake-1",
|
||||
},
|
||||
"session_id": "session",
|
||||
"is_at_or_wake_command": True,
|
||||
},
|
||||
args={},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(self.server.sent_messages[-1].result, {"handled_by": "plugin_two"})
|
||||
await runtime.stop()
|
||||
|
||||
async def test_routes_context_response_back_to_matching_worker(self) -> None:
|
||||
runtime = SupervisorRuntime(
|
||||
server=self.server,
|
||||
plugins_dir=self.plugins_dir,
|
||||
env_manager=FakeEnvManager(),
|
||||
worker_factory=FakeWorkerRuntime,
|
||||
)
|
||||
await runtime.start()
|
||||
|
||||
await self.server.handler(
|
||||
JSONRPCSuccessResponse(
|
||||
jsonrpc="2.0",
|
||||
id="ctx:plugin_one:1",
|
||||
result={"data": "ok"},
|
||||
)
|
||||
)
|
||||
|
||||
worker = runtime._workers_by_name["plugin_one"]
|
||||
self.assertEqual(len(worker.received_context_responses), 1)
|
||||
await runtime.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,6 +1,7 @@
|
||||
from astrbot_sdk.api.components.command import CommandComponent
|
||||
from astrbot_sdk.api.event import AstrMessageEvent, filter
|
||||
from astrbot_sdk.api.star.context import Context
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class HelloCommand(CommandComponent):
|
||||
@@ -10,7 +11,7 @@ class HelloCommand(CommandComponent):
|
||||
@filter.command("hello")
|
||||
async def hello(self, event: AstrMessageEvent):
|
||||
ret = await self.context.conversation_manager.new_conversation("hello")
|
||||
print(f"New conversation created: {ret}")
|
||||
logger.info(f"New conversation created: {ret}")
|
||||
yield event.plain_result(f"Hello, Astrbot! Created conversation ID: {ret}")
|
||||
yield event.plain_result("Hello, Astrbot!")
|
||||
yield event.plain_result("Hello again, Astrbot!")
|
||||
|
||||
@@ -4,6 +4,8 @@ display_name: HelloWorld 插件
|
||||
desc: 一个简单的问候插件示例
|
||||
author: Soulter
|
||||
version: 0.1.0
|
||||
runtime:
|
||||
python: "3.12"
|
||||
components: # 组件列表,将支持自动生成
|
||||
- class: commands.hello:HelloCommand
|
||||
type: command
|
||||
|
||||
1
test_plugin/requirements.txt
Normal file
1
test_plugin/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user