delete old sdk (#7)

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
This commit is contained in:
whatevertogo
2026-03-14 17:49:47 +08:00
committed by GitHub
parent 7df01225d5
commit d5430f7a94
64 changed files with 0 additions and 7537 deletions

View File

@@ -1,168 +0,0 @@
# Inspired by MoonshotAI/kosong, credits to MoonshotAI/kosong authors for the original implementation.
# License: Apache License 2.0
from typing import Any, ClassVar, Literal, cast
from pydantic import BaseModel, GetCoreSchemaHandler
from pydantic_core import core_schema
class ContentPart(BaseModel):
"""A part of the content in a message."""
__content_part_registry: ClassVar[dict[str, type["ContentPart"]]] = {}
type: str
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
invalid_subclass_error_msg = f"ContentPart subclass {cls.__name__} must have a `type` field of type `str`"
type_value = getattr(cls, "type", None)
if type_value is None or not isinstance(type_value, str):
raise ValueError(invalid_subclass_error_msg)
cls.__content_part_registry[type_value] = cls
@classmethod
def __get_pydantic_core_schema__(
cls, source_type: Any, handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
# If we're dealing with the base ContentPart class, use custom validation
if cls.__name__ == "ContentPart":
def validate_content_part(value: Any) -> Any:
# if it's already an instance of a ContentPart subclass, return it
if hasattr(value, "__class__") and issubclass(value.__class__, cls):
return value
# if it's a dict with a type field, dispatch to the appropriate subclass
if isinstance(value, dict) and "type" in value:
type_value: Any | None = cast(dict[str, Any], value).get("type")
if not isinstance(type_value, str):
raise ValueError(f"Cannot validate {value} as ContentPart")
target_class = cls.__content_part_registry[type_value]
return target_class.model_validate(value)
raise ValueError(f"Cannot validate {value} as ContentPart")
return core_schema.no_info_plain_validator_function(validate_content_part)
# for subclasses, use the default schema
return handler(source_type)
class TextPart(ContentPart):
"""
>>> TextPart(text="Hello, world!").model_dump()
{'type': 'text', 'text': 'Hello, world!'}
"""
type: str = "text"
text: str
class ImageURLPart(ContentPart):
"""
>>> ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
{'type': 'image_url', 'image_url': 'http://example.com/image.jpg'}
"""
class ImageURL(BaseModel):
url: str
"""The URL of the image, can be data URI scheme like `data:image/png;base64,...`."""
id: str | None = None
"""The ID of the image, to allow LLMs to distinguish different images."""
type: str = "image_url"
image_url: str
class AudioURLPart(ContentPart):
"""
>>> AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
{'type': 'audio_url', 'audio_url': {'url': 'https://example.com/audio.mp3', 'id': None}}
"""
class AudioURL(BaseModel):
url: str
"""The URL of the audio, can be data URI scheme like `data:audio/aac;base64,...`."""
id: str | None = None
"""The ID of the audio, to allow LLMs to distinguish different audios."""
type: str = "audio_url"
audio_url: AudioURL
class ToolCall(BaseModel):
"""
A tool call requested by the assistant.
>>> ToolCall(
... id="123",
... function=ToolCall.FunctionBody(
... name="function",
... arguments="{}"
... ),
... ).model_dump()
{'type': 'function', 'id': '123', 'function': {'name': 'function', 'arguments': '{}'}}
"""
class FunctionBody(BaseModel):
name: str
arguments: str | None
type: Literal["function"] = "function"
id: str
"""The ID of the tool call."""
function: FunctionBody
"""The function body of the tool call."""
class ToolCallPart(BaseModel):
"""A part of the tool call."""
arguments_part: str | None = None
"""A part of the arguments of the tool call."""
class Message(BaseModel):
"""A message in a conversation."""
role: Literal[
"system",
"user",
"assistant",
"tool",
]
content: str | list[ContentPart]
"""The content of the message."""
class AssistantMessageSegment(Message):
"""A message segment from the assistant."""
role: Literal["assistant"] = "assistant"
tool_calls: list[ToolCall] | list[dict] | None = None
class ToolCallMessageSegment(Message):
"""A message segment representing a tool call."""
role: Literal["tool"] = "tool"
tool_call_id: str
class UserMessageSegment(Message):
"""A message segment from the user."""
role: Literal["user"] = "user"
class SystemMessageSegment(Message):
"""A message segment from the system."""
role: Literal["system"] = "system"

View File

@@ -1,17 +0,0 @@
from dataclasses import dataclass
from typing import Any, Generic
from typing_extensions import TypeVar
TContext = TypeVar("TContext", default=Any)
@dataclass
class ContextWrapper(Generic[TContext]):
"""A context for running an agent, which can be used to pass additional data or state."""
context: TContext
tool_call_timeout: int = 60 # Default tool call timeout in seconds
NoContext = ContextWrapper[None]

View File

@@ -1,286 +0,0 @@
from collections.abc import Awaitable, Callable
from typing import Any, Generic
import jsonschema
import mcp
from deprecated import deprecated
from pydantic import model_validator
from pydantic.dataclasses import dataclass
from .run_context import ContextWrapper, TContext
ParametersType = dict[str, Any]
@dataclass
class ToolSchema:
"""A class representing the schema of a tool for function calling."""
name: str
"""The name of the tool."""
description: str
"""The description of the tool."""
parameters: ParametersType
"""The parameters of the tool, in JSON Schema format."""
@model_validator(mode="after")
def validate_parameters(self) -> "ToolSchema":
jsonschema.validate(
self.parameters, jsonschema.Draft202012Validator.META_SCHEMA
)
return self
@dataclass
class FunctionTool(ToolSchema, Generic[TContext]):
"""A callable tool, for function calling."""
handler: Callable[..., Awaitable[Any]] | None = None
"""a callable that implements the tool's functionality. It should be an async function."""
handler_module_path: str | None = None
"""
The module path of the handler function. This is empty when the origin is mcp.
This field must be retained, as the handler will be wrapped in functools.partial during initialization,
causing the handler's __module__ to be functools
"""
active: bool = True
"""
Whether the tool is active. This field is a special field for AstrBot.
You can ignore it when integrating with other frameworks.
"""
def __repr__(self):
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})"
async def call(
self, context: ContextWrapper[TContext], **kwargs
) -> str | mcp.types.CallToolResult:
"""Run the tool with the given arguments. The handler field has priority."""
raise NotImplementedError(
"FunctionTool.call() must be implemented by subclasses or set a handler."
)
class ToolSet:
"""A set of function tools that can be used in function calling.
This class provides methods to add, remove, and retrieve tools, as well as
convert the tools to different API formats (OpenAI, Anthropic, Google GenAI).
"""
def __init__(self, tools: list[FunctionTool] | None = None):
self.tools: list[FunctionTool] = tools or []
def empty(self) -> bool:
"""Check if the tool set is empty."""
return len(self.tools) == 0
def add_tool(self, tool: FunctionTool):
"""Add a tool to the set."""
# 检查是否已存在同名工具
for i, existing_tool in enumerate(self.tools):
if existing_tool.name == tool.name:
self.tools[i] = tool
return
self.tools.append(tool)
def remove_tool(self, name: str):
"""Remove a tool by its name."""
self.tools = [tool for tool in self.tools if tool.name != name]
def get_tool(self, name: str) -> FunctionTool | None:
"""Get a tool by its name."""
for tool in self.tools:
if tool.name == name:
return tool
return None
@deprecated(reason="Use add_tool() instead", version="4.0.0")
def add_func(
self,
name: str,
func_args: list,
desc: str,
handler: Callable[..., Awaitable[Any]],
):
"""Add a function tool to the set."""
params = {
"type": "object", # hard-coded here
"properties": {},
}
for param in func_args:
params["properties"][param["name"]] = {
"type": param["type"],
"description": param["description"],
}
_func = FunctionTool(
name=name,
parameters=params,
description=desc,
handler=handler,
)
self.add_tool(_func)
@deprecated(reason="Use remove_tool() instead", version="4.0.0")
def remove_func(self, name: str):
"""Remove a function tool by its name."""
self.remove_tool(name)
@deprecated(reason="Use get_tool() instead", version="4.0.0")
def get_func(self, name: str) -> FunctionTool | None:
"""Get all function tools."""
return self.get_tool(name)
@property
def func_list(self) -> list[FunctionTool]:
"""Get the list of function tools."""
return self.tools
def openai_schema(self, omit_empty_parameter_field: bool = False) -> list[dict]:
"""Convert tools to OpenAI API function calling schema format."""
result = []
for tool in self.tools:
func_def = {
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
},
}
if (
tool.parameters and tool.parameters.get("properties")
) or not omit_empty_parameter_field:
func_def["function"]["parameters"] = tool.parameters
result.append(func_def)
return result
def anthropic_schema(self) -> list[dict]:
"""Convert tools to Anthropic API format."""
result = []
for tool in self.tools:
input_schema = {"type": "object"}
if tool.parameters:
input_schema["properties"] = tool.parameters.get("properties", {})
input_schema["required"] = tool.parameters.get("required", [])
tool_def = {
"name": tool.name,
"description": tool.description,
"input_schema": input_schema,
}
result.append(tool_def)
return result
def google_schema(self) -> dict:
"""Convert tools to Google GenAI API format."""
def convert_schema(schema: dict) -> dict:
"""Convert schema to Gemini API format."""
supported_types = {
"string",
"number",
"integer",
"boolean",
"array",
"object",
"null",
}
supported_formats = {
"string": {"enum", "date-time"},
"integer": {"int32", "int64"},
"number": {"float", "double"},
}
if "anyOf" in schema:
return {"anyOf": [convert_schema(s) for s in schema["anyOf"]]}
result = {}
if "type" in schema and schema["type"] in supported_types:
result["type"] = schema["type"]
if "format" in schema and schema["format"] in supported_formats.get(
result["type"],
set(),
):
result["format"] = schema["format"]
else:
result["type"] = "null"
support_fields = {
"title",
"description",
"enum",
"minimum",
"maximum",
"maxItems",
"minItems",
"nullable",
"required",
}
result.update({k: schema[k] for k in support_fields if k in schema})
if "properties" in schema:
properties = {}
for key, value in schema["properties"].items():
prop_value = convert_schema(value)
if "default" in prop_value:
del prop_value["default"]
properties[key] = prop_value
if properties:
result["properties"] = properties
if "items" in schema:
result["items"] = convert_schema(schema["items"])
return result
tools = []
for tool in self.tools:
d: dict[str, Any] = {
"name": tool.name,
"description": tool.description,
}
if tool.parameters:
d["parameters"] = convert_schema(tool.parameters)
tools.append(d)
declarations = {}
if tools:
declarations["function_declarations"] = tools
return declarations
@deprecated(reason="Use openai_schema() instead", version="4.0.0")
def get_func_desc_openai_style(self, omit_empty_parameter_field: bool = False):
return self.openai_schema(omit_empty_parameter_field)
@deprecated(reason="Use anthropic_schema() instead", version="4.0.0")
def get_func_desc_anthropic_style(self):
return self.anthropic_schema()
@deprecated(reason="Use google_schema() instead", version="4.0.0")
def get_func_desc_google_genai_style(self):
return self.google_schema()
def names(self) -> list[str]:
"""获取所有工具的名称列表"""
return [tool.name for tool in self.tools]
def __len__(self):
return len(self.tools)
def __bool__(self):
return len(self.tools) > 0
def __iter__(self):
return iter(self.tools)
def __repr__(self):
return f"ToolSet(tools={self.tools})"
def __str__(self):
return f"ToolSet(tools={self.tools})"

View File

@@ -1,5 +0,0 @@
from .cli.main import cli
if __name__ == "__main__":
cli()

View File

@@ -1 +0,0 @@
class AstrBotConfig(dict): ...

View File

@@ -1,224 +0,0 @@
from astr_agent_sdk.message import AssistantMessageSegment, UserMessageSegment
from ...api.basic.entities import Conversation
class BaseConversationManager:
"""负责管理会话与 LLM 的对话,某个会话当前正在用哪个对话。"""
async def _trigger_session_deleted(self, unified_msg_origin: str) -> None:
"""触发会话删除回调.
Args:
unified_msg_origin: 会话ID
"""
...
async def new_conversation(
self,
unified_msg_origin: str,
platform_id: str | None = None,
content: list[dict] | None = None,
title: str | None = None,
persona_id: str | None = None,
) -> str:
"""新建对话,并将当前会话的对话转移到新对话.
Args:
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
Returns:
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
"""
...
async def switch_conversation(
self, unified_msg_origin: str, conversation_id: str
) -> None:
"""切换会话的对话
Args:
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
"""
...
async def delete_conversation(
self,
unified_msg_origin: str,
conversation_id: str | None = None,
):
"""删除会话的对话,当 conversation_id 为 None 时删除会话当前的对话
Args:
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
"""
...
async def delete_conversations_by_user_id(self, unified_msg_origin: str):
"""删除会话的所有对话
Args:
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
"""
...
async def get_curr_conversation_id(self, unified_msg_origin: str) -> str | None:
"""获取会话当前的对话 ID
Args:
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
Returns:
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
"""
...
async def get_conversation(
self,
unified_msg_origin: str,
conversation_id: str,
create_if_not_exists: bool = False,
) -> Conversation | None:
"""获取会话的对话.
Args:
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
create_if_not_exists (bool): 如果对话不存在,是否创建一个新的对话
Returns:
conversation (Conversation): 对话对象
"""
...
async def get_conversations(
self,
unified_msg_origin: str | None = None,
platform_id: str | None = None,
) -> list[Conversation]:
"""获取对话列表.
Args:
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id可选
platform_id (str): 平台 ID, 可选参数, 用于过滤对话
Returns:
conversations (List[Conversation]): 对话对象列表
"""
...
async def get_filtered_conversations(
self,
page: int = 1,
page_size: int = 20,
platform_ids: list[str] | None = None,
search_query: str = "",
**kwargs,
) -> tuple[list[Conversation], int]:
"""获取过滤后的对话列表.
Args:
page (int): 页码, 默认为 1
page_size (int): 每页大小, 默认为 20
platform_ids (list[str]): 平台 ID 列表, 可选
search_query (str): 搜索查询字符串, 可选
Returns:
conversations (list[Conversation]): 对话对象列表
"""
...
async def update_conversation(
self,
unified_msg_origin: str,
conversation_id: str | None = None,
history: list[dict] | None = None,
title: str | None = None,
persona_id: str | None = None,
) -> None:
"""更新会话的对话.
Args:
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
history (List[Dict]): 对话历史记录, 是一个字典列表, 每个字典包含 role 和 content 字段
"""
...
async def update_conversation_title(
self,
unified_msg_origin: str,
title: str,
conversation_id: str | None = None,
) -> None:
"""更新会话的对话标题.
Args:
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
title (str): 对话标题
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
Deprecated:
Use `update_conversation` with `title` parameter instead.
"""
...
async def update_conversation_persona_id(
self,
unified_msg_origin: str,
persona_id: str,
conversation_id: str | None = None,
) -> None:
"""更新会话的对话 Persona ID.
Args:
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
persona_id (str): 对话 Persona ID
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
Deprecated:
Use `update_conversation` with `persona_id` parameter instead.
"""
...
async def add_message_pair(
self,
cid: str,
user_message: UserMessageSegment | dict,
assistant_message: AssistantMessageSegment | dict,
) -> None:
"""Add a user-assistant message pair to the conversation history.
Args:
cid (str): Conversation ID
user_message (UserMessageSegment | dict): OpenAI-format user message object or dict
assistant_message (AssistantMessageSegment | dict): OpenAI-format assistant message object or dict
Raises:
Exception: If the conversation with the given ID is not found
"""
...
async def get_human_readable_context(
self,
unified_msg_origin: str,
conversation_id: str,
page: int = 1,
page_size: int = 10,
) -> tuple[list[str], int]:
"""获取人类可读的上下文.
Args:
unified_msg_origin (str): 统一的消息来源字符串。格式为 platform_name:message_type:session_id
conversation_id (str): 对话 ID, 是 uuid 格式的字符串
page (int): 页码
page_size (int): 每页大小
"""
...

View File

@@ -1,23 +0,0 @@
from dataclasses import dataclass
@dataclass
class Conversation:
"""The conversation entity representing a chat session."""
platform_id: str
"""The platform ID in AstrBot"""
user_id: str
"""The user ID associated with the conversation."""
cid: str
"""The conversation ID, in UUID format."""
history: str = ""
"""The conversation history as a string."""
title: str | None = ""
"""The title of the conversation. For now, it's only used in WebChat."""
persona_id: str | None = ""
"""The persona ID associated with the conversation."""
created_at: int = 0
"""The timestamp when the conversation was created."""
updated_at: int = 0
"""The timestamp when the conversation was last updated."""

View File

@@ -1,2 +0,0 @@
class CommandComponent:
pass

View File

@@ -1,5 +0,0 @@
from .astr_message_event import AstrMessageEvent
__all__ = [
"AstrMessageEvent",
]

View File

@@ -1,370 +0,0 @@
from __future__ import annotations
import typing as T
from .astrbot_message import AstrBotMessage, Group
from ...api.platform.platform_metadata import PlatformMetadata
from ...api.event.message_type import MessageType
from ...api.event.message_session import MessageSession
from ...api.event.event_result import MessageEventResult
from ...api.message.chain import MessageChain
from ...api.message.components import BaseMessageComponent
from dataclasses import dataclass, field
from pydantic import BaseModel, Field
class AstrMessageEventModel(BaseModel):
message_str: str
message_obj: AstrBotMessage
platform_meta: PlatformMetadata
session_id: str
role: T.Literal["admin", "member"] = "member"
is_wake: bool = False
is_at_or_wake_command: bool = False
extras: dict = Field(default_factory=dict)
result: MessageEventResult | None = None
has_send_oper: bool = False
call_llm: bool = False
plugins_name: list[str] = Field(default_factory=list)
@classmethod
def from_event(cls, event: AstrMessageEvent) -> AstrMessageEventModel:
return cls(
message_str=event.message_str,
message_obj=event.message_obj,
platform_meta=event.platform_meta,
session_id=event.session_id,
role=event.role,
is_wake=event.is_wake,
is_at_or_wake_command=event.is_at_or_wake_command,
extras=event._extras,
result=event._result,
has_send_oper=event.has_send_oper,
call_llm=event.call_llm,
plugins_name=event._plugins_name,
)
def to_event(self) -> AstrMessageEvent:
event = AstrMessageEvent(
message_str=self.message_str,
message_obj=self.message_obj,
platform_meta=self.platform_meta,
session_id=self.session_id,
role=self.role,
is_wake=self.is_wake,
is_at_or_wake_command=self.is_at_or_wake_command,
_extras=self.extras,
_result=self.result,
has_send_oper=self.has_send_oper,
call_llm=self.call_llm,
_plugins_name=self.plugins_name,
)
return event
@dataclass
class AstrMessageEvent:
message_str: str
"""消息的纯文本内容"""
message_obj: AstrBotMessage
"""消息对象"""
platform_meta: PlatformMetadata
"""平台适配器的元信息"""
session_id: str
"""会话 ID"""
role: T.Literal["admin", "member"] = "member"
"""消息发送者的角色,如 "admin", "member""""
is_wake: bool = False
"""是否唤醒(是否通过 WakingStage)"""
is_at_or_wake_command: bool = False
"""是否艾特机器人或通过唤醒命令触发的消息"""
_extras: dict = field(default_factory=dict)
"""存储额外的信息"""
_result: MessageEventResult | None = None
"""消息事件的结果"""
has_send_oper: bool = False
"""是否已经发送过操作"""
call_llm: bool = False
"""是否调用 LLM"""
_plugins_name: list[str] = field(default_factory=list)
"""处理该事件的插件名称列表"""
def __post_init__(self):
self.session = MessageSession(
platform_name=self.platform_meta.id,
message_type=self.message_obj.type,
session_id=self.session_id,
)
self.unified_msg_origin = str(self.session)
self.platform = self.platform_meta # back compatibility
def get_platform_name(self) -> str:
"""
获取这个事件所属的平台的类型(如 aiocqhttp, slack, discord 等)。
NOTE: 用户可能会同时运行多个相同类型的平台适配器。
"""
return self.platform_meta.name
def get_platform_id(self):
"""
获取这个事件所属的平台的 ID。
NOTE: 用户可能会同时运行多个相同类型的平台适配器,但能确定的是 ID 是唯一的。
"""
return self.platform_meta.id
def get_message_str(self) -> str:
"""获取消息字符串。"""
return self.message_str
def get_messages(self) -> list[BaseMessageComponent]:
"""获取消息链。"""
return self.message_obj.message
def get_message_type(self) -> MessageType:
"""获取消息类型。"""
return self.message_obj.type
def get_session_id(self) -> str:
"""获取会话id。"""
return self.session_id
def get_group_id(self) -> str:
"""获取群组id。如果不是群组消息返回空字符串。"""
return self.message_obj.group_id
def get_self_id(self) -> str:
"""获取机器人自身的id。"""
return self.message_obj.self_id
def get_sender_id(self) -> str:
"""获取消息发送者的id。"""
return self.message_obj.sender.user_id
def get_sender_name(self) -> str | None:
"""获取消息发送者的名称。(可能会返回空字符串)"""
return self.message_obj.sender.nickname
def set_extra(self, key, value):
"""设置额外的信息。"""
self._extras[key] = value
def get_extra(self, key: str | None = None, default=None) -> T.Any:
"""获取额外的信息。"""
if key is None:
return self._extras
return self._extras.get(key, default)
def clear_extra(self):
"""清除额外的信息。"""
self._extras.clear()
def is_private_chat(self) -> bool:
"""是否是私聊。"""
return self.message_obj.type.value == (MessageType.FRIEND_MESSAGE).value
def is_wake_up(self) -> bool:
"""是否是唤醒机器人的事件。"""
return self.is_wake
def is_admin(self) -> bool:
"""是否是管理员。"""
return self.role == "admin"
# async def send_streaming(
# self,
# generator: AsyncGenerator[MessageChain, None],
# use_fallback: bool = False,
# ):
# """发送流式消息到消息平台,使用异步生成器。
# 目前仅支持: telegramqq official 私聊。
# Fallback仅支持 aiocqhttp。
# """
# asyncio.create_task(
# Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name),
# )
# self._has_send_oper = True
def set_result(self, result: MessageEventResult | str):
"""设置消息事件的结果。
Note:
事件处理器可以通过设置结果来控制事件是否继续传播,并向消息适配器发送消息。
如果没有设置 `MessageEventResult` 中的 result_type默认为 CONTINUE。即事件将会继续向后面的 listener 或者 command 传播。
Example:
```
async def ban_handler(self, event: AstrMessageEvent):
if event.get_sender_id() in self.blacklist:
event.set_result(MessageEventResult().set_console_log("由于用户在黑名单,因此消息事件中断处理。")).set_result_type(EventResultType.STOP)
return
async def check_count(self, event: AstrMessageEvent):
self.count += 1
event.set_result(MessageEventResult().set_console_log("数量已增加", logging.DEBUG).set_result_type(EventResultType.CONTINUE))
return
```
"""
if isinstance(result, str):
result = MessageEventResult().message(result)
# 兼容外部插件或调用方传入的 chain=None 的情况,确保为可迭代列表
if isinstance(result, MessageEventResult) and result.chain is None:
result.chain = []
self._result = result
def stop_event(self):
"""终止事件传播。"""
if self._result is None:
self.set_result(MessageEventResult().stop_event())
else:
self._result.stop_event()
def continue_event(self):
"""继续事件传播。"""
if self._result is None:
self.set_result(MessageEventResult().continue_event())
else:
self._result.continue_event()
def is_stopped(self) -> bool:
"""是否终止事件传播。"""
if self._result is None:
return False # 默认是继续传播
return self._result.is_stopped()
def should_call_llm(self, call_llm: bool):
"""是否在此消息事件中禁止默认的 LLM 请求。
只会阻止 AstrBot 默认的 LLM 请求链路,不会阻止插件中的 LLM 请求。
"""
self.call_llm = call_llm
def get_result(self) -> MessageEventResult | None:
"""获取消息事件的结果。"""
return self._result
def clear_result(self):
"""清除消息事件的结果。"""
self._result = None
"""消息链相关"""
def make_result(self) -> MessageEventResult:
"""创建一个空的消息事件结果。
Example:
```python
# 纯文本回复
yield event.make_result().message("Hi")
# 发送图片
yield event.make_result().url_image("https://example.com/image.jpg")
yield event.make_result().file_image("image.jpg")
```
"""
return MessageEventResult()
def plain_result(self, text: str) -> MessageEventResult:
"""创建一个空的消息事件结果,只包含一条文本消息。"""
return MessageEventResult().message(text)
def image_result(self, url_or_path: str) -> MessageEventResult:
"""创建一个空的消息事件结果,只包含一条图片消息。
根据开头是否包含 http 来判断是网络图片还是本地图片。
"""
if url_or_path.startswith("http"):
return MessageEventResult().url_image(url_or_path)
return MessageEventResult().file_image(url_or_path)
def chain_result(self, chain: list[BaseMessageComponent]) -> MessageEventResult:
"""创建一个空的消息事件结果,包含指定的消息链。"""
mer = MessageEventResult()
mer.chain = chain
return mer
# """LLM 请求相关"""
# def request_llm(
# self,
# prompt: str,
# func_tool_manager=None,
# session_id: str | None = None,
# image_urls: list[str] | None = None,
# contexts: list | None = None,
# system_prompt: str = "",
# conversation: Conversation | None = None,
# ) -> ProviderRequest:
# """创建一个 LLM 请求。
# Examples:
# ```py
# yield event.request_llm(prompt="hi")
# ```
# prompt: 提示词
# system_prompt: 系统提示词
# session_id: 已经过时,留空即可
# image_urls: 可以是 base64:// 或者 http:// 开头的图片链接,也可以是本地图片路径。
# contexts: 当指定 contexts 时,将会使用 contexts 作为上下文。如果同时传入了 conversation将会忽略 conversation。
# func_tool_manager: 函数工具管理器,用于调用函数工具。用 self.context.get_llm_tool_manager() 获取。
# conversation: 可选。如果指定,将在指定的对话中进行 LLM 请求。对话的人格会被用于 LLM 请求,并且结果将会被记录到对话中。
# """
# if image_urls is None:
# image_urls = []
# if contexts is None:
# contexts = []
# if len(contexts) > 0 and conversation:
# conversation = None
# return ProviderRequest(
# prompt=prompt,
# session_id=session_id,
# image_urls=image_urls,
# func_tool=func_tool_manager,
# contexts=contexts,
# system_prompt=system_prompt,
# conversation=conversation,
# )
async def send(self, message: MessageChain):
"""发送消息到消息平台。
Args:
message (MessageChain): 消息链,具体使用方式请参考文档。
"""
...
async def react(self, emoji: str):
"""对消息添加表情回应。
默认实现为发送一条包含该表情的消息。
注意:此实现并不一定符合所有平台的原生“表情回应”行为。
如需支持平台原生的消息反应功能,请在对应平台的子类中重写本方法。
"""
...
async def get_group(self, group_id: str | None = None, **kwargs) -> Group | None:
"""获取一个群聊的数据, 如果不填写 group_id: 如果是私聊消息,返回 None。如果是群聊消息返回当前群聊的数据。
适配情况:
- aiocqhttp(OneBotv11)
"""

View File

@@ -1,98 +0,0 @@
import time
from dataclasses import dataclass
from .message_type import MessageType
from ..message.components import BaseMessageComponent
@dataclass
class MessageMember:
user_id: str
nickname: str | None = None
def __str__(self):
return (
f"User ID: {self.user_id},"
f"Nickname: {self.nickname if self.nickname else 'N/A'}"
)
@dataclass
class Group:
group_id: str
"""群号"""
group_name: str | None = None
"""群名称"""
group_avatar: str | None = None
"""群头像"""
group_owner: str | None = None
"""群主 id"""
group_admins: list[str] | None = None
"""群管理员 id"""
members: list[MessageMember] | None = None
"""所有群成员"""
def __str__(self):
return (
f"Group ID: {self.group_id}\n"
f"Name: {self.group_name if self.group_name else 'N/A'}\n"
f"Avatar: {self.group_avatar if self.group_avatar else 'N/A'}\n"
f"Owner ID: {self.group_owner if self.group_owner else 'N/A'}\n"
f"Admin IDs: {self.group_admins if self.group_admins else 'N/A'}\n"
f"Members Len: {len(self.members) if self.members else 0}\n"
f"First Member: {self.members[0] if self.members else 'N/A'}\n"
)
@dataclass
class AstrBotMessage:
"""AstrBot 的消息对象"""
type: MessageType
"""消息类型"""
self_id: str
"""机器人自身 ID"""
session_id: str
"""会话 ID"""
message_id: str
"""消息 ID"""
sender: MessageMember
"""发送者"""
message: list[BaseMessageComponent]
"""消息链组件列表"""
message_str: str
"""纯文本消息字符串"""
raw_message: dict
"""原始消息对象"""
timestamp: int
"""消息时间戳"""
group: Group | None = None
"""群信息,如果是私聊则为 None"""
def __init__(self, **kwargs) -> None:
self.timestamp = int(time.time())
for key, value in kwargs.items():
setattr(self, key, value)
def __str__(self) -> str:
return str(self.__dict__)
@property
def group_id(self) -> str:
"""向后兼容的 group_id 属性
群组id如果为私聊则为空
"""
if self.group:
return self.group.group_id
return ""
@group_id.setter
def group_id(self, value: str):
"""设置 group_id"""
if value:
if self.group:
self.group.group_id = value
else:
self.group = Group(group_id=value)
else:
self.group = None

View File

@@ -1,93 +0,0 @@
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from typing import AsyncGenerator
from ..message.chain import MessageChain
class EventResultType(enum.Enum):
"""用于描述事件处理的结果类型。
Attributes:
CONTINUE: 事件将会继续传播
STOP: 事件将会终止传播
"""
CONTINUE = enum.auto()
STOP = enum.auto()
class ResultContentType(enum.Enum):
"""用于描述事件结果的内容的类型。"""
LLM_RESULT = enum.auto()
"""调用 LLM 产生的结果"""
GENERAL_RESULT = enum.auto()
"""普通的消息结果"""
STREAMING_RESULT = enum.auto()
"""调用 LLM 产生的流式结果"""
STREAMING_FINISH = enum.auto()
"""流式输出完成"""
@dataclass
class MessageEventResult(MessageChain):
"""MessageEventResult 描述了一整条消息中带有的所有组件以及事件处理的结果。
现代消息平台的一条富文本消息中可能由多个组件构成如文本、图片、At 等,并且保留了顺序。
Attributes:
`chain` (list): 用于顺序存储各个组件。
`use_t2i_` (bool): 用于标记是否使用文本转图片服务。默认为 None即跟随用户的设置。当设置为 True 时,将会使用文本转图片服务。
`result_type` (EventResultType): 事件处理的结果类型。
"""
result_type: EventResultType | None = field(
default_factory=lambda: EventResultType.CONTINUE,
)
result_content_type: ResultContentType | None = field(
default_factory=lambda: ResultContentType.GENERAL_RESULT,
)
# async_stream: AsyncGenerator | None = None
# """异步流"""
def stop_event(self) -> MessageEventResult:
"""终止事件传播。"""
self.result_type = EventResultType.STOP
return self
def continue_event(self) -> MessageEventResult:
"""继续事件传播。"""
self.result_type = EventResultType.CONTINUE
return self
def is_stopped(self) -> bool:
"""是否终止事件传播。"""
return self.result_type == EventResultType.STOP
def set_async_stream(self, stream: AsyncGenerator) -> MessageEventResult:
"""设置异步流。"""
self.async_stream = stream
return self
def set_result_content_type(self, typ: ResultContentType) -> MessageEventResult:
"""设置事件处理的结果类型。
Args:
result_type (EventResultType): 事件处理的结果类型。
"""
self.result_content_type = typ
return self
def is_llm_result(self) -> bool:
"""是否为 LLM 结果。"""
return self.result_content_type == ResultContentType.LLM_RESULT
# 为了兼容旧版代码,保留 CommandResult 的别名
CommandResult = MessageEventResult

View File

@@ -1,19 +0,0 @@
from __future__ import annotations
import enum
class EventType(enum.Enum):
"""表示一个 AstrBot 内部事件的类型。如适配器消息事件、LLM 请求事件、发送消息前的事件等
用于对 Handler 的职能分组。
"""
OnAstrBotLoadedEvent = enum.auto() # AstrBot 加载完成
OnPlatformLoadedEvent = enum.auto() # 平台加载完成
AdapterMessageEvent = enum.auto() # 收到适配器发来的消息
OnLLMRequestEvent = enum.auto() # 收到 LLM 请求(可以是用户也可以是插件)
OnLLMResponseEvent = enum.auto() # LLM 响应后
OnDecoratingResultEvent = enum.auto() # 发送消息前
OnCallingFuncToolEvent = enum.auto() # 调用函数工具
OnAfterMessageSentEvent = enum.auto() # 发送消息后

View File

@@ -1,65 +0,0 @@
from ...runtime.stars.filter.custom_filter import CustomFilter
from ...runtime.stars.filter.event_message_type import (
EventMessageType,
EventMessageTypeFilter,
)
from ...runtime.stars.filter.permission import PermissionType, PermissionTypeFilter
from ...runtime.stars.filter.platform_adapter_type import (
PlatformAdapterType,
PlatformAdapterTypeFilter,
)
from ...runtime.stars.registry.register import (
register_after_message_sent as after_message_sent,
)
from ...runtime.stars.registry.register import register_command as command
from ...runtime.stars.registry.register import register_command_group as command_group
from ...runtime.stars.registry.register import register_custom_filter as custom_filter
from ...runtime.stars.registry.register import (
register_event_message_type as event_message_type,
)
# from ...runtime.stars.registry.register import register_llm_tool as llm_tool
from ...runtime.stars.registry.register import (
register_on_astrbot_loaded as on_astrbot_loaded,
)
from ...runtime.stars.registry.register import (
register_on_decorating_result as on_decorating_result,
)
from ...runtime.stars.registry.register import register_on_llm_request as on_llm_request
from ...runtime.stars.registry.register import (
register_on_llm_response as on_llm_response,
)
from ...runtime.stars.registry.register import (
register_on_platform_loaded as on_platform_loaded,
)
from ...runtime.stars.registry.register import (
register_permission_type as permission_type,
)
from ...runtime.stars.registry.register import (
register_platform_adapter_type as platform_adapter_type,
)
from ...runtime.stars.registry.register import register_regex as regex
__all__ = [
"CustomFilter",
"EventMessageType",
"EventMessageTypeFilter",
"PermissionType",
"PermissionTypeFilter",
"PlatformAdapterType",
"PlatformAdapterTypeFilter",
"after_message_sent",
"command",
"command_group",
"custom_filter",
"event_message_type",
# "llm_tool",
"on_astrbot_loaded",
"on_decorating_result",
"on_llm_request",
"on_llm_response",
"on_platform_loaded",
"permission_type",
"platform_adapter_type",
"regex",
]

View File

@@ -1,32 +0,0 @@
from dataclasses import dataclass
from ..event.message_type import MessageType
@dataclass
class MessageSession:
"""
描述一条消息在 AstrBot 中对应的会话的唯一标识。
如果您需要实例化 MessageSession请不要给 platform_id 赋值(或者同时给 platform_name 和 platform_id 赋值相同值)。
它会在 __post_init__ 中自动设置为 platform_name 的值。
"""
platform_name: str
"""平台适配器实例的唯一标识符。自 AstrBot v4.0.0 起,该字段实际为 platform_id。"""
message_type: MessageType
session_id: str
platform_id: str | None = None
def __str__(self):
return f"{self.platform_id}:{self.message_type.value}:{self.session_id}"
def __post_init__(self):
self.platform_id = self.platform_name
@staticmethod
def from_str(session_str: str):
platform_id, message_type, session_id = session_str.split(":")
return MessageSession(platform_id, MessageType(message_type), session_id)
MessageSesion = MessageSession # back compatibility

View File

@@ -1,7 +0,0 @@
from enum import Enum
class MessageType(Enum):
GROUP_MESSAGE = "GroupMessage" # 群组形式的消息
FRIEND_MESSAGE = "FriendMessage" # 私聊、好友等单聊消息
OTHER_MESSAGE = "OtherMessage" # 其他类型的消息,如系统消息等

View File

@@ -1,136 +0,0 @@
from . import components as Comp
from dataclasses import dataclass, field
@dataclass
class MessageChain:
"""MessageChain 描述了一整条消息中带有的所有组件。
现代消息平台的一条富文本消息中可能由多个组件构成如文本、图片、At 等,并且保留了顺序。
Attributes:
`chain` (list): 用于顺序存储各个组件。
`use_t2i_` (bool): 用于标记是否使用文本转图片服务。默认为 None即跟随用户的设置。当设置为 True 时,将会使用文本转图片服务。
"""
chain: list[Comp.BaseMessageComponent] = field(default_factory=list)
use_t2i_: bool | None = None # None 为跟随用户设置
type: str | None = None
"""消息链承载的消息的类型。可选,用于让消息平台区分不同业务场景的消息链。"""
def message(self, message: str):
"""添加一条文本消息到消息链 `chain` 中。
Example:
CommandResult().message("Hello ").message("world!")
# 输出 Hello world!
"""
self.chain.append(Comp.Plain(text=message))
return self
def at(self, name: str, qq: str):
"""添加一条 At 消息到消息链 `chain` 中。
Example:
CommandResult().at("张三", "12345678910")
# 输出 @张三
"""
self.chain.append(Comp.At(user_id=qq, user_name=name))
return self
def at_all(self):
"""添加一条 AtAll 消息到消息链 `chain` 中。
Example:
CommandResult().at_all()
# 输出 @所有人
"""
self.chain.append(Comp.AtAll())
return self
def error(self, message: str):
"""[Deprecated] 添加一条错误消息到消息链 `chain` 中
Example:
CommandResult().error("解析失败")
"""
self.chain.append(Comp.Plain(text=message))
return self
def url_image(self, url: str):
"""添加一条图片消息https 链接)到消息链 `chain` 中。
Note:
如果需要发送本地图片,请使用 `file_image` 方法。
Example:
CommandResult().image("https://example.com/image.jpg")
"""
self.chain.append(Comp.Image(file=url))
return self
def file_image(self, path: str):
"""添加一条图片消息(本地文件路径)到消息链 `chain` 中。
Note:
如果需要发送网络图片,请使用 `url_image` 方法。
Example:
CommandResult().file_image("image.jpg")
"""
self.chain.append(Comp.Image(file=path))
return self
def base64_image(self, base64_str: str):
"""添加一条图片消息base64 编码字符串)到消息链 `chain` 中。
Example:
CommandResult().base64_image("iVBORw0KGgoAAAANSUhEUgAAAAUA...")
"""
self.chain.append(Comp.Image(file=base64_str))
return self
def use_t2i(self, use_t2i: bool):
"""设置是否使用文本转图片服务。
Args:
use_t2i (bool): 是否使用文本转图片服务。默认为 None即跟随用户的设置。当设置为 True 时,将会使用文本转图片服务。
"""
self.use_t2i_ = use_t2i
return self
def get_plain_text(self) -> str:
"""获取纯文本消息。这个方法将获取 chain 中所有 Plain 组件的文本并拼接成一条消息。空格分隔。"""
return " ".join(
[comp.text for comp in self.chain if isinstance(comp, Comp.Plain)]
)
def squash_plain(self):
"""将消息链中的所有 Plain 消息段聚合到第一个 Plain 消息段中。"""
if not self.chain:
return None
new_chain = []
first_plain = None
plain_texts = []
for comp in self.chain:
if isinstance(comp, Comp.Plain):
if first_plain is None:
first_plain = comp
new_chain.append(comp)
plain_texts.append(comp.text)
else:
new_chain.append(comp)
if first_plain is not None:
first_plain.text = "".join(plain_texts)
self.chain = new_chain
return self

View File

@@ -1,225 +0,0 @@
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field
from typing import Literal
class ComponentType(str, Enum):
# Basic Segment Types
Plain = "Plain" # plain text message
Image = "Image" # image
Record = "Record" # audio
Video = "Video" # video
File = "File" # file attachment
# IM-specific Segment Types
Face = "Face" # Emoji segment for Tencent QQ platform
At = "At" # mention a user in IM apps
Node = "Node" # a node in a forwarded message
Nodes = "Nodes" # a forwarded message consisting of multiple nodes
Poke = "Poke" # a poke message for Tencent QQ platform
Reply = "Reply" # a reply message segment
Forward = "Forward" # a forwarded message segment
RPS = "RPS"
Dice = "Dice"
Shake = "Shake"
Share = "Share"
Contact = "Contact"
Location = "Location"
Music = "Music"
Json = "Json"
Unknown = "Unknown"
WechatEmoji = "WechatEmoji"
CompT = ComponentType
class BaseMessageComponent(BaseModel):
type: CompT
def to_dict(self) -> dict:
"""Unified dict format"""
return self.model_dump()
class Plain(BaseMessageComponent):
"""Represents a plain text message segment."""
type: Literal[CompT.Plain] = CompT.Plain
text: str
class Image(BaseMessageComponent):
type: Literal[CompT.Image] = CompT.Image
file: str
"""base64-encoded image data, or file path, or HTTP URL"""
class Record(BaseMessageComponent):
type: Literal[CompT.Record] = CompT.Record
file: str
"""base64-encoded audio data, or file path, or HTTP URL"""
class Video(BaseMessageComponent):
type: Literal[CompT.Video] = CompT.Video
file: str
"""The video file URL."""
class File(BaseMessageComponent):
type: Literal[CompT.File] = CompT.File
file_name: str
mime_type: str | None = None
file: str
"""The file URL."""
class At(BaseMessageComponent):
type: Literal[CompT.At] = CompT.At
user_id: str | None = None
user_name: str | None = None
class AtAll(At):
user_id: str = "all"
class Reply(BaseMessageComponent):
type: Literal[CompT.Reply] = CompT.Reply
id: str | int
"""所引用的消息 ID"""
chain: list[BaseMessageComponent] | None = []
"""被引用的消息段列表"""
sender_id: int | None | str = 0
"""被引用的消息对应的发送者的 ID"""
sender_nickname: str | None = ""
"""被引用的消息对应的发送者的昵称"""
time: int | None = 0
"""被引用的消息发送时间"""
message_str: str | None = ""
"""被引用的消息解析后的纯文本消息字符串"""
class Node(BaseMessageComponent):
type: Literal[CompT.Node] = CompT.Node
sender_id: str
nickname: str | None = None
content: list[BaseMessageComponent] = Field(default_factory=list)
class Nodes(BaseMessageComponent):
type: Literal[CompT.Nodes] = CompT.Nodes
nodes: list[Node] = Field(default_factory=list)
class Face(BaseMessageComponent):
type: Literal[CompT.Face] = CompT.Face
id: int
class RPS(BaseMessageComponent):
type: Literal[CompT.RPS] = CompT.RPS
class Dice(BaseMessageComponent):
type: Literal[CompT.Dice] = CompT.Dice
class Shake(BaseMessageComponent):
type: Literal[CompT.Shake] = CompT.Shake
class Share(BaseMessageComponent):
type: Literal[CompT.Share] = CompT.Share
url: str
title: str
content: str | None = ""
image: str | None = ""
class Contact(BaseMessageComponent):
type: Literal[CompT.Contact] = CompT.Contact
_type: str # type 字段冲突
id: int | None = 0
class Location(BaseMessageComponent):
type: Literal[CompT.Location] = CompT.Location
lat: float
lon: float
title: str | None = ""
content: str | None = ""
class Music(BaseMessageComponent):
type: Literal[CompT.Music] = CompT.Music
_type: str
id: int | None = 0
url: str | None = ""
audio: str | None = ""
title: str | None = ""
content: str | None = ""
image: str | None = ""
class Poke(BaseMessageComponent):
type: Literal[CompT.Poke] = CompT.Poke
id: int | None = 0
qq: int | None = 0
class Forward(BaseMessageComponent):
type: Literal[CompT.Forward] = CompT.Forward
id: str
class Json(BaseMessageComponent):
type: Literal[CompT.Json] = CompT.Json
data: dict
class Unknown(BaseMessageComponent):
type: Literal[CompT.Unknown] = CompT.Unknown
text: str
class WechatEmoji(BaseMessageComponent):
type: Literal[CompT.WechatEmoji] = CompT.WechatEmoji
md5: str | None = ""
md5_len: int | None = 0
cdnurl: str | None = ""
def __init__(self, **_):
super().__init__(**_)
ComponentTypes = {
# Basic Message Segments
"plain": Plain,
"text": Plain,
"image": Image,
"record": Record,
"video": Video,
"file": File,
# IM-specific Message Segments
"face": Face,
"at": At,
"rps": RPS,
"dice": Dice,
"shake": Shake,
"share": Share,
"contact": Contact,
"location": Location,
"music": Music,
"reply": Reply,
"poke": Poke,
"forward": Forward,
"node": Node,
"nodes": Nodes,
"json": Json,
"unknown": Unknown,
"WechatEmoji": WechatEmoji,
}

View File

@@ -1,18 +0,0 @@
from dataclasses import dataclass
@dataclass
class PlatformMetadata:
name: str
"""平台的名称,即平台的类型,如 aiocqhttp, discord, slack"""
description: str
"""平台的描述"""
id: str
"""平台的唯一标识符,用于配置中识别特定平台"""
default_config_tmpl: dict | None = None
"""平台的默认配置模板"""
adapter_display_name: str | None = None
"""显示在 WebUI 配置页中的平台名称,如空则是 name"""
logo_path: str | None = None
"""平台适配器的 logo 文件路径(相对于插件目录)"""

View File

@@ -1,126 +0,0 @@
from __future__ import annotations
import json
from anthropic.types import Message as AnthropicMessage
from google.genai.types import GenerateContentResponse
from openai.types.chat.chat_completion import ChatCompletion
from dataclasses import dataclass, field
from ..message.chain import MessageChain
from ..message import components as Comp
from typing import Any
from astr_agent_sdk.message import ToolCall
@dataclass
class LLMResponse:
role: str
"""角色, assistant, tool, err"""
result_chain: MessageChain | None = None
"""返回的消息链"""
tools_call_args: list[dict[str, Any]] = field(default_factory=list)
"""工具调用参数"""
tools_call_name: list[str] = field(default_factory=list)
"""工具调用名称"""
tools_call_ids: list[str] = field(default_factory=list)
"""工具调用 ID"""
raw_completion: (
ChatCompletion | GenerateContentResponse | AnthropicMessage | None
) = None
_new_record: dict[str, Any] | None = None
_completion_text: str = ""
is_chunk: bool = False
"""是否是流式输出的单个 Chunk"""
def __init__(
self,
role: str,
completion_text: str = "",
result_chain: MessageChain | None = None,
tools_call_args: list[dict[str, Any]] | None = None,
tools_call_name: list[str] | None = None,
tools_call_ids: list[str] | None = None,
raw_completion: ChatCompletion
| GenerateContentResponse
| AnthropicMessage
| None = None,
_new_record: dict[str, Any] | None = None,
is_chunk: bool = False,
):
"""初始化 LLMResponse
Args:
role (str): 角色, assistant, tool, err
completion_text (str, optional): 返回的结果文本,已经过时,推荐使用 result_chain. Defaults to "".
result_chain (MessageChain, optional): 返回的消息链. Defaults to None.
tools_call_args (List[Dict[str, any]], optional): 工具调用参数. Defaults to None.
tools_call_name (List[str], optional): 工具调用名称. Defaults to None.
raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None.
"""
if tools_call_args is None:
tools_call_args = []
if tools_call_name is None:
tools_call_name = []
if tools_call_ids is None:
tools_call_ids = []
self.role = role
self.completion_text = completion_text
self.result_chain = result_chain
self.tools_call_args = tools_call_args
self.tools_call_name = tools_call_name
self.tools_call_ids = tools_call_ids
self.raw_completion = raw_completion
self._new_record = _new_record
self.is_chunk = is_chunk
@property
def completion_text(self):
if self.result_chain:
return self.result_chain.get_plain_text()
return self._completion_text
@completion_text.setter
def completion_text(self, value):
if self.result_chain:
self.result_chain.chain = [
comp
for comp in self.result_chain.chain
if not isinstance(comp, Comp.Plain)
] # 清空 Plain 组件
self.result_chain.chain.insert(0, Comp.Plain(text=value))
else:
self._completion_text = value
def to_openai_tool_calls(self) -> list[dict]:
"""Convert to OpenAI tool calls format. Deprecated, use to_openai_to_calls_model instead."""
ret = []
for idx, tool_call_arg in enumerate(self.tools_call_args):
ret.append(
{
"id": self.tools_call_ids[idx],
"function": {
"name": self.tools_call_name[idx],
"arguments": json.dumps(tool_call_arg),
},
"type": "function",
},
)
return ret
def to_openai_to_calls_model(self) -> list[ToolCall]:
"""The same as to_openai_tool_calls but return pydantic model."""
ret = []
for idx, tool_call_arg in enumerate(self.tools_call_args):
ret.append(
ToolCall(
id=self.tools_call_ids[idx],
function=ToolCall.FunctionBody(
name=self.tools_call_name[idx],
arguments=json.dumps(tool_call_arg),
),
),
)
return ret

View File

@@ -1,152 +0,0 @@
from abc import ABC
from typing import Any, Callable
from ..basic.conversation_mgr import BaseConversationManager
from astr_agent_sdk.tool import ToolSet, FunctionTool
from astr_agent_sdk.message import Message
from ..provider.entities import LLMResponse
from ..message.chain import MessageChain
class Context(ABC):
conversation_manager: BaseConversationManager
persona_manager: Any
def __init__(self):
self._registered_managers: dict[str, Any] = {}
self._registered_functions: dict[str, Callable] = {}
def _register_component(self, *components: Any) -> None:
"""Register a components instance and its public methods.
This allows the components's methods to be called via RPC using the pattern:
ComponentClassName.method_name
Args:
components: The components instance to register
"""
for component in components:
class_name = component.__class__.__name__
self._registered_managers[class_name] = component
# Register all public methods (not starting with _)
for attr_name in dir(component):
if not attr_name.startswith("_"):
attr = getattr(component, attr_name)
if callable(attr):
full_name = f"{class_name}.{attr_name}"
self._registered_functions[full_name] = attr
async def llm_generate(
self,
chat_provider_id: str,
prompt: str | None = None,
image_urls: list[str] | None = None,
tools: ToolSet | None = None,
system_prompt: str | None = None,
contexts: list[Message] | list[dict] | None = None,
**kwargs: Any,
) -> LLMResponse:
"""Call the LLM to generate a response. The method will not automatically execute tool calls. If you want to use tool calls, please use `tool_loop_agent()`.
Args:
chat_provider_id: The chat provider ID to use.
prompt: The prompt to send to the LLM, if `contexts` and `prompt` are both provided, `prompt` will be appended as the last user message
image_urls: List of image URLs to include in the prompt, if `contexts` and `prompt` are both provided, `image_urls` will be appended to the last user message
tools: ToolSet of tools available to the LLM
system_prompt: System prompt to guide the LLM's behavior, if provided, it will always insert as the first system message in the context
contexts: context messages for the LLM
**kwargs: Additional keyword arguments for LLM generation, OpenAI compatible
Raises:
ChatProviderNotFoundError: If the specified chat provider ID is not found
Exception: For other errors during LLM generation
"""
...
async def tool_loop_agent(
self,
chat_provider_id: str,
prompt: str | None = None,
image_urls: list[str] | None = None,
tools: ToolSet | None = None,
system_prompt: str | None = None,
contexts: list[Message] | list[dict] | None = None,
max_steps: int = 30,
**kwargs: Any,
) -> LLMResponse:
"""Run an agent loop that allows the LLM to call tools iteratively until a final answer is produced.
Args:
chat_provider_id: The chat provider ID to use.
prompt: The prompt to send to the LLM, if `contexts` and `prompt` are both provided, `prompt` will be appended as the last user message
image_urls: List of image URLs to include in the prompt, if `contexts` and `prompt` are both provided, `image_urls` will be appended to the last user message
tools: ToolSet of tools available to the LLM
system_prompt: System prompt to guide the LLM's behavior, if provided, it will always insert as the first system message in the context
contexts: context messages for the LLM
max_steps: Maximum number of tool calls before stopping the loop
**kwargs: Additional keyword arguments for LLM generation, OpenAI compatible
Returns:
The final LLMResponse after tool calls are completed.
Raises:
ChatProviderNotFoundError: If the specified chat provider ID is not found
Exception: For other errors during LLM generation
"""
...
async def send_message(
self,
session: str,
message_chain: MessageChain,
) -> None:
"""Send a message to a user or group.
Args:
session: unified message origin(umo), this can represent a user or group in a specific platform instance
message_chain: The MessageChain to send
Raises:
Exception: If sending the message fails
"""
...
async def add_llm_tools(self, *tools: FunctionTool) -> None:
"""Add tools to the LLM's toolset.
Args:
tools: The FunctionTool instances to add
"""
...
async def put_kv_data(
self,
key: str,
value: dict,
) -> None:
"""Insert a key-value pair data. The data will permanently stored in AstrBot unless user explicitly deleted.
Args:
key: The key to insert
value: The value to insert
"""
...
async def get_kv_data(self, key: str) -> dict | None:
"""Get a value by key from the key-value store.
Args:
key: The key to retrieve
Returns:
The value associated with the key, or None if not found
"""
...
async def delete_kv_data(self, key: str) -> None:
"""Delete a key-value pair by key.
Args:
key: The key to delete
"""
...

View File

@@ -1,59 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from ..basic.astrbot_config import AstrBotConfig
@dataclass
class StarMetadata:
"""
插件的元数据。
当 activated 为 False 时star_cls 可能为 None请不要在插件未激活时调用 star_cls 的方法。
"""
name: str | None = None
"""插件名"""
author: str | None = None
"""插件作者"""
desc: str | None = None
"""插件简介"""
version: str | None = None
"""插件版本"""
repo: str | None = None
"""插件仓库地址"""
# star_cls_type: type[Star] | None = None
# """插件的类对象的类型"""
module_path: str | None = None
"""插件的模块路径"""
# star_cls: Star | None = None
# """插件的类对象"""
# module: ModuleType | None = None
# """插件的模块对象"""
root_dir_name: str | None = None
"""插件的目录名称"""
reserved: bool = False
"""是否是 AstrBot 的保留插件"""
activated: bool = True
"""是否被激活"""
config: AstrBotConfig | None = None
"""插件配置"""
star_handler_full_names: list[str] = field(default_factory=list)
"""注册的 Handler 的全名列表"""
display_name: str | None = None
"""用于展示的插件名称"""
logo_path: str | None = None
"""插件 Logo 的路径"""
def __str__(self) -> str:
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
def __repr__(self) -> str:
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"

View File

@@ -1,3 +0,0 @@
from .main import cli
__all__ = ["cli"]

View File

@@ -1,76 +0,0 @@
import asyncio
import sys
import click
from loguru import logger
from ..runtime.serve import run_plugin_worker, run_supervisor, run_websocket_server
def setup_logger(verbose: bool = False):
"""Configure loguru for CLI output"""
# Remove default handler
logger.remove()
# Add custom handler with CLI-friendly format
log_format = (
"<green>{time:HH:mm:ss}</green> | "
"<level>{level: <8}</level> | "
"<level>{message}</level>"
)
level = "DEBUG" if verbose else "INFO"
logger.add(
sys.stderr,
format=log_format,
level=level,
colorize=True,
)
@click.group()
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
@click.pass_context
def cli(ctx, verbose):
"""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=str),
help="Directory containing plugin folders",
)
@click.pass_context
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))
if __name__ == "__main__":
cli()

View File

@@ -1,8 +0,0 @@
# AstrBot SDK Runtime Context API
这个包下存储了暴露给 AstrBot 插件的 Context API 的 RPC 实现。
## 组件
- `Context`:这是在实例化插件时,注入到插件中的上下文对象。它封装了插件可以调用的各种功能组件。
- `ConversationManager`:这是一个管理对话相关的功能组件。它提供了与对话历史、用户信息等相关的操作接口。

View File

@@ -1,23 +0,0 @@
from __future__ import annotations
from ...api.star.context import Context as BaseContext
from .conversation_mgr import ConversationManager
from ..star_runner import StarRunner
class Context(BaseContext):
def __init__(self, conversation_manager: ConversationManager):
super().__init__()
self.conversation_manager = conversation_manager
# Auto-register the conversation manager
self._register_component(self.conversation_manager)
@classmethod
def default_context(cls, runner: StarRunner) -> Context:
"""Create a default context instance.
Args:
runner: Optional StarRunner instance to inject into conversation manager.
If provided, enables RPC functionality.
"""
conversation_manager = ConversationManager(runner)
return cls(conversation_manager=conversation_manager)

View File

@@ -1,140 +0,0 @@
from astr_agent_sdk.message import AssistantMessageSegment, UserMessageSegment
from astrbot_sdk.api.basic.entities import Conversation
from ...api.basic.conversation_mgr import BaseConversationManager
from ..star_runner import StarRunner
class ConversationManager(BaseConversationManager):
def __init__(self, runner: StarRunner):
"""Initialize ConversationManager.
Args:
runner: Optional StarRunner instance for RPC functionality.
"""
self.runner = runner
async def new_conversation(
self,
unified_msg_origin: str,
platform_id: str | None = None,
content: list[dict] | None = None,
title: str | None = None,
persona_id: str | None = None,
) -> str:
result = await self.runner.call_context_function(
f"{self.__class__.__name__}.{self.new_conversation.__name__}",
{
"unified_msg_origin": unified_msg_origin,
"platform_id": platform_id,
"content": content,
"title": title,
"persona_id": persona_id,
},
)
return result["data"]
async def switch_conversation(
self, unified_msg_origin: str, conversation_id: str
) -> None:
await self.runner.call_context_function(
f"{self.__class__.__name__}.{self.switch_conversation.__name__}",
{
"unified_msg_origin": unified_msg_origin,
"conversation_id": conversation_id,
},
)
async def delete_conversation(
self,
unified_msg_origin: str,
conversation_id: str | None = None,
) -> None:
await self.runner.call_context_function(
f"{self.__class__.__name__}.{self.delete_conversation.__name__}",
{
"unified_msg_origin": unified_msg_origin,
"conversation_id": conversation_id,
},
)
async def delete_conversations_by_user_id(self, unified_msg_origin: str) -> None:
await self.runner.call_context_function(
f"{self.__class__.__name__}.{self.delete_conversations_by_user_id.__name__}",
{
"unified_msg_origin": unified_msg_origin,
},
)
async def get_curr_conversation_id(self, unified_msg_origin: str) -> str | None:
result = await self.runner.call_context_function(
f"{self.__class__.__name__}.{self.get_curr_conversation_id.__name__}",
{
"unified_msg_origin": unified_msg_origin,
},
)
return result["data"]
async def get_conversation(
self,
unified_msg_origin: str,
conversation_id: str,
create_if_not_exists: bool = False,
) -> Conversation | None:
result = await self.runner.call_context_function(
f"{self.__class__.__name__}.{self.get_conversation.__name__}",
{
"unified_msg_origin": unified_msg_origin,
"conversation_id": conversation_id,
"create_if_not_exists": create_if_not_exists,
},
)
return Conversation(**result["data"]) if result["data"] else None
async def get_conversations(
self, unified_msg_origin: str | None = None, platform_id: str | None = None
) -> list[Conversation]:
result = await self.runner.call_context_function(
f"{self.__class__.__name__}.{self.get_conversations.__name__}",
{
"unified_msg_origin": unified_msg_origin,
"platform_id": platform_id,
},
)
return [Conversation(**conv) for conv in result["data"]]
async def update_conversation(
self,
unified_msg_origin: str,
conversation_id: str | None = None,
history: list[dict] | None = None,
title: str | None = None,
persona_id: str | None = None,
) -> None:
await self.runner.call_context_function(
f"{self.__class__.__name__}.{self.update_conversation.__name__}",
{
"unified_msg_origin": unified_msg_origin,
"conversation_id": conversation_id,
"history": history,
"title": title,
"persona_id": persona_id,
},
)
async def add_message_pair(
self,
cid: str,
user_message: UserMessageSegment | dict,
assistant_message: AssistantMessageSegment | dict,
) -> None:
"""Add a user-assistant message pair to the conversation history.
Args:
cid (str): Conversation ID
user_message (UserMessageSegment | dict): OpenAI-format user message object or dict
assistant_message (AssistantMessageSegment | dict): OpenAI-format assistant message object or dict
Raises:
Exception: If the conversation with the given ID is not found
"""
...

View File

@@ -1,39 +0,0 @@
"""
VPL means Virtual Star Layer.
In the AstrBot 5.0 architecture, VPL is a layer that allows different types of stars to interact with the core system in a standardized way.
Currently, AstrBot has two types of stars:
1. Legacy Stars: These are the traditional stars that still running in the same runtime as AstrBot core.
2. New Stars: These are the modern stars that run in isolated runtime, they communicate with AstrBot core through stdio streams or websocket.
The VPL module provides the necessary abstractions and interfaces to manage these stars seamlessly,
let AstrBot core interact with both types of stars without needing to know the underlying implementation details.
"""
from .stars.virtual import VirtualStar
from .stars.new_star import NewStdioStar, NewWebSocketStar
from ..api.star.context import Context
# from .types import StarURI, StarType
class Galaxy:
"""Manages the lifecycle and interactions of Virtual Stars (plugins) within AstrBot."""
vs_map: dict[str, VirtualStar] = {}
async def connect_to_stdio_star(
self, context: Context, star_name: str, config: dict
) -> NewStdioStar:
"""Connect to a new-style stdio star given its name."""
star = NewStdioStar(context=context, **config)
await star.initialize()
self.vs_map[star_name] = star
return star
async def connect_to_websocket_star(
self, context: Context, star_name: str, config: dict
) -> NewWebSocketStar:
"""Connect to a new-style websocket star given its name."""
star = NewWebSocketStar(context=context, **config)
await star.initialize()
self.vs_map[star_name] = star
return star

View File

@@ -1,7 +0,0 @@
# AstrBor SDK 与 Core 通信的数据交换实现
这个包下存储了 AstrBot 插件运行时与 AstrBot Core 之间通信的数据交换实现。
AstrBot SDK 设计了两种传输协议,即 stdio 和 WebSockets用于实现 AstrBot 插件与 AstrBot Core 之间的双向通信。
在这两种传输协议之上,我们使用 JSON-RPC 2.0 作为通信的消息格式和调用规范。

View File

@@ -1,208 +0,0 @@
# JSON-RPC Server Implementation
This directory contains industry-standard implementations of JSON-RPC 2.0 servers for inter-process communication.
## Overview
The implementation follows best practices:
- **Clean separation of concerns**: Servers handle only communication, not business logic
- **Async/await**: Non-blocking I/O for better performance
- **Type safety**: Full type hints with Pydantic models
- **Error handling**: Proper logging and error propagation
- **Resource management**: Clean startup/shutdown lifecycle
## Architecture
### Base Class: `JSONRPCServer`
Abstract base class defining the server interface:
- `set_message_handler(handler)`: Register a callback for incoming messages
- `start()`: Start the server
- `stop()`: Stop the server and cleanup
- `send_message(message)`: Send a JSON-RPC message
### STDIO Server: `StdioServer`
Communicates via standard input/output using line-delimited JSON.
**Features:**
- One JSON-RPC message per line
- Non-blocking async I/O using executors
- Thread-safe write operations with asyncio locks
- Graceful EOF handling
**Use cases:**
- Plugin subprocess communication
- Command-line tools
- Pipeline-based architectures
**Example:**
```python
from astrbot_sdk.runtime.server import StdioServer
from astrbot_sdk.runtime.rpc.jsonrpc import JSONRPCMessage
server = StdioServer()
def handle_message(message: JSONRPCMessage):
# Process the message
pass
server.set_message_handler(handle_message)
await server.start()
```
### WebSocket Server: `WebSocketServer`
Communicates via WebSocket connections.
**Features:**
- Single active connection (typical for IPC)
- Heartbeat/ping-pong for connection health
- Support for text and binary messages
- Graceful connection lifecycle management
- Built on aiohttp for production readiness
**Configuration:**
```python
from astrbot_sdk.runtime.server import WebSocketServer
server = WebSocketServer(
host="127.0.0.1",
port=8765,
path="/rpc",
heartbeat=30.0 # seconds, 0 to disable
)
```
**Use cases:**
- Network-based plugin communication
- Development/debugging (easier to inspect)
- Multiple plugin instances
## Message Format
All servers use JSON-RPC 2.0 format:
**Request:**
```json
{
"jsonrpc": "2.0",
"id": "unique-id",
"method": "method_name",
"params": {"key": "value"}
}
```
**Success Response:**
```json
{
"jsonrpc": "2.0",
"id": "unique-id",
"result": {"data": "response"}
}
```
**Error Response:**
```json
{
"jsonrpc": "2.0",
"id": "unique-id",
"error": {
"code": -32600,
"message": "Invalid Request",
"data": null
}
}
```
## Usage Examples
See the `examples/` directory:
- `server_stdio_example.py`: STDIO server with echo handler
- `server_websocket_example.py`: WebSocket server with echo handler
- `client_stdio_test.py`: Test client for STDIO
- `client_websocket_test.py`: Test client for WebSocket
### Running STDIO Example
Terminal 1 (server):
```bash
python examples/server_stdio_example.py
```
Then type JSON-RPC requests:
```json
{"jsonrpc":"2.0","id":"1","method":"test","params":{"hello":"world"}}
```
Or use the test client:
```bash
python examples/client_stdio_test.py | python examples/server_stdio_example.py
```
### Running WebSocket Example
Terminal 1 (server):
```bash
python examples/server_websocket_example.py
```
Terminal 2 (client):
```bash
python examples/client_websocket_test.py
```
## Design Principles
1. **No business logic**: Servers only handle transport and serialization
2. **Callback-based**: Use `set_message_handler()` for loose coupling
3. **Async-first**: All I/O operations are non-blocking
4. **Production-ready**: Proper error handling, logging, and resource cleanup
5. **Testable**: Easy to mock and test with custom stdin/stdout
## Integration with AstrBot SDK
These servers are designed to be used by the Virtual Plugin Layer (VPL):
```python
# In plugin runtime (subprocess)
from astrbot_sdk.runtime.server import StdioServer
server = StdioServer()
server.set_message_handler(handle_core_requests)
await server.start()
# In AstrBot Core
# Spawn plugin subprocess with stdio transport
# Send JSON-RPC requests to plugin stdin
# Receive JSON-RPC responses from plugin stdout
```
## Thread Safety
- Both servers use `asyncio.Lock` for write operations
- Message handlers are called synchronously but can schedule async tasks
- Servers must run in an asyncio event loop
## Error Handling
- Parse errors are logged but don't crash the server
- Connection errors trigger cleanup and can be recovered
- User code exceptions in message handlers are contained

View File

@@ -1,5 +0,0 @@
from .base import JSONRPCClient
from .stdio import StdioClient
from .websocket import WebSocketClient
__all__ = ["JSONRPCClient", "StdioClient", "WebSocketClient"]

View File

@@ -1,14 +0,0 @@
from __future__ import annotations
from abc import ABC
from ..transport import JSONRPCTransport
class JSONRPCClient(JSONRPCTransport, ABC):
"""Base class for JSON-RPC clients.
Handles pure communication (reading/writing JSON-RPC messages).
"""
def __init__(self) -> None:
super().__init__()

View File

@@ -1,222 +0,0 @@
from __future__ import annotations
import asyncio
import json
import os
import subprocess
from typing import IO, Any
from loguru import logger
from ..jsonrpc import (
JSONRPCErrorResponse,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCSuccessResponse,
)
from .base import JSONRPCClient
class StdioClient(JSONRPCClient):
"""JSON-RPC client using standard input/output for communication."""
def __init__(
self,
command: list[str],
cwd: str | None = None,
env: dict[str, str] | None = None,
) -> None:
"""Initialize the STDIO client.
Args:
command: Command to start subprocess (e.g., ['python', 'plugin.py'])
cwd: Working directory for subprocess
"""
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
self._read_task: asyncio.Task | None = None
self._write_lock = asyncio.Lock()
async def start(self) -> None:
"""Start the client and launch subprocess."""
if self._running:
logger.warning("StdioClient is already running")
return
self._running = True
# Start subprocess
await self._start_subprocess()
self._read_task = asyncio.create_task(self._read_loop())
logger.info("StdioClient started")
async def _start_subprocess(self) -> None:
"""Start the subprocess and connect to its stdio."""
logger.info(f"Starting subprocess: {' '.join(self._command)}")
try:
self._process = subprocess.Popen(
self._command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=self._cwd,
env=self._env,
text=True,
bufsize=1, # Line buffered
)
# Use subprocess's stdio
self._stdin = self._process.stdout # Read from subprocess stdout
self._stdout = self._process.stdin # Write to subprocess stdin
logger.info(f"Subprocess started with PID {self._process.pid}")
# Start monitoring stderr
asyncio.create_task(self._monitor_stderr())
except Exception as e:
logger.error(f"Failed to start subprocess: {e}")
raise
async def _monitor_stderr(self) -> None:
"""Monitor subprocess stderr and log output."""
if not self._process or not self._process.stderr:
return
loop = asyncio.get_event_loop()
try:
while self._running and self._process.poll() is None:
line = await loop.run_in_executor(None, self._process.stderr.readline)
if line:
logger.debug(f"[Subprocess stderr] {line.strip()}")
else:
break
except Exception as e:
logger.error(f"Error monitoring stderr: {e}")
async def stop(self) -> None:
"""Stop the client and terminate subprocess if running."""
if not self._running:
return
self._running = False
# Cancel read task
if self._read_task:
self._read_task.cancel()
try:
await self._read_task
except asyncio.CancelledError:
pass
self._read_task = None
# 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:
self._process.wait(timeout=5.0)
logger.info("Subprocess terminated gracefully")
except subprocess.TimeoutExpired:
logger.warning("Subprocess did not terminate, killing...")
self._process.kill()
self._process.wait()
logger.info("Subprocess killed")
self._process = None
logger.info("StdioClient stopped")
async def send_message(self, message: JSONRPCMessage) -> None:
"""Send a JSON-RPC message to stdout.
Args:
message: The JSON-RPC message to send
"""
async with self._write_lock:
try:
json_str = message.model_dump_json(exclude_none=True)
await asyncio.get_event_loop().run_in_executor(
None, self._write_line, json_str
)
except Exception as e:
logger.error(f"Failed to send message: {e}")
raise
def _write_line(self, line: str) -> None:
"""Write a line to stdout (synchronous helper)."""
if self._stdout:
self._stdout.write(line + "\n")
self._stdout.flush()
async def _read_loop(self) -> None:
"""Main loop to read messages from stdin."""
if not self._stdin:
logger.error("No stdin available for reading")
return
logger.debug("Started reading from stdin")
loop = asyncio.get_event_loop()
try:
while self._running:
# Read line from stdin in executor to avoid blocking
line = await loop.run_in_executor(None, self._stdin.readline)
if not line:
# EOF reached
logger.info("EOF reached on stdin")
break
line = line.strip()
if not line:
continue
try:
# Parse JSON-RPC message
message = self._parse_message(line)
await self._handle_message(message)
except Exception as e:
logger.error(f"Failed to parse message: {e}, raw line: {line}")
except asyncio.CancelledError:
logger.debug("Read loop cancelled")
raise
except Exception as e:
logger.error(f"Error in read loop: {e}")
finally:
logger.debug("Stopped reading from stdin")
def _parse_message(self, line: str) -> JSONRPCMessage:
"""Parse a JSON-RPC message from a string.
Args:
line: JSON string to parse
Returns:
Parsed JSONRPCMessage (Request, SuccessResponse, or ErrorResponse)
"""
data = json.loads(line)
# Determine message type based on presence of fields
if "method" in data:
return JSONRPCRequest.model_validate(data)
elif "error" in data:
return JSONRPCErrorResponse.model_validate(data)
elif "result" in data:
return JSONRPCSuccessResponse.model_validate(data)
else:
raise ValueError(f"Invalid JSON-RPC message: {data}")

View File

@@ -1,235 +0,0 @@
from __future__ import annotations
import asyncio
import json
import aiohttp
from loguru import logger
from ..jsonrpc import (
JSONRPCErrorResponse,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCSuccessResponse,
)
from .base import JSONRPCClient
class WebSocketClient(JSONRPCClient):
"""JSON-RPC client using WebSocket for communication."""
def __init__(
self,
url: str,
heartbeat: float = 30.0,
auto_reconnect: bool = True,
reconnect_interval: float = 5.0,
) -> None:
"""Initialize the WebSocket client.
Args:
url: WebSocket server URL (e.g., ws://127.0.0.1:8765/rpc)
heartbeat: Heartbeat interval in seconds (0 to disable)
auto_reconnect: Whether to automatically reconnect on disconnection
reconnect_interval: Interval between reconnection attempts in seconds
"""
super().__init__()
self._url = url
self._heartbeat = heartbeat
self._auto_reconnect = auto_reconnect
self._reconnect_interval = reconnect_interval
self._session: aiohttp.ClientSession | None = None
self._ws: aiohttp.ClientWebSocketResponse | None = None
self._write_lock = asyncio.Lock()
self._read_task: asyncio.Task | None = None
self._reconnect_task: asyncio.Task | None = None
async def start(self) -> None:
"""Connect to the WebSocket server."""
if self._running:
logger.warning("WebSocketClient is already running")
return
self._running = True
self._session = aiohttp.ClientSession()
await self._connect()
logger.info(f"WebSocketClient started and connected to {self._url}")
async def _connect(self) -> None:
"""Establish WebSocket connection to the server."""
try:
if not self._session:
raise RuntimeError("Session not initialized")
self._ws = await self._session.ws_connect(
self._url,
heartbeat=self._heartbeat if self._heartbeat > 0 else None,
)
logger.info(f"Connected to WebSocket server: {self._url}")
# Start reading messages
self._read_task = asyncio.create_task(self._read_loop())
except Exception as e:
logger.error(f"Failed to connect to WebSocket server: {e}")
if self._auto_reconnect and self._running:
logger.info(
f"Will retry connection in {self._reconnect_interval} seconds..."
)
await asyncio.sleep(self._reconnect_interval)
if self._running:
await self._connect()
else:
raise
async def stop(self) -> None:
"""Disconnect from the WebSocket server and cleanup resources."""
if not self._running:
return
self._running = False
# Cancel reconnection task if running
if self._reconnect_task and not self._reconnect_task.done():
self._reconnect_task.cancel()
try:
await self._reconnect_task
except asyncio.CancelledError:
pass
self._reconnect_task = None
# Cancel read task
if self._read_task and not self._read_task.done():
self._read_task.cancel()
try:
await self._read_task
except asyncio.CancelledError:
pass
self._read_task = None
# Close WebSocket connection
if self._ws and not self._ws.closed:
await self._ws.close()
self._ws = None
# Close session
if self._session and not self._session.closed:
await self._session.close()
self._session = None
logger.info("WebSocketClient stopped")
async def send_message(self, message: JSONRPCMessage) -> None:
"""Send a JSON-RPC message through the WebSocket.
Args:
message: The JSON-RPC message to send
Raises:
RuntimeError: If no WebSocket connection is active
"""
if not self._ws or self._ws.closed:
raise RuntimeError("No active WebSocket connection")
async with self._write_lock:
try:
json_str = message.model_dump_json(exclude_none=True)
await self._ws.send_str(json_str)
except Exception as e:
logger.error(f"Failed to send message: {e}")
raise
async def _read_loop(self) -> None:
"""Main loop to read messages from WebSocket."""
if not self._ws:
logger.error("WebSocket connection not established")
return
logger.debug("Started reading from WebSocket")
try:
async for msg in self._ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
message = self._parse_message(msg.data)
await self._handle_message(message)
except Exception as e:
logger.error(
f"Failed to parse message: {e}, raw data: {msg.data}"
)
elif msg.type == aiohttp.WSMsgType.BINARY:
try:
text = msg.data.decode("utf-8")
message = self._parse_message(text)
await self._handle_message(message)
except Exception as e:
logger.error(f"Failed to parse binary message: {e}")
elif msg.type == aiohttp.WSMsgType.ERROR:
if self._ws:
logger.error(f"WebSocket error: {self._ws.exception()}")
break
elif msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
aiohttp.WSMsgType.CLOSED,
):
logger.debug("WebSocket closing")
break
except asyncio.CancelledError:
logger.debug("Read loop cancelled")
raise
except Exception as e:
logger.error(f"Error in read loop: {e}")
finally:
logger.debug("Stopped reading from WebSocket")
# Handle reconnection
if self._running and self._auto_reconnect:
logger.info("Connection lost, attempting to reconnect...")
self._reconnect_task = asyncio.create_task(self._reconnect())
async def _reconnect(self) -> None:
"""Attempt to reconnect to the WebSocket server."""
while self._running and self._auto_reconnect:
try:
logger.info(
f"Reconnecting to {self._url} in {self._reconnect_interval} seconds..."
)
await asyncio.sleep(self._reconnect_interval)
if not self._running:
break
await self._connect()
logger.info("Reconnected successfully")
break
except Exception as e:
logger.error(f"Reconnection failed: {e}")
# Continue loop to retry
def _parse_message(self, data: str) -> JSONRPCMessage:
"""Parse a JSON-RPC message from a string.
Args:
data: JSON string to parse
Returns:
Parsed JSONRPCMessage (Request, SuccessResponse, or ErrorResponse)
"""
obj = json.loads(data)
# Determine message type based on presence of fields
if "method" in obj:
return JSONRPCRequest.model_validate(obj)
elif "error" in obj:
return JSONRPCErrorResponse.model_validate(obj)
elif "result" in obj:
return JSONRPCSuccessResponse.model_validate(obj)
else:
raise ValueError(f"Invalid JSON-RPC message: {obj}")

View File

@@ -1,39 +0,0 @@
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class _JSONRPCBaseMessage(BaseModel):
jsonrpc: Literal["2.0"]
model_config = ConfigDict(extra="forbid")
class JSONRPCRequest(_JSONRPCBaseMessage):
id: str | None = None
method: str
params: dict[str, Any] = Field(default_factory=dict)
"""A request that expects a response."""
class _Result(_JSONRPCBaseMessage):
id: str | None
class JSONRPCSuccessResponse(_Result):
result: dict[str, Any] = Field(default_factory=dict)
"""A successful response to a request."""
class JSONRPCErrorData(BaseModel):
code: int
message: str
data: Any | None = None
class JSONRPCErrorResponse(_Result):
error: JSONRPCErrorData
"""An error response to a request."""
JSONRPCMessage = JSONRPCRequest | JSONRPCSuccessResponse | JSONRPCErrorResponse

View File

@@ -1,219 +0,0 @@
import asyncio
from typing import Any
from loguru import logger
from .jsonrpc import (
JSONRPCMessage,
JSONRPCRequest,
JSONRPCSuccessResponse,
JSONRPCErrorResponse,
)
from .transport import JSONRPCTransport
from ..types import (
HandlerStreamStartNotification,
HandlerStreamUpdateNotification,
HandlerStreamEndNotification,
)
class RPCRequestHelper:
"""Manages RPC communication state and pending requests.
Supports both single-response and streaming (multi-response) RPC patterns:
- Single response: Uses asyncio.Future
- Streaming: Uses asyncio.Queue for multiple responses
"""
def __init__(self):
self._request_id_counter = 0
self.pending_requests: dict[
str, asyncio.Future[JSONRPCMessage] | asyncio.Queue[Any]
] = {}
def _generate_request_id(self) -> str:
"""Generate a unique request ID."""
self._request_id_counter += 1
return str(self._request_id_counter)
async def call_rpc(
self, transport_impl: JSONRPCTransport, message: JSONRPCMessage
) -> JSONRPCMessage | None:
"""Send RPC request and wait for a single response.
Args:
transport_impl: The transport to send the message through
message: The JSON-RPC request message
Returns:
The JSON-RPC response message, or None if no response expected
"""
if message.id is None:
await transport_impl.send_message(message)
return None
future: asyncio.Future[JSONRPCMessage] = (
asyncio.get_event_loop().create_future()
)
self.pending_requests[message.id] = future
await transport_impl.send_message(message)
result = await future
return result
async def call_rpc_streaming(
self, transport_impl: JSONRPCTransport, message: JSONRPCMessage
) -> asyncio.Queue[Any]:
"""Send RPC request and expect multiple streaming responses.
The responses will be delivered via notifications with methods:
- handler_stream_start: Stream started
- handler_stream_update: New data available
- handler_stream_end: Stream completed
Args:
transport_impl: The transport to send the message through
message: The JSON-RPC request message
Returns:
An asyncio.Queue that will receive streamed results
"""
if message.id is None:
raise ValueError("Streaming RPC calls require a request ID")
queue: asyncio.Queue[Any] = asyncio.Queue()
self.pending_requests[message.id] = queue
await transport_impl.send_message(message)
return queue
def resolve_pending_request(
self, message: JSONRPCSuccessResponse | JSONRPCErrorResponse
):
"""Resolve a pending request with a response.
For single-response requests (Future), sets the result/exception.
For streaming requests (Queue), logs completion/error but queue is managed separately.
Args:
message: The JSON-RPC response message
"""
if message.id not in self.pending_requests:
logger.warning(f"Received response for unknown request ID: {message.id}")
return
pending = self.pending_requests[message.id]
if isinstance(pending, asyncio.Future):
# Single response mode
self.pending_requests.pop(message.id)
if not pending.done():
if isinstance(message, JSONRPCSuccessResponse):
pending.set_result(message)
else:
pending.set_exception(
RuntimeError(
f"RPC Error {message.error.code}: {message.error.message}"
)
)
elif isinstance(pending, asyncio.Queue):
# Streaming mode - final response received
if isinstance(message, JSONRPCSuccessResponse):
logger.debug(f"Streaming request {message.id} completed successfully")
else:
logger.error(
f"Streaming request {message.id} failed: {message.error.message}"
)
# Put error marker in queue
asyncio.create_task(
pending.put({"_error": True, "message": message.error.message})
)
async def handle_stream_notification(self, notification: JSONRPCRequest) -> None:
"""Handle incoming streaming notifications.
Processes handler_stream_start/update/end notifications and updates
the corresponding queue.
Args:
notification: The streaming notification message
Raises:
ValueError: If the notification method is not a valid stream notification
"""
# Validate notification method
if notification.method not in [
"handler_stream_start",
"handler_stream_update",
"handler_stream_end",
]:
raise ValueError(
f"Invalid stream notification method: {notification.method}"
)
# Extract common parameters
params = notification.params
request_id = params.get("id")
if not request_id or request_id not in self.pending_requests:
logger.warning(
f"Received stream notification for unknown request ID: {request_id}"
)
return
pending = self.pending_requests.get(request_id)
if not isinstance(pending, asyncio.Queue):
logger.warning(f"Request {request_id} is not a streaming request")
return
if notification.method == "handler_stream_start":
try:
typed_notification = HandlerStreamStartNotification.model_validate(
notification.model_dump()
)
logger.debug(
f"Stream started for handler {typed_notification.params.handler_full_name}"
)
except Exception as e:
logger.error(f"Invalid handler_stream_start notification: {e}")
# Optionally put a start marker in the queue if needed
# await pending.put({"_stream_start": True})
elif notification.method == "handler_stream_update":
try:
typed_notification = HandlerStreamUpdateNotification.model_validate(
notification.model_dump()
)
# Put the streamed data into the queue
data = typed_notification.params.data
logger.debug(f"Stream update for request {request_id}: {data}")
if data is not None:
await pending.put(data)
except Exception as e:
logger.error(f"Invalid handler_stream_update notification: {e}")
elif notification.method == "handler_stream_end":
try:
typed_notification = HandlerStreamEndNotification.model_validate(
notification.model_dump()
)
logger.debug(
f"Stream ended for handler {typed_notification.params.handler_full_name}"
)
# Put a sentinel value to indicate stream end
await pending.put({"_stream_end": True})
# Clean up the pending request after a short delay
asyncio.create_task(self._cleanup_stream_request(request_id))
except Exception as e:
logger.error(f"Invalid handler_stream_end notification: {e}")
async def _cleanup_stream_request(
self, request_id: str, delay: float = 1.0
) -> None:
"""Clean up a streaming request after a delay.
Args:
request_id: The request ID to clean up
delay: Delay before cleanup in seconds
"""
await asyncio.sleep(delay)
if request_id in self.pending_requests:
self.pending_requests.pop(request_id)
logger.debug(f"Cleaned up streaming request {request_id}")

View File

@@ -1,9 +0,0 @@
from .base import JSONRPCServer
from .stdio import StdioServer
from .websockets import WebSocketServer
__all__ = [
"JSONRPCServer",
"StdioServer",
"WebSocketServer",
]

View File

@@ -1,15 +0,0 @@
from __future__ import annotations
from abc import ABC
from ..transport import JSONRPCTransport
class JSONRPCServer(JSONRPCTransport, ABC):
"""Base class for JSON-RPC servers.
Handles pure communication (reading/writing JSON-RPC messages).
Server runs in plugin process and receives messages from AstrBot.
"""
def __init__(self) -> None:
super().__init__()

View File

@@ -1,152 +0,0 @@
from __future__ import annotations
import asyncio
import json
import sys
from typing import IO, Any
from loguru import logger
from ..jsonrpc import (
JSONRPCErrorResponse,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCSuccessResponse,
)
from .base import JSONRPCServer
class StdioServer(JSONRPCServer):
"""JSON-RPC server using standard input/output for communication.
This runs in the plugin process and communicates with AstrBot via stdio.
"""
def __init__(
self,
stdin: IO[Any] | None = None,
stdout: IO[Any] | None = None,
) -> None:
"""Initialize the STDIO server.
Args:
stdin: Input stream to read from (defaults to sys.stdin)
stdout: Output stream to write to (defaults to sys.stdout)
"""
super().__init__()
self._stdin = stdin or sys.stdin
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."""
if self._running:
logger.warning("StdioServer is already running")
return
self._running = True
self._read_task = asyncio.create_task(self._read_loop())
logger.info("StdioServer started")
async def stop(self) -> None:
"""Stop the server and cleanup resources."""
if not self._running:
return
self._running = False
self._closed_event.set()
# Cancel read task
if self._read_task:
self._read_task.cancel()
try:
await self._read_task
except asyncio.CancelledError:
pass
self._read_task = None
logger.info("StdioServer stopped")
async def send_message(self, message: JSONRPCMessage) -> None:
"""Send a JSON-RPC message to stdout.
Args:
message: The JSON-RPC message to send
"""
async with self._write_lock:
try:
json_str = message.model_dump_json(exclude_none=True)
await asyncio.get_event_loop().run_in_executor(
None, self._write_line, json_str
)
except Exception as e:
logger.error(f"Failed to send message: {e}")
raise
def _write_line(self, line: str) -> None:
"""Write a line to stdout (synchronous helper)."""
self._stdout.write(line + "\n")
self._stdout.flush()
async def _read_loop(self) -> None:
"""Main loop to read messages from stdin."""
logger.debug("Started reading from stdin")
loop = asyncio.get_event_loop()
try:
while self._running:
# Read line from stdin in executor to avoid blocking
line = await loop.run_in_executor(None, self._stdin.readline)
if not line:
# EOF reached
logger.info("EOF reached on stdin")
self._running = False
self._closed_event.set()
break
line = line.strip()
if not line:
continue
try:
# Parse JSON-RPC message
message = self._parse_message(line)
asyncio.create_task(self._handle_message(message))
except Exception as e:
logger.error(f"Failed to parse message: {e}, raw line: {line}")
except asyncio.CancelledError:
logger.debug("Read loop cancelled")
raise
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.
Args:
line: JSON string to parse
Returns:
Parsed JSONRPCMessage (Request, SuccessResponse, or ErrorResponse)
"""
data = json.loads(line)
# Determine message type based on presence of fields
if "method" in data:
return JSONRPCRequest.model_validate(data)
elif "error" in data:
return JSONRPCErrorResponse.model_validate(data)
elif "result" in data:
return JSONRPCSuccessResponse.model_validate(data)
else:
raise ValueError(f"Invalid JSON-RPC message: {data}")

View File

@@ -1,236 +0,0 @@
from __future__ import annotations
import asyncio
import json
import aiohttp
from aiohttp import web
from loguru import logger
from ..jsonrpc import (
JSONRPCErrorResponse,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCSuccessResponse,
)
from .base import JSONRPCServer
class WebSocketServer(JSONRPCServer):
"""JSON-RPC server using WebSocket for communication.
This runs in the plugin process and accepts connections from AstrBot via WebSocket.
"""
def __init__(
self,
host: str = "127.0.0.1",
port: int = 0, # 0 means auto-assign
path: str = "/",
heartbeat: float = 30.0,
) -> None:
"""Initialize the WebSocket server.
Args:
host: Host to bind to
port: Port to bind to (0 for auto-assign)
path: WebSocket endpoint path
heartbeat: Heartbeat interval in seconds (0 to disable)
"""
super().__init__()
self._host = host
self._port = port
self._path = path
self._heartbeat = heartbeat
self._app: web.Application | None = None
self._runner: web.AppRunner | None = None
self._site: web.TCPSite | None = None
self._ws: web.WebSocketResponse | None = None
self._write_lock = asyncio.Lock()
self._actual_port: int | None = None
async def start(self) -> None:
"""Start the WebSocket server and begin listening for connections."""
if self._running:
logger.warning("WebSocketServer is already running")
return
self._running = True
self._app = web.Application()
self._app.router.add_get(self._path, self._handle_websocket)
self._runner = web.AppRunner(self._app)
await self._runner.setup()
self._site = web.TCPSite(self._runner, self._host, self._port)
await self._site.start()
# Get the actual port (useful when port=0)
if self._site._server and hasattr(self._site._server, "sockets"):
sockets = getattr(self._site._server, "sockets", None)
if sockets:
for socket in sockets:
self._actual_port = socket.getsockname()[1]
break
logger.info(
f"WebSocketServer started on ws://{self._host}:{self._actual_port or self._port}{self._path}"
)
async def _handle_websocket(self, request: web.Request) -> web.WebSocketResponse:
"""Handle incoming WebSocket connections.
Args:
request: The aiohttp request object
Returns:
WebSocket response
"""
ws = web.WebSocketResponse(
heartbeat=self._heartbeat if self._heartbeat > 0 else None
)
await ws.prepare(request)
# Only allow one connection at a time (typical for plugin IPC)
if self._ws and not self._ws.closed:
logger.warning(
"Rejecting new connection - already have an active connection"
)
await ws.close(
code=1008, message=b"Server already has an active connection"
)
return ws
self._ws = ws
logger.info(f"WebSocket connection established from {request.remote}")
try:
await self._message_loop(ws)
except Exception as e:
logger.error(f"Error in WebSocket message loop: {e}")
finally:
if self._ws == ws:
self._ws = None
logger.info("WebSocket connection closed")
return ws
async def _message_loop(self, ws: web.WebSocketResponse) -> None:
"""Main loop to receive messages from WebSocket.
Args:
ws: The WebSocket response object
"""
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
message = self._parse_message(msg.data)
asyncio.create_task(self._handle_message(message))
except Exception as e:
logger.error(f"Failed to parse message: {e}, raw data: {msg.data}")
elif msg.type == aiohttp.WSMsgType.BINARY:
try:
text = msg.data.decode("utf-8")
message = self._parse_message(text)
asyncio.create_task(self._handle_message(message))
except Exception as e:
logger.error(f"Failed to parse binary message: {e}")
elif msg.type == aiohttp.WSMsgType.ERROR:
logger.error(f"WebSocket error: {ws.exception()}")
break
elif msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
aiohttp.WSMsgType.CLOSED,
):
logger.debug("WebSocket closing")
break
async def stop(self) -> None:
"""Stop the WebSocket server and cleanup resources."""
if not self._running:
return
self._running = False
# Close active WebSocket connection
if self._ws and not self._ws.closed:
await self._ws.close()
self._ws = None
# Cleanup server
if self._site:
await self._site.stop()
self._site = None
if self._runner:
await self._runner.cleanup()
self._runner = None
self._app = None
logger.info("WebSocketServer stopped")
async def send_message(self, message: JSONRPCMessage) -> None:
"""Send a JSON-RPC message through the WebSocket.
Args:
message: The JSON-RPC message to send
Raises:
RuntimeError: If no WebSocket connection is active
"""
if not self._ws or self._ws.closed:
raise RuntimeError("No active WebSocket connection")
async with self._write_lock:
try:
json_str = message.model_dump_json(exclude_none=True)
await self._ws.send_str(json_str)
except Exception as e:
logger.error(f"Failed to send message: {e}")
raise
@property
def port(self) -> int | None:
"""Get the actual port the server is listening on.
Returns:
Port number, or None if server is not started
"""
return self._actual_port or self._port
@property
def url(self) -> str | None:
"""Get the WebSocket URL the server is listening on.
Returns:
WebSocket URL, or None if server is not started
"""
if self._actual_port or self._port:
port = self._actual_port or self._port
return f"ws://{self._host}:{port}{self._path}"
return None
def _parse_message(self, data: str) -> JSONRPCMessage:
"""Parse a JSON-RPC message from a string.
Args:
data: JSON string to parse
Returns:
Parsed JSONRPCMessage (Request, SuccessResponse, or ErrorResponse)
"""
obj = json.loads(data)
# Determine message type based on presence of fields
if "method" in obj:
return JSONRPCRequest.model_validate(obj)
elif "error" in obj:
return JSONRPCErrorResponse.model_validate(obj)
elif "result" in obj:
return JSONRPCSuccessResponse.model_validate(obj)
else:
raise ValueError(f"Invalid JSON-RPC message: {obj}")

View File

@@ -1,48 +0,0 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable, Awaitable
from .jsonrpc import JSONRPCMessage
MessageHandler = Callable[[JSONRPCMessage], Awaitable[None]]
class JSONRPCTransport(ABC):
"""Base class for JSON-RPC transport layers."""
def __init__(self) -> None:
self._handler: MessageHandler | None = None
self._running = False
def set_message_handler(self, handler: MessageHandler) -> None:
"""Set the handler to be called when a message is received.
Args:
handler: Callback function that receives a JSONRPCMessage
"""
self._message_handler = handler
@abstractmethod
async def start(self) -> None:
"""Start the transport layer."""
pass
@abstractmethod
async def stop(self) -> None:
"""Stop the transport layer and cleanup resources."""
pass
@abstractmethod
async def send_message(self, message: JSONRPCMessage) -> None:
"""Send a JSON-RPC message.
Args:
message: The JSON-RPC message to send
"""
pass
async def _handle_message(self, message: JSONRPCMessage) -> None:
"""Internal method to dispatch received messages to the handler."""
if self._message_handler:
await self._message_handler(message)

View File

@@ -1,135 +0,0 @@
import asyncio
import signal
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 .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(
host: str = "127.0.0.1",
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
)
runner = StarRunner(server)
context = Context.default_context(runner=runner)
star_manager = StarManager(context=context)
star_manager.discover_star(Path(plugin_dir) if plugin_dir else None)
await runner.run()
stop_event = asyncio.Event()
_install_signal_handlers(stop_event)
logger.info("Server is running. Press Ctrl+C to stop.")
try:
await stop_event.wait()
finally:
logger.info("Shutting down...")
await server.stop()
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(Path(plugin_dir))
try:
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 plugin worker...")
await runner.stop()
if original_stdout is not None:
sys.stdout = original_stdout

View File

@@ -1,202 +0,0 @@
import inspect
from loguru import logger
from typing import Any
from .rpc.server.base import JSONRPCServer
from .stars.registry import star_map, star_handlers_registry
from .rpc.jsonrpc import (
JSONRPCMessage,
JSONRPCRequest,
JSONRPCSuccessResponse,
JSONRPCErrorResponse,
JSONRPCErrorData,
)
from .rpc.request_helper import RPCRequestHelper
from .types import (
CallHandlerRequest,
HandlerStreamStartNotification,
HandlerStreamUpdateNotification,
HandlerStreamEndNotification,
)
class HandshakeHandler:
"""Handles the handshake protocol to exchange plugin metadata."""
async def handle(self, message: JSONRPCRequest) -> JSONRPCSuccessResponse:
"""Build and return handshake response with plugin metadata."""
payload = {}
for star_name, star in star_map.items():
payload[star_name] = star.__dict__
handlers = []
for handler_full_name in star.star_handler_full_names:
handler = star_handlers_registry.get_handler_by_full_name(
handler_full_name
)
if handler is None:
continue
handlers.append(handler.dump_model())
payload[star_name]["handlers"] = handlers
return JSONRPCSuccessResponse(
jsonrpc="2.0",
id=message.id,
result=payload,
)
class HandlerExecutor:
"""Executes plugin handlers and manages streaming results."""
def __init__(self, rpc_request_helper: RPCRequestHelper):
self.rpc_request_helper = rpc_request_helper
async def execute(self, message: JSONRPCRequest, server: JSONRPCServer):
"""Execute a handler and stream results back to the caller."""
params = CallHandlerRequest.Params.model_validate(message.params)
handler_full_name = params.handler_full_name
event_model = params.event
args = params.args
event = event_model.to_event()
handler = star_handlers_registry.get_handler_by_full_name(handler_full_name)
if handler is None:
await self._send_error(
server, message.id, -32601, f"Handler not found: {handler_full_name}"
)
return
try:
await self._execute_and_stream(
server, message.id, handler_full_name, handler.handler(event, **args)
)
except Exception as e:
await self._send_error(server, message.id, -32000, str(e))
async def _execute_and_stream(
self,
server: JSONRPCServer,
request_id: str | None,
handler_name: str,
ready_to_call,
):
"""Execute handler and stream results."""
# Send start notification
await server.send_message(
HandlerStreamStartNotification(
jsonrpc="2.0",
method="handler_stream_start",
params=HandlerStreamStartNotification.Params(
id=request_id,
handler_full_name=handler_name,
),
)
)
try:
if inspect.iscoroutine(ready_to_call):
result = await ready_to_call
# Send update notification
await server.send_message(
HandlerStreamUpdateNotification(
jsonrpc="2.0",
method="handler_stream_update",
params=HandlerStreamUpdateNotification.Params(
id=request_id,
handler_full_name=handler_name,
data=result,
),
)
)
elif inspect.isasyncgen(ready_to_call):
async for ret in ready_to_call:
# Send update notification for each item
await server.send_message(
HandlerStreamUpdateNotification(
jsonrpc="2.0",
method="handler_stream_update",
params=HandlerStreamUpdateNotification.Params(
id=request_id,
handler_full_name=handler_name,
data=ret,
),
)
)
except Exception as e:
logger.error(f"Error during handler {handler_name}: {e}")
finally:
# Send end notification
await server.send_message(
HandlerStreamEndNotification(
jsonrpc="2.0",
method="handler_stream_end",
params=HandlerStreamEndNotification.Params(
id=request_id,
handler_full_name=handler_name,
),
)
)
async def _send_error(
self, server: JSONRPCServer, request_id: str | None, code: int, message: str
):
"""Send an error response."""
response = JSONRPCErrorResponse(
jsonrpc="2.0",
id=request_id,
error=JSONRPCErrorData(code=code, message=message),
)
await server.send_message(response)
class StarRunner:
"""Main runner to handle RPC messages and route them to handlers."""
def __init__(self, server: JSONRPCServer):
self.server = server
self.rpc_request_helper = RPCRequestHelper()
self.handler_executor = HandlerExecutor(self.rpc_request_helper)
self.handshake_handler = HandshakeHandler()
async def call_context_function(
self, method_name: str, params: dict[str, Any]
) -> dict[str, Any]:
result = await self.rpc_request_helper.call_rpc(
self.server,
JSONRPCRequest(
jsonrpc="2.0",
id=self.rpc_request_helper._generate_request_id(),
method="call_context_function",
params={
"name": method_name,
"args": params,
},
),
)
if isinstance(result, JSONRPCSuccessResponse):
return result.result
elif isinstance(result, JSONRPCErrorResponse):
raise Exception(f"RPC Error {result.error.code}: {result.error.message}")
else:
raise Exception("Invalid RPC response")
async def _handle_messages(self, message: JSONRPCMessage):
"""Route messages to appropriate handlers."""
if isinstance(message, JSONRPCRequest):
if message.method == "handshake":
response = await self.handshake_handler.handle(message)
await self.server.send_message(response)
elif message.method == "call_handler":
await self.handler_executor.execute(message, self.server)
else:
logger.warning(f"Unknown method from client: {message.method}")
elif isinstance(message, (JSONRPCSuccessResponse, JSONRPCErrorResponse)):
self.rpc_request_helper.resolve_pending_request(message)
async def run(self):
self.server.set_message_handler(handler=self._handle_messages)
await self.server.start()
async def stop(self):
await self.server.stop()

View File

@@ -1,14 +0,0 @@
import abc
from ....api.basic.astrbot_config import AstrBotConfig
from ....api.event import AstrMessageEvent
class HandlerFilter(abc.ABC):
@abc.abstractmethod
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
"""是否应当被过滤"""
raise NotImplementedError
__all__ = ["AstrBotConfig", "AstrMessageEvent", "HandlerFilter"]

View File

@@ -1,218 +0,0 @@
import inspect
import re
import types
import typing
from typing import Any
from ....api.basic.astrbot_config import AstrBotConfig
from ....api.event import AstrMessageEvent
from ...stars.registry import StarHandlerMetadata
from . import HandlerFilter
from .custom_filter import CustomFilter
class GreedyStr(str):
"""标记指令完成其他参数接收后的所有剩余文本。"""
def unwrap_optional(annotation) -> tuple:
"""去掉 Optional[T] / Union[T, None] / T|None返回 T"""
args = typing.get_args(annotation)
non_none_args = [a for a in args if a is not type(None)]
if len(non_none_args) == 1:
return (non_none_args[0],)
if len(non_none_args) > 1:
return tuple(non_none_args)
return ()
# 标准指令受到 wake_prefix 的制约。
class CommandFilter(HandlerFilter):
"""标准指令过滤器"""
def __init__(
self,
command_name: str,
alias: set | None = None,
handler_md: StarHandlerMetadata | None = None,
parent_command_names: list[str] | None = None,
):
self.command_name = command_name
self.alias = alias if alias else set()
self.parent_command_names = (
parent_command_names if parent_command_names is not None else [""]
)
if handler_md:
self.init_handler_md(handler_md)
self.custom_filter_list: list[CustomFilter] = []
# Cache for complete command names list
self._cmpl_cmd_names: list | None = None
def print_types(self):
parts = []
for k, v in self.handler_params.items():
if isinstance(v, type):
parts.append(f"{k}({v.__name__}),")
elif isinstance(v, types.UnionType) or typing.get_origin(v) is typing.Union:
parts.append(f"{k}({v}),")
else:
parts.append(f"{k}({type(v).__name__})={v},")
result = "".join(parts).rstrip(",")
return result
def init_handler_md(self, handle_md: StarHandlerMetadata):
self.handler_md = handle_md
signature = inspect.signature(self.handler_md.handler)
self.handler_params = {} # 参数名 -> 参数类型,如果有默认值则为默认值
idx = 0
for k, v in signature.parameters.items():
if idx < 2:
# 忽略前两个参数,即 self 和 event
idx += 1
continue
if v.default == inspect.Parameter.empty:
self.handler_params[k] = v.annotation
else:
self.handler_params[k] = v.default
def get_handler_md(self) -> StarHandlerMetadata:
return self.handler_md
def add_custom_filter(self, custom_filter: CustomFilter):
self.custom_filter_list.append(custom_filter)
def custom_filter_ok(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
for custom_filter in self.custom_filter_list:
if not custom_filter.filter(event, cfg):
return False
return True
def validate_and_convert_params(
self,
params: list[Any],
param_type: dict[str, type],
) -> dict[str, Any]:
"""将参数列表 params 根据 param_type 转换为参数字典。"""
result = {}
param_items = list(param_type.items())
for i, (param_name, param_type_or_default_val) in enumerate(param_items):
is_greedy = param_type_or_default_val is GreedyStr
if is_greedy:
# GreedyStr 必须是最后一个参数
if i != len(param_items) - 1:
raise ValueError(
f"参数 '{param_name}' (GreedyStr) 必须是最后一个参数。",
)
# 将剩余的所有部分合并成一个字符串
remaining_params = params[i:]
result[param_name] = " ".join(remaining_params)
break
# 没有 GreedyStr 的情况
if i >= len(params):
if (
isinstance(param_type_or_default_val, (type, types.UnionType))
or typing.get_origin(param_type_or_default_val) is typing.Union
or param_type_or_default_val is inspect.Parameter.empty
):
# 是类型
raise ValueError(
f"必要参数缺失。该指令完整参数: {self.print_types()}",
)
# 是默认值
result[param_name] = param_type_or_default_val
else:
# 尝试强制转换
try:
if param_type_or_default_val is None:
if params[i].isdigit():
result[param_name] = int(params[i])
else:
result[param_name] = params[i]
elif isinstance(param_type_or_default_val, str):
# 如果 param_type_or_default_val 是字符串,直接赋值
result[param_name] = params[i]
elif isinstance(param_type_or_default_val, bool):
# 处理布尔类型
lower_param = str(params[i]).lower()
if lower_param in ["true", "yes", "1"]:
result[param_name] = True
elif lower_param in ["false", "no", "0"]:
result[param_name] = False
else:
raise ValueError(
f"参数 {param_name} 必须是布尔值true/false, yes/no, 1/0",
)
elif isinstance(param_type_or_default_val, int):
result[param_name] = int(params[i])
elif isinstance(param_type_or_default_val, float):
result[param_name] = float(params[i])
else:
origin = typing.get_origin(param_type_or_default_val)
if origin in (typing.Union, types.UnionType):
# 注解是联合类型
# NOTE: 目前没有处理联合类型嵌套相关的注解写法
nn_types = unwrap_optional(param_type_or_default_val)
if len(nn_types) == 1:
# 只有一个非 NoneType 类型
result[param_name] = nn_types[0](params[i])
else:
# 没有或者有多个非 NoneType 类型,这里我们暂时直接赋值为原始值。
# NOTE: 目前还没有做类型校验
result[param_name] = params[i]
else:
result[param_name] = param_type_or_default_val(params[i])
except ValueError:
raise ValueError(
f"参数 {param_name} 类型错误。完整参数: {self.print_types()}",
)
return result
def get_complete_command_names(self):
if self._cmpl_cmd_names is not None:
return self._cmpl_cmd_names
self._cmpl_cmd_names = [
f"{parent} {cmd}" if parent else cmd
for cmd in [self.command_name] + list(self.alias)
for parent in self.parent_command_names or [""]
]
return self._cmpl_cmd_names
def equals(self, message_str: str) -> bool:
for full_cmd in self.get_complete_command_names():
if message_str == full_cmd:
return True
return False
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
if not event.is_at_or_wake_command:
return False
if not self.custom_filter_ok(event, cfg):
return False
# 检查是否以指令开头
message_str = re.sub(r"\s+", " ", event.get_message_str().strip())
ok = False
for full_cmd in self.get_complete_command_names():
if message_str.startswith(f"{full_cmd} ") or message_str == full_cmd:
ok = True
message_str = message_str[len(full_cmd) :].strip()
if not ok:
return False
# 分割为列表
ls = message_str.split(" ")
# 去除空字符串
ls = [param for param in ls if param]
params = {}
try:
params = self.validate_and_convert_params(ls, self.handler_params)
except ValueError as e:
raise e
event.set_extra("parsed_params", params)
return True

View File

@@ -1,133 +0,0 @@
from __future__ import annotations
from ....api.basic.astrbot_config import AstrBotConfig
from ....api.event import AstrMessageEvent
from . import HandlerFilter
from .command import CommandFilter
from .custom_filter import CustomFilter
# 指令组受到 wake_prefix 的制约。
class CommandGroupFilter(HandlerFilter):
def __init__(
self,
group_name: str,
alias: set | None = None,
parent_group: CommandGroupFilter | None = None,
):
self.group_name = group_name
self.alias = alias if alias else set()
self.sub_command_filters: list[CommandFilter | CommandGroupFilter] = []
self.custom_filter_list: list[CustomFilter] = []
self.parent_group = parent_group
# Cache for complete command names list
self._cmpl_cmd_names: list | None = None
def add_sub_command_filter(
self,
sub_command_filter: CommandFilter | CommandGroupFilter,
):
self.sub_command_filters.append(sub_command_filter)
def add_custom_filter(self, custom_filter: CustomFilter):
self.custom_filter_list.append(custom_filter)
def get_complete_command_names(self) -> list[str]:
"""遍历父节点获取完整的指令名。
新版本 v3.4.29 采用预编译指令,不再从指令组递归遍历子指令,因此这个方法是返回包括别名在内的整个指令名列表。
"""
if self._cmpl_cmd_names is not None:
return self._cmpl_cmd_names
parent_cmd_names = (
self.parent_group.get_complete_command_names() if self.parent_group else []
)
if not parent_cmd_names:
# 根节点
return [self.group_name] + list(self.alias)
result = []
candidates = [self.group_name] + list(self.alias)
for parent_cmd_name in parent_cmd_names:
for candidate in candidates:
result.append(parent_cmd_name + " " + candidate)
self._cmpl_cmd_names = result
return result
# 以树的形式打印出来
def print_cmd_tree(
self,
sub_command_filters: list[CommandFilter | CommandGroupFilter],
prefix: str = "",
event: AstrMessageEvent | None = None,
cfg: AstrBotConfig | None = None,
) -> str:
parts = []
for sub_filter in sub_command_filters:
if isinstance(sub_filter, CommandFilter):
custom_filter_pass = True
if event and cfg:
custom_filter_pass = sub_filter.custom_filter_ok(event, cfg)
if custom_filter_pass:
cmd_th = sub_filter.print_types()
line = f"{prefix}├── {sub_filter.command_name}"
if cmd_th:
line += f" ({cmd_th})"
else:
line += " (无参数指令)"
if sub_filter.handler_md and sub_filter.handler_md.desc:
line += f": {sub_filter.handler_md.desc}"
parts.append(line + "\n")
elif isinstance(sub_filter, CommandGroupFilter):
custom_filter_pass = True
if event and cfg:
custom_filter_pass = sub_filter.custom_filter_ok(event, cfg)
if custom_filter_pass:
parts.append(f"{prefix}├── {sub_filter.group_name}\n")
parts.append(
sub_filter.print_cmd_tree(
sub_filter.sub_command_filters,
prefix + "",
event=event,
cfg=cfg,
)
)
return "".join(parts)
def custom_filter_ok(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
for custom_filter in self.custom_filter_list:
if not custom_filter.filter(event, cfg):
return False
return True
def startswith(self, message_str: str) -> bool:
return message_str.startswith(tuple(self.get_complete_command_names()))
def equals(self, message_str: str) -> bool:
return message_str in self.get_complete_command_names()
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
if not event.is_at_or_wake_command:
return False
# 判断当前指令组的自定义过滤器
if not self.custom_filter_ok(event, cfg):
return False
if self.equals(event.message_str.strip()):
tree = (
self.group_name
+ "\n"
+ self.print_cmd_tree(self.sub_command_filters, event=event, cfg=cfg)
)
raise ValueError(
f"参数不足。{self.group_name} 指令组下有如下指令,请参考:\n" + tree,
)
return self.startswith(event.message_str)

View File

@@ -1,61 +0,0 @@
from abc import ABCMeta, abstractmethod
from ....api.basic.astrbot_config import AstrBotConfig
from ....api.event import AstrMessageEvent
from . import HandlerFilter
class CustomFilterMeta(ABCMeta):
def __and__(cls, other):
if not issubclass(other, CustomFilter):
raise TypeError("Operands must be subclasses of CustomFilter.")
return CustomFilterAnd(cls(), other())
def __or__(cls, other):
if not issubclass(other, CustomFilter):
raise TypeError("Operands must be subclasses of CustomFilter.")
return CustomFilterOr(cls(), other())
class CustomFilter(HandlerFilter, metaclass=CustomFilterMeta):
def __init__(self, raise_error: bool = True, **kwargs):
self.raise_error = raise_error
@abstractmethod
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
"""一个用于重写的自定义Filter"""
raise NotImplementedError
def __or__(self, other):
return CustomFilterOr(self, other)
def __and__(self, other):
return CustomFilterAnd(self, other)
class CustomFilterOr(CustomFilter):
def __init__(self, filter1: CustomFilter, filter2: CustomFilter):
super().__init__()
if not isinstance(filter1, (CustomFilter, CustomFilterAnd, CustomFilterOr)):
raise ValueError(
"CustomFilter lass can only operate with other CustomFilter.",
)
self.filter1 = filter1
self.filter2 = filter2
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
return self.filter1.filter(event, cfg) or self.filter2.filter(event, cfg)
class CustomFilterAnd(CustomFilter):
def __init__(self, filter1: CustomFilter, filter2: CustomFilter):
super().__init__()
if not isinstance(filter1, (CustomFilter, CustomFilterAnd, CustomFilterOr)):
raise ValueError(
"CustomFilter lass can only operate with other CustomFilter.",
)
self.filter1 = filter1
self.filter2 = filter2
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
return self.filter1.filter(event, cfg) and self.filter2.filter(event, cfg)

View File

@@ -1,33 +0,0 @@
import enum
from ....api.basic.astrbot_config import AstrBotConfig
from ....api.event import AstrMessageEvent
from ....api.event.message_type import MessageType
from . import HandlerFilter
class EventMessageType(enum.Flag):
GROUP_MESSAGE = enum.auto()
PRIVATE_MESSAGE = enum.auto()
OTHER_MESSAGE = enum.auto()
ALL = GROUP_MESSAGE | PRIVATE_MESSAGE | OTHER_MESSAGE
MESSAGE_TYPE_2_EVENT_MESSAGE_TYPE = {
MessageType.GROUP_MESSAGE: EventMessageType.GROUP_MESSAGE,
MessageType.FRIEND_MESSAGE: EventMessageType.PRIVATE_MESSAGE,
MessageType.OTHER_MESSAGE: EventMessageType.OTHER_MESSAGE,
}
class EventMessageTypeFilter(HandlerFilter):
def __init__(self, event_message_type: EventMessageType):
self.event_message_type = event_message_type
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
message_type = event.get_message_type()
if message_type in MESSAGE_TYPE_2_EVENT_MESSAGE_TYPE:
event_message_type = MESSAGE_TYPE_2_EVENT_MESSAGE_TYPE[message_type]
return bool(event_message_type & self.event_message_type)
return False

View File

@@ -1,29 +0,0 @@
import enum
from ....api.basic.astrbot_config import AstrBotConfig
from ....api.event import AstrMessageEvent
from . import HandlerFilter
class PermissionType(enum.Flag):
"""权限类型。当选择 MEMBERADMIN 也可以通过。"""
ADMIN = enum.auto()
MEMBER = enum.auto()
class PermissionTypeFilter(HandlerFilter):
def __init__(self, permission_type: PermissionType, raise_error: bool = True):
self.permission_type = permission_type
self.raise_error = raise_error
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
"""过滤器"""
if self.permission_type == PermissionType.ADMIN:
if not event.is_admin():
# event.stop_event()
# raise ValueError(f"您 (ID: {event.get_sender_id()}) 没有权限操作管理员指令。")
return False
return True

View File

@@ -1,71 +0,0 @@
import enum
from ....api.basic.astrbot_config import AstrBotConfig
from ....api.event import AstrMessageEvent
from . import HandlerFilter
class PlatformAdapterType(enum.Flag):
AIOCQHTTP = enum.auto()
QQOFFICIAL = enum.auto()
TELEGRAM = enum.auto()
WECOM = enum.auto()
LARK = enum.auto()
WECHATPADPRO = enum.auto()
DINGTALK = enum.auto()
DISCORD = enum.auto()
SLACK = enum.auto()
KOOK = enum.auto()
VOCECHAT = enum.auto()
WEIXIN_OFFICIAL_ACCOUNT = enum.auto()
SATORI = enum.auto()
MISSKEY = enum.auto()
ALL = (
AIOCQHTTP
| QQOFFICIAL
| TELEGRAM
| WECOM
| LARK
| WECHATPADPRO
| DINGTALK
| DISCORD
| SLACK
| KOOK
| VOCECHAT
| WEIXIN_OFFICIAL_ACCOUNT
| SATORI
| MISSKEY
)
ADAPTER_NAME_2_TYPE = {
"aiocqhttp": PlatformAdapterType.AIOCQHTTP,
"qq_official": PlatformAdapterType.QQOFFICIAL,
"telegram": PlatformAdapterType.TELEGRAM,
"wecom": PlatformAdapterType.WECOM,
"lark": PlatformAdapterType.LARK,
"dingtalk": PlatformAdapterType.DINGTALK,
"discord": PlatformAdapterType.DISCORD,
"slack": PlatformAdapterType.SLACK,
"kook": PlatformAdapterType.KOOK,
"wechatpadpro": PlatformAdapterType.WECHATPADPRO,
"vocechat": PlatformAdapterType.VOCECHAT,
"weixin_official_account": PlatformAdapterType.WEIXIN_OFFICIAL_ACCOUNT,
"satori": PlatformAdapterType.SATORI,
"misskey": PlatformAdapterType.MISSKEY,
}
class PlatformAdapterTypeFilter(HandlerFilter):
def __init__(self, platform_adapter_type_or_str: PlatformAdapterType | str):
if isinstance(platform_adapter_type_or_str, str):
self.platform_type = ADAPTER_NAME_2_TYPE.get(platform_adapter_type_or_str)
else:
self.platform_type = platform_adapter_type_or_str
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
adapter_name = event.get_platform_name()
if adapter_name in ADAPTER_NAME_2_TYPE and self.platform_type is not None:
return bool(ADAPTER_NAME_2_TYPE[adapter_name] & self.platform_type)
return False

View File

@@ -1,18 +0,0 @@
import re
from ....api.basic.astrbot_config import AstrBotConfig
from ....api.event import AstrMessageEvent
from . import HandlerFilter
# 正则表达式过滤器不会受到 wake_prefix 的制约。
class RegexFilter(HandlerFilter):
"""正则表达式过滤器"""
def __init__(self, regex: str):
self.regex_str = regex
self.regex = re.compile(regex)
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
return bool(self.regex.match(event.get_message_str().strip()))

View File

@@ -1,248 +0,0 @@
from __future__ import annotations
import asyncio
import os
import inspect
from pathlib import Path
from typing import Any, AsyncGenerator
from loguru import logger
from ...api.event.astr_message_event import AstrMessageEvent
from ...api.star.star import StarMetadata
from .registry import EventType, StarHandlerMetadata
from ..rpc.jsonrpc import (
JSONRPCErrorResponse,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCSuccessResponse,
)
from ..rpc.client import JSONRPCClient
from ..rpc.client.stdio import StdioClient
from ..rpc.client.websocket import WebSocketClient
from ..rpc.request_helper import RPCRequestHelper
from .virtual import VirtualStar
from .new_star_utils import (
ClientHandshakeHandler,
PluginRequestHandler,
HandlerProxyFactory,
)
class NewStar(VirtualStar):
"""NewStar implementation for isolated plugin runtime.
NewStar runs plugins in separate processes and communicates via JSON-RPC.
This provides better isolation, security, and compatibility.
"""
def __init__(
self,
client: JSONRPCClient,
context: Any,
) -> None:
"""Initialize a NewStar instance.
Args:
client: JSON-RPC client for communication
context: Context instance for managing managers and their functions
"""
super().__init__(context)
self._client = client
self._metadata: dict[str, StarMetadata] = {}
self._handlers: list[StarHandlerMetadata] = []
self._active = False
# Use RPCRequestHelper for managing requests
self._rpc_helper = RPCRequestHelper()
# Initialize specialized handlers
self._handshake_handler = ClientHandshakeHandler(self._rpc_helper)
self._plugin_request_handler = PluginRequestHandler(context)
self._handler_proxy_factory = HandlerProxyFactory(client, self._rpc_helper)
# Set up message handler
self._client.set_message_handler(self._handle_message)
async def _handle_message(self, message: JSONRPCMessage) -> None:
"""Handle incoming JSON-RPC messages from the plugin.
Args:
message: The received JSON-RPC message
"""
if isinstance(message, JSONRPCSuccessResponse) or isinstance(
message,
JSONRPCErrorResponse,
):
# Delegate to RPCRequestHelper
self._rpc_helper.resolve_pending_request(message)
elif isinstance(message, JSONRPCRequest):
# Handle notifications from plugin (streaming events or method calls)
if message.method in [
"handler_stream_start",
"handler_stream_update",
"handler_stream_end",
]:
await self._rpc_helper.handle_stream_notification(message)
else:
# Plugin is calling a method on the core - delegate to PluginRequestHandler
asyncio.create_task(
self._plugin_request_handler.handle_request(message, self._client)
)
async def initialize(self) -> None:
"""Start the plugin process and establish connection."""
# Start the client (which may start a subprocess for STDIO)
await self._client.start()
logger.info("Client started and ready for communication")
async def handshake(self) -> dict[str, StarMetadata]:
"""Perform handshake to retrieve plugin metadata.
Returns:
Plugin metadata including name, version, handlers, etc.
"""
# Delegate to ClientHandshakeHandler
(
self._metadata,
self._handlers,
) = await self._handshake_handler.perform_handshake(self._client)
# Set up handler proxies
self._handler_proxy_factory.setup_handlers(self._handlers)
return self._metadata
def get_triggered_handlers(
self, event: AstrMessageEvent
) -> list[StarHandlerMetadata]:
"""Get the list of handlers that should be triggered for this event.
Args:
event: The message event
Returns:
List of handler metadata that should handle this event
"""
# For AdapterMessageEvent, return relevant handlers
# This is cached locally, no RPC needed
triggered = []
for handler in self._handlers:
if handler.event_type == EventType.AdapterMessageEvent:
# In practice, you'd check filters here
triggered.append(handler)
return triggered
async def call_handler(
self,
handler: StarHandlerMetadata,
event: AstrMessageEvent,
*args,
**kwargs,
) -> AsyncGenerator[Any, None]:
"""Call a specific handler in the plugin.
Args:
handler: The handler metadata
event: The message event
*args: Additional positional arguments
**kwargs: Additional keyword arguments
Returns:
An async generator yielding results from the handler
"""
logger.debug(f"Calling handler: {handler.handler_name}")
# Call the handler proxy
assert inspect.isasyncgenfunction(handler.handler), (
"Handler proxy must be an async generator function"
)
async for result in handler.handler(event, **kwargs):
yield result
async def stop(self) -> None:
"""Stop the NewStar and cleanup resources."""
await self._client.stop()
logger.info("NewStar client stopped.")
class NewStdioStar(NewStar):
"""NewStar implementation using STDIO communication.
This class automatically starts the plugin subprocess and manages its lifecycle.
"""
def __init__(
self,
plugins_dir: str,
python_executable: str = "python",
context: Any = None,
**kwargs: Any,
) -> None:
"""Initialize a STDIO-based NewStar.
Args:
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
"""
if not os.path.exists(plugins_dir):
raise FileNotFoundError(f"Plugins directory not found: {plugins_dir}")
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=plugins_dir, env=env)
super().__init__(client, context=context)
class NewWebSocketStar(NewStar):
"""NewStar implementation using WebSocket communication.
Note: WebSocket-based stars do not start the plugin process.
The plugin should be started externally and connect to the specified WebSocket URL.
"""
def __init__(
self,
url: str,
heartbeat: float = 30.0,
reconnect_interval: float = 5.0,
context: Any = None,
**kwargs: Any,
) -> None:
"""Initialize a WebSocket-based NewStar.
Args:
url: WebSocket server URL that the plugin will connect to
heartbeat: Heartbeat interval in seconds
reconnect_interval: Interval between reconnection attempts in seconds
context: Context instance for managing managers and their functions
"""
client = WebSocketClient(
url=url, heartbeat=heartbeat, reconnect_interval=reconnect_interval
)
super().__init__(client, context=context)
self._url = url
self._heartbeat = heartbeat
self._reconnect_interval = reconnect_interval

View File

@@ -1,266 +0,0 @@
import asyncio
import inspect
from typing import Any, AsyncGenerator
from loguru import logger
from ...api.event.astr_message_event import AstrMessageEvent, AstrMessageEventModel
from ...api.star.star import StarMetadata
from ...api.star.context import Context
from ..rpc.client import JSONRPCClient
from ..rpc.request_helper import RPCRequestHelper
from ..rpc.jsonrpc import (
JSONRPCSuccessResponse,
JSONRPCRequest,
JSONRPCErrorResponse,
JSONRPCErrorData,
)
from ..types import CallHandlerRequest, HandshakeRequest
from .registry import StarHandlerMetadata, EventType
class HandlerProxyFactory:
"""Creates proxy functions for remote handler invocation."""
def __init__(self, client: JSONRPCClient, rpc_helper: RPCRequestHelper):
"""Initialize the handler proxy factory.
Args:
client: JSON-RPC client for communication
rpc_helper: RPC request helper for making RPC calls
"""
self._client = client
self._rpc_helper = rpc_helper
def create_handler_proxy(self, handler_full_name: str):
"""Create a proxy function that calls the handler via RPC.
Args:
handler_full_name: The full name of the handler
Returns:
An async generator function that proxies calls to the remote handler.
"""
async def handler_proxy(
event: AstrMessageEvent, **kwargs
) -> AsyncGenerator[Any, None]:
"""Proxy function for remote handler invocation.
Yields results from the remote handler via streaming.
"""
request_id = self._rpc_helper._generate_request_id()
request = CallHandlerRequest(
jsonrpc="2.0",
id=request_id,
method="call_handler",
params=CallHandlerRequest.Params(
handler_full_name=handler_full_name,
event=AstrMessageEventModel.from_event(event),
args=kwargs,
),
)
queue = await self._rpc_helper.call_rpc_streaming(self._client, request)
try:
while True:
item = await asyncio.wait_for(queue.get(), timeout=30.0)
if isinstance(item, dict) and item.get("_stream_end"):
break
if isinstance(item, dict) and item.get("_error"):
raise RuntimeError(item.get("message", "Unknown error"))
yield self._deserialize_result(item)
except asyncio.TimeoutError:
raise RuntimeError(f"RPC call to {handler_full_name} timed out")
return handler_proxy
def setup_handlers(self, handlers: list[StarHandlerMetadata]) -> None:
"""Set up handler proxies for all handlers.
Args:
handlers: List of handler metadata to set up
"""
for handler in handlers:
handler.handler = self.create_handler_proxy(handler.handler_full_name)
logger.info(f"Set up {len(handlers)} handler proxies")
def _deserialize_result(self, result: Any) -> Any:
"""Deserialize result from JSON-RPC response.
Args:
result: The result from the plugin
Returns:
Deserialized result object
"""
# For now, return as-is
# In practice, we might want to reconstruct MessageEventResult etc.
return result
class ClientHandshakeHandler:
"""Handles the handshake protocol to retrieve plugin metadata."""
def __init__(self, rpc_helper: RPCRequestHelper):
"""Initialize the handshake handler.
Args:
rpc_helper: RPC request helper for making RPC calls
"""
self._rpc_helper = rpc_helper
async def perform_handshake(
self, client: JSONRPCClient
) -> tuple[dict[str, StarMetadata], list[StarHandlerMetadata]]:
"""Perform handshake to retrieve plugin metadata.
Args:
client: JSON-RPC client for communication
Returns:
Tuple of (metadata dict, handlers list)
Raises:
RuntimeError: If handshake fails
"""
logger.info("Performing handshake with plugin...")
response = await self._rpc_helper.call_rpc(
client,
HandshakeRequest(
jsonrpc="2.0",
id=self._rpc_helper._generate_request_id(),
method="handshake",
),
)
if not isinstance(response, JSONRPCSuccessResponse):
raise RuntimeError("Handshake failed: Invalid response from plugin")
result = response.result
if not isinstance(result, dict):
raise RuntimeError("Handshake failed: Invalid response from plugin")
metadata_dict: dict[str, StarMetadata] = {}
handlers_list: list[StarHandlerMetadata] = []
# Placeholder handler that will be replaced later
def _placeholder_handler(*args, **kwargs):
raise NotImplementedError("Handler proxy not set up yet")
# Parse metadata
for star_name, star_info in result.items():
handlers_data = star_info.pop("handlers", None)
metadata = StarMetadata(**star_info)
metadata_dict[star_name] = metadata
# Parse handlers
if handlers_data:
for handler_data in handlers_data:
handler_meta = 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, # Will be replaced by HandlerProxyFactory
event_filters=[],
desc=handler_data.get("desc", ""),
extras_configs=handler_data.get("extras_configs", {}),
)
handlers_list.append(handler_meta)
logger.info(
f"Handshake complete: {len(metadata_dict)} stars loaded, "
f"{metadata_dict.keys()}, {len(handlers_list)} handlers registered."
)
return metadata_dict, handlers_list
class PluginRequestHandler:
"""Handles JSON-RPC requests from plugins calling core methods."""
def __init__(self, context: Context):
"""Initialize the plugin request handler.
Args:
context: Context instance for managing managers and their functions
"""
self._context = context
async def handle_request(
self, request: JSONRPCRequest, client: JSONRPCClient
) -> None:
"""Handle a JSON-RPC request from the plugin.
Args:
request: The JSON-RPC request from the plugin
client: The client to send response back
"""
result: Any = None
try:
method = request.method
params = request.params or {}
if method == "call_context_function":
result = await self._handle_context_function_call(params)
else:
raise ValueError(f"Unknown method: {method}")
# Send success response
response = JSONRPCSuccessResponse(
jsonrpc="2.0",
id=request.id,
result={
"data": result,
},
)
await client.send_message(response)
except Exception as e:
logger.error(f"Error handling plugin request: {e}")
# Send error response
error_response = JSONRPCErrorResponse(
jsonrpc="2.0",
id=request.id,
error=JSONRPCErrorData(
code=-32603,
message=str(e),
),
)
await client.send_message(error_response)
async def _handle_context_function_call(self, params: dict) -> Any:
"""Handle call_context_function requests.
Args:
params: Request parameters containing function name and args
Returns:
Result from the function call
Raises:
ValueError: If function is not found
"""
func_full_name = params.get("name", "")
args = params.get("args", {})
logger.debug(
f"Plugin called call_context_function: {func_full_name} with args: {args}"
)
# Get the registered function from context
func = self._context._registered_functions.get(func_full_name)
if func is None:
raise ValueError(f"Function not found: {func_full_name}")
# Call the function
if inspect.iscoroutinefunction(func):
result = await func(**args)
else:
result = func(**args)
logger.debug(f"call_context_function result: {result}")
return result

View File

@@ -1,182 +0,0 @@
from __future__ import annotations
import enum
from collections.abc import Awaitable, Callable, AsyncGenerator
from dataclasses import dataclass, field
from typing import Any, Generic, TypeVar
from ..filter import HandlerFilter
from ....api.star.star import StarMetadata
from ....api.star.context import Context as BaseContext
T = TypeVar("T", bound="StarHandlerMetadata")
class EventType(enum.Enum):
"""表示一个 AstrBot 内部事件的类型。如适配器消息事件、LLM 请求事件、发送消息前的事件等
用于对 Handler 的职能分组。
"""
OnAstrBotLoadedEvent = enum.auto()
"""AstrBot 加载完成"""
OnPlatformLoadedEvent = enum.auto()
"""平台适配器加载完成"""
AdapterMessageEvent = enum.auto()
"""收到适配器消息事件"""
OnLLMRequestEvent = enum.auto()
"""LLM 请求前"""
OnLLMResponseEvent = enum.auto()
"""LLM 响应后"""
OnDecoratingResultEvent = enum.auto()
"""发送消息前"""
OnCallingFuncToolEvent = enum.auto()
"""调用函数工具前"""
OnAfterMessageSentEvent = enum.auto()
"""发送消息后"""
@dataclass
class StarHandlerMetadata:
"""描述一个 Star 所注册的某一个 Handler。"""
event_type: EventType
"""Handler 的事件类型"""
handler_full_name: str
'''格式为 f"{handler.__module__}_{handler.__name__}"'''
handler_name: str
"""Handler 的名字,也就是方法名"""
handler_module_path: str
"""Handler 所在的模块路径。"""
handler: Callable[..., Awaitable[Any] | AsyncGenerator[Any, None]]
"""Handler 的函数对象,应当是一个异步函数"""
event_filters: list[HandlerFilter]
"""一个适配器消息事件过滤器,用于描述这个 Handler 能够处理、应该处理的适配器消息事件"""
desc: str = ""
"""Handler 的描述信息"""
extras_configs: dict = field(default_factory=dict)
"""插件注册的一些其他的信息, 如 priority 等"""
def __lt__(self, other: StarHandlerMetadata):
"""定义小于运算符以支持优先队列"""
return self.extras_configs.get("priority", 0) < other.extras_configs.get(
"priority",
0,
)
def dump_model(self) -> dict[str, Any]:
"""将 Handler 的元数据转换为字典形式,便于序列化。"""
p = self.__dict__.copy()
p.pop("handler")
p.pop("event_filters")
return p
class StarHandlerRegistry(Generic[T]):
def __init__(self):
self.star_handlers_map: dict[str, StarHandlerMetadata] = {}
self._handlers: list[StarHandlerMetadata] = []
def append(self, handler: StarHandlerMetadata):
"""添加一个 Handler并保持按优先级有序"""
if "priority" not in handler.extras_configs:
handler.extras_configs["priority"] = 0
self.star_handlers_map[handler.handler_full_name] = handler
self._handlers.append(handler)
self._handlers.sort(key=lambda h: -h.extras_configs["priority"])
def _print_handlers(self):
for handler in self._handlers:
print(handler.handler_full_name)
def get_handlers_by_event_type(
self,
event_type: EventType,
only_activated=True,
plugins_name: list[str] | None = None,
) -> list[StarHandlerMetadata]:
handlers = []
for handler in self._handlers:
# 过滤事件类型
if handler.event_type != event_type:
continue
# 过滤启用状态
if only_activated:
plugin = star_map.get(handler.handler_module_path)
if not (plugin and plugin.activated):
continue
# 过滤插件白名单
if plugins_name is not None and plugins_name != ["*"]:
plugin = star_map.get(handler.handler_module_path)
if not plugin:
continue
if (
plugin.name not in plugins_name
and event_type
not in (
EventType.OnAstrBotLoadedEvent,
EventType.OnPlatformLoadedEvent,
)
and not plugin.reserved
):
continue
handlers.append(handler)
return handlers
def get_handler_by_full_name(self, full_name: str) -> StarHandlerMetadata | None:
return self.star_handlers_map.get(full_name, None)
def get_handlers_by_module_name(
self,
module_name: str,
) -> list[StarHandlerMetadata]:
return [
handler
for handler in self._handlers
if handler.handler_module_path == module_name
]
def clear(self):
self.star_handlers_map.clear()
self._handlers.clear()
def remove(self, handler: StarHandlerMetadata):
self.star_handlers_map.pop(handler.handler_full_name, None)
self._handlers = [h for h in self._handlers if h != handler]
def __iter__(self):
return iter(self._handlers)
def __len__(self):
return len(self._handlers)
class Star:
"""所有插件的基类。每一个插件都应当继承自这个类,并实现相应的方法。"""
def __init__(self, context: BaseContext):
self.context = context
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not star_map.get(cls.__module__):
metadata = StarMetadata(
# star_cls_type=cls,
module_path=cls.__module__,
)
star_map[cls.__module__] = metadata
star_registry.append(metadata)
else:
# star_map[cls.__module__].star_cls_type = cls
star_map[cls.__module__].module_path = cls.__module__
star_handlers_registry = StarHandlerRegistry() # type: ignore
star_map: dict[str, StarMetadata] = {}
star_registry: list[StarMetadata] = []

View File

@@ -1,515 +0,0 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
# import docstring_parser
from loguru import logger
# from astrbot.core.agent.agent import Agent
# from astrbot.core.agent.handoff import HandoffTool
# from astrbot.core.agent.hooks import BaseAgentRunHooks
# from astrbot.core.agent.tool import FunctionTool
# from astrbot.core.astr_agent_context import AstrAgentContext
# from astrbot.core.provider.register import llm_tools
from ..filter.command import CommandFilter
from ..filter.command_group import CommandGroupFilter
from ..filter.custom_filter import CustomFilterAnd, CustomFilterOr
from ..filter.event_message_type import EventMessageType, EventMessageTypeFilter
from ..filter.permission import PermissionType, PermissionTypeFilter
from ..filter.platform_adapter_type import (
PlatformAdapterType,
PlatformAdapterTypeFilter,
)
from ..filter.regex import RegexFilter
from ..registry import star_handlers_registry, StarHandlerMetadata, EventType
def get_handler_full_name(awaitable: Callable[..., Awaitable[Any]]) -> str:
"""获取 Handler 的全名"""
return f"{awaitable.__module__}:{awaitable.__qualname__}"
def get_handler_or_create(
handler: Callable[..., Awaitable[Any]],
event_type: EventType,
dont_add=False,
**kwargs,
) -> StarHandlerMetadata:
"""获取 Handler 或者创建一个新的 Handler"""
handler_full_name = get_handler_full_name(handler)
md = star_handlers_registry.get_handler_by_full_name(handler_full_name)
if md:
return md
md = StarHandlerMetadata(
event_type=event_type,
handler_full_name=handler_full_name,
handler_name=handler.__name__,
handler_module_path=handler.__module__,
handler=handler,
event_filters=[],
)
# 插件handler的附加额外信息
if handler.__doc__:
md.desc = handler.__doc__.strip()
if "desc" in kwargs:
md.desc = kwargs["desc"]
del kwargs["desc"]
md.extras_configs = kwargs
if not dont_add:
star_handlers_registry.append(md)
return md
def register_command(
command_name: str | None = None,
sub_command: str | None = None,
alias: set | None = None,
**kwargs,
):
"""注册一个 Command."""
new_command = None
add_to_event_filters = False
if isinstance(command_name, RegisteringCommandable):
# 子指令
if sub_command is not None:
parent_command_names = (
command_name.parent_group.get_complete_command_names()
)
new_command = CommandFilter(
sub_command,
alias,
None,
parent_command_names=parent_command_names,
)
command_name.parent_group.add_sub_command_filter(new_command)
else:
logger.warning(
f"注册指令{command_name} 的子指令时未提供 sub_command 参数。",
)
# 裸指令
elif command_name is None:
logger.warning("注册裸指令时未提供 command_name 参数。")
else:
new_command = CommandFilter(command_name, alias, None)
add_to_event_filters = True
def decorator(awaitable):
if not add_to_event_filters:
kwargs["sub_command"] = (
True # 打一个标记,表示这是一个子指令,再 wakingstage 阶段这个 handler 将会直接被跳过(其父指令会接管)
)
handler_md = get_handler_or_create(
awaitable,
EventType.AdapterMessageEvent,
**kwargs,
)
if new_command:
new_command.init_handler_md(handler_md)
handler_md.event_filters.append(new_command)
return awaitable
return decorator
def register_custom_filter(custom_type_filter, *args, **kwargs):
"""注册一个自定义的 CustomFilter
Args:
custom_type_filter: 在裸指令时为CustomFilter对象
在指令组时为父指令的RegisteringCommandable对象即self或者command_group的返回
raise_error: 如果没有权限,是否抛出错误到消息平台,并且停止事件传播。默认为 True
"""
add_to_event_filters = False
raise_error = True
# 判断是否是指令组指令组则添加到指令组的CommandGroupFilter对象中在waking_check的时候一起判断
if isinstance(custom_type_filter, RegisteringCommandable):
# 子指令, 此时函数为RegisteringCommandable对象的方法首位参数为RegisteringCommandable对象的self。
parent_register_commandable = custom_type_filter
custom_filter = args[0]
if len(args) > 1:
raise_error = args[1]
else:
# 裸指令
add_to_event_filters = True
custom_filter = custom_type_filter
if args:
raise_error = args[0]
if not isinstance(custom_filter, (CustomFilterAnd, CustomFilterOr)):
custom_filter = custom_filter(raise_error)
def decorator(awaitable):
# 裸指令子指令与指令组的区分指令组会因为标记跳过wake。
if (
not add_to_event_filters and isinstance(awaitable, RegisteringCommandable)
) or (add_to_event_filters and isinstance(awaitable, RegisteringCommandable)):
# 指令组 与 根指令组添加到本层的grouphandle中一起判断
awaitable.parent_group.add_custom_filter(custom_filter)
else:
handler_md = get_handler_or_create(
awaitable,
EventType.AdapterMessageEvent,
**kwargs,
)
if not add_to_event_filters and not isinstance(
awaitable,
RegisteringCommandable,
):
# 底层子指令
handle_full_name = get_handler_full_name(awaitable)
for (
sub_handle
) in parent_register_commandable.parent_group.sub_command_filters:
# 所有符合fullname一致的子指令handle添加自定义过滤器。
# 不确定是否会有多个子指令有一样的fullname比如一个方法添加多个command装饰器
sub_handle_md = sub_handle.get_handler_md()
if (
sub_handle_md
and sub_handle_md.handler_full_name == handle_full_name
):
sub_handle.add_custom_filter(custom_filter)
else:
# 裸指令
handler_md = get_handler_or_create(
awaitable,
EventType.AdapterMessageEvent,
**kwargs,
)
handler_md.event_filters.append(custom_filter)
return awaitable
return decorator
def register_command_group(
command_group_name: str | None = None,
sub_command: str | None = None,
alias: set | None = None,
**kwargs,
):
"""注册一个 CommandGroup"""
new_group = None
if isinstance(command_group_name, RegisteringCommandable):
# 子指令组
if sub_command is None:
logger.warning(f"{command_group_name} 指令组的子指令组 sub_command 未指定")
else:
new_group = CommandGroupFilter(
sub_command,
alias,
parent_group=command_group_name.parent_group,
)
command_group_name.parent_group.add_sub_command_filter(new_group)
# 根指令组
elif command_group_name is None:
logger.warning("根指令组的名称未指定")
else:
new_group = CommandGroupFilter(command_group_name, alias)
def decorator(obj):
if new_group:
handler_md = get_handler_or_create(
obj,
EventType.AdapterMessageEvent,
**kwargs,
)
handler_md.event_filters.append(new_group)
return RegisteringCommandable(new_group)
raise ValueError("注册指令组失败。")
return decorator
class RegisteringCommandable:
"""用于指令组级联注册"""
group: Callable[..., Callable[..., RegisteringCommandable]] = register_command_group
command: Callable[..., Callable[..., None]] = register_command
custom_filter: Callable[..., Callable[..., None]] = register_custom_filter
def __init__(self, parent_group: CommandGroupFilter):
self.parent_group = parent_group
def register_event_message_type(event_message_type: EventMessageType, **kwargs):
"""注册一个 EventMessageType"""
def decorator(awaitable):
handler_md = get_handler_or_create(
awaitable,
EventType.AdapterMessageEvent,
**kwargs,
)
handler_md.event_filters.append(EventMessageTypeFilter(event_message_type))
return awaitable
return decorator
def register_platform_adapter_type(
platform_adapter_type: PlatformAdapterType,
**kwargs,
):
"""注册一个 PlatformAdapterType"""
def decorator(awaitable):
handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent)
handler_md.event_filters.append(
PlatformAdapterTypeFilter(platform_adapter_type),
)
return awaitable
return decorator
def register_regex(regex: str, **kwargs):
"""注册一个 Regex"""
def decorator(awaitable):
handler_md = get_handler_or_create(
awaitable,
EventType.AdapterMessageEvent,
**kwargs,
)
handler_md.event_filters.append(RegexFilter(regex))
return awaitable
return decorator
def register_permission_type(permission_type: PermissionType, raise_error: bool = True):
"""注册一个 PermissionType
Args:
permission_type: PermissionType
raise_error: 如果没有权限,是否抛出错误到消息平台,并且停止事件传播。默认为 True
"""
def decorator(awaitable):
handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent)
handler_md.event_filters.append(
PermissionTypeFilter(permission_type, raise_error),
)
return awaitable
return decorator
def register_on_astrbot_loaded(**kwargs):
"""当 AstrBot 加载完成时"""
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnAstrBotLoadedEvent, **kwargs)
return awaitable
return decorator
def register_on_platform_loaded(**kwargs):
"""当平台加载完成时"""
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnPlatformLoadedEvent, **kwargs)
return awaitable
return decorator
def register_on_llm_request(**kwargs):
"""当有 LLM 请求时的事件
Examples:
```py
from astrbot.api.provider import ProviderRequest
@on_llm_request()
async def test(self, event: AstrMessageEvent, request: ProviderRequest) -> None:
request.system_prompt += "你是一个猫娘..."
```
请务必接收两个参数event, request
"""
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnLLMRequestEvent, **kwargs)
return awaitable
return decorator
def register_on_llm_response(**kwargs):
"""当有 LLM 请求后的事件
Examples:
```py
from astrbot.api.provider import LLMResponse
@on_llm_response()
async def test(self, event: AstrMessageEvent, response: LLMResponse) -> None:
...
```
请务必接收两个参数event, request
"""
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnLLMResponseEvent, **kwargs)
return awaitable
return decorator
# def register_llm_tool(name: str | None = None, **kwargs):
# """为函数调用function-calling / tools-use添加工具。
# 请务必按照以下格式编写一个工具包括函数注释AstrBot 会尝试解析该函数注释)
# ```
# @llm_tool(name="get_weather") # 如果 name 不填,将使用函数名
# async def get_weather(event: AstrMessageEvent, location: str):
# \'\'\'获取天气信息。
# Args:
# location(string): 地点
# \'\'\'
# # 处理逻辑
# ```
# 可接受的参数类型有string, number, object, array, boolean。
# 返回值:
# - 返回 str结果会被加入下一次 LLM 请求的 prompt 中,用于让 LLM 总结工具返回的结果
# - 返回 None结果不会被加入下一次 LLM 请求的 prompt 中。
# 可以使用 yield 发送消息、终止事件。
# 发送消息:请参考文档。
# 终止事件:
# ```
# event.stop_event()
# yield
# ```
# """
# name_ = name
# registering_agent = None
# if kwargs.get("registering_agent"):
# registering_agent = kwargs["registering_agent"]
# def decorator(awaitable: Callable[..., Awaitable[Any]]):
# llm_tool_name = name_ if name_ else awaitable.__name__
# func_doc = awaitable.__doc__ or ""
# docstring = docstring_parser.parse(func_doc)
# args = []
# for arg in docstring.params:
# args.append(
# {
# "type": arg.type_name,
# "name": arg.arg_name,
# "description": arg.description,
# },
# )
# # print(llm_tool_name, registering_agent)
# if not registering_agent:
# doc_desc = docstring.description.strip() if docstring.description else ""
# md = get_handler_or_create(awaitable, EventType.OnCallingFuncToolEvent)
# llm_tools.add_func(llm_tool_name, args, doc_desc, md.handler)
# else:
# assert isinstance(registering_agent, RegisteringAgent)
# # print(f"Registering tool {llm_tool_name} for agent", registering_agent._agent.name)
# if registering_agent._agent.tools is None:
# registering_agent._agent.tools = []
# desc = docstring.description.strip() if docstring.description else ""
# tool = llm_tools.spec_to_func(llm_tool_name, args, desc, awaitable)
# registering_agent._agent.tools.append(tool)
# return awaitable
# return decorator
# class RegisteringAgent:
# """用于 Agent 注册"""
# def llm_tool(self, *args, **kwargs):
# kwargs["registering_agent"] = self
# return register_llm_tool(*args, **kwargs)
# def __init__(self, agent: Agent[AstrAgentContext]):
# self._agent = agent
# def register_agent(
# name: str,
# instruction: str,
# tools: list[str | FunctionTool] | None = None,
# run_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None,
# ):
# """注册一个 Agent
# Args:
# name: Agent 的名称
# instruction: Agent 的指令
# tools: Agent 使用的工具列表
# run_hooks: Agent 运行时的钩子函数
# """
# tools_ = tools or []
# def decorator(awaitable: Callable[..., Awaitable[Any]]):
# AstrAgent = Agent[AstrAgentContext]
# agent = AstrAgent(
# name=name,
# instructions=instruction,
# tools=tools_,
# run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](),
# )
# handoff_tool = HandoffTool(agent=agent)
# handoff_tool.handler = awaitable
# llm_tools.func_list.append(handoff_tool)
# return RegisteringAgent(agent)
# return decorator
def register_on_decorating_result(**kwargs):
"""在发送消息前的事件"""
def decorator(awaitable):
_ = get_handler_or_create(
awaitable,
EventType.OnDecoratingResultEvent,
**kwargs,
)
return awaitable
return decorator
def register_after_message_sent(**kwargs):
"""在消息发送后的事件"""
def decorator(awaitable):
_ = get_handler_or_create(
awaitable,
EventType.OnAfterMessageSentEvent,
**kwargs,
)
return awaitable
return decorator

View File

@@ -1,108 +0,0 @@
import yaml
import importlib
import functools
import sys
from pathlib import Path
from loguru import logger
from .registry import star_handlers_registry, star_map, star_registry
from ..api.context import Context
from ...api.star.star import StarMetadata
class StarManager:
def __init__(self, context: Context) -> None:
self.context = context
def discover_star(self, root_dir: Path | None = None):
"""
Discover star via plugin.yaml.
Args:
root_dir (Path | None): The root directory to search for plugin.yaml. Defaults to None, which means the current working directory.
"""
if root_dir is None:
root_dir = Path.cwd()
else:
root_dir = Path(root_dir).resolve()
path = root_dir / "plugin.yaml"
if not path.exists():
logger.warning("No plugin.yaml found in the current directory.")
return []
# Add the plugin directory to sys.path so we can import its modules
root_dir_str = str(root_dir)
if root_dir_str not in sys.path:
sys.path.insert(0, root_dir_str)
logger.debug(f"Added {root_dir_str} to sys.path")
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
# Try to find logo.png
logo_path = None
if Path(root_dir / "logo.png").exists():
logo_path = str(root_dir / "logo.png")
# Validate required fields
star_name = data.get("name")
if not star_name:
logger.error("Plugin name is required in plugin.yaml.")
return []
# Load components
components = data.get("components", [])
full_name_list = []
for comp in components:
class_ = comp.get("class", "")
logger.debug(f"Loading component: {class_}")
if not class_:
logger.warning(f"Component without class found: {comp}")
continue
module_path, class_name = class_.rsplit(":", 1)
if not module_path:
logger.warning(f"Invalid component without module: {comp}")
continue
# dynamically register the component
try:
logger.debug(f"Importing module: {module_path}")
module_type = importlib.import_module(module_path)
logger.debug(f"Successfully loaded component module: {module_path}")
component_cls = getattr(module_type, class_name)
# Instantiate the component with context
ccls = component_cls(self.context)
# add to full name list
for h in star_handlers_registry._handlers:
if h.handler_full_name.startswith(f"{class_}."):
# bind the instance
h.handler = functools.partial(h.handler, ccls)
full_name_list.append(h.handler_full_name)
except Exception as e:
logger.error(f"Failed to load component {module_path}: {e}")
continue
# Register the star metadata
star_module_path = f"{star_name}.main"
star_metadata = StarMetadata(
name=data.get("name"),
author=data.get("author"),
desc=data.get("desc"),
version=data.get("version"),
repo=data.get("repo"),
module_path=star_module_path,
root_dir_name=root_dir.name,
reserved=False,
star_handler_full_names=full_name_list,
display_name=data.get("display_name"),
logo_path=logo_path,
)
star_map[star_module_path] = star_metadata
star_registry.append(star_metadata)
logger.info(f"Discovered {len(star_handlers_registry)} star handlers:")
for md in star_handlers_registry:
logger.info(
f" - {md.handler_full_name} with {len(md.event_filters)} filters"
)

View File

@@ -1,125 +0,0 @@
import typing as T
from abc import ABC, abstractmethod
from ...api.event.astr_message_event import AstrMessageEvent
from ...api.star.star import StarMetadata
from .registry import StarHandlerMetadata
from ...api.star.context import Context
class VirtualStar(ABC):
"""Abstract base class for virtual plugin implementations.
VirtualStar defines the interface for plugins that can run in isolated
runtime environments (separate processes). It handles the complete lifecycle
of a plugin from initialization to shutdown.
"""
def __init__(self, context: Context) -> None:
self._context = context
@abstractmethod
async def initialize(self) -> None:
"""Establish connection and initialize the plugin.
This method should:
- Start the plugin process (if applicable)
- Establish communication channels
- Wait for the plugin to be ready
Raises:
RuntimeError: If initialization fails
"""
...
@abstractmethod
async def handshake(self) -> StarMetadata:
"""Perform handshake to retrieve plugin metadata.
This method should:
- Request plugin metadata from the plugin
- Cache handler information locally
- Validate the plugin's compatibility
Returns:
StarMetadata: Complete plugin metadata including handlers
Raises:
RuntimeError: If handshake fails or times out
"""
...
# @abstractmethod
# async def turn_on(self) -> None:
# """Attach and prepare resources. Only call when the plugin is not active.
# This method should:
# - Activate the plugin
# - Initialize any runtime resources
# - Prepare the plugin to handle events
# Raises:
# RuntimeError: If activation fails
# """
# ...
# @abstractmethod
# async def turn_off(self) -> None:
# """Detach and clean up resources. Make the plugin inactive.
# This method should:
# - Deactivate the plugin
# - Release runtime resources
# - Keep the process running but idle
# Raises:
# RuntimeError: If deactivation fails
# """
# ...
@abstractmethod
def get_triggered_handlers(
self,
event: AstrMessageEvent,
) -> list[StarHandlerMetadata]:
"""Get the list of handlers that should be triggered for this event.
This method uses cached handler metadata to determine which handlers
should handle the given event. No RPC calls should be made here.
Args:
event: The message event to check
Returns:
List of handler metadata that match the event
"""
...
@abstractmethod
async def call_handler(
self,
handler: StarHandlerMetadata,
event: AstrMessageEvent,
*args,
**kwargs,
) -> T.AsyncGenerator[T.Any, None]:
"""Call a registered handler in the plugin.
This method should:
- Serialize the event and arguments
- Call the handler via RPC
- Wait for and return the result
Args:
handler: The handler metadata
event: The message event
*args: Additional positional arguments
**kwargs: Additional keyword arguments
Returns:
An async generator yielding results from the handler
Raises:
RuntimeError: If the handler call fails or times out
"""
...

View File

@@ -1,564 +0,0 @@
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=60.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}")

View File

@@ -1,67 +0,0 @@
from __future__ import annotations
from pydantic import BaseModel, Field, model_validator
from .rpc.jsonrpc import JSONRPCRequest
from typing import Any, Literal, Type
from ..api.event.astr_message_event import AstrMessageEventModel
class HandshakeRequest(JSONRPCRequest):
class Params(BaseModel):
pass
method: Literal["handshake"]
params: Params = Field(default_factory=Params)
class CallHandlerRequest(JSONRPCRequest):
class Params(BaseModel):
handler_full_name: str
event: AstrMessageEventModel
args: dict[str, Any] = {}
@model_validator(mode="before")
@classmethod
def validate_event_data(cls: Type[CallHandlerRequest.Params], data: Any) -> Any:
if isinstance(data, dict):
event_data = data.get("event")
if isinstance(event_data, dict):
data["event"] = AstrMessageEventModel.model_validate(event_data)
return data
method: Literal["call_handler"]
params: Params | dict = Field(default_factory=dict)
class HandlerStreamStartNotification(JSONRPCRequest):
"""Notification sent when a handler stream starts."""
class Params(BaseModel):
id: str | None # The original request ID
handler_full_name: str
method: Literal["handler_stream_start"] = "handler_stream_start"
params: Params # type: ignore[assignment]
class HandlerStreamUpdateNotification(JSONRPCRequest):
"""Notification sent when a handler stream has new data."""
class Params(BaseModel):
id: str | None # The original request ID
handler_full_name: str
data: Any # The streamed data
method: Literal["handler_stream_update"] = "handler_stream_update"
params: Params # type: ignore[assignment]
class HandlerStreamEndNotification(JSONRPCRequest):
"""Notification sent when a handler stream ends."""
class Params(BaseModel):
id: str | None # The original request ID
handler_full_name: str
method: Literal["handler_stream_end"] = "handler_stream_end"
params: Params # type: ignore[assignment]

View File

@@ -1,321 +0,0 @@
from __future__ import annotations
import argparse
import asyncio
import json
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any
import yaml
try:
import psutil
except ImportError: # pragma: no cover - optional dependency
psutil = None
from astrbot_sdk.api.star.context import Context
from astrbot_sdk.runtime.galaxy import Galaxy
PLUGIN_COUNT = 8
TARGET_PYTHON = "3.12"
HANDSHAKE_TIMEOUT_SECONDS = 60.0
class BenchmarkContext(Context):
pass
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Generate 8 Python 3.12 plugins and measure resource usage for the "
"independent worker runtime."
)
)
parser.add_argument(
"--python-executable",
default=sys.executable,
help="Python executable used to launch the supervisor process.",
)
parser.add_argument(
"--plugins-dir",
type=Path,
default=None,
help="Optional directory to write generated plugins into.",
)
parser.add_argument(
"--keep-plugins-dir",
action="store_true",
help="Keep the generated plugins directory instead of deleting it.",
)
parser.add_argument(
"--output-json",
type=Path,
default=None,
help="Optional path to write the benchmark report JSON.",
)
return parser.parse_args()
def write_plugin(plugins_dir: Path, index: int) -> None:
plugin_name = f"plugin_{index:03d}"
command_name = f"bench_{index:03d}"
plugin_dir = plugins_dir / plugin_name
commands_dir = plugin_dir / "commands"
commands_dir.mkdir(parents=True, exist_ok=True)
(commands_dir / "__init__.py").write_text("", encoding="utf-8")
manifest = {
"_schema_version": 2,
"name": plugin_name,
"display_name": plugin_name,
"desc": f"Resource benchmark plugin {index}",
"author": "codex",
"version": "0.1.0",
"runtime": {"python": TARGET_PYTHON},
"components": [
{
"class": f"commands.plugin_{index:03d}:BenchmarkCommand{index:03d}",
"type": "command",
"name": command_name,
"description": command_name,
}
],
}
(plugin_dir / "plugin.yaml").write_text(
yaml.safe_dump(manifest, sort_keys=False),
encoding="utf-8",
)
(plugin_dir / "requirements.txt").write_text("", encoding="utf-8")
module_source = f"""
from astrbot_sdk.api.components.command import CommandComponent
from astrbot_sdk.api.event import AstrMessageEvent, filter
from astrbot_sdk.api.star.context import Context
class BenchmarkCommand{index:03d}(CommandComponent):
def __init__(self, context: Context):
self.context = context
@filter.command("{command_name}")
async def handle(self, event: AstrMessageEvent):
yield event.plain_result("{plugin_name}:{command_name}")
""".strip()
(commands_dir / f"plugin_{index:03d}.py").write_text(
module_source + "\n",
encoding="utf-8",
)
def _collect_with_psutil(root_pid: int) -> dict[str, Any]:
assert psutil is not None
root_process = psutil.Process(root_pid)
processes = [root_process] + root_process.children(recursive=True)
entries: list[dict[str, Any]] = []
total_rss = 0
for process in processes:
try:
rss = process.memory_info().rss
total_rss += rss
entries.append(
{
"pid": process.pid,
"name": process.name(),
"rss_mb": round(rss / 1024 / 1024, 2),
"cmdline": process.cmdline(),
}
)
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
entries.sort(key=lambda item: item["pid"])
return {
"collector": "psutil",
"process_count": len(entries),
"total_rss_mb": round(total_rss / 1024 / 1024, 2),
"processes": entries,
}
def _collect_with_ps(root_pid: int) -> dict[str, Any]:
process = subprocess.run(
["ps", "-axo", "pid,ppid,rss,comm"],
capture_output=True,
text=True,
check=True,
)
children_by_parent: dict[int, list[tuple[int, int, str]]] = {}
rss_by_pid: dict[int, int] = {}
for line in process.stdout.splitlines()[1:]:
parts = line.strip().split(None, 3)
if len(parts) != 4:
continue
pid, ppid, rss_kb, command = parts
pid_int = int(pid)
ppid_int = int(ppid)
rss_int = int(rss_kb)
rss_by_pid[pid_int] = rss_int
children_by_parent.setdefault(ppid_int, []).append((pid_int, rss_int, command))
queue = [root_pid]
seen: set[int] = set()
entries: list[dict[str, Any]] = []
total_rss = 0
while queue:
pid = queue.pop(0)
if pid in seen:
continue
seen.add(pid)
rss_kb = rss_by_pid.get(pid)
command = None
for siblings in children_by_parent.values():
for child_pid, child_rss, child_command in siblings:
if child_pid == pid:
rss_kb = child_rss
command = child_command
break
if command is not None:
break
if rss_kb is not None:
total_rss += rss_kb * 1024
entries.append(
{
"pid": pid,
"name": command or "unknown",
"rss_mb": round((rss_kb * 1024) / 1024 / 1024, 2),
"cmdline": [command] if command else [],
}
)
for child_pid, _child_rss, _child_command in children_by_parent.get(pid, []):
queue.append(child_pid)
entries.sort(key=lambda item: item["pid"])
return {
"collector": "ps",
"process_count": len(entries),
"total_rss_mb": round(total_rss / 1024 / 1024, 2),
"processes": entries,
}
def collect_process_tree_metrics(root_pid: int) -> dict[str, Any]:
if psutil is not None:
try:
return _collect_with_psutil(root_pid)
except (PermissionError, psutil.Error):
pass
return _collect_with_ps(root_pid)
async def terminate_process(process: Any) -> None:
if process is None or process.poll() is not None:
return
process.terminate()
try:
await asyncio.to_thread(process.wait, 10.0)
except Exception:
process.kill()
await asyncio.to_thread(process.wait, 10.0)
async def run_benchmark(plugins_dir: Path, python_executable: str) -> dict[str, Any]:
for index in range(PLUGIN_COUNT):
write_plugin(plugins_dir, index)
galaxy = Galaxy()
context = BenchmarkContext()
started_at = time.perf_counter()
star = await galaxy.connect_to_stdio_star(
context=context,
star_name="resource-benchmark",
config={
"plugins_dir": str(plugins_dir),
"python_executable": python_executable,
},
)
connected_at = time.perf_counter()
client_process = getattr(star._client, "_process", None)
metadata: dict[str, Any] = {}
handshake_error: str | None = None
try:
metadata = await asyncio.wait_for(
star.handshake(),
timeout=HANDSHAKE_TIMEOUT_SECONDS,
)
except Exception as exc:
handshake_error = f"{exc.__class__.__name__}: {exc}"
measured_at = time.perf_counter()
metrics = collect_process_tree_metrics(client_process.pid) if client_process else {}
loaded_plugins = sorted(
metadata_item.name
for metadata_item in metadata.values()
if getattr(metadata_item, "name", None)
)
stop_error: str | None = None
try:
await star.stop()
except Exception as exc:
stop_error = f"{exc.__class__.__name__}: {exc}"
await terminate_process(client_process)
return {
"plugin_count": PLUGIN_COUNT,
"target_python": TARGET_PYTHON,
"python_executable": python_executable,
"loaded_plugin_count": len(loaded_plugins),
"loaded_plugins": loaded_plugins,
"connect_duration_ms": round((connected_at - started_at) * 1000, 2),
"handshake_duration_ms": round((measured_at - connected_at) * 1000, 2),
"startup_total_duration_ms": round((measured_at - started_at) * 1000, 2),
"handshake_error": handshake_error,
"metrics": metrics,
"stop_error": stop_error,
}
def main() -> None:
args = parse_args()
temp_dir: tempfile.TemporaryDirectory[str] | None = None
plugins_dir = args.plugins_dir
if plugins_dir is None:
temp_dir = tempfile.TemporaryDirectory(prefix="astrbot-8-plugin-bench-")
plugins_dir = Path(temp_dir.name)
else:
plugins_dir.mkdir(parents=True, exist_ok=True)
try:
report = asyncio.run(
run_benchmark(
plugins_dir=plugins_dir,
python_executable=args.python_executable,
)
)
finally:
if temp_dir is not None and not args.keep_plugins_dir:
temp_dir.cleanup()
report["plugins_dir"] = str(plugins_dir)
if args.output_json is not None:
args.output_json.write_text(
json.dumps(report, ensure_ascii=True, indent=2),
encoding="utf-8",
)
print(json.dumps(report, ensure_ascii=True, indent=2))
if __name__ == "__main__":
main()

View File

@@ -1,76 +0,0 @@
import asyncio
from astrbot_sdk.runtime.galaxy import Galaxy
from astrbot_sdk.api.event import AstrMessageEvent
from astrbot_sdk.api.event.astrbot_message import AstrBotMessage, MessageMember
from astrbot_sdk.api.platform.platform_metadata import PlatformMetadata
from astrbot_sdk.api.event.message_type import MessageType
from astrbot_sdk.api.star.context import Context
from astrbot_sdk.api.basic.conversation_mgr import BaseConversationManager
class ConversationManager(BaseConversationManager):
async def new_conversation(
self,
unified_msg_origin: str,
platform_id: str | None = None,
content: list[dict] | None = None,
title: str | None = None,
persona_id: str | None = None,
) -> str:
import uuid
return str(uuid.uuid4())
class TestContext(Context):
def __init__(self, conversation_manager: ConversationManager):
super().__init__()
self.conversation_manager = conversation_manager
self._register_component(self.conversation_manager)
async def amain():
galaxy = Galaxy()
conversation_manager = ConversationManager()
context = TestContext(conversation_manager)
star = await galaxy.connect_to_websocket_star(
context=context,
star_name="hello",
config={
"url": "ws://127.0.0.1:8765",
},
)
print("Connected to websocket star 'hello'")
md = await star.handshake()
print(f"Handshake metadata: {md}")
abm = AstrBotMessage()
abm.type = MessageType.FRIEND_MESSAGE
abm.self_id = "astrbot_123"
abm.session_id = "test_session"
abm.message_id = "msg_001"
abm.message_str = "hello"
abm.sender = MessageMember(
user_id="user_123", nickname="User123"
) # Simplified for this example
abm.group = None
abm.message = []
abm.raw_message = {}
event = AstrMessageEvent(
message_str=abm.message_str,
message_obj=abm,
platform_meta=PlatformMetadata(
name="fake", description="Fake Platform", id="fake_1"
),
session_id="test_session",
)
async for result in star.call_handler(star._handlers[0], event):
print(f"Handler result: {result}")
await star.stop()
if __name__ == "__main__":
asyncio.run(amain())

View File

@@ -1,322 +0,0 @@
from __future__ import annotations
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()