mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
* feat: support local plugin install * fix: make editable plugin install symlink * fix: harden local plugin install * Update tests/test_cli_plugin.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update astrbot/cli/commands/cmd_plug.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
347 lines
12 KiB
Python
347 lines
12 KiB
Python
import shutil
|
|
import tempfile
|
|
import uuid
|
|
from enum import Enum
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
from zipfile import ZipFile
|
|
|
|
import click
|
|
import httpx
|
|
import yaml
|
|
|
|
from .version_comparator import VersionComparator
|
|
|
|
|
|
class PluginStatus(str, Enum):
|
|
INSTALLED = "installed"
|
|
NEED_UPDATE = "needs-update"
|
|
NOT_INSTALLED = "not-installed"
|
|
NOT_PUBLISHED = "unpublished"
|
|
|
|
|
|
LOCAL_PLUGIN_COPY_IGNORE = shutil.ignore_patterns(
|
|
".git",
|
|
"__pycache__",
|
|
"*.pyc",
|
|
".venv",
|
|
"venv",
|
|
".idea",
|
|
".vscode",
|
|
".zed",
|
|
)
|
|
|
|
|
|
def _validate_plugin_dir_name(plugin_name: str, source_path: Path) -> str:
|
|
plugin_name = plugin_name.strip()
|
|
plugin_path = Path(plugin_name)
|
|
has_separator = "/" in plugin_name or "\\" in plugin_name
|
|
if (
|
|
not plugin_name
|
|
or plugin_name in {".", ".."}
|
|
or plugin_path.is_absolute()
|
|
or has_separator
|
|
or plugin_path.name != plugin_name
|
|
):
|
|
raise click.ClickException(
|
|
f"Local plugin {source_path} metadata.yaml has invalid name: {plugin_name}"
|
|
)
|
|
return plugin_name
|
|
|
|
|
|
def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
|
"""Download code from a Git repository and extract to the specified path"""
|
|
temp_dir = Path(tempfile.mkdtemp())
|
|
try:
|
|
# Parse repository info
|
|
repo_namespace = url.split("/")[-2:]
|
|
author = repo_namespace[0]
|
|
repo = repo_namespace[1]
|
|
|
|
# Try to get the latest release
|
|
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
|
|
try:
|
|
with httpx.Client(
|
|
proxy=proxy if proxy else None,
|
|
follow_redirects=True,
|
|
) as client:
|
|
resp = client.get(release_url)
|
|
resp.raise_for_status()
|
|
releases = resp.json()
|
|
|
|
if releases:
|
|
# Use the latest release
|
|
download_url = releases[0]["zipball_url"]
|
|
else:
|
|
# No release found, use default branch
|
|
click.echo(f"Downloading {author}/{repo} from default branch")
|
|
download_url = f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
|
|
except Exception as e:
|
|
click.echo(f"Failed to get release info: {e}. Using provided URL directly")
|
|
download_url = url
|
|
|
|
# Apply proxy
|
|
if proxy:
|
|
download_url = f"{proxy}/{download_url}"
|
|
|
|
# Download and extract
|
|
with httpx.Client(
|
|
proxy=proxy if proxy else None,
|
|
follow_redirects=True,
|
|
) as client:
|
|
resp = client.get(download_url)
|
|
if (
|
|
resp.status_code == 404
|
|
and "archive/refs/heads/master.zip" in download_url
|
|
):
|
|
alt_url = download_url.replace("master.zip", "main.zip")
|
|
click.echo("Branch 'master' not found, trying 'main' branch")
|
|
resp = client.get(alt_url)
|
|
resp.raise_for_status()
|
|
else:
|
|
resp.raise_for_status()
|
|
zip_content = BytesIO(resp.content)
|
|
with ZipFile(zip_content) as z:
|
|
z.extractall(temp_dir)
|
|
namelist = z.namelist()
|
|
root_dir = Path(namelist[0]).parts[0] if namelist else ""
|
|
if target_path.exists():
|
|
shutil.rmtree(target_path)
|
|
shutil.move(temp_dir / root_dir, target_path)
|
|
finally:
|
|
if temp_dir.exists():
|
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
|
|
|
|
def load_yaml_metadata(plugin_dir: Path) -> dict:
|
|
"""Load plugin metadata from metadata.yaml file
|
|
|
|
Args:
|
|
plugin_dir: Plugin directory path
|
|
|
|
Returns:
|
|
dict: Dictionary containing metadata, or empty dict if loading fails
|
|
|
|
"""
|
|
yaml_path = plugin_dir / "metadata.yaml"
|
|
if yaml_path.exists():
|
|
try:
|
|
return yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
|
|
except Exception as e:
|
|
click.echo(f"Failed to read {yaml_path}: {e}", err=True)
|
|
return {}
|
|
|
|
|
|
def build_plug_list(plugins_dir: Path) -> list:
|
|
"""Build plugin list containing local and online plugin information
|
|
|
|
Args:
|
|
plugins_dir (Path): Plugin directory path
|
|
|
|
Returns:
|
|
list: List of dicts containing plugin information
|
|
|
|
"""
|
|
# Get local plugin info
|
|
result = []
|
|
if plugins_dir.is_dir():
|
|
for plugin_dir in plugins_dir.iterdir():
|
|
if not plugin_dir.is_dir():
|
|
continue
|
|
|
|
# Load metadata from metadata.yaml
|
|
metadata = load_yaml_metadata(plugin_dir)
|
|
|
|
if "desc" not in metadata and "description" in metadata:
|
|
metadata["desc"] = metadata["description"]
|
|
|
|
# If metadata loaded successfully, add to result list
|
|
if metadata and all(
|
|
k in metadata for k in ["name", "desc", "version", "author", "repo"]
|
|
):
|
|
result.append(
|
|
{
|
|
"name": str(metadata.get("name", "")),
|
|
"desc": str(metadata.get("desc", "")),
|
|
"version": str(metadata.get("version", "")),
|
|
"author": str(metadata.get("author", "")),
|
|
"repo": str(metadata.get("repo", "")),
|
|
"status": PluginStatus.INSTALLED,
|
|
"local_path": str(plugin_dir),
|
|
},
|
|
)
|
|
|
|
# Get online plugin list
|
|
online_plugins_dict = {}
|
|
try:
|
|
with httpx.Client() as client:
|
|
resp = client.get("https://api.soulter.top/astrbot/plugins")
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
for plugin_id, plugin_info in data.items():
|
|
online_plugins_dict[str(plugin_id)] = {
|
|
"name": str(plugin_id),
|
|
"desc": str(plugin_info.get("desc", "")),
|
|
"version": str(plugin_info.get("version", "")),
|
|
"author": str(plugin_info.get("author", "")),
|
|
"repo": str(plugin_info.get("repo", "")),
|
|
"status": PluginStatus.NOT_INSTALLED,
|
|
"local_path": None,
|
|
}
|
|
except Exception as e:
|
|
click.echo(f"Failed to get online plugin list: {e}", err=True)
|
|
|
|
# Compare with online plugins and update status
|
|
for local_plugin in result:
|
|
online_plugin = online_plugins_dict.pop(local_plugin["name"], None)
|
|
if online_plugin is None:
|
|
# Local plugin is not published online
|
|
local_plugin["status"] = PluginStatus.NOT_PUBLISHED
|
|
continue
|
|
|
|
if (
|
|
VersionComparator.compare_version(
|
|
local_plugin["version"],
|
|
online_plugin["version"],
|
|
)
|
|
< 0
|
|
):
|
|
local_plugin["status"] = PluginStatus.NEED_UPDATE
|
|
|
|
# Add uninstalled online plugins
|
|
result.extend(online_plugins_dict.values())
|
|
|
|
return result
|
|
|
|
|
|
def _cleanup_local_plugin_target(target_path: Path) -> None:
|
|
if target_path.is_symlink() or target_path.is_file():
|
|
target_path.unlink(missing_ok=True)
|
|
elif target_path.exists():
|
|
shutil.rmtree(target_path, ignore_errors=True)
|
|
|
|
|
|
def _copy_local_plugin(source_path: Path, plugins_dir: Path, target_path: Path) -> None:
|
|
temp_target = plugins_dir / f".{target_path.name}.tmp-{uuid.uuid4().hex}"
|
|
try:
|
|
shutil.copytree(source_path, temp_target, ignore=LOCAL_PLUGIN_COPY_IGNORE)
|
|
temp_target.rename(target_path)
|
|
except FileExistsError:
|
|
raise click.ClickException(
|
|
f"Plugin {target_path.name} already exists"
|
|
) from None
|
|
except Exception:
|
|
raise
|
|
finally:
|
|
if temp_target.exists() or temp_target.is_symlink():
|
|
_cleanup_local_plugin_target(temp_target)
|
|
|
|
|
|
def install_local_plugin(
|
|
source_path: Path,
|
|
plugins_dir: Path,
|
|
editable: bool = False,
|
|
) -> None:
|
|
"""Install a plugin from a local directory."""
|
|
source_path = source_path.expanduser().resolve()
|
|
plugins_dir = plugins_dir.resolve()
|
|
|
|
if not source_path.exists() or not source_path.is_dir():
|
|
raise click.ClickException(f"Local plugin path does not exist: {source_path}")
|
|
|
|
metadata = load_yaml_metadata(source_path)
|
|
plugin_name = metadata.get("name")
|
|
if not isinstance(plugin_name, str) or not plugin_name.strip():
|
|
raise click.ClickException(
|
|
f"Local plugin {source_path} must contain metadata.yaml with a valid name"
|
|
)
|
|
plugin_name = _validate_plugin_dir_name(plugin_name, source_path)
|
|
|
|
target_path = plugins_dir / plugin_name
|
|
if target_path.exists():
|
|
raise click.ClickException(f"Plugin {plugin_name} already exists")
|
|
|
|
try:
|
|
plugins_dir.mkdir(parents=True, exist_ok=True)
|
|
if editable:
|
|
try:
|
|
target_path.symlink_to(source_path, target_is_directory=True)
|
|
except OSError as e:
|
|
raise click.ClickException(
|
|
f"Failed to create symlink for editable install: {e}. "
|
|
"On Windows, you may need to run as Administrator or enable Developer Mode."
|
|
) from e
|
|
else:
|
|
_copy_local_plugin(source_path, plugins_dir, target_path)
|
|
click.echo(f"Plugin {plugin_name} installed successfully from {source_path}")
|
|
except FileExistsError:
|
|
raise click.ClickException(f"Plugin {plugin_name} already exists") from None
|
|
except click.ClickException:
|
|
raise
|
|
except Exception as e:
|
|
if editable and target_path.is_symlink():
|
|
_cleanup_local_plugin_target(target_path)
|
|
raise click.ClickException(
|
|
f"Error installing local plugin {plugin_name}: {e}"
|
|
) from e
|
|
|
|
|
|
def manage_plugin(
|
|
plugin: dict,
|
|
plugins_dir: Path,
|
|
is_update: bool = False,
|
|
proxy: str | None = None,
|
|
) -> None:
|
|
"""Install or update a plugin
|
|
|
|
Args:
|
|
plugin (dict): Plugin info dict
|
|
plugins_dir (Path): Plugins directory
|
|
is_update (bool, optional): Whether this is an update operation. Defaults to False
|
|
proxy (str, optional): Proxy server address
|
|
|
|
"""
|
|
plugin_name = plugin["name"]
|
|
repo_url = plugin["repo"]
|
|
|
|
# If updating and local path exists, use it directly
|
|
if is_update and plugin.get("local_path"):
|
|
target_path = Path(plugin["local_path"])
|
|
else:
|
|
target_path = plugins_dir / plugin_name
|
|
|
|
backup_path = Path(f"{target_path}_backup") if is_update else None
|
|
|
|
# Check if plugin exists
|
|
if is_update and not target_path.exists():
|
|
raise click.ClickException(
|
|
f"Plugin {plugin_name} is not installed and cannot be updated"
|
|
)
|
|
|
|
# Backup existing plugin
|
|
if is_update and backup_path is not None and backup_path.exists():
|
|
shutil.rmtree(backup_path)
|
|
if is_update and backup_path is not None:
|
|
shutil.copytree(target_path, backup_path)
|
|
|
|
try:
|
|
click.echo(
|
|
f"{'Updating' if is_update else 'Downloading'} plugin {plugin_name} from {repo_url}...",
|
|
)
|
|
get_git_repo(repo_url, target_path, proxy)
|
|
|
|
# Update succeeded, delete backup
|
|
if is_update and backup_path is not None and backup_path.exists():
|
|
shutil.rmtree(backup_path)
|
|
click.echo(
|
|
f"Plugin {plugin_name} {'updated' if is_update else 'installed'} successfully"
|
|
)
|
|
except Exception as e:
|
|
if target_path.exists():
|
|
shutil.rmtree(target_path, ignore_errors=True)
|
|
if is_update and backup_path is not None and backup_path.exists():
|
|
shutil.move(backup_path, target_path)
|
|
raise click.ClickException(
|
|
f"Error {'updating' if is_update else 'installing'} plugin {plugin_name}: {e}",
|
|
)
|