Files
AstrBot/astrbot/core/star/star_manager.py
LIghtJUNction 95430ee6f8 Feat/sdk integration (#6807)
* Add legacy session waiter compatibility

* Expand legacy astrbot package compatibility

* Tighten external legacy plugin compatibility smoke tests

* Consolidate controlled legacy facade compatibility

* docs: clarify compat package boundaries

* test: align runtime fixtures with maintained samples

* fix: preserve unicode sample fixtures in runtime tests

* Implement legacy hook and tool compat runtime

* Refactor legacy runtime execution boundary

* 增强旧版兼容性,添加多个旧路径入口和相关功能

* 增强旧版兼容性,添加适配器边界的启动和关闭钩子支持

* Refactor legacy API and LLM compatibility logic

- Moved legacy LLM and tool compatibility logic from `_legacy_api.py` to a new module `_legacy_llm.py` for better organization and separation of concerns.
- Updated `_legacy_api.py` to import necessary components from `_legacy_llm.py`, removing redundant code.
- Enhanced database client functionality by adding support for batch read/write operations and change event subscriptions.
- Improved documentation in the database client and capability router to reflect new features.
- Refined environment management process in the loader to better handle plugin grouping and virtual environment management.

* 补充插件分组环境测试覆盖

* feat: Enhance CLI and testing capabilities

- Added a new script entry point `astrbot-sdk` in `pyproject.toml`.
- Introduced `has_waiter` method in `SessionWaiterManager` to check for existing waiters.
- Updated `cli.py` to improve error handling and added context to error messages.
- Implemented local development support in `cli.py` with a new `dev` command for running plugins against a mock core.
- Created a new testing module `astrbot_sdk.testing` with utilities for local development and plugin testing.
- Added comprehensive tests for the new testing module and CLI commands.
- Improved compatibility and error messaging for plugin loading failures.

* feat: 添加插件初始化、验证和构建命令,增强 CLI 功能

* feat: add platform client documentation and examples

- Introduced platform client documentation in `docs/v4/clients/platform.md` detailing methods for sending messages, images, and managing group members.
- Added example plugins for LLM chat and database functionalities in `docs/v4/examples/README.md`, `docs/v4/examples/llm-chat/README.md`, and `docs/v4/examples/database/README.md`.
- Enhanced quickstart guide with links to new documentation and example plugins.
- Implemented runtime contract tests to ensure compatibility of public capabilities and hooks.

* Refactor legacy runtime handling and improve plugin loading

- Updated `handler_dispatcher.py` to streamline legacy runtime preparation and dispatching results.
- Enhanced `loader.py` to simplify legacy plugin detection and manifest building.
- Added tests for new HTTPClient and MetadataClient functionalities.
- Introduced tests for legacy context metadata methods and legacy loader helpers.
- Improved legacy runtime tests to cover new functionality and edge cases.

* refactor: 更新兼容层和导入路径,优化文档描述

* Support grouped plugin workers in shared environments

- add group metadata driven worker startup for shared env plans
- track per-plugin handler and capability ownership inside grouped workers
- update runtime and smoke tests for grouped worker session behavior

* Add v4 compat layer and legacy shims

- Introduce private v4 compatibility surface using
  _legacy_api.py, _legacy_runtime.py, _legacy_loader.py plus new
  _legacy_context.py and _legacy_star.py to centralize legacy adapters
  while keeping public APIs thin.
- Extend InitializeOutput to carry protocol_version for negotiated
  protocol, enabling runtime to adapt to the chosen v4 version.
- Add lightweight legacy support for Star/Context via new LegacyStar and
  LegacyContext shims and expose legacy API through the aggregate
  _legacy_api entry point.
- Ensure legacy loader preserves class declaration order by iterating
  module.__dict__ instead of relying on alphabetical sorting.
- Add tests: protocol_version handling in InitializeOutput, legacy
  main component order preservation, and embedded-newline framing in
  transport tests.

* Add architecture doc and refine API compat

- Add PROJECT_ARCHITECTURE.md documenting architecture, compat surface,
  and testing notes.
- Update astrbot_sdk.api.__init__ to clarify it is a compatibility
  implementation layer, not a simple facade, and list migration targets.
- Normalize platform in AstrMessageEvent.to_payload to emit a string id
  by using get_platform_id().

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* delete old sdk

* delete old sdk (#7)

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>

* feat(cli): normalize plugin init skeletons

Add interactive plugin init prompts and normalize generated plugin names to the astrbot_plugin_ convention.
Update CLI tests for the new skeleton layout and ignore generated plugin directories in git and coverage tooling.
Also include related runtime logging adjustments from the current worktree.

* Create lint.yml

* Update lint.yml

* clean it

* feat: Enhance handler and capability dispatchers with improved error handling

- Updated HandlerDispatcher to raise TypeError for uninjectable required parameters, logging errors appropriately.
- Refactored CapabilityDispatcher to raise TypeError for missing required parameters during capability execution.
- Renamed _load_plugin_config to load_plugin_config for clarity and consistency.
- Introduced _sync_plugin_registry method in SupervisorRuntime to manage plugin capabilities more effectively.
- Enhanced capability registration logic to handle naming conflicts with better logging and automatic renaming.
- Added tests for handler and capability dispatchers to ensure proper error handling and functionality.
- Implemented new HTTP and metadata capabilities with corresponding tests for registration and retrieval.
- Improved MemoryClient methods with additional tests for save_with_ttl, get_many, delete_many, and stats.
- Added tests for the testing module to ensure proper import and functionality of PluginHarness.

* feat: 更新 SDK 描述,重构插件调用上下文,移除插件 ID 传递,增强能力路由和 HTTP 客户端的插件身份管理

* refactor: 更新文档和代码注释,优化兼容性描述,增强可读性

* feat: 添加插件热重载功能,支持文件变更时自动重新加载插件

* feat: 增强错误处理,添加上下文信息,优化插件组件加载和参数注入校验

* feat: 添加 hello_plugin 示例,包含插件结构、命令处理和测试用例

* refactor: 删除过时的架构文档、变更日志和兼容矩阵文件

* refactor: 更新兼容层弃用通知,优化文档结构和可读性

* refactor: 更新项目架构文档,增强能力客户端和执行边界的描述,移除兼容层设计章节

* feat(errors): Enhance AstrBotError with detailed documentation and examples

feat(events): Expand MessageEvent with reply capabilities and detailed docstrings

fix(loader): Ensure plugin path is correctly managed in sys.path

feat(star): Improve Star class documentation and lifecycle method descriptions

feat(testing): Add plugin metadata handling in MockContext and enhance PluginHarness

feat(hello): Refactor HelloPlugin to utilize new reply methods and structured capabilities

test(decorators): Add tests for input/output model support in provide_capability

test(events): Implement tests for reply_image and reply_chain methods in MessageEvent

test(http): Validate API registration with capability handler references and error handling

test(tests): Enhance tests for plugin harness and directory handling in dev commands

* feat(runtime): add configurable msgpack wire codec support

* fix(runtime): align msgpack framing with transport defaults

* fix(runtime): preserve json transport compatibility

* fix(cli): scope worker wire codec option

* feat: 添加 AGENTS.md 文档,描述 v4 架构约束和开发命令
refactor: 更新 HandlerDispatcher 和 WorkerSession,增强参数处理和结果汇总逻辑

* fix(test): 更新 init_plugin 测试以匹配新的目录命名规范

CLI 的 _normalize_init_plugin_name 函数现在自动添加 astrbot_plugin_ 前缀,
测试期望的目录名从 demo_plugin 更新为 astrbot_plugin_demo_plugin。

* Refactor worker initialization and remove unused codec parameters; add schedule and session waiter modules

- Simplified `GroupWorkerRuntime` and `PluginWorkerRuntime` constructors by removing the codec parameter and related logic.
- Introduced `schedule.py` to define `ScheduleContext` for managing scheduled tasks with a clear structure and payload handling.
- Added `session_waiter.py` for session-based conversational flow management, including `SessionController` and `SessionWaiterManager` for handling multi-turn dialogues.
- Enhanced testing utilities in `testing.py` by removing unused classes and streamlining the structure.
- Created `types.py` to introduce `GreedyStr` for improved command parameter parsing.

* feat: 添加 LLM 工具管理和会话级别状态管理能力

- 新增 llm/ 模块,包含 LLMToolSpec、ProviderRequest、AgentSpec 等实体
- 新增 LLMToolManager 用于管理 LLM 工具注册和激活状态
- 新增 SessionPluginManager 用于会话级别的插件启用状态管理
- 新增 SessionServiceManager 用于会话级别的 LLM/TTS 服务状态管理
- 新增 RegistryClient 用于查询 handler 元数据和设置白名单
- 扩展 CapabilityRouter 内置能力,支持 session.* 和 registry.* 命名空间
- 增强描述符和装饰器以支持新的 trigger 类型

* feat: 大幅增强 SDK 核心功能和文档

新增模块:
- clients/files.py: 文件上传/下载客户端
- clients/managers.py: 会话/LLM/Provider 管理器
- clients/provider.py: LLM Provider 客户端
- conversation.py: 对话上下文管理
- plugin_kv.py: 插件 KV 存储辅助
- runtime/limiter.py: 限流器
- star_tools.py: Star 工具函数
- docs/: 完整的 SDK 使用文档 (01-05)

功能增强:
- Context 大幅扩展,增加 reply/send_image/typing 等便捷方法
- 装饰器增强,支持 on_llm_request/on_provider_request 等
- 内置 schemas 扩展,覆盖更多 capability 定义
- capability_router_builtins 大幅扩展内置能力实现
- handler_dispatcher 增强参数注入和错误处理
- Star 基类增加生命周期钩子和工具方法

* Add comprehensive API documentation for types and utilities in AstrBot SDK

- Introduced `types.md` detailing type aliases, generics, and Pydantic models used in the SDK.
- Added `utils.md` covering utility classes and functions including CancelToken, MessageSession, command groups, and session management.
- Included usage examples and detailed descriptions for each component to enhance developer understanding and ease of use.

* feat: 添加高级方法和辅助函数文档,增强消息组件和事件处理功能

* feat: 增强过滤器类型和能力路由文档,添加 Provider 和会话管理功能

* change location

* delete no need thing

* delete again

* feat: add Star plugin base class and StarTools utility class

- Introduced `Star` class as a base for v4 native plugins, providing lifecycle methods and context management.
- Added `StarTools` class for accessing runtime context and managing LLM tools.
- Implemented `PluginHarness` for local development and testing of plugins, allowing for message dispatching and lifecycle management.
- Created `GreedyStr` type for enhanced command parameter parsing, enabling the capture of remaining command text as a single argument.
- Added testing utilities and mock capabilities for plugin development.

* delete: remove hello_plugin example and its related files

* Remove obsolete test files for testing module, top-level modules, transport, and wire codecs

- Deleted `test_testing_module.py` as it is no longer needed.
- Removed `test_top_level_modules.py` which had no content.
- Eliminated `test_transport.py` due to redundancy.
- Cleared out `test_wire_codecs.py` as part of the cleanup.

* fix(runtime): avoid creating Star instance in on_error fallback

* fix(runtime): avoid virtual dispatch in Star.on_error fallback

* refactor(runtime): unify command matching logic (#25)

* refactor(testing): share command matching with handler dispatcher

* fix:添加公共函数文件

* fix: simplify register_task completion handling (#27)

* fix: simplify register_task completion handling

Remove duplicated cancellation logging in Context.register_task while keeping Future inputs compatible with asyncio.create_task semantics. Add regression coverage for coroutine, Future, cancellation, and failure paths.

* fix: prioritize local src in tests_v4

Ensure tests_v4 always imports the working tree package by moving src to sys.path[0] even when another checkout or installed copy is already present.

* chore: sync subtree from AstrBot

* feat: replay non-sdk changes on clean sdk subtree baseline

* feat(sdk): add merged provider config capability support

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat(sdk): add merged provider config bridge and client

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat(sdk): add merged provider config capability support

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(sdk): tighten bridge cast typing

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat(sdk): add merged provider config bridge and client

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* test(sdk): cover merged provider config parity

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat: 完善 memory 向量检索与索引统计 (#28)

Co-authored-by: united_pooh <united_pooh@outlook.com>

* feat(tests): 添加测试用例以验证 register_task 的行为并更新测试运行说明

* Merge commit 'e45bade147ff44b43860ecff12067309e59c151a' into feat/sdk-integration

* Squashed 'astrbot-sdk/' changes from 7dda6077..85342f14

85342f14 feat(tests): 添加测试用例以验证 register_task 的行为并更新测试运行说明
fdffc09b Merge pull request #26 from united-pooh/fix/fix-star-on-error-fallback
3b09747c feat: 完善 memory 向量检索与索引统计 (#28)
665c9c69 fix(runtime): avoid virtual dispatch in Star.on_error fallback
200559a5 fix(runtime): avoid creating Star instance in on_error fallback

git-subtree-dir: astrbot-sdk
git-subtree-split: 85342f149b

* feat(tests): 添加测试用例以验证平台和消息类型过滤器的冲突处理

* docs: remove redundant testing instructions from AGENTS.md

* docs: remove redundant testing instructions from AGENTS.md

* docs: remove redundant testing instructions from AGENTS.md

* Merge commit '5ac9401852ddb46f337da6bcc0f9b66eed265da9' into feat/sdk-integration

* Squashed 'astrbot-sdk/' changes from 85342f14..09beabeb

09beabeb feat(tests): 添加测试用例以验证平台和消息类型过滤器的冲突处理

git-subtree-dir: astrbot-sdk
git-subtree-split: 09beabeb62

* feat: enhance SDK plugin configuration handling and logging

* feat: enhance SDK plugin configuration handling and logging

* feat: enhance SDK plugin configuration handling and logging

* feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器

* feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器

* feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器

* feat: 更新文档以反映SDK负载的JSON可序列化要求和延迟导入设计约束

* feat: add conversation.get_current capability and related schemas

- Introduced CONVERSATION_GET_CURRENT_INPUT_SCHEMA and CONVERSATION_GET_CURRENT_OUTPUT_SCHEMA for handling current conversation requests.
- Implemented _conversation_get_current method in BuiltinCapabilityRouterMixin to manage current conversation retrieval and creation.
- Registered the new capability in CoreCapabilityBridge.
- Enhanced HandlerDispatcher to inject provider request, LLM response, and event result payloads into the event handling process.
- Updated tests to validate the new functionality and ensure proper payload handling.

* feat: add conversation.get_current capability and related schemas

- Introduced CONVERSATION_GET_CURRENT_INPUT_SCHEMA and CONVERSATION_GET_CURRENT_OUTPUT_SCHEMA for handling current conversation requests.
- Implemented _conversation_get_current method in BuiltinCapabilityRouterMixin to manage current conversation retrieval and creation.
- Registered the new capability in CoreCapabilityBridge.
- Enhanced HandlerDispatcher to inject provider request, LLM response, and event result payloads into the event handling process.
- Updated tests to validate the new functionality and ensure proper payload handling.

* feat: add conversation.get_current capability and related schemas

- Introduced CONVERSATION_GET_CURRENT_INPUT_SCHEMA and CONVERSATION_GET_CURRENT_OUTPUT_SCHEMA for handling current conversation requests.
- Implemented _conversation_get_current method in BuiltinCapabilityRouterMixin to manage current conversation retrieval and creation.
- Registered the new capability in CoreCapabilityBridge.
- Enhanced HandlerDispatcher to inject provider request, LLM response, and event result payloads into the event handling process.
- Updated tests to validate the new functionality and ensure proper payload handling.

* Refactor tool call handling in SdkPluginBridge

- Introduced a dictionary to map tool call IDs to tool names for better clarity and efficiency.
- Enhanced the extraction of tool call information from raw results, ensuring compatibility with both dictionary and object formats.
- Updated the logic to retrieve tool names based on tool call IDs, improving the robustness of the tool calls result processing.

* Refactor tool call handling in SdkPluginBridge

- Introduced a dictionary to map tool call IDs to tool names for better clarity and efficiency.
- Enhanced the extraction of tool call information from raw results, ensuring compatibility with both dictionary and object formats.
- Updated the logic to retrieve tool names based on tool call IDs, improving the robustness of the tool calls result processing.

* Refactor tool call handling in SdkPluginBridge

- Introduced a dictionary to map tool call IDs to tool names for better clarity and efficiency.
- Enhanced the extraction of tool call information from raw results, ensuring compatibility with both dictionary and object formats.
- Updated the logic to retrieve tool names based on tool call IDs, improving the robustness of the tool calls result processing.

* feat: add session and system capabilities for plugin management and event handling

- Implemented SessionCapabilityMixin with methods to manage session-level plugin states and handlers.
- Added SystemCapabilityMixin to handle system-level functionalities including file management, event handling, and dynamic command registration.
- Introduced methods for enabling/disabling plugins, filtering handlers, and managing LLM and TTS service states.
- Registered various system capabilities for data directory access, HTML rendering, and event streaming.

* feat: add session and system capabilities for plugin management and event handling

- Implemented SessionCapabilityMixin with methods to manage session-level plugin states and handlers.
- Added SystemCapabilityMixin to handle system-level functionalities including file management, event handling, and dynamic command registration.
- Introduced methods for enabling/disabling plugins, filtering handlers, and managing LLM and TTS service states.
- Registered various system capabilities for data directory access, HTML rendering, and event streaming.

* Squashed 'astrbot-sdk/' changes from 09beabeb..3204c9db

3204c9db Merge sdk-remote dev into feat/sdk-integration
3a2d715e Refactor tool call handling in SdkPluginBridge
ed1b9665 feat: add conversation.get_current capability and related schemas
e74123bb feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器
bb361cf9 feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器
c6237f52 Merge sdk-remote/dev into astrbot-sdk subtree
e12029ff feat: enhance SDK plugin configuration handling and logging
5e54bbb3 feat: enhance SDK plugin configuration handling and logging
f48e2041 Merge commit '5ac9401852ddb46f337da6bcc0f9b66eed265da9' into feat/sdk-integration
619672e6 Merge commit '5ac9401852ddb46f337da6bcc0f9b66eed265da9' into feat/sdk-integration
d5a3796d docs: remove redundant testing instructions from AGENTS.md
323e3f4d docs: remove redundant testing instructions from AGENTS.md
f8438a7b Merge commit 'e45bade147ff44b43860ecff12067309e59c151a' into feat/sdk-integration
96d1df85 Merge commit 'e45bade147ff44b43860ecff12067309e59c151a' into feat/sdk-integration
f8a7e253 feat(sdk): add merged provider config bridge and client
752dc6cf feat(sdk): add merged provider config capability support

git-subtree-dir: astrbot-sdk
git-subtree-split: 3204c9db9f

* Implement feature X to enhance user experience and optimize performance

* Implement feature X to enhance user experience and optimize performance

* chore(sdk): stop tracking uv.lock

* feat(sdk): add merged provider config capability support

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat(sdk): add merged provider config bridge and client

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat(tests): 添加测试用例以验证 register_task 的行为并更新测试运行说明

* feat(tests): 添加测试用例以验证平台和消息类型过滤器的冲突处理

* docs: remove redundant testing instructions from AGENTS.md

* feat: enhance SDK plugin configuration handling and logging

* feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器

* feat: add conversation.get_current capability and related schemas

- Introduced CONVERSATION_GET_CURRENT_INPUT_SCHEMA and CONVERSATION_GET_CURRENT_OUTPUT_SCHEMA for handling current conversation requests.
- Implemented _conversation_get_current method in BuiltinCapabilityRouterMixin to manage current conversation retrieval and creation.
- Registered the new capability in CoreCapabilityBridge.
- Enhanced HandlerDispatcher to inject provider request, LLM response, and event result payloads into the event handling process.
- Updated tests to validate the new functionality and ensure proper payload handling.

* Refactor tool call handling in SdkPluginBridge

- Introduced a dictionary to map tool call IDs to tool names for better clarity and efficiency.
- Enhanced the extraction of tool call information from raw results, ensuring compatibility with both dictionary and object formats.
- Updated the logic to retrieve tool names based on tool call IDs, improving the robustness of the tool calls result processing.

* feat: add session and system capabilities for plugin management and event handling

- Implemented SessionCapabilityMixin with methods to manage session-level plugin states and handlers.
- Added SystemCapabilityMixin to handle system-level functionalities including file management, event handling, and dynamic command registration.
- Introduced methods for enabling/disabling plugins, filtering handlers, and managing LLM and TTS service states.
- Registered various system capabilities for data directory access, HTML rendering, and event streaming.

* fix(runtime): avoid creating Star instance in on_error fallback

* fix(runtime): avoid virtual dispatch in Star.on_error fallback

* feat: refactor injected parameter handling and introduce is_framework_injected_parameter utility

* Squashed 'astrbot-sdk/' changes from 027c15b4..d078e510

d078e510 feat: refactor injected parameter handling and introduce is_framework_injected_parameter utility
461f7276 Merge branch 'dev' of https://github.com/united-pooh/astrbot-sdk into dev
5ead59c4 fix(runtime): avoid virtual dispatch in Star.on_error fallback
d2382858 fix(runtime): avoid creating Star instance in on_error fallback
e961e361 feat: add session and system capabilities for plugin management and event handling
5a46321a Refactor tool call handling in SdkPluginBridge
47698448 feat: add conversation.get_current capability and related schemas
9b35bec8 feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器
48a20240 feat: enhance SDK plugin configuration handling and logging
cb593a53 docs: remove redundant testing instructions from AGENTS.md
f4942076 feat(tests): 添加测试用例以验证平台和消息类型过滤器的冲突处理
b0f8b2d6 feat(tests): 添加测试用例以验证 register_task 的行为并更新测试运行说明
6e417c6d feat(sdk): add merged provider config bridge and client
659eabce feat(sdk): add merged provider config capability support
REVERT: 027c15b4 Implement feature X to enhance user experience and optimize performance
REVERT: c272661f chore: pull sdk subtree from dev (resolve delete/modify conflict)
REVERT: 0a2a3592 feat: add session and system capabilities for plugin management and event handling
REVERT: 3204c9db Merge sdk-remote dev into feat/sdk-integration
REVERT: 36443f1d Refactor tool call handling in SdkPluginBridge
REVERT: 3a2d715e Refactor tool call handling in SdkPluginBridge
REVERT: b93c2c2b feat: add conversation.get_current capability and related schemas
REVERT: ed1b9665 feat: add conversation.get_current capability and related schemas
REVERT: e74123bb feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器
REVERT: bb361cf9 feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器
REVERT: c6237f52 Merge sdk-remote/dev into astrbot-sdk subtree
REVERT: e12029ff feat: enhance SDK plugin configuration handling and logging
REVERT: 5e54bbb3 feat: enhance SDK plugin configuration handling and logging
REVERT: f48e2041 Merge commit '5ac9401852ddb46f337da6bcc0f9b66eed265da9' into feat/sdk-integration
REVERT: 619672e6 Merge commit '5ac9401852ddb46f337da6bcc0f9b66eed265da9' into feat/sdk-integration
REVERT: d5a3796d docs: remove redundant testing instructions from AGENTS.md
REVERT: 323e3f4d docs: remove redundant testing instructions from AGENTS.md
REVERT: f8438a7b Merge commit 'e45bade147ff44b43860ecff12067309e59c151a' into feat/sdk-integration
REVERT: 96d1df85 Merge commit 'e45bade147ff44b43860ecff12067309e59c151a' into feat/sdk-integration
REVERT: f8a7e253 feat(sdk): add merged provider config bridge and client
REVERT: 752dc6cf feat(sdk): add merged provider config capability support

git-subtree-dir: astrbot-sdk
git-subtree-split: d078e51051

* refactor: reorganize imports and enhance type hints in sdk_bridge modules

* refactor: update import paths to use Path for better compatibility

* refactor(injection): centralize legacy injected parameter filtering

* fix(testing): use public session waiter probe in PluginHarness

* docs: add TODO for documentation content in _command_model.py

* feat: add memory management capabilities to CoreCapabilityBridge and implement unit tests

* feat: enhance memory search functionality and improve metadata retrieval in SDK

* feat: add memory management attributes and typed provider method to CapabilityMixinHost

* Implement feature X to enhance user experience and optimize performance

* Refactor memory utility functions and enhance memory capability mixin

- Added new utility functions for memory management in _memory_utils.py.
- Refactored memory capability mixin methods to utilize the new utility functions for better readability and maintainability.
- Updated PROJECT_ARCHITECTURE.md to reflect changes in documentation and structure.

* Squashed 'astrbot-sdk/' changes from d078e510..208bc591

208bc591 Merge pull request #30 from united-pooh/refactor/unify-legacy-injected-params
d86534a2 docs: add TODO for documentation content in _command_model.py
090724a7 refactor(injection): centralize legacy injected parameter filtering

git-subtree-dir: astrbot-sdk
git-subtree-split: 208bc591dd

* Initial plan

* docs: fix path, Python version, and client API table in PROJECT_ARCHITECTURE.md

Co-authored-by: whatevertogo <149563971+whatevertogo@users.noreply.github.com>

* Squashed 'astrbot-sdk/' changes from 208bc591..ad5e8d13

ad5e8d13 Merge pull request #37 from united-pooh/sdk/whatevertogo
5751701f Merge pull request #38 from united-pooh/copilot/sub-pr-37
e21acba5 docs: fix path, Python version, and client API table in PROJECT_ARCHITECTURE.md
ee67cab4 Initial plan
7d921570 Refactor memory utility functions and enhance memory capability mixin

git-subtree-dir: astrbot-sdk
git-subtree-split: ad5e8d1397

* docs: fix TODO comment formatting in _command_model.py

* 集成SDK命令候选项,优化Telegram和Discord平台适配器的命令收集逻辑

* 删除AI女友插件的单元测试文件

* 更新文档,添加Telegram和Discord原生命令菜单的注册说明

* Squashed 'astrbot-sdk/' changes from ad5e8d13..5003da58

5003da58 Merge pull request #40 from united-pooh/sdk/whatevertogo
b5084c44 Merge branch 'sdk/whatevertogo' of https://github.com/united-pooh/astrbot-sdk into sdk/whatevertogo
7559edf7 docs: fix TODO comment formatting in _command_model.py

git-subtree-dir: astrbot-sdk
git-subtree-split: 5003da58f5

* fix(testing): route session waiter followups through dispatcher (#33)

* fix(testing): route session waiter followups through dispatcher

* fix(testing): preserve waiter context and completion state

* fix(runtime): preserve session waiter plugin identity

* fix(runtime): scope session waiters by plugin

* fix(testing): isolate waiter replacement and followup drains

* fix(runtime): normalize waiter routing inputs

* fix(cli): route protocol stdout at command entry (#41)

* 添加内存后端支持,优化插件内存管理逻辑

* feat(memory): enhance memory schemas and add namespace support

- Updated MEMORY_SEARCH_INPUT_SCHEMA to include `namespace` and `include_descendants`.
- Modified MEMORY_SEARCH_OUTPUT_SCHEMA to allow nullable `namespace`.
- Added `namespace` to MEMORY_GET_INPUT_SCHEMA, MEMORY_DELETE_INPUT_SCHEMA, MEMORY_SAVE_WITH_TTL_INPUT_SCHEMA, MEMORY_GET_MANY_INPUT_SCHEMA, and MEMORY_DELETE_MANY_INPUT_SCHEMA.
- Enhanced MEMORY_STATS_INPUT_SCHEMA to support `namespace` and `include_descendants`.
- Updated MEMORY_GET_OUTPUT_SCHEMA and MEMORY_STATS_OUTPUT_SCHEMA to include `namespace` and `namespace_count`.
- Introduced `_memory_backends` in CapabilityRouterHost and CapabilityRouterBridgeBase for better memory management.
- Refactored MemoryCapabilityMixin to utilize memory backends for plugin-specific memory operations.
- Improved memory search functionality to respect namespaces and include descendants based on input parameters.
- Added tests to validate memory operations across different namespaces and ensure persistence across restarts.
- Implemented error handling in the handler dispatcher to manage exceptions gracefully.

* fix: guard session_waiter blocking usage

* fix(runtime): preserve request-scoped system event overlays

* test(runtime): lock peer initialization and transport failure semantics

* test(loader): cover plugin reload and import isolation regressions

* refactor(supervisor): clarify plugin registry sync phases

* test(clients): cover provider lifecycle regressions

* feat(cli): improve astr init defaults

* feat(plugin): add plugin ID validation and data directory resolution

- Implemented `validate_plugin_id` to ensure safe plugin identifiers.
- Added `resolve_plugin_data_dir` to resolve plugin data directories securely.
- Updated memory and system capabilities to utilize new plugin ID validation.
- Refactored session waiter management to simplify plugin ID handling.
- Enhanced tests for plugin ID validation and data directory resolution.

* fix(cli): exit cleanly on init abort

* feat(memory): enhance namespace handling and add tests for memory client

* feat(agent): add tool status message handling and improve SDK command integration

* Refactor SDK structure for backward compatibility

- Moved message result and session classes to internal modules while preserving legacy import paths for compatibility.
- Updated imports across the SDK to reflect the new internal structure.
- Enhanced session waiter management to support multiple plugins and improve error handling.
- Added tests to ensure LLM tool registration and session waiter functionality align with dispatcher expectations.
- Cleaned up code and improved documentation for clarity and maintainability.

* 增强异步下载功能,更新组件导入方式,并添加相关测试用例

* 添加任务重入锁以支持会话等待器的嵌套清理,并更新相关测试用例以验证后续消息的序列化处理

* 添加 .astrbot_sdk_testing 到 .gitignore 文件

* Add unit tests for provider management and tool capabilities

- Introduced new test suite for provider platform management in `test_sdk_provider_platform_management.py`, covering scenarios for merged provider configurations, reserved plugin checks, and provider management functionalities.
- Added tests for tool capabilities and provider queries in `test_sdk_provider_tool_platform_capabilities.py`, validating interactions with LLM tools and specialized proxies.
- Removed obsolete `test_sdk_transport.py` as it contained outdated tests for transport layer functionality.

* 添加多个模块和测试用例,增强SDK功能并支持单元测试

* fix(bridge): add missing capability registrations for db/memory/http/metadata

Register methods for db, memory, http, and metadata capabilities exist in
BasicCapabilityMixin but were never called in CoreCapabilityBridge.__init__.
This caused SDK plugins using ctx.memory, ctx.db, ctx.http to fail with
"LookupError: capability not found".

* feat(kb): enhance knowledge base capabilities with document management and serialization

* 添加知识库文档管理功能,包括文档上传、列表、获取、删除和刷新能力,更新相关的能力路由和协议模式

* feat(conversation): add ability to unset conversation persona and update related methods

* 添加对话管理和元数据管理功能,包括清空对话人格和保存插件配置的能力

* feat(conversation): add test for unsetting conversation persona and verify state

* feat(plugin): add save_plugin_config method and related tests for plugin configuration persistence

* 优化插件配置保存方法的代码格式

* feat(sdk): enhance handler metadata with descriptions, priority, kind, and admin requirements

* 添加描述、优先级和其他元数据到处理程序元数据和描述符中,优化相关功能

* feat(sdk): enhance SDK integration with local extras handling and message payloads

* 添加对 MessageEvent 的额外字段支持,优化事件处理器的参数注入,增强 SDK 本地临时数据的管理能力

* 增强插件日志记录功能,添加控制台输出格式化和路径标签支持,新增单元测试以验证日志格式

* feat: Enhance command and tool management in dashboard

- Refactor CommandRoute to utilize AstrBotCoreLifecycle for improved command handling.
- Introduce command_key for commands to streamline toggling, renaming, and permission updates.
- Implement support for SDK commands in the dashboard, marking them as read-only.
- Update ToolTable and CommandTable components to use new command_key and tool_key properties.
- Add runtime_kind and plugin_id to tools for better management.
- Enhance API tests to cover SDK commands and tools, ensuring proper functionality and error handling.
- Update localization files to include new messages related to SDK commands.

* 优化插件日志记录和能力代理模块,增强异常处理,确保优先级参数为整数

* feat: 增强LLM能力,添加聊天提供者协议和异常处理,更新单元测试以验证提供者有效性

* feat: Implement SDK skill management capabilities

- Added SkillCapabilityMixin to handle skill registration, unregistration, and listing.
- Integrated skill management into the CoreCapabilityBridge.
- Enhanced SkillManager to support SDK-registered skills, including loading, saving, and syncing skills.
- Updated computer_client to utilize SkillManager for skill synchronization with sandboxes.
- Refactored skill export functionality in SkillsRoute to accommodate new skill management structure.
- Introduced tests for SDK skill registration, unregistration, and syncing to ensure functionality.
- Improved skill handling in the dashboard and test suite to reflect changes in skill management.

* 添加技能注册功能,包含技能注册、注销和列出能力的实现,增强插件能力管理

* 删除CLAUDE.md文档,移除过时的已知问题描述

* 删除 AGENTS.md 文档,移除过时的架构约束和开发命令说明

* 更新测试用例,修复插件发现失败时的错误信息,确保使用正确的运行时字段

* 修复错误处理和权限检查,新增单元测试以验证功能

* format

* 增强命名空间管理,优化数据库操作,添加 HTTP 路由验证,新增并发隔离测试,完善命令模型解析单元测试

* 增强 HTTP 路由功能,添加 HTTP 方法注销逻辑的单元测试

* 格式化日志输出,优化批量插入时无内容提供的调试信息

* fix: add uv dependency for plugin environment groups

* 删除代码审查文档 CODE_REVIEW_ISSUES.md

* 添加消息历史管理功能,包括消息记录的增删查改,完善相关能力混合类和测试用例

* 实现 SDK 消息历史管理功能,包括记录的增删查改操作

* Add unit tests for MCP contract and capabilities in SDK

- Implemented `_mcp_contract.py` to test local MCP server functionalities including listing, enabling, and disabling servers.
- Created `test_sdk_mcp_capabilities.py` to cover various aspects of the CoreCapabilityBridge and SdkPluginBridge, including session management and global MCP server operations.
- Introduced fake classes to simulate MCP server behavior and manage tool configurations for testing.
- Ensured comprehensive coverage of MCP session lifecycle, including opening, listing tools, calling tools, and closing sessions.

* feat(mcp): Implement local and global MCP server management capabilities

- Added MCP management client to the context for local/global MCP service management.
- Introduced decorators to acknowledge global MCP risk for plugins.
- Defined schemas for MCP server operations including get, list, enable, disable, and session management.
- Created MCP capability mixin to handle local and global MCP server operations.
- Enhanced provider capabilities to include active local MCP tool names.
- Updated capability router to support MCP functionalities and maintain session state.
- Added tests for MCP functionalities, ensuring proper behavior and risk acknowledgment.

---------

Co-authored-by: whatevertogo <149563971+whatevertogo@users.noreply.github.com>
Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: letr <letr007@foxmail.com>
Co-authored-by: united_pooh <united_pooh@outlook.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: united_pooh <united_pooh@icloud.com>
Co-authored-by: Lishiling <m18384519631@163.com>
Co-authored-by: Li-shi-ling <114913764+Li-shi-ling@users.noreply.github.com>
Co-authored-by: letr <123731298+letr007@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: catDforD <3276453835@qq.com>
Co-authored-by: united_pooh <united_pooh@MBP-F6H6T2CYVV-2219.local>
2026-03-22 22:44:05 +08:00

1793 lines
70 KiB
Python

"""插件的重载、启停、安装、卸载等操作。"""
import asyncio
import contextlib
import functools
import inspect
import json
import keyword
import logging
import os
import sys
import tempfile
import traceback
from types import ModuleType
import anyio
import yaml
from anyio import to_thread
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
from astrbot.core import (
DependencyConflictError,
logger,
pip_installer,
sp,
)
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config.default import VERSION
from astrbot.core.platform.register import unregister_platform_adapters_by_module
from astrbot.core.provider.register import llm_tools
from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_path,
get_astrbot_plugin_path,
get_astrbot_temp_path,
)
from astrbot.core.utils.io import remove_dir
from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.requirements_utils import (
plan_missing_requirements_install,
)
from . import StarMetadata
from .command_management import sync_command_configs
from .context import Context
from .error_messages import format_plugin_error
from .filter.permission import PermissionType, PermissionTypeFilter
from .star import star_map, star_registry
from .star_handler import EventType, star_handlers_registry
from .updator import PluginUpdator
try:
from watchfiles import PythonFilter, awatch
except ImportError:
if os.getenv("ASTRBOT_RELOAD", "0") == "1":
logger.warning("未安装 watchfiles,无法实现插件的热重载。")
class PluginVersionIncompatibleError(Exception):
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
class PluginDependencyInstallError(Exception):
"""Raised when plugin dependency installation fails."""
def __init__(
self,
*,
plugin_label: str,
requirements_path: str,
error: Exception,
) -> None:
message = f"插件 {plugin_label} 依赖安装失败: {error!s}"
super().__init__(message)
self.plugin_label = plugin_label
self.requirements_path = requirements_path
self.error = error
@contextlib.asynccontextmanager
async def _temporary_filtered_requirements_file(
*,
install_lines: tuple[str, ...],
):
filtered_requirements_path: str | None = None
temp_dir = get_astrbot_temp_path()
# Create temp dir without blocking the event loop
await to_thread.run_sync(functools.partial(os.makedirs, temp_dir, exist_ok=True))
try:
def _create_temp():
with tempfile.NamedTemporaryFile(
mode="w",
suffix="_plugin_requirements.txt",
delete=False,
dir=temp_dir,
encoding="utf-8",
) as filtered_requirements_file:
filtered_requirements_file.write("\n".join(install_lines) + "\n")
return filtered_requirements_file.name
filtered_requirements_path = await to_thread.run_sync(_create_temp)
try:
yield filtered_requirements_path
finally:
if filtered_requirements_path and os.path.exists(
filtered_requirements_path
):
try:
await to_thread.run_sync(os.remove, filtered_requirements_path)
except OSError as exc:
logger.warning(
"删除临时插件依赖文件失败:%s(路径:%s)",
exc,
filtered_requirements_path,
)
except Exception:
# Let exceptions propagate to callers (do not swallow)
raise
async def _install_requirements_with_precheck(
*,
plugin_label: str,
requirements_path: str,
) -> None:
install_plan = plan_missing_requirements_install(requirements_path)
if install_plan is None:
logger.info(
f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): "
f"{requirements_path}"
)
await pip_installer.install(requirements_path=requirements_path)
return
if not install_plan.missing_names:
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
return
if not install_plan.install_lines:
fallback_reason = install_plan.fallback_reason or "unknown reason"
logger.info(
"检测到插件 %s 缺失依赖,但无法安全裁剪 requirements,回退到完整安装: %s (%s)",
plugin_label,
requirements_path,
fallback_reason,
)
await pip_installer.install(requirements_path=requirements_path)
return
logger.info(
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
f"{requirements_path} -> {sorted(install_plan.missing_names)}"
)
async with _temporary_filtered_requirements_file(
install_lines=install_plan.install_lines,
) as filtered_requirements_path:
await pip_installer.install(requirements_path=filtered_requirements_path)
class PluginManager:
def __init__(self, context: Context, config: AstrBotConfig) -> None:
from .star_tools import StarTools
self.tasks = set()
self.updator = PluginUpdator()
self.context = context
self.context._star_manager = self
StarTools.initialize(context)
self.config = config
self.plugin_store_path = get_astrbot_plugin_path()
"""存储插件的路径。即 data/plugins"""
self.plugin_config_path = get_astrbot_config_path()
"""存储插件配置的路径。data/config"""
self.reserved_plugin_path = os.path.join(
get_astrbot_path(), "astrbot", "builtin_stars"
)
"""保留插件的路径。在 astrbot/builtin_stars 目录下"""
self.conf_schema_fname = "_conf_schema.json"
self.logo_fname = "logo.png"
"""插件配置 Schema 文件名"""
self._pm_lock = asyncio.Lock()
"""StarManager操作互斥锁"""
self.failed_plugin_dict = {}
"""加载失败插件的信息,用于后续可能的热重载"""
self.failed_plugin_info = ""
if os.getenv("ASTRBOT_RELOAD", "0") == "1":
_watch_plugins_changes = asyncio.create_task(self._watch_plugins_changes())
self.tasks.add(_watch_plugins_changes)
_watch_plugins_changes.add_done_callback(self.tasks.discard)
async def _watch_plugins_changes(self) -> None:
"""监视插件文件变化"""
try:
async for changes in awatch(
self.plugin_store_path,
self.reserved_plugin_path,
watch_filter=PythonFilter(),
recursive=True,
):
# 处理文件变化
await self._handle_file_changes(changes)
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"插件热重载监视任务异常: {e!s}")
logger.error(traceback.format_exc())
async def _handle_file_changes(self, changes) -> None:
"""处理文件变化"""
logger.info(f"检测到文件变化: {changes}")
plugins_to_check = []
for star in star_registry:
if not star.activated:
continue
if star.root_dir_name is None:
continue
if star.reserved:
plugin_dir_path = os.path.join(
self.reserved_plugin_path,
star.root_dir_name,
)
else:
plugin_dir_path = os.path.join(
self.plugin_store_path,
star.root_dir_name,
)
plugins_to_check.append((plugin_dir_path, star.name))
reloaded_plugins = set()
for change in changes:
_, file_path = change
for plugin_dir_path, plugin_name in plugins_to_check:
if (
os.path.commonpath([plugin_dir_path])
== os.path.commonpath([plugin_dir_path, file_path])
and plugin_name not in reloaded_plugins
):
logger.info(f"检测到插件 {plugin_name} 文件变化,正在重载...")
await self.reload(plugin_name)
reloaded_plugins.add(plugin_name)
break
@staticmethod
def _get_classes(arg: ModuleType):
"""获取指定模块(可以理解为一个 python 文件)下所有的类"""
classes = []
clsmembers = inspect.getmembers(arg, inspect.isclass)
for name, _ in clsmembers:
if name.lower().endswith("plugin") or name.lower() == "main":
classes.append(name)
break
return classes
@staticmethod
def _get_modules(path):
modules = []
dirs = os.listdir(path)
# 遍历文件夹,找到 main.py 或者和文件夹同名的文件
for d in dirs:
if os.path.isdir(os.path.join(path, d)):
if os.path.exists(os.path.join(path, d, "main.py")):
module_str = "main"
elif os.path.exists(os.path.join(path, d, d + ".py")):
module_str = d
else:
logger.info(f"插件 {d} 未找到 main.py 或者 {d}.py,跳过。")
continue
if os.path.exists(os.path.join(path, d, "main.py")) or os.path.exists(
os.path.join(path, d, d + ".py"),
):
modules.append(
{
"pname": d,
"module": module_str,
"module_path": os.path.join(path, d, module_str),
},
)
return modules
def _get_plugin_modules(self) -> list[dict]:
plugins = []
if os.path.exists(self.plugin_store_path):
plugins.extend(self._get_modules(self.plugin_store_path))
if os.path.exists(self.reserved_plugin_path):
_p = self._get_modules(self.reserved_plugin_path)
for p in _p:
p["reserved"] = True
plugins.extend(_p)
return plugins
async def _check_plugin_dept_update(
self, target_plugin: str | None = None
) -> bool | None:
"""检查插件的依赖
如果 target_plugin 为 None,则检查所有插件的依赖
"""
plugin_dir = self.plugin_store_path
if not await anyio.Path(plugin_dir).exists():
return False
to_update = []
if target_plugin:
to_update.append(target_plugin)
else:
for p in self.context.get_all_stars():
to_update.append(p.root_dir_name)
for p in to_update:
plugin_path = os.path.join(plugin_dir, p)
await self._ensure_plugin_requirements(plugin_path, p)
return True
async def _ensure_plugin_requirements(
self,
plugin_dir_path: str,
plugin_label: str,
) -> None:
requirements_path = os.path.join(plugin_dir_path, "requirements.txt")
if not await anyio.Path(requirements_path).exists():
return
try:
await _install_requirements_with_precheck(
plugin_label=plugin_label,
requirements_path=requirements_path,
)
except asyncio.CancelledError:
raise
except DependencyConflictError as e:
logger.error(f"插件 {plugin_label} 依赖冲突: {e!s}")
raise
except Exception as e:
dependency_error = PluginDependencyInstallError(
plugin_label=plugin_label,
requirements_path=requirements_path,
error=e,
)
logger.exception(str(dependency_error))
raise dependency_error from e
async def _import_plugin_with_dependency_recovery(
self,
path: str,
module_str: str,
root_dir_name: str,
requirements_path: str,
) -> ModuleType:
try:
return __import__(path, fromlist=[module_str])
except (ModuleNotFoundError, ImportError) as import_exc:
if await anyio.Path(requirements_path).exists():
try:
logger.info(
f"插件 {root_dir_name} 导入失败,尝试从已安装依赖恢复: {import_exc!s}"
)
pip_installer.prefer_installed_dependencies(
requirements_path=requirements_path
)
module = __import__(path, fromlist=[module_str])
logger.info(
f"插件 {root_dir_name} 已从 site-packages 恢复依赖,跳过重新安装。"
)
return module
except Exception as recover_exc:
logger.info(
f"插件 {root_dir_name} 已安装依赖恢复失败,将重新安装依赖: {recover_exc!s}"
)
await self._check_plugin_dept_update(target_plugin=root_dir_name)
return __import__(path, fromlist=[module_str])
@staticmethod
def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | None:
"""先寻找 metadata.yaml 文件,如果不存在,则使用插件对象的 info() 函数获取元数据。
Notes: 旧版本 AstrBot 插件可能使用的是 info() 函数来获取元数据。
"""
metadata = None
if not os.path.exists(plugin_path):
raise Exception("插件不存在。")
if os.path.exists(os.path.join(plugin_path, "metadata.yaml")):
with open(
os.path.join(plugin_path, "metadata.yaml"),
encoding="utf-8",
) as f:
metadata = yaml.safe_load(f)
elif plugin_obj and hasattr(plugin_obj, "info"):
# 使用 info() 函数
metadata = plugin_obj.info()
if isinstance(metadata, dict):
if "desc" not in metadata and "description" in metadata:
metadata["desc"] = metadata["description"]
if (
"name" not in metadata
or "desc" not in metadata
or "version" not in metadata
or "author" not in metadata
):
raise Exception(
"插件元数据信息不完整。name, desc, version, author 是必须的字段。",
)
metadata = StarMetadata(
name=metadata["name"],
author=metadata["author"],
desc=metadata["desc"],
version=metadata["version"],
repo=metadata["repo"] if "repo" in metadata else None,
display_name=metadata.get("display_name", None),
support_platforms=(
[
platform_id
for platform_id in metadata["support_platforms"]
if isinstance(platform_id, str)
]
if isinstance(metadata.get("support_platforms"), list)
else []
),
astrbot_version=(
metadata["astrbot_version"]
if isinstance(metadata.get("astrbot_version"), str)
else None
),
)
return metadata
@staticmethod
def _normalize_plugin_dir_name(plugin_name: str) -> str:
return plugin_name.strip()
@staticmethod
def _validate_importable_name(plugin_name: str) -> None:
if "/" in plugin_name or "\\" in plugin_name:
raise ValueError(
"metadata.yaml 中 name 含有路径分隔符,不可用于 importlib 加载。"
)
if not plugin_name.isidentifier() or keyword.iskeyword(plugin_name):
raise Exception(
"metadata.yaml 中 name 不是合法的模块名称(应为合法 Python 标识符且非关键字)。"
)
@staticmethod
def _get_plugin_dir_name_from_metadata(plugin_path: str) -> str:
metadata_path = os.path.join(plugin_path, "metadata.yaml")
if not os.path.exists(metadata_path):
raise Exception("未找到 metadata.yaml,无法获取插件目录名。")
with open(metadata_path, encoding="utf-8") as f:
metadata = yaml.safe_load(f)
if not isinstance(metadata, dict):
raise Exception("metadata.yaml 格式错误。")
plugin_name = metadata.get("name")
if not isinstance(plugin_name, str) or not plugin_name.strip():
raise Exception("metadata.yaml 中缺少 name 字段。")
plugin_dir_name = PluginManager._normalize_plugin_dir_name(plugin_name)
if not plugin_dir_name:
raise Exception("metadata.yaml 中 name 字段内容非法。")
PluginManager._validate_importable_name(plugin_dir_name)
return plugin_dir_name
@staticmethod
def _validate_astrbot_version_specifier(
version_spec: str | None,
) -> tuple[bool, str | None]:
if not version_spec:
return True, None
normalized_spec = version_spec.strip()
if not normalized_spec:
return True, None
try:
specifier = SpecifierSet(normalized_spec)
except InvalidSpecifier:
return (
False,
"astrbot_version 格式无效,请使用 PEP 440 版本范围格式,例如 >=4.16,<5。",
)
try:
current_version = Version(VERSION)
except InvalidVersion:
return (
False,
f"AstrBot 当前版本 {VERSION} 无法被解析,无法校验插件版本范围。",
)
if current_version not in specifier:
return (
False,
f"当前 AstrBot 版本为 {VERSION},不满足插件要求的 astrbot_version: {normalized_spec}",
)
return True, None
@staticmethod
def _get_plugin_related_modules(
plugin_root_dir: str,
is_reserved: bool = False,
) -> list[str]:
"""获取与指定插件相关的所有已加载模块名
根据插件根目录名和是否为保留插件,从 sys.modules 中筛选出相关的模块名
Args:
plugin_root_dir: 插件根目录名
is_reserved: 是否是保留插件,影响模块路径前缀
Returns:
list[str]: 与该插件相关的模块名列表
"""
prefix = "astrbot.builtin_stars." if is_reserved else "data.plugins."
return [
key
for key in list(sys.modules.keys())
if key.startswith(f"{prefix}{plugin_root_dir}")
]
def _purge_modules(
self,
module_patterns: list[str] | None = None,
root_dir_name: str | None = None,
is_reserved: bool = False,
) -> None:
"""从 sys.modules 中移除指定的模块
可以基于模块名模式或插件目录名移除模块,用于清理插件相关的模块缓存
Args:
module_patterns: 要移除的模块名模式列表(例如 ["data.plugins", "astrbot.builtin_stars"])
root_dir_name: 插件根目录名,用于移除与该插件相关的所有模块
is_reserved: 插件是否为保留插件(影响模块路径前缀)
"""
if module_patterns:
for pattern in module_patterns:
for key in list(sys.modules.keys()):
if key.startswith(pattern):
del sys.modules[key]
logger.debug(f"删除模块 {key}")
if root_dir_name:
for module_name in self._get_plugin_related_modules(
root_dir_name,
is_reserved,
):
try:
del sys.modules[module_name]
logger.debug(f"删除模块 {module_name}")
except KeyError:
logger.warning(f"模块 {module_name} 未载入")
def _cleanup_plugin_state(self, dir_name: str) -> None:
plugin_root_name = "data.plugins."
# 清理 sys.modules
for key in list(sys.modules.keys()):
if key.startswith(f"{plugin_root_name}{dir_name}"):
logger.info(f"清除了插件{dir_name}中的{key}模块")
del sys.modules[key]
possible_paths = [
f"{plugin_root_name}{dir_name}.main",
f"{plugin_root_name}{dir_name}.{dir_name}",
]
# 清理 handlers
for path in possible_paths:
handlers = star_handlers_registry.get_handlers_by_module_name(path)
for handler in handlers:
star_handlers_registry.remove(handler)
logger.info(f"清理处理器: {handler.handler_name}")
# 清理工具
for tool in list(llm_tools.func_list):
if tool.handler_module_path in possible_paths:
llm_tools.func_list.remove(tool)
logger.info(f"清理工具: {tool.name}")
def _build_failed_plugin_record(
self,
*,
root_dir_name: str,
plugin_dir_path: str,
reserved: bool,
error: BaseException | str,
error_trace: str,
) -> dict:
record: dict = {
"name": root_dir_name,
"error": str(error),
"traceback": error_trace,
"reserved": reserved,
}
try:
metadata = self._load_plugin_metadata(plugin_path=plugin_dir_path)
if metadata:
record.update(
{
"name": metadata.name,
"author": metadata.author,
"desc": metadata.desc,
"version": metadata.version,
"repo": metadata.repo,
"display_name": metadata.display_name,
"support_platforms": metadata.support_platforms,
"astrbot_version": metadata.astrbot_version,
}
)
except Exception as metadata_error:
logger.debug(
f"读取失败插件 {root_dir_name} 元数据失败: {metadata_error!s}",
)
return record
def _rebuild_failed_plugin_info(self) -> None:
if not self.failed_plugin_dict:
self.failed_plugin_info = ""
return
lines = []
for dir_name, info in self.failed_plugin_dict.items():
if isinstance(info, dict):
error = info.get("error", "未知错误")
display_name = info.get("display_name") or info.get("name") or dir_name
version = info.get("version") or info.get("astrbot_version")
if version:
lines.append(
f"加载插件「{display_name}」(目录: {dir_name}, 版本: {version}) 时出现问题,原因:{error}",
)
else:
lines.append(
f"加载插件「{display_name}」(目录: {dir_name}) 时出现问题,原因:{error}",
)
else:
error = str(info)
lines.append(f"加载插件目录 {dir_name} 时出现问题,原因:{error}")
self.failed_plugin_info = "\n".join(lines) + "\n"
async def reload_failed_plugin(self, dir_name):
"""
重新加载未注册(加载失败)的插件
Args:
dir_name (str): 要重载的特定插件名称。
Returns:
tuple: 返回 load() 方法的结果,包含 (success, error_message)
- success (bool): 重载是否成功
- error_message (str|None): 错误信息,成功时为 None
"""
async with self._pm_lock:
if dir_name not in self.failed_plugin_dict:
return False, "插件不存在于失败列表中"
self._cleanup_plugin_state(dir_name)
plugin_path = os.path.join(self.plugin_store_path, dir_name)
await self._ensure_plugin_requirements(plugin_path, dir_name)
success, error = await self.load(specified_dir_name=dir_name)
if success:
self.failed_plugin_dict.pop(dir_name, None)
self._rebuild_failed_plugin_info()
return success, None
else:
return False, error
async def reload(self, specified_plugin_name=None):
"""重新加载插件
Args:
specified_plugin_name (str, optional): 要重载的特定插件名称。
如果为 None,则重载所有插件。
Returns:
tuple: 返回 load() 方法的结果,包含 (success, error_message)
- success (bool): 重载是否成功
- error_message (str|None): 错误信息,成功时为 None
"""
async with self._pm_lock:
specified_module_path = None
if specified_plugin_name:
for smd in star_registry:
if smd.name == specified_plugin_name:
specified_module_path = smd.module_path
break
# 终止插件
if not specified_module_path:
# 重载所有插件
for smd in star_registry:
try:
await self._terminate_plugin(smd)
except Exception as e:
logger.warning(traceback.format_exc())
logger.warning(
f"插件 {smd.name} 未被正常终止: {e!s}, 可能会导致该插件运行不正常。",
)
if smd.name and smd.module_path:
await self._unbind_plugin(smd.name, smd.module_path)
star_handlers_registry.clear()
star_map.clear()
star_registry.clear()
else:
# 只重载指定插件
smd = star_map.get(specified_module_path)
if smd:
try:
await self._terminate_plugin(smd)
except Exception as e:
logger.warning(traceback.format_exc())
logger.warning(
f"插件 {smd.name} 未被正常终止: {e!s}, 可能会导致该插件运行不正常。",
)
if smd.name:
await self._unbind_plugin(smd.name, specified_module_path)
result = await self.load(specified_module_path)
return result
async def load(
self,
specified_module_path=None,
specified_dir_name=None,
ignore_version_check: bool = False,
):
"""载入插件。
当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。
Args:
specified_module_path (str, optional): 指定要加载的插件模块路径。例如: "data.plugins.my_plugin.main"
specified_dir_name (str, optional): 指定要加载的插件目录名。例如: "my_plugin"
Returns:
tuple: (success, error_message)
- success (bool): 是否全部加载成功
- error_message (str|None): 错误信息,成功时为 None
"""
inactivated_plugins = await sp.global_get("inactivated_plugins", [])
inactivated_llm_tools = await sp.global_get("inactivated_llm_tools", [])
alter_cmd = await sp.global_get("alter_cmd", {})
plugin_modules = self._get_plugin_modules()
if plugin_modules is None:
return False, "未找到任何插件模块"
has_load_error = False
# 导入插件模块,并尝试实例化插件类
for plugin_module in plugin_modules:
try:
module_str = plugin_module["module"]
# module_path = plugin_module['module_path']
root_dir_name = plugin_module["pname"] # 插件的目录名
reserved = plugin_module.get(
"reserved",
False,
) # 是否是保留插件。目前在 astrbot/builtin_stars 目录下的都是保留插件。保留插件不可以卸载。
plugin_dir_path = (
os.path.join(self.plugin_store_path, root_dir_name)
if not reserved
else os.path.join(self.reserved_plugin_path, root_dir_name)
)
requirements_path = os.path.join(plugin_dir_path, "requirements.txt")
path = "data.plugins." if not reserved else "astrbot.builtin_stars."
path += root_dir_name + "." + module_str
# 检查是否需要载入指定的插件
if specified_module_path and path != specified_module_path:
continue
if specified_dir_name and root_dir_name != specified_dir_name:
continue
logger.info(f"正在载入插件 {root_dir_name} ...")
# 尝试导入模块
try:
module = await self._import_plugin_with_dependency_recovery(
path=path,
module_str=module_str,
root_dir_name=root_dir_name,
requirements_path=requirements_path,
)
except Exception as e:
error_trace = traceback.format_exc()
logger.error(error_trace)
logger.error(f"插件 {root_dir_name} 导入失败。原因:{e!s}")
has_load_error = True
self.failed_plugin_dict[root_dir_name] = (
self._build_failed_plugin_record(
root_dir_name=root_dir_name,
plugin_dir_path=plugin_dir_path,
reserved=reserved,
error=e,
error_trace=error_trace,
)
)
if path in star_map:
logger.info("失败插件依旧在插件列表中,正在清理...")
metadata = star_map.pop(path)
if metadata in star_registry:
star_registry.remove(metadata)
continue
# 检查 _conf_schema.json
plugin_config = None
plugin_schema_path = os.path.join(
plugin_dir_path,
self.conf_schema_fname,
)
if await anyio.Path(plugin_schema_path).exists():
# 加载插件配置
async with await anyio.open_file(
plugin_schema_path, encoding="utf-8"
) as f:
plugin_config = AstrBotConfig(
config_path=os.path.join(
self.plugin_config_path,
f"{root_dir_name}_config.json",
),
schema=json.loads(await f.read()),
)
logo_path = os.path.join(plugin_dir_path, self.logo_fname)
if path in star_map:
# 通过 __init__subclass__ 注册插件
metadata = star_map[path]
try:
# yaml 文件的元数据优先
metadata_yaml = self._load_plugin_metadata(
plugin_path=plugin_dir_path,
)
if metadata_yaml:
metadata.name = metadata_yaml.name
metadata.author = metadata_yaml.author
metadata.desc = metadata_yaml.desc
metadata.version = metadata_yaml.version
metadata.repo = metadata_yaml.repo
metadata.display_name = metadata_yaml.display_name
metadata.support_platforms = metadata_yaml.support_platforms
metadata.astrbot_version = metadata_yaml.astrbot_version
except Exception as e:
logger.warning(
f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。",
)
if not ignore_version_check:
is_valid, error_message = (
self._validate_astrbot_version_specifier(
metadata.astrbot_version,
)
)
if not is_valid:
raise PluginVersionIncompatibleError(
error_message
or "The plugin is not compatible with the current AstrBot version."
)
logger.info(metadata)
metadata.config = plugin_config
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (metadata.author or "unknown").lower().replace("/", "_")
plugin_id = f"{p_author}/{p_name}"
# 在实例化前注入类属性,保证插件 __init__ 可读取这些值
if metadata.star_cls_type:
setattr(metadata.star_cls_type, "name", p_name)
setattr(metadata.star_cls_type, "author", p_author)
setattr(metadata.star_cls_type, "plugin_id", plugin_id)
if path not in inactivated_plugins:
# 只有没有禁用插件时才实例化插件类
if plugin_config and metadata.star_cls_type:
try:
metadata.star_cls = metadata.star_cls_type(
context=self.context,
config=plugin_config,
)
except TypeError as _:
metadata.star_cls = metadata.star_cls_type(
context=self.context,
)
elif metadata.star_cls_type:
metadata.star_cls = metadata.star_cls_type(
context=self.context,
)
if metadata.star_cls:
setattr(metadata.star_cls, "name", p_name)
setattr(metadata.star_cls, "author", p_author)
setattr(metadata.star_cls, "plugin_id", plugin_id)
else:
logger.info(f"插件 {metadata.name} 已被禁用。")
metadata.module = module
metadata.root_dir_name = root_dir_name
metadata.reserved = reserved
assert metadata.module_path is not None, (
f"插件 {metadata.name} 的模块路径为空。"
)
assert metadata.star_cls is not None, (
f"插件 {metadata.name} 的实例为空。"
)
# 绑定 handler
related_handlers = (
star_handlers_registry.get_handlers_by_module_name(
metadata.module_path,
)
)
for handler in related_handlers:
handler.handler = functools.partial(
handler.handler,
metadata.star_cls,
)
# 绑定 llm_tool handler
for func_tool in llm_tools.func_list:
if isinstance(func_tool, HandoffTool):
need_apply = []
sub_tools = func_tool.agent.tools
if sub_tools:
for sub_tool in sub_tools:
if isinstance(sub_tool, FunctionTool):
need_apply.append(sub_tool)
else:
need_apply = [func_tool]
for ft in need_apply:
if (
ft.handler
and ft.handler.__module__ == metadata.module_path
):
ft.handler_module_path = metadata.module_path
ft.handler = functools.partial(
ft.handler,
metadata.star_cls,
)
if ft.name in inactivated_llm_tools:
ft.active = False
else:
# v3.4.0 以前的方式注册插件
logger.debug(
f"插件 {path} 未通过装饰器注册。尝试通过旧版本方式载入。",
)
classes = self._get_classes(module)
if path not in inactivated_plugins:
# 只有没有禁用插件时才实例化插件类
if plugin_config:
try:
obj = getattr(module, classes[0])(
context=self.context,
config=plugin_config,
) # 实例化插件类
except TypeError as _:
obj = getattr(module, classes[0])(
context=self.context,
) # 实例化插件类
else:
obj = getattr(module, classes[0])(
context=self.context,
) # 实例化插件类
metadata = self._load_plugin_metadata(
plugin_path=plugin_dir_path,
plugin_obj=obj,
)
if not metadata:
raise Exception(f"无法找到插件 {plugin_dir_path} 的元数据。")
if not ignore_version_check:
is_valid, error_message = (
self._validate_astrbot_version_specifier(
metadata.astrbot_version,
)
)
if not is_valid:
raise PluginVersionIncompatibleError(
error_message
or "The plugin is not compatible with the current AstrBot version."
)
metadata.star_cls = obj
metadata.config = plugin_config
metadata.module = module
metadata.root_dir_name = root_dir_name
metadata.reserved = reserved
metadata.star_cls_type = obj.__class__
metadata.module_path = path
star_map[path] = metadata
star_registry.append(metadata)
# 禁用/启用插件
if metadata.module_path in inactivated_plugins:
metadata.activated = False
# Plugin logo path
if await anyio.Path(logo_path).exists():
metadata.logo_path = logo_path
assert metadata.module_path, f"插件 {metadata.name} 模块路径为空"
full_names = []
for handler in star_handlers_registry.get_handlers_by_module_name(
metadata.module_path,
):
full_names.append(handler.handler_full_name)
# 检查并且植入自定义的权限过滤器(alter_cmd)
if (
metadata.name in alter_cmd
and handler.handler_name in alter_cmd[metadata.name]
):
cmd_type = alter_cmd[metadata.name][handler.handler_name].get(
"permission",
"member",
)
found_permission_filter = False
for filter_ in handler.event_filters:
if isinstance(filter_, PermissionTypeFilter):
if cmd_type == "admin":
filter_.permission_type = PermissionType.ADMIN
else:
filter_.permission_type = PermissionType.MEMBER
found_permission_filter = True
break
if not found_permission_filter:
handler.event_filters.append(
PermissionTypeFilter(
PermissionType.ADMIN
if cmd_type == "admin"
else PermissionType.MEMBER,
),
)
logger.debug(
f"插入权限过滤器 {cmd_type}{metadata.name}{handler.handler_name} 方法。",
)
metadata.star_handler_full_names = full_names
# 执行 initialize() 方法
if hasattr(metadata.star_cls, "initialize") and metadata.star_cls:
await metadata.star_cls.initialize()
# 触发插件加载事件
handlers = star_handlers_registry.get_handlers_by_event_type(
EventType.OnPluginLoadedEvent,
)
for handler in handlers:
try:
logger.info(
f"hook(on_plugin_loaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}",
)
await handler.handler(metadata)
except Exception:
logger.error(traceback.format_exc())
sdk_plugin_bridge = getattr(self.context, "sdk_plugin_bridge", None)
if sdk_plugin_bridge is not None:
try:
await sdk_plugin_bridge.dispatch_system_event(
"plugin_loaded",
{
"plugin_name": metadata.name,
"display_name": metadata.display_name or metadata.name,
"version": metadata.version,
},
)
except Exception as exc:
logger.warning("SDK plugin_loaded dispatch failed: %s", exc)
except BaseException as e:
logger.error(f"----- 插件 {root_dir_name} 载入失败 -----")
errors = traceback.format_exc()
for line in errors.split("\n"):
logger.error(f"| {line}")
logger.error("----------------------------------")
has_load_error = True
self.failed_plugin_dict[root_dir_name] = (
self._build_failed_plugin_record(
root_dir_name=root_dir_name,
plugin_dir_path=plugin_dir_path,
reserved=reserved,
error=e,
error_trace=errors,
)
)
# 记录注册失败的插件名称,以便后续重载插件
if path in star_map:
logger.info("失败插件依旧在插件列表中,正在清理...")
metadata = star_map.pop(path)
if metadata in star_registry:
star_registry.remove(metadata)
# 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
try:
await sync_command_configs()
except Exception as e:
logger.error(f"同步指令配置失败: {e!s}")
logger.error(traceback.format_exc())
self._rebuild_failed_plugin_info()
if has_load_error:
return False, self.failed_plugin_info
return True, None
async def _cleanup_failed_plugin_install(
self,
dir_name: str,
plugin_path: str,
) -> None:
plugin = None
for star in self.context.get_all_stars():
if star.root_dir_name == dir_name:
plugin = star
break
if plugin and plugin.name and plugin.module_path:
try:
await self._terminate_plugin(plugin)
except Exception:
logger.warning(traceback.format_exc())
try:
await self._unbind_plugin(plugin.name, plugin.module_path)
except Exception:
logger.warning(traceback.format_exc())
if await anyio.Path(plugin_path).exists():
try:
await to_thread.run_sync(remove_dir, plugin_path)
logger.warning(f"已清理安装失败的插件目录: {plugin_path}")
except Exception as e:
logger.warning(
f"清理安装失败插件目录失败: {plugin_path},原因: {e!s}",
)
plugin_config_path = os.path.join(
self.plugin_config_path,
f"{dir_name}_config.json",
)
if await anyio.Path(plugin_config_path).exists():
try:
await to_thread.run_sync(os.remove, plugin_config_path)
logger.warning(f"已清理安装失败插件配置: {plugin_config_path}")
except Exception as e:
logger.warning(
f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}",
)
def _cleanup_plugin_optional_artifacts(
self,
*,
root_dir_name: str,
plugin_label: str,
delete_config: bool,
delete_data: bool,
) -> None:
if delete_config:
config_file = os.path.join(
self.plugin_config_path,
f"{root_dir_name}_config.json",
)
if os.path.exists(config_file):
try:
os.remove(config_file)
logger.info(f"已删除插件 {plugin_label} 的配置文件")
except Exception as e:
logger.warning(f"删除插件配置文件失败 ({plugin_label}): {e!s}")
if delete_data:
data_base_dir = os.path.dirname(self.plugin_store_path)
for data_dir_name in ("plugin_data", "plugins_data"):
plugin_data_dir = os.path.join(
data_base_dir,
data_dir_name,
root_dir_name,
)
if os.path.exists(plugin_data_dir):
try:
remove_dir(plugin_data_dir)
logger.info(
f"已删除插件 {plugin_label} 的持久化数据 ({data_dir_name})",
)
except Exception as e:
logger.warning(
f"删除插件持久化数据失败 ({data_dir_name}, {plugin_label}): {e!s}",
)
def _track_failed_install_dir(
self,
*,
dir_name: str,
plugin_path: str,
error: Exception,
) -> None:
if (
not dir_name
or not plugin_path
or not os.path.isdir(plugin_path)
or dir_name in self.failed_plugin_dict
):
return
for star in self.context.get_all_stars():
if star.root_dir_name == dir_name:
return
self.failed_plugin_dict[dir_name] = self._build_failed_plugin_record(
root_dir_name=dir_name,
plugin_dir_path=plugin_path,
reserved=False,
error=error,
error_trace=traceback.format_exc(),
)
self._rebuild_failed_plugin_info()
async def install_plugin(
self, repo_url: str, proxy: str = "", ignore_version_check: bool = False
):
"""从仓库 URL 安装插件
从指定的仓库 URL 下载并安装插件,然后加载该插件到系统中
Args:
repo_url (str): 要安装的插件仓库 URL
proxy (str, optional): 用于下载的代理服务器。默认为空字符串。
Returns:
dict | None: 安装成功时返回包含插件信息的字典:
- repo: 插件的仓库 URL
- readme: README.md 文件的内容(如果存在)
如果找不到插件元数据则返回 None。
"""
# this metric is for displaying plugins installation count in webui
_task_install_star = asyncio.create_task(
Metric.upload(
et="install_star",
repo=repo_url,
),
)
self.tasks.add(_task_install_star)
async with self._pm_lock:
plugin_path = ""
dir_name = ""
try:
_, repo_name, _ = self.updator.parse_github_url(repo_url)
repo_name = self.updator.format_name(repo_name)
plugin_path = os.path.join(self.plugin_store_path, repo_name)
if await anyio.Path(plugin_path).exists():
raise Exception(
f"安装失败:目录 {os.path.basename(plugin_path)} 已存在。"
)
plugin_path = await self.updator.install(repo_url, proxy)
# reload the plugin
dir_name = os.path.basename(plugin_path)
metadata_dir_name = self._get_plugin_dir_name_from_metadata(plugin_path)
target_plugin_path = os.path.join(
self.plugin_store_path,
metadata_dir_name,
)
if (
target_plugin_path != plugin_path
and await anyio.Path(target_plugin_path).exists()
):
raise Exception(f"安装失败:目录 {metadata_dir_name} 已存在。")
if target_plugin_path != plugin_path:
os.rename(plugin_path, target_plugin_path)
plugin_path = target_plugin_path
dir_name = metadata_dir_name
await self._ensure_plugin_requirements(
plugin_path,
dir_name,
)
success, error_message = await self.load(
specified_dir_name=dir_name,
ignore_version_check=ignore_version_check,
)
if not success:
raise Exception(
error_message
or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。"
)
# Get the plugin metadata to return repo info
plugin = self.context.get_registered_star(dir_name)
if not plugin:
# Try to find by other name if directory name doesn't match plugin name
for star in self.context.get_all_stars():
if star.root_dir_name == dir_name:
plugin = star
break
# Extract README.md content if exists
readme_content = None
readme_path = os.path.join(plugin_path, "README.md")
if not await anyio.Path(readme_path).exists():
readme_path = os.path.join(plugin_path, "readme.md")
if await anyio.Path(readme_path).exists():
try:
readme_content = await anyio.Path(readme_path).read_text(
encoding="utf-8"
)
except Exception as e:
logger.warning(
f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}",
)
plugin_info = None
if plugin:
plugin_info = {
"repo": plugin.repo,
"readme": readme_content,
"name": plugin.name,
}
return plugin_info
except Exception as e:
self._track_failed_install_dir(
dir_name=dir_name,
plugin_path=plugin_path,
error=e,
)
if dir_name and plugin_path:
logger.warning(
f"安装插件 {dir_name} 失败,插件安装目录:{plugin_path}",
)
raise
async def uninstall_plugin(
self,
plugin_name: str,
delete_config: bool = False,
delete_data: bool = False,
) -> None:
"""卸载指定的插件。
Args:
plugin_name (str): 要卸载的插件名称
delete_config (bool): 是否删除插件配置文件,默认为 False
delete_data (bool): 是否删除插件数据,默认为 False
Raises:
Exception: 当插件不存在、是保留插件时,或删除插件文件夹失败时抛出异常
"""
async with self._pm_lock:
plugin = self.context.get_registered_star(plugin_name)
if not plugin:
raise Exception("插件不存在。")
if plugin.reserved:
raise Exception("该插件是 AstrBot 保留插件,无法卸载。")
root_dir_name = plugin.root_dir_name
ppath = self.plugin_store_path
# 终止插件
try:
await self._terminate_plugin(plugin)
except Exception as e:
logger.warning(traceback.format_exc())
logger.warning(
f"插件 {plugin_name} 未被正常终止 {e!s}, 可能会导致资源泄露等问题。",
)
# 从 star_registry 和 star_map 中删除
if plugin.module_path is None or root_dir_name is None:
raise Exception(f"插件 {plugin_name} 数据不完整,无法卸载。")
await self._unbind_plugin(plugin_name, plugin.module_path)
# 删除插件文件夹
try:
remove_dir(os.path.join(ppath, root_dir_name))
except Exception as e:
raise Exception(
f"移除插件成功,但是删除插件文件夹失败: {e!s}。您可以手动删除该文件夹,位于 addons/plugins/ 下。",
)
self._cleanup_plugin_optional_artifacts(
root_dir_name=root_dir_name,
plugin_label=plugin_name,
delete_config=delete_config,
delete_data=delete_data,
)
async def uninstall_failed_plugin(
self,
dir_name: str,
delete_config: bool = False,
delete_data: bool = False,
) -> None:
"""卸载加载失败的插件(按目录名)。"""
async with self._pm_lock:
failed_info = self.failed_plugin_dict.get(dir_name)
if not failed_info:
raise Exception(
format_plugin_error("not_found_in_failed_list"),
)
if isinstance(failed_info, dict) and failed_info.get("reserved"):
raise Exception(
format_plugin_error("reserved_plugin_cannot_uninstall"),
)
self._cleanup_plugin_state(dir_name)
plugin_path = os.path.join(self.plugin_store_path, dir_name)
if await anyio.Path(plugin_path).exists():
try:
remove_dir(plugin_path)
except Exception as e:
raise Exception(
format_plugin_error(
"failed_plugin_dir_remove_error",
error=f"{e!s}",
),
)
else:
logger.debug(
"插件目录不存在,视为已部分卸载状态,继续清理失败插件记录和可选产物: %s",
plugin_path,
)
plugin_label = dir_name
if isinstance(failed_info, dict):
plugin_label = (
failed_info.get("display_name")
or failed_info.get("name")
or dir_name
)
self._cleanup_plugin_optional_artifacts(
root_dir_name=dir_name,
plugin_label=plugin_label,
delete_config=delete_config,
delete_data=delete_data,
)
self.failed_plugin_dict.pop(dir_name, None)
self._rebuild_failed_plugin_info()
async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str) -> None:
"""解绑并移除一个插件。
Args:
plugin_name: 要解绑的插件名称
plugin_module_path: 插件的完整模块路径
"""
plugin = None
del star_map[plugin_module_path]
for i, p in enumerate(star_registry):
if p.name == plugin_name:
plugin = p
del star_registry[i]
break
for handler in star_handlers_registry.get_handlers_by_module_name(
plugin_module_path,
):
logger.info(
f"移除了插件 {plugin_name} 的处理函数 {handler.handler_name} ({len(star_handlers_registry)})",
)
star_handlers_registry.remove(handler)
for k in [
k
for k in star_handlers_registry.star_handlers_map
if k.startswith(plugin_module_path)
]:
del star_handlers_registry.star_handlers_map[k]
# llm_tools 中移除该插件的工具函数绑定
to_remove = []
for func_tool in llm_tools.func_list:
mp = func_tool.handler_module_path
if (
mp
and mp.startswith(plugin_module_path)
and not mp.endswith(("astrbot.builtin_stars", "data.plugins"))
):
to_remove.append(func_tool)
for func_tool in to_remove:
llm_tools.func_list.remove(func_tool)
# Unregister platform adapters registered by this plugin
# module_path is like "data.plugins.my_plugin.main", extract prefix like "data.plugins.my_plugin"
module_prefix = ".".join(plugin_module_path.split(".")[:-1])
if module_prefix:
unregistered_adapters = unregister_platform_adapters_by_module(
module_prefix
)
for adapter_name in unregistered_adapters:
logger.info(
f"移除了插件 {plugin_name} 的平台适配器 {adapter_name}",
)
if plugin is None:
return
self._purge_modules(
root_dir_name=plugin.root_dir_name,
is_reserved=plugin.reserved,
)
async def update_plugin(self, plugin_name: str, proxy="") -> None:
"""升级一个插件"""
plugin = self.context.get_registered_star(plugin_name)
if not plugin:
raise Exception("插件不存在。")
if plugin.reserved:
raise Exception("该插件是 AstrBot 保留插件,无法更新。")
await self.updator.update(plugin, proxy=proxy)
if plugin.root_dir_name:
plugin_dir_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
await self._ensure_plugin_requirements(
plugin_dir_path,
plugin_name,
)
await self.reload(plugin_name)
async def turn_off_plugin(self, plugin_name: str) -> None:
"""禁用一个插件。
调用插件的 terminate() 方法,
将插件的 module_path 加入到 data/shared_preferences.json 的 inactivated_plugins 列表中。
并且同时将插件启用的 llm_tool 禁用。
"""
async with self._pm_lock:
plugin = self.context.get_registered_star(plugin_name)
if not plugin:
raise Exception("插件不存在。")
# 调用插件的终止方法
await self._terminate_plugin(plugin)
# 加入到 shared_preferences 中
inactivated_plugins: list = await sp.global_get("inactivated_plugins", [])
if plugin.module_path not in inactivated_plugins:
inactivated_plugins.append(plugin.module_path)
inactivated_llm_tools: list = list(
set(await sp.global_get("inactivated_llm_tools", [])),
) # 后向兼容
# 禁用插件启用的 llm_tool
for func_tool in llm_tools.func_list:
mp = func_tool.handler_module_path
if (
plugin.module_path
and mp
and plugin.module_path.startswith(mp)
and not mp.endswith(("astrbot.builtin_stars", "data.plugins"))
):
func_tool.active = False
if func_tool.name not in inactivated_llm_tools:
inactivated_llm_tools.append(func_tool.name)
await sp.global_put("inactivated_plugins", inactivated_plugins)
await sp.global_put("inactivated_llm_tools", inactivated_llm_tools)
plugin.activated = False
@staticmethod
async def _terminate_plugin(star_metadata: StarMetadata) -> None:
"""终止插件,调用插件的 terminate() 和 __del__() 方法"""
logger.info(f"正在终止插件 {star_metadata.name} ...")
if not star_metadata.activated:
# 说明之前已经被禁用了
logger.debug(f"插件 {star_metadata.name} 未被激活,不需要终止,跳过。")
return
if star_metadata.star_cls is None:
return
if "__del__" in star_metadata.star_cls_type.__dict__:
loop = asyncio.get_running_loop()
future = loop.run_in_executor(
None,
star_metadata.star_cls.__del__,
)
def _log_del_exception(fut: asyncio.Future) -> None:
if fut.cancelled():
return
if (exc := fut.exception()) is not None:
logger.error(
"插件 %s 在 __del__ 中抛出了异常:%r",
star_metadata.name,
exc,
)
future.add_done_callback(_log_del_exception)
elif "terminate" in star_metadata.star_cls_type.__dict__:
await star_metadata.star_cls.terminate()
# 触发插件卸载事件
handlers = star_handlers_registry.get_handlers_by_event_type(
EventType.OnPluginUnloadedEvent,
)
for handler in handlers:
try:
logger.info(
f"hook(on_plugin_unloaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}",
)
await handler.handler(star_metadata)
except Exception:
logger.error(traceback.format_exc())
sdk_plugin_bridge = (
getattr(star_metadata.star_cls.context, "sdk_plugin_bridge", None)
if getattr(star_metadata, "star_cls", None)
else None
)
if sdk_plugin_bridge is not None:
try:
await sdk_plugin_bridge.dispatch_system_event(
"plugin_unloaded",
{
"plugin_name": star_metadata.name,
"display_name": star_metadata.display_name
or star_metadata.name,
"version": star_metadata.version,
},
)
except Exception as exc:
logger.warning("SDK plugin_unloaded dispatch failed: %s", exc)
async def turn_on_plugin(self, plugin_name: str) -> None:
plugin = self.context.get_registered_star(plugin_name)
if plugin is None:
raise Exception(f"插件 {plugin_name} 不存在。")
inactivated_plugins: list = await sp.global_get("inactivated_plugins", [])
inactivated_llm_tools: list = await sp.global_get("inactivated_llm_tools", [])
if plugin.module_path in inactivated_plugins:
inactivated_plugins.remove(plugin.module_path)
await sp.global_put("inactivated_plugins", inactivated_plugins)
# 启用插件启用的 llm_tool
for func_tool in llm_tools.func_list:
mp = func_tool.handler_module_path
if (
plugin.module_path
and mp
and plugin.module_path.startswith(mp)
and not mp.endswith(("astrbot.builtin_stars", "data.plugins"))
and func_tool.name in inactivated_llm_tools
):
inactivated_llm_tools.remove(func_tool.name)
func_tool.active = True
await sp.global_put("inactivated_llm_tools", inactivated_llm_tools)
await self.reload(plugin_name)
async def install_plugin_from_file(
self, zip_file_path: str, ignore_version_check: bool = False
):
dir_name = os.path.splitext(os.path.basename(zip_file_path))[0]
desti_dir = tempfile.mkdtemp(
dir=self.plugin_store_path, prefix="plugin_upload_"
)
temp_desti_dir = desti_dir
try:
self.updator.unzip_file(zip_file_path, desti_dir)
metadata_dir_name = self._get_plugin_dir_name_from_metadata(desti_dir)
target_plugin_path = os.path.join(
self.plugin_store_path,
metadata_dir_name,
)
if (
target_plugin_path != desti_dir
and await anyio.Path(target_plugin_path).exists()
):
raise Exception(f"安装失败:目录 {metadata_dir_name} 已存在。")
if target_plugin_path != desti_dir:
os.rename(desti_dir, target_plugin_path)
dir_name = metadata_dir_name
desti_dir = target_plugin_path
# remove the zip
try:
os.remove(zip_file_path)
except BaseException as e:
logger.warning(f"删除插件压缩包失败: {e!s}")
await self._ensure_plugin_requirements(desti_dir, dir_name)
# await self.reload()
success, error_message = await self.load(
specified_dir_name=dir_name,
ignore_version_check=ignore_version_check,
)
if not success:
raise Exception(
error_message or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。"
)
# Get the plugin metadata to return repo info
plugin = self.context.get_registered_star(dir_name)
if not plugin:
# Try to find by other name if directory name doesn't match plugin name
for star in self.context.get_all_stars():
if star.root_dir_name == dir_name:
plugin = star
break
# Extract README.md content if exists
readme_content = None
readme_path = os.path.join(desti_dir, "README.md")
if not await anyio.Path(readme_path).exists():
readme_path = os.path.join(desti_dir, "readme.md")
if await anyio.Path(readme_path).exists():
try:
readme_content = await anyio.Path(readme_path).read_text(
encoding="utf-8"
)
except Exception as e:
logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}")
plugin_info = None
if plugin:
plugin_info = {
"repo": plugin.repo,
"readme": readme_content,
"name": plugin.name,
}
if plugin.repo:
_task_install_star_f = asyncio.create_task(
Metric.upload(
et="install_star_f", # install star
repo=plugin.repo,
),
)
self.tasks.add(_task_install_star_f)
_task_install_star_f.add_done_callback(self.tasks.discard)
return plugin_info
except Exception as e:
self._track_failed_install_dir(
dir_name=dir_name,
plugin_path=desti_dir,
error=e,
)
logger.warning(
f"安装插件 {dir_name} 失败,插件安装目录:{desti_dir}",
)
raise
finally:
if (
temp_desti_dir != desti_dir
and await anyio.Path(temp_desti_dir).is_dir()
):
try:
remove_dir(temp_desti_dir)
except Exception as e:
logger.warning(
f"清理临时插件解压目录失败: {temp_desti_dir},原因: {e!s}",
)