Skip to content

feat: supports to download plugins via astrbot official plugin storage#7930

Merged
Soulter merged 2 commits intomasterfrom
feat/plugin-download
May 1, 2026
Merged

feat: supports to download plugins via astrbot official plugin storage#7930
Soulter merged 2 commits intomasterfrom
feat/plugin-download

Conversation

@Soulter
Copy link
Copy Markdown
Member

@Soulter Soulter commented May 1, 2026

Modifications / 改动点

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果


Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”

  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Support installing plugins from official plugin storage with explicit download URLs while surfacing compatibility and source details in the UI.

New Features:

  • Allow backend plugin installer to accept an optional plugin archive download URL in addition to the repository URL.
  • Display plugin download source and GitHub security warnings in the plugin installation dialog.
  • Show plugin compatibility status badges and supported platforms for plugins in the market and detail views.

Enhancements:

  • Cache and annotate plugin market entries with AstrBot version compatibility information via a new compatibility-check API.
  • Improve updater and logging messages to be clearer and more user-friendly.
  • Adjust plugin card layout and styling to better present compatibility and platform information.

Tests:

  • Add an async test ensuring the plugin updater prefers the provided download URL over fetching from GitHub when installing.

@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. area:webui The bug / feature is about webui(dashboard) of astrbot. feature:plugin The bug / feature is about AstrBot plugin system. labels May 1, 2026
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • In dashboard/routes/plugin.py, download_url = str(post_data.get("download_url") or "").trim() will fail because Python strings don’t have trim(); this should be changed to .strip().
  • The new exception and log messages in astrbot/core/star/updator.py were switched from Chinese to English; consider aligning language/style with the rest of the project’s logging and error messages for consistency.
  • annotateMarketCompatibility mutates plugin objects by attaching astrbot_compat_* fields; if these are view-only concerns, consider storing this compatibility data separately (e.g., a side map keyed by plugin ID) to avoid mixing view state into the core plugin model.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `dashboard/routes/plugin.py`, `download_url = str(post_data.get("download_url") or "").trim()` will fail because Python strings don’t have `trim()`; this should be changed to `.strip()`.
- The new exception and log messages in `astrbot/core/star/updator.py` were switched from Chinese to English; consider aligning language/style with the rest of the project’s logging and error messages for consistency.
- `annotateMarketCompatibility` mutates plugin objects by attaching `astrbot_compat_*` fields; if these are view-only concerns, consider storing this compatibility data separately (e.g., a side map keyed by plugin ID) to avoid mixing view state into the core plugin model.

## Individual Comments

### Comment 1
<location path="astrbot/dashboard/routes/plugin.py" line_range="507-510" />
<code_context>

         post_data = await request.get_json()
         repo_url = post_data["url"]
+        download_url = str(post_data.get("download_url") or "").strip()
         ignore_version_check = bool(post_data.get("ignore_version_check", False))

</code_context>
<issue_to_address>
**🚨 issue (security):** Validate and restrict `download_url` before passing it to the installer to avoid arbitrary external downloads.

`download_url` is currently taken directly from the request and passed through to `install_plugin` / `_download_file`, which enables arbitrary URL downloads and potential SSRF.

Please validate and normalize `download_url` before use, for example by:
- Requiring HTTPS
- Restricting to an allowlist of trusted domains (and possibly paths)
- Optionally rejecting/stripping query parameters if unnecessary

If validation fails, consider rejecting the request or falling back to the repo-based download path.
</issue_to_address>

### Comment 2
<location path="astrbot/core/star/updator.py" line_range="21-27" />
<code_context>
         return self.plugin_store_path

-    async def install(self, repo_url: str, proxy="") -> str:
+    async def install(self, repo_url: str, proxy="", download_url: str = "") -> str:
         _, repo_name, _ = self.parse_github_url(repo_url)
         repo_name = self.format_name(repo_name)
         plugin_path = os.path.join(self.plugin_store_path, repo_name)
-        await self.download_from_repo_url(plugin_path, repo_url, proxy)
+        if download_url:
+            logger.info(f"Downloading plugin archive for {repo_name}: {download_url}")
+            await self._download_file(download_url, plugin_path + ".zip")
+        else:
+            await self.download_from_repo_url(plugin_path, repo_url, proxy)
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Consider reusing proxy/validation logic for `download_url` or documenting that proxy is intentionally bypassed.

When `download_url` is set, `install` bypasses `download_from_repo_url` and calls `_download_file` directly, so the `proxy` argument is never used. In proxy-dependent environments this may break downloads if `download_url` still points to GitHub (or similar). Either make the proxy bypass explicit and documented (e.g., pass `proxy=None` and restrict `download_url` to safe hosts), or thread the proxy through `_download_file` so behavior matches `download_from_repo_url`.

Suggested implementation:

```python
    async def install(self, repo_url: str, proxy: str = "", download_url: str = "") -> str:
        """
        Install a plugin from a GitHub repository URL.

        If ``download_url`` is provided, it will be used as the direct archive source,
        and the same proxy settings used for ``download_from_repo_url`` will be
        threaded through to the underlying download implementation.
        """
        _, repo_name, _ = self.parse_github_url(repo_url)
        repo_name = self.format_name(repo_name)
        plugin_path = os.path.join(self.plugin_store_path, repo_name)

        if download_url:
            logger.info(f"Downloading plugin archive for {repo_name}: {download_url}")
            # Keep proxy behavior consistent with download_from_repo_url
            await self._download_file(download_url, plugin_path + ".zip", proxy=proxy)
        else:
            await self.download_from_repo_url(plugin_path, repo_url, proxy)

        self.unzip_file(plugin_path + ".zip", plugin_path)

        return plugin_path

```

To fully implement proxy reuse for `download_url`, you will also need to:

1. Update the `_download_file` method signature in this file (or its base class) to accept the `proxy` argument, for example:
   - From: `async def _download_file(self, url: str, destination: str) -> None:`
   - To:   `async def _download_file(self, url: str, destination: str, proxy: str = "") -> None:`
2. Propagate the `proxy` argument inside `_download_file` to the HTTP client you are using (e.g., `aiohttp`, `httpx`, or `requests`), so that downloads respect the proxy configuration.
3. Update all other call sites of `_download_file` (if any) to either:
   - Pass the appropriate `proxy` value, or
   - Explicitly pass `proxy=""` (or rely on the default) where proxying is not desired, documenting that behavior if relevant.
</issue_to_address>

### Comment 3
<location path="dashboard/src/views/extension/useExtensionPage.js" line_range="43" />
<code_context>
   const { tm } = useModuleI18n("features/extension");
   const router = useRouter();
   const route = useRoute();
+  const marketCompatibilityCache = new Map();

   const getSelectedGitHubProxy = () => {
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the new install URL handling and compatibility-checking logic into separate composables/services so `useExtensionPage` only orchestrates them instead of implementing all details inline.

You can keep all new behavior while reducing the complexity you’ve added by extracting the two new “responsibilities” (install URL logic and compatibility logic) into small composables/services and letting `useExtensionPage` orchestrate them.

### 1. Extract compatibility logic + cache into a composable

Right now `marketCompatibilityCache`, `normalizeAstrBotVersionSpec`, `checkAstrBotVersionCompatibility`, and `annotateMarketCompatibility` all live inside `useExtensionPage`. You can move them into a dedicated composable and keep the hook focused on orchestration:

```ts
// usePluginCompatibility.ts
import axios from "axios";

export const usePluginCompatibility = () => {
  const marketCompatibilityCache = new Map<string, {
    checked: boolean;
    compatible: boolean;
    message: string;
  }>();

  const normalizeAstrBotVersionSpec = (value: unknown) =>
    String(value || "").trim();

  const checkAstrBotVersionCompatibility = async (versionSpec: unknown) => {
    const normalizedSpec = normalizeAstrBotVersionSpec(versionSpec);
    if (!normalizedSpec) {
      return { checked: false, compatible: true, message: "" };
    }

    if (marketCompatibilityCache.has(normalizedSpec)) {
      return marketCompatibilityCache.get(normalizedSpec)!;
    }

    try {
      const res = await axios.post("/api/plugin/check-compat", {
        astrbot_version: normalizedSpec,
      });
      const result = {
        checked: res.data.status === "ok",
        compatible:
          res.data.status === "ok" ? !!res.data.data?.compatible : true,
        message: res.data.data?.message || "",
      };
      marketCompatibilityCache.set(normalizedSpec, result);
      return result;
    } catch (err) {
      console.debug("Failed to check plugin compatibility:", err);
      const result = { checked: false, compatible: true, message: "" };
      marketCompatibilityCache.set(normalizedSpec, result);
      return result;
    }
  };

  const annotateMarketCompatibility = async (plugins: any[]) => {
    const specs = [
      ...new Set(
        plugins
          .map((plugin) => normalizeAstrBotVersionSpec(plugin?.astrbot_version))
          .filter(Boolean),
      ),
    ];

    await Promise.all(specs.map((spec) => checkAstrBotVersionCompatibility(spec)));

    plugins.forEach((plugin) => {
      const spec = normalizeAstrBotVersionSpec(plugin?.astrbot_version);
      const result = spec ? marketCompatibilityCache.get(spec) : null;
      plugin.astrbot_compat_checked = !!result?.checked;
      plugin.astrbot_compatible = result ? result.compatible : true;
      plugin.astrbot_compat_message = result?.message || "";
    });
  };

  return {
    annotateMarketCompatibility,
  };
};
```

Then `useExtensionPage` just uses it:

```ts
// inside useExtensionPage
const { annotateMarketCompatibility } = usePluginCompatibility();

// ...
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
await annotateMarketCompatibility(pluginMarketData.value);
checkUpdate();
refreshRandomPlugins();
```

This removes the cache and the compatibility helpers from the hook while preserving all behavior.

### 2. Extract install URL/source helpers into a small helper/composable

The URL-related helpers are also self-contained and can be grouped:

```ts
// useInstallSource.ts
import { computed, Ref } from "vue";

const normalizeInstallUrl = (value: unknown) =>
  String(value || "").trim().replace(/\/+$/, "");

const isGithubRepoUrl = (value: unknown) =>
  /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+(?:\.git)?(?:\/tree\/[^/\s]+)?$/i.test(
    normalizeInstallUrl(value),
  );

export const useInstallSource = (
  selectedInstallPlugin: Ref<any | null>,
  extensionUrl: Ref<string>,
) => {
  const selectedInstallDownloadUrl = computed(() => {
    const plugin = selectedInstallPlugin.value;
    const downloadUrl = String(plugin?.download_url || "").trim();
    if (!downloadUrl) return "";
    if (normalizeInstallUrl(plugin?.repo) !== normalizeInstallUrl(extensionUrl.value)) {
      return "";
    }
    return downloadUrl;
  });

  const selectedInstallSourceUrl = computed(
    () => selectedInstallDownloadUrl.value || String(extensionUrl.value || "").trim(),
  );

  const installUsesGithubSource = computed(
    () => !selectedInstallDownloadUrl.value && isGithubRepoUrl(extensionUrl.value),
  );

  return {
    selectedInstallDownloadUrl,
    selectedInstallSourceUrl,
    installUsesGithubSource,
  };
};
```

Then in `useExtensionPage`:

```ts
// inside useExtensionPage
const {
  selectedInstallDownloadUrl,
  selectedInstallSourceUrl,
  installUsesGithubSource,
} = useInstallSource(selectedInstallPlugin, extension_url);

const installPlugin = () => {
  // ...
  return axios.post("/api/plugin/install", {
    url: extension_url.value,
    download_url: selectedInstallDownloadUrl.value,
    proxy: selectedInstallDownloadUrl.value ? "" : getSelectedGitHubProxy(),
    ignore_version_check: ignoreVersionCheck,
  });
};
```

This keeps the new behavior (`download_url` handling, GitHub source detection) but removes the low-level URL helpers and computed definitions from the already-large hook.

These two extractions should significantly reduce the cognitive load in `useExtensionPage` without changing any functionality.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +507 to 510
download_url = str(post_data.get("download_url") or "").strip()
ignore_version_check = bool(post_data.get("ignore_version_check", False))

proxy: str = post_data.get("proxy", None)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Validate and restrict download_url before passing it to the installer to avoid arbitrary external downloads.

download_url is currently taken directly from the request and passed through to install_plugin / _download_file, which enables arbitrary URL downloads and potential SSRF.

Please validate and normalize download_url before use, for example by:

  • Requiring HTTPS
  • Restricting to an allowlist of trusted domains (and possibly paths)
  • Optionally rejecting/stripping query parameters if unnecessary

If validation fails, consider rejecting the request or falling back to the repo-based download path.

Comment on lines +21 to +27
async def install(self, repo_url: str, proxy="", download_url: str = "") -> str:
_, repo_name, _ = self.parse_github_url(repo_url)
repo_name = self.format_name(repo_name)
plugin_path = os.path.join(self.plugin_store_path, repo_name)
await self.download_from_repo_url(plugin_path, repo_url, proxy)
if download_url:
logger.info(f"Downloading plugin archive for {repo_name}: {download_url}")
await self._download_file(download_url, plugin_path + ".zip")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Consider reusing proxy/validation logic for download_url or documenting that proxy is intentionally bypassed.

When download_url is set, install bypasses download_from_repo_url and calls _download_file directly, so the proxy argument is never used. In proxy-dependent environments this may break downloads if download_url still points to GitHub (or similar). Either make the proxy bypass explicit and documented (e.g., pass proxy=None and restrict download_url to safe hosts), or thread the proxy through _download_file so behavior matches download_from_repo_url.

Suggested implementation:

    async def install(self, repo_url: str, proxy: str = "", download_url: str = "") -> str:
        """
        Install a plugin from a GitHub repository URL.

        If ``download_url`` is provided, it will be used as the direct archive source,
        and the same proxy settings used for ``download_from_repo_url`` will be
        threaded through to the underlying download implementation.
        """
        _, repo_name, _ = self.parse_github_url(repo_url)
        repo_name = self.format_name(repo_name)
        plugin_path = os.path.join(self.plugin_store_path, repo_name)

        if download_url:
            logger.info(f"Downloading plugin archive for {repo_name}: {download_url}")
            # Keep proxy behavior consistent with download_from_repo_url
            await self._download_file(download_url, plugin_path + ".zip", proxy=proxy)
        else:
            await self.download_from_repo_url(plugin_path, repo_url, proxy)

        self.unzip_file(plugin_path + ".zip", plugin_path)

        return plugin_path

To fully implement proxy reuse for download_url, you will also need to:

  1. Update the _download_file method signature in this file (or its base class) to accept the proxy argument, for example:
    • From: async def _download_file(self, url: str, destination: str) -> None:
    • To: async def _download_file(self, url: str, destination: str, proxy: str = "") -> None:
  2. Propagate the proxy argument inside _download_file to the HTTP client you are using (e.g., aiohttp, httpx, or requests), so that downloads respect the proxy configuration.
  3. Update all other call sites of _download_file (if any) to either:
    • Pass the appropriate proxy value, or
    • Explicitly pass proxy="" (or rely on the default) where proxying is not desired, documenting that behavior if relevant.

const { tm } = useModuleI18n("features/extension");
const router = useRouter();
const route = useRoute();
const marketCompatibilityCache = new Map();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the new install URL handling and compatibility-checking logic into separate composables/services so useExtensionPage only orchestrates them instead of implementing all details inline.

You can keep all new behavior while reducing the complexity you’ve added by extracting the two new “responsibilities” (install URL logic and compatibility logic) into small composables/services and letting useExtensionPage orchestrate them.

1. Extract compatibility logic + cache into a composable

Right now marketCompatibilityCache, normalizeAstrBotVersionSpec, checkAstrBotVersionCompatibility, and annotateMarketCompatibility all live inside useExtensionPage. You can move them into a dedicated composable and keep the hook focused on orchestration:

// usePluginCompatibility.ts
import axios from "axios";

export const usePluginCompatibility = () => {
  const marketCompatibilityCache = new Map<string, {
    checked: boolean;
    compatible: boolean;
    message: string;
  }>();

  const normalizeAstrBotVersionSpec = (value: unknown) =>
    String(value || "").trim();

  const checkAstrBotVersionCompatibility = async (versionSpec: unknown) => {
    const normalizedSpec = normalizeAstrBotVersionSpec(versionSpec);
    if (!normalizedSpec) {
      return { checked: false, compatible: true, message: "" };
    }

    if (marketCompatibilityCache.has(normalizedSpec)) {
      return marketCompatibilityCache.get(normalizedSpec)!;
    }

    try {
      const res = await axios.post("/api/plugin/check-compat", {
        astrbot_version: normalizedSpec,
      });
      const result = {
        checked: res.data.status === "ok",
        compatible:
          res.data.status === "ok" ? !!res.data.data?.compatible : true,
        message: res.data.data?.message || "",
      };
      marketCompatibilityCache.set(normalizedSpec, result);
      return result;
    } catch (err) {
      console.debug("Failed to check plugin compatibility:", err);
      const result = { checked: false, compatible: true, message: "" };
      marketCompatibilityCache.set(normalizedSpec, result);
      return result;
    }
  };

  const annotateMarketCompatibility = async (plugins: any[]) => {
    const specs = [
      ...new Set(
        plugins
          .map((plugin) => normalizeAstrBotVersionSpec(plugin?.astrbot_version))
          .filter(Boolean),
      ),
    ];

    await Promise.all(specs.map((spec) => checkAstrBotVersionCompatibility(spec)));

    plugins.forEach((plugin) => {
      const spec = normalizeAstrBotVersionSpec(plugin?.astrbot_version);
      const result = spec ? marketCompatibilityCache.get(spec) : null;
      plugin.astrbot_compat_checked = !!result?.checked;
      plugin.astrbot_compatible = result ? result.compatible : true;
      plugin.astrbot_compat_message = result?.message || "";
    });
  };

  return {
    annotateMarketCompatibility,
  };
};

Then useExtensionPage just uses it:

// inside useExtensionPage
const { annotateMarketCompatibility } = usePluginCompatibility();

// ...
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
await annotateMarketCompatibility(pluginMarketData.value);
checkUpdate();
refreshRandomPlugins();

This removes the cache and the compatibility helpers from the hook while preserving all behavior.

2. Extract install URL/source helpers into a small helper/composable

The URL-related helpers are also self-contained and can be grouped:

// useInstallSource.ts
import { computed, Ref } from "vue";

const normalizeInstallUrl = (value: unknown) =>
  String(value || "").trim().replace(/\/+$/, "");

const isGithubRepoUrl = (value: unknown) =>
  /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+(?:\.git)?(?:\/tree\/[^/\s]+)?$/i.test(
    normalizeInstallUrl(value),
  );

export const useInstallSource = (
  selectedInstallPlugin: Ref<any | null>,
  extensionUrl: Ref<string>,
) => {
  const selectedInstallDownloadUrl = computed(() => {
    const plugin = selectedInstallPlugin.value;
    const downloadUrl = String(plugin?.download_url || "").trim();
    if (!downloadUrl) return "";
    if (normalizeInstallUrl(plugin?.repo) !== normalizeInstallUrl(extensionUrl.value)) {
      return "";
    }
    return downloadUrl;
  });

  const selectedInstallSourceUrl = computed(
    () => selectedInstallDownloadUrl.value || String(extensionUrl.value || "").trim(),
  );

  const installUsesGithubSource = computed(
    () => !selectedInstallDownloadUrl.value && isGithubRepoUrl(extensionUrl.value),
  );

  return {
    selectedInstallDownloadUrl,
    selectedInstallSourceUrl,
    installUsesGithubSource,
  };
};

Then in useExtensionPage:

// inside useExtensionPage
const {
  selectedInstallDownloadUrl,
  selectedInstallSourceUrl,
  installUsesGithubSource,
} = useInstallSource(selectedInstallPlugin, extension_url);

const installPlugin = () => {
  // ...
  return axios.post("/api/plugin/install", {
    url: extension_url.value,
    download_url: selectedInstallDownloadUrl.value,
    proxy: selectedInstallDownloadUrl.value ? "" : getSelectedGitHubProxy(),
    ignore_version_check: ignoreVersionCheck,
  });
};

This keeps the new behavior (download_url handling, GitHub source detection) but removes the low-level URL helpers and computed definitions from the already-large hook.

These two extractions should significantly reduce the cognitive load in useExtensionPage without changing any functionality.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the ability to install plugins via a direct download URL, bypassing standard repository downloads when provided. It also implements a compatibility check for plugins against the current AstrBot version, with corresponding UI updates to display compatibility status, supported platforms, and installation source warnings. Backend components, including the plugin manager and updator, were modified to support these features, and several log messages were translated to English. Feedback suggests simplifying the logic in star_manager.py by removing a redundant conditional check when calling the installation method.

Comment thread astrbot/core/star/star_manager.py
@Soulter Soulter merged commit ac5cb9b into master May 1, 2026
21 checks passed
@Soulter Soulter deleted the feat/plugin-download branch May 1, 2026 05:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:webui The bug / feature is about webui(dashboard) of astrbot. feature:plugin The bug / feature is about AstrBot plugin system. size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant