Files
AstrBot/astrbot/core/plugin/client.py
LIghtJUNction 2a5e55641d feat(abp): initial ABP protocol implementation
- 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
2026-03-27 18:07:09 +08:00

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)