diff --git a/src/astr_agent_sdk/message.py b/src/astr_agent_sdk/message.py deleted file mode 100644 index 11128c0f6..000000000 --- a/src/astr_agent_sdk/message.py +++ /dev/null @@ -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" diff --git a/src/astr_agent_sdk/run_context.py b/src/astr_agent_sdk/run_context.py deleted file mode 100644 index 395817679..000000000 --- a/src/astr_agent_sdk/run_context.py +++ /dev/null @@ -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] diff --git a/src/astr_agent_sdk/tool.py b/src/astr_agent_sdk/tool.py deleted file mode 100644 index ae240d2e0..000000000 --- a/src/astr_agent_sdk/tool.py +++ /dev/null @@ -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})" diff --git a/src/astrbot_sdk/__main__.py b/src/astrbot_sdk/__main__.py deleted file mode 100644 index b0847a229..000000000 --- a/src/astrbot_sdk/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .cli.main import cli - - -if __name__ == "__main__": - cli() diff --git a/src/astrbot_sdk/api/basic/astrbot_config.py b/src/astrbot_sdk/api/basic/astrbot_config.py deleted file mode 100644 index 92ac8ed06..000000000 --- a/src/astrbot_sdk/api/basic/astrbot_config.py +++ /dev/null @@ -1 +0,0 @@ -class AstrBotConfig(dict): ... diff --git a/src/astrbot_sdk/api/basic/conversation_mgr.py b/src/astrbot_sdk/api/basic/conversation_mgr.py deleted file mode 100644 index 4d775ceb2..000000000 --- a/src/astrbot_sdk/api/basic/conversation_mgr.py +++ /dev/null @@ -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): 每页大小 - - """ - ... diff --git a/src/astrbot_sdk/api/basic/entities.py b/src/astrbot_sdk/api/basic/entities.py deleted file mode 100644 index 05c272f31..000000000 --- a/src/astrbot_sdk/api/basic/entities.py +++ /dev/null @@ -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.""" diff --git a/src/astrbot_sdk/api/components/command.py b/src/astrbot_sdk/api/components/command.py deleted file mode 100644 index af63250ac..000000000 --- a/src/astrbot_sdk/api/components/command.py +++ /dev/null @@ -1,2 +0,0 @@ -class CommandComponent: - pass diff --git a/src/astrbot_sdk/api/event/__init__.py b/src/astrbot_sdk/api/event/__init__.py deleted file mode 100644 index c6fd1daf5..000000000 --- a/src/astrbot_sdk/api/event/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .astr_message_event import AstrMessageEvent - -__all__ = [ - "AstrMessageEvent", -] diff --git a/src/astrbot_sdk/api/event/astr_message_event.py b/src/astrbot_sdk/api/event/astr_message_event.py deleted file mode 100644 index 25be2c70f..000000000 --- a/src/astrbot_sdk/api/event/astr_message_event.py +++ /dev/null @@ -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, - # ): - # """发送流式消息到消息平台,使用异步生成器。 - # 目前仅支持: telegram,qq 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) - """ diff --git a/src/astrbot_sdk/api/event/astrbot_message.py b/src/astrbot_sdk/api/event/astrbot_message.py deleted file mode 100644 index 3275dd95a..000000000 --- a/src/astrbot_sdk/api/event/astrbot_message.py +++ /dev/null @@ -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 diff --git a/src/astrbot_sdk/api/event/event_result.py b/src/astrbot_sdk/api/event/event_result.py deleted file mode 100644 index c9d349569..000000000 --- a/src/astrbot_sdk/api/event/event_result.py +++ /dev/null @@ -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 diff --git a/src/astrbot_sdk/api/event/event_type.py b/src/astrbot_sdk/api/event/event_type.py deleted file mode 100644 index 723582fc6..000000000 --- a/src/astrbot_sdk/api/event/event_type.py +++ /dev/null @@ -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() # 发送消息后 diff --git a/src/astrbot_sdk/api/event/filter.py b/src/astrbot_sdk/api/event/filter.py deleted file mode 100644 index c6fe7d5d7..000000000 --- a/src/astrbot_sdk/api/event/filter.py +++ /dev/null @@ -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", -] diff --git a/src/astrbot_sdk/api/event/message_session.py b/src/astrbot_sdk/api/event/message_session.py deleted file mode 100644 index 6e855c041..000000000 --- a/src/astrbot_sdk/api/event/message_session.py +++ /dev/null @@ -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 diff --git a/src/astrbot_sdk/api/event/message_type.py b/src/astrbot_sdk/api/event/message_type.py deleted file mode 100644 index 25b7cdc48..000000000 --- a/src/astrbot_sdk/api/event/message_type.py +++ /dev/null @@ -1,7 +0,0 @@ -from enum import Enum - - -class MessageType(Enum): - GROUP_MESSAGE = "GroupMessage" # 群组形式的消息 - FRIEND_MESSAGE = "FriendMessage" # 私聊、好友等单聊消息 - OTHER_MESSAGE = "OtherMessage" # 其他类型的消息,如系统消息等 diff --git a/src/astrbot_sdk/api/message/chain.py b/src/astrbot_sdk/api/message/chain.py deleted file mode 100644 index fa13dedaf..000000000 --- a/src/astrbot_sdk/api/message/chain.py +++ /dev/null @@ -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 diff --git a/src/astrbot_sdk/api/message/components.py b/src/astrbot_sdk/api/message/components.py deleted file mode 100644 index 28bfd7c7d..000000000 --- a/src/astrbot_sdk/api/message/components.py +++ /dev/null @@ -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, -} diff --git a/src/astrbot_sdk/api/platform/platform_metadata.py b/src/astrbot_sdk/api/platform/platform_metadata.py deleted file mode 100644 index f010bc205..000000000 --- a/src/astrbot_sdk/api/platform/platform_metadata.py +++ /dev/null @@ -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 文件路径(相对于插件目录)""" diff --git a/src/astrbot_sdk/api/provider/entities.py b/src/astrbot_sdk/api/provider/entities.py deleted file mode 100644 index d28d4c030..000000000 --- a/src/astrbot_sdk/api/provider/entities.py +++ /dev/null @@ -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 diff --git a/src/astrbot_sdk/api/star/__init__.py b/src/astrbot_sdk/api/star/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/astrbot_sdk/api/star/context.py b/src/astrbot_sdk/api/star/context.py deleted file mode 100644 index 631afc389..000000000 --- a/src/astrbot_sdk/api/star/context.py +++ /dev/null @@ -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 - """ - ... diff --git a/src/astrbot_sdk/api/star/star.py b/src/astrbot_sdk/api/star/star.py deleted file mode 100644 index deef4db8c..000000000 --- a/src/astrbot_sdk/api/star/star.py +++ /dev/null @@ -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}" diff --git a/src/astrbot_sdk/cli/__init__.py b/src/astrbot_sdk/cli/__init__.py deleted file mode 100644 index ef8650159..000000000 --- a/src/astrbot_sdk/cli/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .main import cli - -__all__ = ["cli"] diff --git a/src/astrbot_sdk/cli/main.py b/src/astrbot_sdk/cli/main.py deleted file mode 100644 index e070a820d..000000000 --- a/src/astrbot_sdk/cli/main.py +++ /dev/null @@ -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 = ( - "{time:HH:mm:ss} | " - "{level: <8} | " - "{message}" - ) - - 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() diff --git a/src/astrbot_sdk/runtime/api/README.md b/src/astrbot_sdk/runtime/api/README.md deleted file mode 100644 index b3afae19f..000000000 --- a/src/astrbot_sdk/runtime/api/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# AstrBot SDK Runtime Context API - -这个包下存储了暴露给 AstrBot 插件的 Context API 的 RPC 实现。 - -## 组件 - -- `Context`:这是在实例化插件时,注入到插件中的上下文对象。它封装了插件可以调用的各种功能组件。 -- `ConversationManager`:这是一个管理对话相关的功能组件。它提供了与对话历史、用户信息等相关的操作接口。 diff --git a/src/astrbot_sdk/runtime/api/context.py b/src/astrbot_sdk/runtime/api/context.py deleted file mode 100644 index b76ccf5fa..000000000 --- a/src/astrbot_sdk/runtime/api/context.py +++ /dev/null @@ -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) diff --git a/src/astrbot_sdk/runtime/api/conversation_mgr.py b/src/astrbot_sdk/runtime/api/conversation_mgr.py deleted file mode 100644 index 9d490ab1e..000000000 --- a/src/astrbot_sdk/runtime/api/conversation_mgr.py +++ /dev/null @@ -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 - """ - ... diff --git a/src/astrbot_sdk/runtime/galaxy.py b/src/astrbot_sdk/runtime/galaxy.py deleted file mode 100644 index e498dff6f..000000000 --- a/src/astrbot_sdk/runtime/galaxy.py +++ /dev/null @@ -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 diff --git a/src/astrbot_sdk/runtime/rpc/README.md b/src/astrbot_sdk/runtime/rpc/README.md deleted file mode 100644 index aac32be25..000000000 --- a/src/astrbot_sdk/runtime/rpc/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# AstrBor SDK 与 Core 通信的数据交换实现 - -这个包下存储了 AstrBot 插件运行时与 AstrBot Core 之间通信的数据交换实现。 - -AstrBot SDK 设计了两种传输协议,即 stdio 和 WebSockets,用于实现 AstrBot 插件与 AstrBot Core 之间的双向通信。 - -在这两种传输协议之上,我们使用 JSON-RPC 2.0 作为通信的消息格式和调用规范。 diff --git a/src/astrbot_sdk/runtime/rpc/client/README.md b/src/astrbot_sdk/runtime/rpc/client/README.md deleted file mode 100644 index 9298147b5..000000000 --- a/src/astrbot_sdk/runtime/rpc/client/README.md +++ /dev/null @@ -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 diff --git a/src/astrbot_sdk/runtime/rpc/client/__init__.py b/src/astrbot_sdk/runtime/rpc/client/__init__.py deleted file mode 100644 index 5c26615af..000000000 --- a/src/astrbot_sdk/runtime/rpc/client/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .base import JSONRPCClient -from .stdio import StdioClient -from .websocket import WebSocketClient - -__all__ = ["JSONRPCClient", "StdioClient", "WebSocketClient"] diff --git a/src/astrbot_sdk/runtime/rpc/client/base.py b/src/astrbot_sdk/runtime/rpc/client/base.py deleted file mode 100644 index 96941bf72..000000000 --- a/src/astrbot_sdk/runtime/rpc/client/base.py +++ /dev/null @@ -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__() diff --git a/src/astrbot_sdk/runtime/rpc/client/stdio.py b/src/astrbot_sdk/runtime/rpc/client/stdio.py deleted file mode 100644 index f42daa5a2..000000000 --- a/src/astrbot_sdk/runtime/rpc/client/stdio.py +++ /dev/null @@ -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}") diff --git a/src/astrbot_sdk/runtime/rpc/client/websocket.py b/src/astrbot_sdk/runtime/rpc/client/websocket.py deleted file mode 100644 index 6c58fbfda..000000000 --- a/src/astrbot_sdk/runtime/rpc/client/websocket.py +++ /dev/null @@ -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}") diff --git a/src/astrbot_sdk/runtime/rpc/jsonrpc.py b/src/astrbot_sdk/runtime/rpc/jsonrpc.py deleted file mode 100644 index 836fbbb83..000000000 --- a/src/astrbot_sdk/runtime/rpc/jsonrpc.py +++ /dev/null @@ -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 diff --git a/src/astrbot_sdk/runtime/rpc/request_helper.py b/src/astrbot_sdk/runtime/rpc/request_helper.py deleted file mode 100644 index 9e34c5720..000000000 --- a/src/astrbot_sdk/runtime/rpc/request_helper.py +++ /dev/null @@ -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}") diff --git a/src/astrbot_sdk/runtime/rpc/server/__init__.py b/src/astrbot_sdk/runtime/rpc/server/__init__.py deleted file mode 100644 index 3c2033f07..000000000 --- a/src/astrbot_sdk/runtime/rpc/server/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .base import JSONRPCServer -from .stdio import StdioServer -from .websockets import WebSocketServer - -__all__ = [ - "JSONRPCServer", - "StdioServer", - "WebSocketServer", -] diff --git a/src/astrbot_sdk/runtime/rpc/server/base.py b/src/astrbot_sdk/runtime/rpc/server/base.py deleted file mode 100644 index 6176654f4..000000000 --- a/src/astrbot_sdk/runtime/rpc/server/base.py +++ /dev/null @@ -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__() diff --git a/src/astrbot_sdk/runtime/rpc/server/stdio.py b/src/astrbot_sdk/runtime/rpc/server/stdio.py deleted file mode 100644 index aa8da3325..000000000 --- a/src/astrbot_sdk/runtime/rpc/server/stdio.py +++ /dev/null @@ -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}") diff --git a/src/astrbot_sdk/runtime/rpc/server/websockets.py b/src/astrbot_sdk/runtime/rpc/server/websockets.py deleted file mode 100644 index 7da6f3cf9..000000000 --- a/src/astrbot_sdk/runtime/rpc/server/websockets.py +++ /dev/null @@ -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}") diff --git a/src/astrbot_sdk/runtime/rpc/transport.py b/src/astrbot_sdk/runtime/rpc/transport.py deleted file mode 100644 index def0a1ccf..000000000 --- a/src/astrbot_sdk/runtime/rpc/transport.py +++ /dev/null @@ -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) diff --git a/src/astrbot_sdk/runtime/serve.py b/src/astrbot_sdk/runtime/serve.py deleted file mode 100644 index 38cb19afe..000000000 --- a/src/astrbot_sdk/runtime/serve.py +++ /dev/null @@ -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 diff --git a/src/astrbot_sdk/runtime/star_runner.py b/src/astrbot_sdk/runtime/star_runner.py deleted file mode 100644 index 42cd48b23..000000000 --- a/src/astrbot_sdk/runtime/star_runner.py +++ /dev/null @@ -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() diff --git a/src/astrbot_sdk/runtime/stars/filter/__init__.py b/src/astrbot_sdk/runtime/stars/filter/__init__.py deleted file mode 100644 index 0a1b9cb9f..000000000 --- a/src/astrbot_sdk/runtime/stars/filter/__init__.py +++ /dev/null @@ -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"] diff --git a/src/astrbot_sdk/runtime/stars/filter/command.py b/src/astrbot_sdk/runtime/stars/filter/command.py deleted file mode 100755 index c5d1ca42e..000000000 --- a/src/astrbot_sdk/runtime/stars/filter/command.py +++ /dev/null @@ -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 diff --git a/src/astrbot_sdk/runtime/stars/filter/command_group.py b/src/astrbot_sdk/runtime/stars/filter/command_group.py deleted file mode 100755 index 36e55903d..000000000 --- a/src/astrbot_sdk/runtime/stars/filter/command_group.py +++ /dev/null @@ -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) diff --git a/src/astrbot_sdk/runtime/stars/filter/custom_filter.py b/src/astrbot_sdk/runtime/stars/filter/custom_filter.py deleted file mode 100644 index af119f6dd..000000000 --- a/src/astrbot_sdk/runtime/stars/filter/custom_filter.py +++ /dev/null @@ -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) diff --git a/src/astrbot_sdk/runtime/stars/filter/event_message_type.py b/src/astrbot_sdk/runtime/stars/filter/event_message_type.py deleted file mode 100644 index 7cd721067..000000000 --- a/src/astrbot_sdk/runtime/stars/filter/event_message_type.py +++ /dev/null @@ -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 diff --git a/src/astrbot_sdk/runtime/stars/filter/permission.py b/src/astrbot_sdk/runtime/stars/filter/permission.py deleted file mode 100644 index 5e44536aa..000000000 --- a/src/astrbot_sdk/runtime/stars/filter/permission.py +++ /dev/null @@ -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): - """权限类型。当选择 MEMBER,ADMIN 也可以通过。""" - - 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 diff --git a/src/astrbot_sdk/runtime/stars/filter/platform_adapter_type.py b/src/astrbot_sdk/runtime/stars/filter/platform_adapter_type.py deleted file mode 100644 index 49fa08214..000000000 --- a/src/astrbot_sdk/runtime/stars/filter/platform_adapter_type.py +++ /dev/null @@ -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 diff --git a/src/astrbot_sdk/runtime/stars/filter/regex.py b/src/astrbot_sdk/runtime/stars/filter/regex.py deleted file mode 100644 index d88924f05..000000000 --- a/src/astrbot_sdk/runtime/stars/filter/regex.py +++ /dev/null @@ -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())) diff --git a/src/astrbot_sdk/runtime/stars/legacy_star.py b/src/astrbot_sdk/runtime/stars/legacy_star.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/astrbot_sdk/runtime/stars/new_star.py b/src/astrbot_sdk/runtime/stars/new_star.py deleted file mode 100644 index da9fa184d..000000000 --- a/src/astrbot_sdk/runtime/stars/new_star.py +++ /dev/null @@ -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 diff --git a/src/astrbot_sdk/runtime/stars/new_star_utils.py b/src/astrbot_sdk/runtime/stars/new_star_utils.py deleted file mode 100644 index 06ce7d481..000000000 --- a/src/astrbot_sdk/runtime/stars/new_star_utils.py +++ /dev/null @@ -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 diff --git a/src/astrbot_sdk/runtime/stars/registry/__init__.py b/src/astrbot_sdk/runtime/stars/registry/__init__.py deleted file mode 100644 index 6639cf560..000000000 --- a/src/astrbot_sdk/runtime/stars/registry/__init__.py +++ /dev/null @@ -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] = [] diff --git a/src/astrbot_sdk/runtime/stars/registry/register.py b/src/astrbot_sdk/runtime/stars/registry/register.py deleted file mode 100644 index 6cd82ba4f..000000000 --- a/src/astrbot_sdk/runtime/stars/registry/register.py +++ /dev/null @@ -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 diff --git a/src/astrbot_sdk/runtime/stars/star_manager.py b/src/astrbot_sdk/runtime/stars/star_manager.py deleted file mode 100644 index fdf90a590..000000000 --- a/src/astrbot_sdk/runtime/stars/star_manager.py +++ /dev/null @@ -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" - ) diff --git a/src/astrbot_sdk/runtime/stars/virtual.py b/src/astrbot_sdk/runtime/stars/virtual.py deleted file mode 100644 index a8e5e0192..000000000 --- a/src/astrbot_sdk/runtime/stars/virtual.py +++ /dev/null @@ -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 - """ - ... diff --git a/src/astrbot_sdk/runtime/supervisor.py b/src/astrbot_sdk/runtime/supervisor.py deleted file mode 100644 index 3084946e1..000000000 --- a/src/astrbot_sdk/runtime/supervisor.py +++ /dev/null @@ -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}") diff --git a/src/astrbot_sdk/runtime/types.py b/src/astrbot_sdk/runtime/types.py deleted file mode 100644 index 4ded1b400..000000000 --- a/src/astrbot_sdk/runtime/types.py +++ /dev/null @@ -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] diff --git a/src/astrbot_sdk/tests/benchmark_8_plugins_resource_usage.py b/src/astrbot_sdk/tests/benchmark_8_plugins_resource_usage.py deleted file mode 100644 index df7582e7c..000000000 --- a/src/astrbot_sdk/tests/benchmark_8_plugins_resource_usage.py +++ /dev/null @@ -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() diff --git a/src/astrbot_sdk/tests/start_client.py b/src/astrbot_sdk/tests/start_client.py deleted file mode 100644 index b3e9db5ee..000000000 --- a/src/astrbot_sdk/tests/start_client.py +++ /dev/null @@ -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()) diff --git a/src/astrbot_sdk/tests/test_supervisor.py b/src/astrbot_sdk/tests/test_supervisor.py deleted file mode 100644 index 379080c2b..000000000 --- a/src/astrbot_sdk/tests/test_supervisor.py +++ /dev/null @@ -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()