Files
AstrBot/astrbot/dashboard/services/plugin_service.py
Weilong Liao 0d8e8682db refactor(core): migrate backend backbone from Quart to FastAPI and introduce more OpenAPI (#8688)
* refactor: migrate to fastapi

* structure refactor

* fix: pyright fix

* refactor: improve error handling and public messages in plugin services

* feat(api): refactor API client integration and enhance request handling

- Updated API client configuration to use a dedicated HTTP client.
- Introduced utility functions for generating options, queries, and form data for API requests.
- Refactored multiple API methods to utilize the new utility functions for improved consistency and readability.
- Renamed types for clarity and updated import statements accordingly.

feat(docs): add script to update OpenAPI JSON from YAML spec

- Created a Python script to convert OpenAPI YAML specification to JSON format.
- The script supports customizable input and output paths.
- Ensured the script handles directory creation for output paths and validates the YAML structure.

* fix

* feat(auth): implement rate limiting for v1 login endpoint and enhance request handling

* Refactor dashboard API routers to use legacy_router for backward compatibility

- Changed all instances of dashboard_router to legacy_router across multiple API modules including platform, plugins, providers, sessions, skills, stats, subagents, t2i, tools, updates, and asgi_runtime.
- Updated route definitions to ensure existing endpoints remain functional under the new router structure.
- Introduced support for Quart request context in asgi_runtime to enhance compatibility with existing Quart-based plugins.
- Added a test case to validate the functionality of the new Quart request context handling in plugin extensions.

* chore: remove cli test

* fix: update dashboard tests for fastapi migration

* chore: satisfy ruff checks

* fix: update openapi api key scopes

* fix: sync config scope chip selection

* fix: restore quart dependency

* docs: clarify quart plugin api compatibility

* docs: update openapi scope documentation

* fix: use singular skill openapi scope

* fix: hide update service exception details

* fix: address fastapi review comments

* fix: address dashboard review findings

* docs: revert unrelated package deployment changes

* docs: update agent api generation guidance

* feat: add plugin page web api helpers

* docs: add plugin page bridge demo

* fix: type plugin upload files

* fix: stabilize plugin page uploads

* fix: type plugin web request proxy

* docs: remove plugin page docs example

* fix: authenticate plugin page SSE bridge
2026-06-14 15:03:26 +08:00

1208 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import ssl
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import aiohttp
import certifi
from astrbot.api import sp
from astrbot.core import DEMO_MODE, file_token_service, logger
from astrbot.core.computer.computer_client import sync_skills_to_active_sandboxes
from astrbot.core.skills.skill_manager import SkillManager
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.filter.permission import PermissionTypeFilter
from astrbot.core.star.filter.regex import RegexFilter
from astrbot.core.star.star import StarMetadata
from astrbot.core.star.star_handler import EventType, star_handlers_registry
from astrbot.core.star.star_manager import (
PluginManager,
PluginVersionUnsupportedError,
)
from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path
PLUGIN_UPDATE_CONCURRENCY = 3
PLUGIN_OPERATION_FAILED_MESSAGE = "插件操作失败,请查看服务端日志。"
PLUGIN_UPDATE_FAILED_MESSAGE = "更新失败,请查看服务端日志。"
PLUGIN_COMPONENT_TYPE_ORDER = {
"page": 0,
"skill": 1,
"command": 2,
"llm_tool": 3,
"listener": 4,
"hook": 5,
}
LogoTokenResolver = Callable[[str], Awaitable[str | None]]
InstalledAtResolver = Callable[[StarMetadata], str | None]
PluginPagesResolver = Callable[[StarMetadata], Awaitable[list]]
PluginPagesSerializer = Callable[[StarMetadata], Awaitable[list[dict]]]
@dataclass
class RegistrySource:
urls: list[str]
cache_file: str
md5_url: str | None
class PluginServiceError(Exception):
def __init__(
self,
message: str,
*,
public_message: str | None = None,
) -> None:
super().__init__(message)
self.public_message = public_message or message
class PluginServiceWarning(Exception):
def __init__(
self,
message: str,
data: dict[str, Any],
*,
public_message: str | None = None,
) -> None:
super().__init__(message)
self.public_message = public_message or message
self.data = data
class PluginService:
def __init__(
self,
core_lifecycle,
plugin_manager: PluginManager,
) -> None:
self.core_lifecycle = core_lifecycle
self.plugin_manager = plugin_manager
self.translated_event_type = {
EventType.AdapterMessageEvent: "平台消息下发时",
EventType.OnLLMRequestEvent: "LLM 请求时",
EventType.OnLLMResponseEvent: "LLM 响应后",
EventType.OnAgentBeginEvent: "Agent 开始运行时",
EventType.OnAgentDoneEvent: "Agent 运行完成后",
EventType.OnDecoratingResultEvent: "回复消息前",
EventType.OnCallingFuncToolEvent: "函数工具",
EventType.OnAfterMessageSentEvent: "发送消息后",
EventType.OnPluginErrorEvent: "插件报错时",
}
self._logo_cache: dict[str, str] = {}
@staticmethod
def _payload(data: object) -> dict[str, Any]:
return data if isinstance(data, dict) else {}
@staticmethod
def _ensure_not_demo() -> None:
if DEMO_MODE:
raise PluginServiceError(
"You are not permitted to do this operation in demo mode"
)
async def sync_skills_after_plugin_change(self) -> None:
try:
await sync_skills_to_active_sandboxes()
except Exception:
logger.warning("Failed to sync plugin-provided skills to active sandboxes.")
async def check_plugin_version_support(self, data: object) -> dict:
payload = self._payload(data)
version_spec = payload.get("astrbot_version", "")
is_valid, message = self.plugin_manager._validate_astrbot_version_specifier(
version_spec
)
return {
"supported": is_valid,
"message": message,
"astrbot_version": version_spec,
}
async def reload_failed_plugin(self, data: object) -> tuple[None, str]:
self._ensure_not_demo()
payload = data if isinstance(data, dict) else {}
dir_name = payload.get("dir_name")
if not dir_name:
raise PluginServiceError("缺少插件目录名")
success, err = await self.plugin_manager.reload_failed_plugin(dir_name)
if not success:
raise PluginServiceError(f"重载失败: {err}", public_message="重载失败")
await self.sync_skills_after_plugin_change()
return None, f"插件 {dir_name} 重载成功。"
async def reload_plugin(self, data: object) -> tuple[None, str]:
self._ensure_not_demo()
payload = data if isinstance(data, dict) else {}
plugin_name = payload.get("name", None)
success, message = await self.plugin_manager.reload(plugin_name)
if not success:
raise PluginServiceError(
message or "插件重载失败",
public_message="插件重载失败",
)
await self.sync_skills_after_plugin_change()
return None, "重载成功。"
async def list_plugins(
self,
*,
plugin_name: str | None,
logo_token_resolver: LogoTokenResolver,
installed_at_resolver: InstalledAtResolver,
discover_pages: PluginPagesResolver,
) -> tuple[list[dict], str | None]:
plugins = [
plugin
for plugin in self.plugin_manager.context.get_all_stars()
if not (plugin_name and plugin.name != plugin_name)
]
async def process_plugin(plugin: StarMetadata):
logo_url = await self.resolve_plugin_logo_url(plugin, logo_token_resolver)
pages = await discover_pages(plugin)
return plugin, logo_url, pages
results = await asyncio.gather(*(process_plugin(plugin) for plugin in plugins))
payload = []
for plugin, logo_url, pages in results:
if self.is_ghost_plugin(plugin):
continue
payload.append(
{
**self.serialize_plugin_base(
plugin,
logo_url=logo_url,
installed_at=installed_at_resolver(plugin),
),
"pages": [page.name for page in pages],
}
)
return payload, getattr(self.plugin_manager, "failed_plugin_info", None)
async def list_plugins_from_dashboard_query(
self,
*,
plugin_name: str | None,
logo_token_resolver: LogoTokenResolver,
installed_at_resolver: InstalledAtResolver,
discover_pages: PluginPagesResolver,
) -> tuple[list[dict], str | None]:
return await self.list_plugins(
plugin_name=plugin_name,
logo_token_resolver=logo_token_resolver,
installed_at_resolver=installed_at_resolver,
discover_pages=discover_pages,
)
async def get_plugin_detail(
self,
*,
plugin_name: str | None,
logo_token_resolver: LogoTokenResolver,
installed_at_resolver: InstalledAtResolver,
serialize_pages: PluginPagesSerializer,
) -> dict:
if not plugin_name:
raise PluginServiceError("缺少插件名")
for plugin in self.plugin_manager.context.get_all_stars():
if plugin.name != plugin_name:
continue
logo_url = await self.resolve_plugin_logo_url(plugin, logo_token_resolver)
return {
**self.serialize_plugin_base(
plugin,
logo_url=logo_url,
installed_at=installed_at_resolver(plugin),
),
"components": await self.get_plugin_components_info(
plugin,
serialize_pages,
),
}
raise PluginServiceError("插件不存在")
async def get_plugin_detail_from_dashboard_query(
self,
*,
plugin_name: str | None,
logo_token_resolver: LogoTokenResolver,
installed_at_resolver: InstalledAtResolver,
serialize_pages: PluginPagesSerializer,
) -> dict:
return await self.get_plugin_detail(
plugin_name=plugin_name,
logo_token_resolver=logo_token_resolver,
installed_at_resolver=installed_at_resolver,
serialize_pages=serialize_pages,
)
async def resolve_plugin_logo_url(
self,
plugin: StarMetadata,
logo_token_resolver: LogoTokenResolver,
) -> str | None:
if not plugin.logo_path:
return None
logo_token = await logo_token_resolver(plugin.logo_path)
return f"/api/file/{logo_token}" if logo_token else None
async def get_plugin_logo_token(self, logo_path: str) -> str | None:
try:
if token := self._logo_cache.get(logo_path):
if not await file_token_service.check_token_expired(token):
return token
token = await file_token_service.register_file(logo_path, timeout=300)
self._logo_cache[logo_path] = token
return token
except Exception as exc:
logger.warning(f"获取插件 Logo 失败: {exc}")
return None
def resolve_plugin_metadata_dir(self, plugin: StarMetadata) -> Path | None:
if not plugin.root_dir_name:
return None
base_dir = Path(
self.plugin_manager.reserved_plugin_path
if plugin.reserved
else self.plugin_manager.plugin_store_path
)
plugin_dir = base_dir / plugin.root_dir_name
if not plugin_dir.is_dir():
return None
return plugin_dir
def get_plugin_installed_at(self, plugin: StarMetadata) -> str | None:
plugin_dir = self.resolve_plugin_metadata_dir(plugin)
if plugin_dir is None:
return None
try:
return datetime.fromtimestamp(
plugin_dir.stat().st_mtime,
timezone.utc,
).isoformat()
except OSError as exc:
logger.warning(f"获取插件安装时间失败 {plugin.name}: {exc!s}")
return None
@staticmethod
def is_ghost_plugin(plugin: StarMetadata) -> bool:
return not any(
[
plugin.name,
plugin.author,
plugin.desc,
plugin.version,
plugin.display_name,
]
)
@staticmethod
def serialize_plugin_base(
plugin: StarMetadata,
*,
logo_url: str | None,
installed_at: str | None,
) -> dict:
return {
"name": plugin.name,
"marketplace_name": (plugin.name or "").replace("_", "-"),
"repo": "" if plugin.repo is None else str(plugin.repo),
"author": plugin.author,
"desc": plugin.desc,
"version": plugin.version,
"reserved": plugin.reserved,
"activated": plugin.activated,
"online_vesion": "",
"display_name": plugin.display_name,
"logo": logo_url,
"support_platforms": plugin.support_platforms,
"astrbot_version": plugin.astrbot_version,
"installed_at": installed_at,
"i18n": plugin.i18n,
}
def get_failed_plugins(self) -> dict:
return self.plugin_manager.failed_plugin_dict
async def get_plugin_components_info(
self,
plugin: StarMetadata,
serialize_pages: PluginPagesSerializer,
) -> list[dict]:
components = [
*await self.get_plugin_page_components(plugin, serialize_pages),
*self.get_plugin_skill_components(plugin),
*await self.get_plugin_handler_components(plugin.star_handler_full_names),
]
return sorted(
components,
key=lambda item: PLUGIN_COMPONENT_TYPE_ORDER.get(item["type"], 99),
)
async def get_plugin_page_components(
self,
plugin: StarMetadata,
serialize_pages: PluginPagesSerializer,
) -> list[dict]:
pages = await serialize_pages(plugin)
return [
{
"type": "page",
"name": page["title"],
"title": page["title"],
"page_name": page["name"],
"i18n_key": page["i18n_key"],
"description": "Plugin Page entry",
"plugin_name": plugin.name,
"plugin_marketplace_name": (plugin.name or "").replace("_", "-"),
}
for page in pages
]
async def get_plugin_handler_components(
self,
handler_full_names: list[str],
) -> list[dict]:
components = []
for handler_full_name in handler_full_names:
info = {}
handler = star_handlers_registry.star_handlers_map.get(
handler_full_name,
None,
)
if handler is None:
continue
info["event_type"] = handler.event_type.name
info["event_type_h"] = self.translated_event_type.get(
handler.event_type,
handler.event_type.name,
)
info["handler_full_name"] = handler.handler_full_name
info["description"] = handler.desc or "无描述"
info["handler_name"] = handler.handler_name
component_type = "hook"
component: dict[str, Any] | None = None
if handler.event_type == EventType.AdapterMessageEvent:
has_admin = False
for event_filter in handler.event_filters:
if isinstance(event_filter, CommandFilter):
component_type = "command"
info["display_type"] = "指令"
info["cmd"] = self._get_command_filter_display_name(
event_filter
)
component = self._build_command_filter_component(
event_filter,
handler.desc,
)
elif isinstance(event_filter, CommandGroupFilter):
component_type = "command"
info["display_type"] = "指令组"
info["cmd"] = event_filter.get_complete_command_names()[
0
].strip()
component = self._build_command_group_component(
event_filter,
handler.desc,
)
elif isinstance(event_filter, RegexFilter):
component_type = "command"
info["display_type"] = "正则匹配"
info["cmd"] = event_filter.regex_str
component = {
"type": "command",
"name": event_filter.regex_str,
"description": handler.desc or "无描述",
"match": "regex",
}
elif isinstance(event_filter, PermissionTypeFilter):
has_admin = True
info["has_admin"] = has_admin
if "cmd" not in info:
info["cmd"] = "未知"
if "display_type" not in info:
info["display_type"] = "事件监听器"
component_type = "listener"
else:
info["cmd"] = "自动触发"
info["display_type"] = ""
if handler.event_type == EventType.OnCallingFuncToolEvent:
component_type = "llm_tool"
if component is None:
component = {
"type": component_type,
"name": handler.handler_name or handler.event_type.name,
"description": handler.desc or "无描述",
}
else:
component["type"] = component_type
if component_type == "command":
component["event_type"] = info["event_type"]
component["event_type_h"] = info["event_type_h"]
component["handler_name"] = info["handler_name"]
component["has_admin"] = info.get("has_admin", False)
if "display_type" in info:
component["display_type"] = info["display_type"]
if "cmd" in info:
component["command"] = info["cmd"]
else:
component.update(info)
components.append(component)
return self._merge_command_components(components)
def get_plugin_skill_components(self, plugin: StarMetadata) -> list[dict]:
plugin_names = {
str(name)
for name in (plugin.root_dir_name, plugin.name)
if str(name or "").strip()
}
if not plugin_names:
return []
try:
skills = SkillManager().list_skills(
active_only=False,
runtime="local",
show_sandbox_path=False,
)
except Exception as exc:
logger.warning(f"获取插件 Skills 失败 {plugin.name}: {exc!s}")
return []
components = []
for skill in skills:
if skill.source_type != "plugin" or skill.plugin_name not in plugin_names:
continue
components.append(
{
"type": "skill",
"name": skill.name,
"description": skill.description or "无描述",
"path": skill.path,
}
)
return components
@staticmethod
def _get_command_filter_display_name(command_filter: CommandFilter) -> str:
return command_filter.get_complete_command_names()[0].strip()
@staticmethod
def _get_command_description(
command_filter: CommandFilter | CommandGroupFilter,
fallback: str = "",
) -> str:
handler_md = getattr(command_filter, "handler_md", None)
desc = getattr(handler_md, "desc", "") if handler_md else ""
return desc or fallback or "无描述"
def _build_command_filter_component(
self,
command_filter: CommandFilter,
fallback_desc: str = "",
) -> dict:
parts = self._get_command_filter_display_name(command_filter).split()
if not parts:
parts = [command_filter.command_name]
component: dict[str, Any] = {
"type": "command",
"name": parts[-1],
"description": self._get_command_description(
command_filter,
fallback_desc,
),
}
return self._wrap_command_component(parts[:-1], component)
def _build_command_group_component(
self,
command_group_filter: CommandGroupFilter,
fallback_desc: str = "",
) -> dict:
parts = command_group_filter.get_complete_command_names()[0].strip().split()
if not parts:
parts = [command_group_filter.group_name]
subcommands = [
self._build_command_group_child(sub_filter)
for sub_filter in command_group_filter.sub_command_filters
]
component: dict[str, Any] = {
"type": "command",
"name": parts[-1],
"description": self._get_command_description(
command_group_filter,
fallback_desc,
),
}
if subcommands:
component["subcommands"] = subcommands
return self._wrap_command_component(parts[:-1], component)
def _build_command_group_child(
self,
command_filter: CommandFilter | CommandGroupFilter,
) -> dict:
if isinstance(command_filter, CommandGroupFilter):
component: dict[str, Any] = {
"name": command_filter.group_name,
"description": self._get_command_description(command_filter),
}
subcommands = [
self._build_command_group_child(sub_filter)
for sub_filter in command_filter.sub_command_filters
]
if subcommands:
component["subcommands"] = subcommands
return component
return {
"name": command_filter.command_name,
"description": self._get_command_description(command_filter),
}
@staticmethod
def _wrap_command_component(parent_names: list[str], component: dict) -> dict:
for parent_name in reversed(parent_names):
component = {
"type": "command",
"name": parent_name,
"description": "无描述",
"subcommands": [component],
}
return component
def _merge_command_components(self, components: list[dict]) -> list[dict]:
merged: list[dict] = []
for component in components:
if component.get("type") != "command":
merged.append(component)
continue
existing = next(
(
item
for item in merged
if item.get("type") == "command"
and item.get("name") == component.get("name")
and item.get("match") == component.get("match")
),
None,
)
if existing is None:
merged.append(component)
continue
self._merge_command_component(existing, component)
return merged
def _merge_command_component(self, target: dict, source: dict) -> None:
if target.get("description") == "无描述" and source.get("description"):
target["description"] = source["description"]
for key, value in source.items():
if key in {"subcommands", "description"}:
continue
target.setdefault(key, value)
source_subcommands = source.get("subcommands")
if not isinstance(source_subcommands, list):
return
target_subcommands = target.setdefault("subcommands", [])
for source_subcommand in source_subcommands:
if not isinstance(source_subcommand, dict):
continue
existing = next(
(
item
for item in target_subcommands
if isinstance(item, dict)
and item.get("name") == source_subcommand.get("name")
),
None,
)
if existing is None:
target_subcommands.append(source_subcommand)
continue
self._merge_command_component(existing, source_subcommand)
async def get_online_plugins(
self,
*,
custom_registry: str | None,
force_refresh: bool,
) -> tuple[Any, str | None]:
source = self.build_registry_source(custom_registry)
cached_data = None
if not force_refresh and await self.is_cache_valid(source):
cached_data = self.load_plugin_cache(source.cache_file)
if cached_data:
logger.debug("缓存MD5匹配使用缓存的插件市场数据")
return cached_data, None
remote_data = None
ssl_context = ssl.create_default_context(cafile=certifi.where())
connector = aiohttp.TCPConnector(ssl=ssl_context)
for url in source.urls:
try:
async with (
aiohttp.ClientSession(
trust_env=True,
connector=connector,
) as session,
session.get(url) as response,
):
if response.status == 200:
try:
remote_data = await response.json()
except aiohttp.ContentTypeError:
remote_text = await response.text()
remote_data = json.loads(remote_text)
if not remote_data or (
isinstance(remote_data, dict) and len(remote_data) == 0
):
logger.warning(f"远程插件市场数据为空: {url}")
continue
logger.info(
f"成功获取远程插件市场数据,包含 {len(remote_data)} 个插件"
)
current_md5 = await self.fetch_remote_md5(source.md5_url)
self.save_plugin_cache(
source.cache_file,
remote_data,
current_md5,
)
return remote_data, None
logger.error(f"请求 {url} 失败,状态码:{response.status}")
except Exception as exc:
logger.error(f"请求 {url} 失败,错误:{exc}")
if not cached_data:
cached_data = self.load_plugin_cache(source.cache_file)
if cached_data:
logger.warning("远程插件市场数据获取失败,使用缓存数据")
return cached_data, "使用缓存数据,可能不是最新版本"
raise PluginServiceError("获取插件列表失败,且没有可用的缓存数据")
async def get_online_plugins_from_dashboard_query(
self,
*,
custom_registry: str | None,
force_refresh,
) -> tuple[Any, str | None]:
return await self.get_online_plugins(
custom_registry=custom_registry,
force_refresh=self._to_bool(force_refresh),
)
@staticmethod
def build_registry_source(custom_url: str | None) -> RegistrySource:
data_dir = get_astrbot_data_path()
if custom_url:
url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8]
cache_file = os.path.join(data_dir, f"plugins_custom_{url_hash}.json")
md5_url = (
custom_url[:-5] + "-md5.json"
if custom_url.endswith(".json")
else custom_url + "-md5.json"
)
urls = [custom_url]
else:
cache_file = os.path.join(data_dir, "plugins.json")
md5_url = "https://api.soulter.top/astrbot/plugins-md5"
urls = [
"https://api.soulter.top/astrbot/plugins",
"https://github.com/AstrBotDevs/AstrBot_Plugins_Collection/raw/refs/heads/main/plugin_cache_original.json",
]
return RegistrySource(urls=urls, cache_file=cache_file, md5_url=md5_url)
@staticmethod
def load_cached_md5(cache_file: str) -> str | None:
if not os.path.exists(cache_file):
return None
try:
with open(cache_file, encoding="utf-8") as file:
cache_data = json.load(file)
return cache_data.get("md5")
except Exception as exc:
logger.warning(f"Failed to load cached MD5: {exc}")
return None
@staticmethod
async def fetch_remote_md5(md5_url: str | None) -> str | None:
if not md5_url:
return None
try:
ssl_context = ssl.create_default_context(cafile=certifi.where())
connector = aiohttp.TCPConnector(ssl=ssl_context)
async with (
aiohttp.ClientSession(
trust_env=True,
connector=connector,
) as session,
session.get(md5_url) as response,
):
if response.status == 200:
data = await response.json()
return data.get("md5", "")
except Exception as exc:
logger.debug(f"Failed to fetch remote MD5: {exc}")
return None
async def is_cache_valid(self, source: RegistrySource) -> bool:
try:
cached_md5 = self.load_cached_md5(source.cache_file)
if not cached_md5:
logger.debug("MD5 not found in cache, treating cache as invalid")
return False
remote_md5 = await self.fetch_remote_md5(source.md5_url)
if remote_md5 is None:
logger.warning(
"Cannot fetch remote MD5, using cache without validation"
)
return True
is_valid = cached_md5 == remote_md5
logger.debug(
f"Plugin cache: local={cached_md5}, remote={remote_md5}, effective={is_valid}",
)
return is_valid
except Exception as exc:
logger.warning(f"检查缓存有效性失败: {exc}")
return False
@staticmethod
def load_plugin_cache(cache_file: str):
try:
if os.path.exists(cache_file):
with open(cache_file, encoding="utf-8") as file:
cache_data = json.load(file)
if "data" in cache_data and "timestamp" in cache_data:
logger.debug(
f"Loading cached file: {cache_file}, Cache time: {cache_data['timestamp']}",
)
return cache_data["data"]
except Exception as exc:
logger.warning(f"Failed to load plugin market cache: {exc}")
return None
@staticmethod
def save_plugin_cache(cache_file: str, data, md5: str | None = None) -> None:
try:
os.makedirs(os.path.dirname(cache_file), exist_ok=True)
cache_data = {
"timestamp": datetime.now().isoformat(),
"data": data,
"md5": md5 or "",
}
with open(cache_file, "w", encoding="utf-8") as file:
json.dump(cache_data, file, ensure_ascii=False, indent=2)
logger.debug(f"Cached plugin market data: {cache_file}, MD5: {md5}")
except Exception as exc:
logger.warning(f"Failed to save plugin market cache: {exc}")
async def install_plugin(self, data: object) -> tuple[dict, str]:
self._ensure_not_demo()
payload = data if isinstance(data, dict) else {}
repo_url = payload["url"]
download_url = str(payload.get("download_url") or "").strip()
ignore_version_check = bool(payload.get("ignore_version_check", False))
proxy: str | None = payload.get("proxy", None)
if proxy:
proxy = proxy.removesuffix("/")
try:
logger.info(f"正在安装插件 {repo_url}")
plugin_info = await self.plugin_manager.install_plugin(
repo_url,
proxy or "",
ignore_version_check=ignore_version_check,
download_url=download_url,
)
await self.sync_skills_after_plugin_change()
logger.info(f"安装插件 {repo_url} 成功。")
return plugin_info or {}, "安装成功。"
except PluginVersionUnsupportedError as exc:
raise PluginServiceWarning(
str(exc),
{
"warning_type": "astrbot_version_unsupported",
"can_ignore": True,
},
public_message="当前 AstrBot 版本不满足插件要求",
) from exc
async def install_plugin_upload(
self,
*,
upload_file,
ignore_version_check: bool,
) -> tuple[dict, str]:
self._ensure_not_demo()
logger.info(f"正在安装用户上传的插件 {upload_file.filename}")
file_path = os.path.join(
get_astrbot_temp_path(),
f"plugin_upload_{upload_file.filename}",
)
await upload_file.save(file_path)
try:
plugin_info = await self.plugin_manager.install_plugin_from_file(
file_path,
ignore_version_check=ignore_version_check,
)
await self.sync_skills_after_plugin_change()
logger.info(f"安装插件 {upload_file.filename} 成功")
return plugin_info or {}, "安装成功。"
except PluginVersionUnsupportedError as exc:
raise PluginServiceWarning(
str(exc),
{
"warning_type": "astrbot_version_unsupported",
"can_ignore": True,
},
public_message="当前 AstrBot 版本不满足插件要求",
) from exc
async def install_plugin_upload_from_dashboard_form(
self,
*,
upload_file,
ignore_version_check,
) -> tuple[dict, str]:
return await self.install_plugin_upload(
upload_file=upload_file,
ignore_version_check=self._to_bool(ignore_version_check),
)
async def uninstall_plugin(self, data: object) -> tuple[None, str]:
self._ensure_not_demo()
payload = data if isinstance(data, dict) else {}
plugin_name = payload["name"]
delete_config = payload.get("delete_config", False)
delete_data = payload.get("delete_data", False)
logger.info(f"正在卸载插件 {plugin_name}")
await self.plugin_manager.uninstall_plugin(
plugin_name,
delete_config=delete_config,
delete_data=delete_data,
)
await self.sync_skills_after_plugin_change()
logger.info(f"卸载插件 {plugin_name} 成功")
return None, "卸载成功"
async def uninstall_failed_plugin(self, data: object) -> tuple[None, str]:
self._ensure_not_demo()
payload = data if isinstance(data, dict) else {}
dir_name = payload.get("dir_name", "")
delete_config = payload.get("delete_config", False)
delete_data = payload.get("delete_data", False)
if not dir_name:
raise PluginServiceError("缺少失败插件目录名")
logger.info(f"正在卸载失败插件 {dir_name}")
await self.plugin_manager.uninstall_failed_plugin(
dir_name,
delete_config=delete_config,
delete_data=delete_data,
)
await self.sync_skills_after_plugin_change()
logger.info(f"卸载失败插件 {dir_name} 成功")
return None, "卸载成功"
async def update_plugin(self, data: object) -> tuple[None, str]:
self._ensure_not_demo()
payload = data if isinstance(data, dict) else {}
plugin_name = payload["name"]
proxy: str | None = payload.get("proxy", None)
download_url = str(payload.get("download_url") or "").strip()
logger.info(f"正在更新插件 {plugin_name}")
await self.plugin_manager.update_plugin(
plugin_name, proxy or "", download_url=download_url
)
await self.plugin_manager.reload(plugin_name)
await self.sync_skills_after_plugin_change()
logger.info(f"更新插件 {plugin_name} 成功。")
return None, "更新成功。"
async def update_all_plugins(self, data: object) -> tuple[dict, str]:
self._ensure_not_demo()
payload = data if isinstance(data, dict) else {}
plugin_names: list[str] = payload.get("names") or []
proxy: str = payload.get("proxy", "")
download_urls: dict[str, str] = payload.get("download_urls") or {}
if not isinstance(plugin_names, list) or not plugin_names:
raise PluginServiceError("插件列表不能为空")
if not isinstance(download_urls, dict):
download_urls = {}
results = []
sem = asyncio.Semaphore(PLUGIN_UPDATE_CONCURRENCY)
async def _update_one(name: str):
async with sem:
try:
logger.info(f"批量更新插件 {name}")
download_url = str(download_urls.get(name) or "").strip()
await self.plugin_manager.update_plugin(
name, proxy, download_url=download_url
)
return {"name": name, "status": "ok", "message": "更新成功"}
except Exception:
logger.error(
f"/api/plugin/update-all: 更新插件 {name} 失败",
exc_info=True,
)
return {
"name": name,
"status": "error",
"message": PLUGIN_UPDATE_FAILED_MESSAGE,
}
raw_results = await asyncio.gather(
*(_update_one(name) for name in plugin_names),
return_exceptions=True,
)
for name, result in zip(plugin_names, raw_results):
if isinstance(result, asyncio.CancelledError):
raise result
if isinstance(result, BaseException):
logger.error(
f"/api/plugin/update-all: 更新插件 {name} 任务失败: {result!r}"
)
results.append(
{
"name": name,
"status": "error",
"message": PLUGIN_UPDATE_FAILED_MESSAGE,
}
)
else:
results.append(result)
failed = [result for result in results if result["status"] == "error"]
if len(failed) < len(results):
await self.sync_skills_after_plugin_change()
message = (
"批量更新完成,全部成功。"
if not failed
else f"批量更新完成,其中 {len(failed)}/{len(results)} 个插件失败。"
)
return {"results": results}, message
async def set_plugin_enabled(
self, data: object, *, enabled: bool
) -> tuple[None, str]:
self._ensure_not_demo()
payload = data if isinstance(data, dict) else {}
plugin_name = payload["name"]
if enabled:
await self.plugin_manager.turn_on_plugin(plugin_name)
message = "启用成功。"
log_action = "启用"
else:
await self.plugin_manager.turn_off_plugin(plugin_name)
message = "停用成功。"
log_action = "停用"
await self.sync_skills_after_plugin_change()
logger.info(f"{log_action}插件 {plugin_name}")
return None, message
def resolve_plugin_dir(self, plugin_name: str) -> Path:
if not plugin_name:
raise PluginServiceError("插件名称不能为空")
plugin_obj = None
for plugin in self.plugin_manager.context.get_all_stars():
if plugin.name == plugin_name:
plugin_obj = plugin
break
if not plugin_obj:
raise PluginServiceError(f"插件 {plugin_name} 不存在")
if not plugin_obj.root_dir_name:
raise PluginServiceError(f"插件 {plugin_name} 目录不存在")
plugin_dir = (
Path(
self.plugin_manager.reserved_plugin_path
if plugin_obj.reserved
else self.plugin_manager.plugin_store_path
)
/ plugin_obj.root_dir_name
)
if not plugin_dir.is_dir():
raise PluginServiceError(f"无法找到插件 {plugin_name} 的目录")
return plugin_dir
def get_plugin_readme(self, plugin_name: str | None) -> tuple[dict, str]:
if not plugin_name:
logger.warning("插件名称为空")
raise PluginServiceError("插件名称不能为空")
plugin_dir = self.resolve_plugin_dir(plugin_name)
readme_path = plugin_dir / "README.md"
if not readme_path.is_file():
logger.warning(f"插件 {plugin_name} 没有README文件")
raise PluginServiceError(f"插件 {plugin_name} 没有README文件")
try:
return {
"content": readme_path.read_text(encoding="utf-8")
}, "成功获取README内容"
except Exception as exc:
logger.warning(f"读取插件 {plugin_name} README 文件失败: {exc}")
raise PluginServiceError(
"读取README文件失败",
public_message="读取README文件失败",
) from exc
def get_plugin_readme_from_dashboard_query(
self,
plugin_name: str | None,
) -> tuple[dict, str]:
return self.get_plugin_readme(plugin_name)
def get_plugin_changelog(self, plugin_name: str | None) -> tuple[dict, str]:
logger.debug(f"正在获取插件 {plugin_name} 的更新日志")
if not plugin_name:
logger.warning("插件名称为空")
raise PluginServiceError("插件名称不能为空")
plugin_dir = self.resolve_plugin_dir(plugin_name)
changelog_names = ["CHANGELOG.md", "changelog.md", "CHANGELOG", "changelog"]
for name in changelog_names:
changelog_path = plugin_dir / name
if not changelog_path.is_file():
continue
try:
return (
{"content": changelog_path.read_text(encoding="utf-8")},
"成功获取更新日志",
)
except Exception as exc:
logger.warning(f"读取插件 {plugin_name} 更新日志失败: {exc}")
raise PluginServiceError(
"读取更新日志失败",
public_message="读取更新日志失败",
) from exc
logger.warning(f"插件 {plugin_name} 没有更新日志文件")
return {"content": None}, "该插件没有更新日志文件"
def get_plugin_changelog_from_dashboard_query(
self,
plugin_name: str | None,
) -> tuple[dict, str]:
return self.get_plugin_changelog(plugin_name)
@staticmethod
async def get_custom_sources() -> list:
return PluginService._normalize_custom_sources(
await sp.global_get("custom_plugin_sources", [])
)
@staticmethod
async def save_custom_sources(data: object) -> tuple[None, str]:
payload = data if isinstance(data, dict) else {}
sources = PluginService._custom_sources_from_payload(payload)
await sp.global_put("custom_plugin_sources", sources)
return None, "保存成功"
@staticmethod
def _normalize_custom_sources(raw_sources: object) -> list:
return raw_sources if isinstance(raw_sources, list) else []
@staticmethod
def _custom_sources_from_payload(payload: dict[str, Any]) -> list:
sources = payload.get("sources", [])
if not isinstance(sources, list):
raise PluginServiceError("sources fields must be a list")
return sources
@staticmethod
async def create_custom_source(data: object) -> list:
source = data if isinstance(data, dict) else {}
sources = await PluginService.get_custom_sources()
sources.append(source)
await sp.global_put("custom_plugin_sources", sources)
return sources
@staticmethod
async def replace_custom_sources(data: object) -> list:
payload = data if isinstance(data, dict) else {}
sources = PluginService._custom_sources_from_payload(payload)
await sp.global_put("custom_plugin_sources", sources)
return sources
@staticmethod
async def delete_custom_source(source_id: str) -> list:
sources = await PluginService.get_custom_sources()
filtered = [
source
for source in sources
if not (
isinstance(source, dict)
and str(source.get("id") or source.get("url") or source.get("name"))
== source_id
)
and not (isinstance(source, str) and source == source_id)
]
await sp.global_put("custom_plugin_sources", filtered)
return filtered
@staticmethod
def _to_bool(value: object, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
__all__ = [
"PLUGIN_UPDATE_CONCURRENCY",
"PLUGIN_OPERATION_FAILED_MESSAGE",
"PLUGIN_UPDATE_FAILED_MESSAGE",
"PluginService",
"PluginServiceError",
"PluginServiceWarning",
"RegistrySource",
]