mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-17 01:49:15 +08:00
- Add ABP (AstrBot Plugin) protocol Python package (astrbot/core/plugin/) - PluginManager for plugin lifecycle management - PluginClient for out-of-process plugin communication - Transport layer (Stdio, Unix Socket, HTTP) - Data models and constants - Add ABP error codes to Rust error.rs (-32700 to -32211) - Fix Rust server.rs compilation issues - Fix UNIX_EPOODY typo - Fix mutable borrow issues - Fix API response type mismatches - Update _core.pyi type stubs with ABP types - Add openspec change archive for ABP protocol implementation
320 lines
10 KiB
Python
320 lines
10 KiB
Python
"""ABP Plugin Client for out-of-process communication"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Any
|
|
|
|
from .const import (
|
|
ABP_VERSION,
|
|
)
|
|
from .models import (
|
|
HandleEventResult,
|
|
InitializeParams,
|
|
InitializeResult,
|
|
MessageChainItem,
|
|
PluginCapabilities,
|
|
PluginConfig,
|
|
PluginMetadata,
|
|
Tool,
|
|
ToolContent,
|
|
ToolResult,
|
|
)
|
|
from .transport import HttpTransport, StdioTransport, Transport, UnixSocketTransport
|
|
|
|
logger = logging.getLogger("astrbot.plugin.client")
|
|
|
|
|
|
class PluginClient:
|
|
"""Client for communicating with out-of-process plugins.
|
|
|
|
This client implements the ABP protocol for plugin communication,
|
|
supporting JSON-RPC 2.0 over various transports.
|
|
"""
|
|
|
|
def __init__(self, config: PluginConfig) -> None:
|
|
self._config = config
|
|
self._transport: Transport | None = None
|
|
self._initialized = False
|
|
self._capabilities = PluginCapabilities()
|
|
self._metadata: PluginMetadata | None = None
|
|
self._tools: list[Tool] = []
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return self._config.name
|
|
|
|
@property
|
|
def is_initialized(self) -> bool:
|
|
return self._initialized
|
|
|
|
@property
|
|
def capabilities(self) -> PluginCapabilities:
|
|
return self._capabilities
|
|
|
|
@property
|
|
def metadata(self) -> PluginMetadata | None:
|
|
return self._metadata
|
|
|
|
@property
|
|
def tools(self) -> list[Tool]:
|
|
return self._tools
|
|
|
|
async def start(
|
|
self, process: asyncio.subprocess.Process | None = None
|
|
) -> InitializeResult:
|
|
"""Start and initialize the plugin.
|
|
|
|
Args:
|
|
process: For stdio transport, the subprocess handle
|
|
|
|
Returns:
|
|
InitializeResult from the plugin
|
|
"""
|
|
# Create transport based on config
|
|
if self._config.transport == "stdio":
|
|
if not process:
|
|
raise ValueError("stdio transport requires a subprocess")
|
|
self._transport = StdioTransport(process)
|
|
elif self._config.transport == "unix_socket":
|
|
if not self._config.url:
|
|
raise ValueError("unix_socket transport requires socket path in url")
|
|
self._transport = UnixSocketTransport(self._config.url)
|
|
await self._transport.connect()
|
|
elif self._config.transport == "http":
|
|
if not self._config.url:
|
|
raise ValueError("http transport requires URL")
|
|
self._transport = HttpTransport(self._config.url)
|
|
await self._transport.connect()
|
|
else:
|
|
raise ValueError(f"Unknown transport: {self._config.transport}")
|
|
|
|
# Send initialize request
|
|
result = await self._send_initialize()
|
|
|
|
# Parse result
|
|
self._capabilities = PluginCapabilities(
|
|
tools=result.capabilities.tools,
|
|
handlers=result.capabilities.handlers,
|
|
events=result.capabilities.events,
|
|
resources=result.capabilities.resources,
|
|
)
|
|
self._metadata = result.metadata
|
|
self._initialized = True
|
|
|
|
return result
|
|
|
|
async def _send_initialize(self) -> InitializeResult:
|
|
"""Send initialize request to plugin."""
|
|
assert self._transport
|
|
|
|
# Get data directories
|
|
from pathlib import Path
|
|
|
|
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
|
|
|
data_path = Path(get_astrbot_data_path())
|
|
plugin_data_path = data_path / "plugins" / self._config.name
|
|
|
|
params = InitializeParams(
|
|
protocol_version=ABP_VERSION,
|
|
client_info={"name": "astrbot", "version": "4.25.0"},
|
|
capabilities={
|
|
"streaming": True,
|
|
"events": True,
|
|
},
|
|
plugin_config={"user_config": {}},
|
|
data_dirs={
|
|
"root": str(data_path),
|
|
"plugin_data": str(plugin_data_path),
|
|
"temp": str(data_path / "temp"),
|
|
},
|
|
)
|
|
|
|
result = await self._transport.send(
|
|
"initialize",
|
|
{
|
|
"protocolVersion": params.protocol_version,
|
|
"clientInfo": params.client_info,
|
|
"capabilities": params.capabilities,
|
|
"pluginConfig": params.plugin_config,
|
|
"dataDirs": params.data_dirs,
|
|
},
|
|
)
|
|
|
|
return InitializeResult(
|
|
protocol_version=result.get("protocolVersion", ABP_VERSION),
|
|
server_info=result.get(
|
|
"serverInfo", {"name": self._config.name, "version": "1.0.0"}
|
|
),
|
|
capabilities=PluginCapabilities(
|
|
tools=result.get("capabilities", {}).get("tools", False),
|
|
handlers=result.get("capabilities", {}).get("handlers", False),
|
|
events=result.get("capabilities", {}).get("events", False),
|
|
resources=result.get("capabilities", {}).get("resources", False),
|
|
),
|
|
config_schema=result.get("configSchema"),
|
|
metadata=PluginMetadata(
|
|
display_name=result.get("metadata", {}).get("display_name"),
|
|
description=result.get("metadata", {}).get("description"),
|
|
author=result.get("metadata", {}).get("author"),
|
|
homepage=result.get("metadata", {}).get("homepage"),
|
|
support_platforms=result.get("metadata", {}).get(
|
|
"support_platforms", []
|
|
),
|
|
astrbot_version=result.get("metadata", {}).get("astrbot_version"),
|
|
),
|
|
)
|
|
|
|
async def list_tools(self) -> list[Tool]:
|
|
"""List available tools from plugin."""
|
|
if not self._initialized:
|
|
raise RuntimeError("Plugin not initialized")
|
|
assert self._transport
|
|
|
|
result = await self._transport.send("tools/list", {})
|
|
|
|
self._tools = [
|
|
Tool(
|
|
name=t.get("name", ""),
|
|
description=t.get("description", ""),
|
|
input_schema=t.get("parameters", {}),
|
|
)
|
|
for t in result.get("tools", [])
|
|
]
|
|
|
|
return self._tools
|
|
|
|
async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> ToolResult:
|
|
"""Call a tool on the plugin."""
|
|
if not self._initialized:
|
|
raise RuntimeError("Plugin not initialized")
|
|
assert self._transport
|
|
|
|
result = await self._transport.send(
|
|
"tools/call",
|
|
{
|
|
"name": tool_name,
|
|
"arguments": arguments,
|
|
},
|
|
)
|
|
|
|
return ToolResult(
|
|
content=[
|
|
ToolContent(
|
|
type=c.get("type", "text"),
|
|
text=c.get("text"),
|
|
image=c.get("image"),
|
|
url=c.get("url"),
|
|
)
|
|
for c in result.get("content", [])
|
|
]
|
|
)
|
|
|
|
async def handle_event(
|
|
self, event_type: str, event_data: dict[str, Any]
|
|
) -> HandleEventResult:
|
|
"""Handle an event with the plugin."""
|
|
if not self._initialized:
|
|
raise RuntimeError("Plugin not initialized")
|
|
assert self._transport
|
|
|
|
result = await self._transport.send(
|
|
"plugin.handle_event",
|
|
{
|
|
"event_type": event_type,
|
|
"event": event_data,
|
|
},
|
|
)
|
|
|
|
return HandleEventResult(
|
|
handled=result.get("handled", False),
|
|
results=[
|
|
MessageChainItem(type=r.get("type", "plain"), text=r.get("text"))
|
|
for r in result.get("results", [])
|
|
],
|
|
stop_propagation=result.get("stop_propagation", False),
|
|
)
|
|
|
|
async def notify(self, event_type: str, data: dict[str, Any]) -> None:
|
|
"""Send an event notification to the plugin."""
|
|
if not self._initialized:
|
|
raise RuntimeError("Plugin not initialized")
|
|
assert self._transport
|
|
|
|
await self._transport.notify(
|
|
"plugin.notify",
|
|
{
|
|
"event_type": event_type,
|
|
"data": data,
|
|
},
|
|
)
|
|
|
|
async def stop(self) -> None:
|
|
"""Stop the plugin."""
|
|
if self._transport:
|
|
await self._transport.close()
|
|
self._transport = None
|
|
self._initialized = False
|
|
|
|
async def reload(self) -> None:
|
|
"""Reload the plugin (re-initialize without restarting process)."""
|
|
if self._transport:
|
|
await self._transport.send("plugin.reload", {})
|
|
self._initialized = False
|
|
|
|
async def update_config(self, user_config: dict[str, Any]) -> None:
|
|
"""Update plugin configuration."""
|
|
if not self._initialized:
|
|
raise RuntimeError("Plugin not initialized")
|
|
assert self._transport
|
|
|
|
await self._transport.notify(
|
|
"plugin.config_update",
|
|
{"user_config": user_config},
|
|
)
|
|
|
|
async def subscribe(self, event_type: str) -> None:
|
|
"""Subscribe to an event type.
|
|
|
|
Args:
|
|
event_type: Event type to subscribe to (e.g., "llm_request", "tool_called")
|
|
"""
|
|
if not self._initialized:
|
|
raise RuntimeError("Plugin not initialized")
|
|
assert self._transport
|
|
|
|
await self._transport.send(
|
|
"plugin.subscribe",
|
|
{"event_type": event_type},
|
|
)
|
|
logger.debug(f"Plugin {self._config.name} subscribed to {event_type}")
|
|
|
|
async def unsubscribe(self, event_type: str) -> None:
|
|
"""Unsubscribe from an event type.
|
|
|
|
Args:
|
|
event_type: Event type to unsubscribe from
|
|
"""
|
|
if not self._initialized:
|
|
raise RuntimeError("Plugin not initialized")
|
|
assert self._transport
|
|
|
|
await self._transport.send(
|
|
"plugin.unsubscribe",
|
|
{"event_type": event_type},
|
|
)
|
|
logger.debug(f"Plugin {self._config.name} unsubscribed from {event_type}")
|
|
|
|
async def send_notification(self, method: str, params: dict[str, Any]) -> None:
|
|
"""Send a JSON-RPC notification (no response expected).
|
|
|
|
Args:
|
|
method: The JSON-RPC method name
|
|
params: The method parameters
|
|
"""
|
|
if not self._initialized:
|
|
raise RuntimeError("Plugin not initialized")
|
|
assert self._transport
|
|
|
|
await self._transport.notify(method, params)
|