Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions astrbot/dashboard/api/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,24 @@ async def get_plugin_readme_by_id(
)


@router.get("/plugins/market/readme")
async def get_plugin_market_readme(
repo: str = Query(..., description="Full GitHub repo URL"),
ref: str | None = Query(None, description="Git ref"),
proxy: str | None = Query(None, description="Optional GitHub proxy URL prefix"),
_auth: AuthContext = Depends(require_plugin_scope),
service: PluginService = Depends(get_service),
):
return await _run_service(
service.get_market_plugin_readme(
repo=repo,
ref=ref,
proxy=proxy,
),
log_label="/api/plugin/market_readme",
)
Comment thread
qa296 marked this conversation as resolved.


@router.get("/plugins/changelog")
async def get_plugin_changelog_by_id(
plugin_id: str = Query(...),
Expand Down
69 changes: 69 additions & 0 deletions astrbot/dashboard/services/plugin_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
import os
import ssl
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import datetime, timezone
Expand All @@ -29,6 +30,7 @@
PluginVersionUnsupportedError,
)
from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path
from astrbot.core.zip_updator import RepoZipUpdator

PLUGIN_UPDATE_CONCURRENCY = 3
PLUGIN_OPERATION_FAILED_MESSAGE = "插件操作失败,请查看服务端日志。"
Expand All @@ -55,6 +57,11 @@ class RegistrySource:
md5_url: str | None


# 市场插件 README 的内存缓存:(repo, ref) -> (timestamp, content)
_MARKET_README_TTL = 600
_market_readme_cache: dict[tuple[str, str], tuple[float, str]] = {}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.


class PluginServiceError(Exception):
def __init__(
self,
Expand Down Expand Up @@ -1097,6 +1104,68 @@ def get_plugin_readme_from_dashboard_query(
) -> tuple[dict, str]:
return self.get_plugin_readme(plugin_name)

async def get_market_plugin_readme(
self,
*,
repo: str | None,
ref: str | None,
proxy: str | None,
) -> tuple[dict, str]:
"""从 GitHub 仓库获取市场插件的 README。

使用 ``commit_sha``(或分支)锁定到用户浏览的版本,避免读取到与市场
缓存不一致的文档。raw URL 采用 ``github.com/{a}/{r}/raw/{ref}/{file}``
形式,与 gh-proxy 的 ``{proxy}/{url}`` 前缀拼接方式兼容。
"""
if not repo:
raise PluginServiceError("repo 参数不能为空")
try:
author, repo_name, branch = RepoZipUpdator().parse_github_url(repo)
except ValueError as exc:
raise PluginServiceError(
f"无效的 GitHub 仓库地址: {exc}",
public_message="无效的 GitHub 仓库地址",
) from exc

resolved_ref = (ref or branch or "master").strip()

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: Defaulting to "master" may fail for repos that only use "main" when no branch is detected.

If parse_github_url can return a non-default branch or None, always falling back to "master" risks missing READMEs on repos that only use main. Consider either preferring "main" as the fallback, or trying both master and main (or resolving the default branch via the GitHub API) to better support modern repositories.

Suggested implementation:

        resolved_ref = (ref or branch or "main").strip()

If you want to implement the more robust strategy of “trying both master and main” instead of just changing the default, you will need to:

  1. Locate the code below this snippet where resolved_ref is used to build the README URL or Git fetch request.
  2. Replace that single attempt with a loop over candidate refs, e.g.:
    • Start with [resolved_ref] if it was explicitly provided or parsed.
    • If neither ref nor branch was provided, use ["main", "master"] as candidates.
  3. Try fetching the README for each candidate ref in order, returning on the first success and proceeding to the next on 404/Not Found or similar “ref not found” errors.
  4. Adjust the cache key if you implement multi-ref attempts, e.g. cache under the actually-resolved ref rather than the initial candidate, to avoid confusing cache hits.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

registry的commit_sha覆盖率100%

cache_key = (f"{author}/{repo_name}", resolved_ref)
now = time.time()
cached = _market_readme_cache.get(cache_key)
if cached and now - cached[0] < _MARKET_README_TTL:
return {"content": cached[1]}, "成功获取README内容(缓存)"

proxy_prefix = (proxy or "").rstrip("/")
ssl_context = ssl.create_default_context(cafile=certifi.where())
last_status = 0
async with aiohttp.ClientSession(
trust_env=True,
connector=aiohttp.TCPConnector(ssl=ssl_context),
timeout=aiohttp.ClientTimeout(total=15),
) as session:
for fname in ("README.md", "readme.md", "Readme.md", "README.MD"):
raw_url = (
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
f"https://github.com/{author}/{repo_name}"
f"/raw/{resolved_ref}/{fname}"
)
fetch_url = f"{proxy_prefix}/{raw_url}" if proxy_prefix else raw_url
try:
async with session.get(fetch_url) as resp:
last_status = resp.status
if resp.status != 200:
continue
text = await resp.text()
if text and text.strip():
_market_readme_cache[cache_key] = (now, text)
return {"content": text}, "成功获取README内容"
except Exception as exc:
logger.warning(f"获取 {fetch_url} 失败: {exc}")
continue
Comment on lines +1145 to +1162

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.

medium

当前代码通过循环依次(串行)尝试 4 种不同大小写组合的 README 文件名。如果文件不存在或排在较后(如 README.MD),则需要等待多次串行 HTTP 请求,这会带来明显的延迟。建议使用 asyncio.gather 并发请求这 4 个候选地址,以显著降低接口响应时间。

            async def fetch_one(fname: str) -> tuple[int, str | None]:
                raw_url = f"https://github.com/{author}/{repo_name}/raw/{resolved_ref}/{fname}"
                fetch_url = f"{proxy_prefix}/{raw_url}" if proxy_prefix else raw_url
                try:
                    async with session.get(fetch_url) as resp:
                        if resp.status == 200:
                            text = await resp.text()
                            return resp.status, text
                        return resp.status, None
                except Exception as exc:
                    logger.warning(f"获取 {fetch_url} 失败: {exc}")
                    return 0, None

            results = await asyncio.gather(
                *(fetch_one(fname) for fname in ("README.md", "readme.md", "Readme.md", "README.MD"))
            )
            
            last_status = 0
            for status, text in results:
                if status == 200 and text and text.strip():
                    _market_readme_cache[cache_key] = (now, text)
                    return {"content": text}, "成功获取README内容"
                if status != 0:
                    last_status = status

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

绝大多数情况都写作README.md,并发会浪费 4 倍网络开销


raise PluginServiceError(
f"未找到 README 文件(最后状态码: {last_status})",
public_message="未找到该插件的 README 文件",
)

def get_plugin_changelog(self, plugin_name: str | None) -> tuple[dict, str]:
logger.debug(f"正在获取插件 {plugin_name} 的更新日志")
if not plugin_name:
Expand Down
12 changes: 11 additions & 1 deletion dashboard/src/api/generated/openapi-v1/sdk.gen.ts

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions dashboard/src/api/generated/openapi-v1/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ export type ChatProjectRequest = {
};

export type ChatRequest = {
/**
* Caller-declared WebChat sender/session owner. This value is used as the message sender identity and may participate in sender-ID-based command permission checks. Treat chat-scoped API keys as trusted backend credentials and map or validate usernames before accepting end-user input.
*/
username?: string;
session_id?: string;
/**
Expand Down Expand Up @@ -1679,6 +1682,27 @@ export type GetPluginReadmeByIdResponse = (string);

export type GetPluginReadmeByIdError = unknown;

export type GetPluginMarketReadmeData = {
query: {
/**
* Optional GitHub proxy URL prefix (e.g. https://gh-proxy.com).
*/
proxy?: string;
/**
* Git ref (commit_sha preferred; falls back to master).
*/
ref?: string;
/**
* Full GitHub repo URL (https://github.com/{author}/{repo}).
*/
repo: string;
};
};

export type GetPluginMarketReadmeResponse = (string);

export type GetPluginMarketReadmeError = unknown;

export type GetPluginChangelogByIdData = {
query: {
plugin_id: string;
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/api/v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1246,6 +1246,11 @@ export const pluginApi = {
openApiV1.getPluginReadmeById({ query: { plugin_id: pluginId } }),
);
},
marketReadme(params: { repo: string; ref?: string; proxy?: string }) {
return typed<OpenConfig>(
openApiV1.getPluginMarketReadme({ query: params }),
);
},
changelog(pluginId: string) {
return typed<OpenConfig>(
openApiV1.getPluginChangelogById({ query: { plugin_id: pluginId } }),
Expand Down
28 changes: 23 additions & 5 deletions dashboard/src/views/extension/PluginDetailPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -610,15 +610,32 @@ const fetchReadme = async () => {
renderedReadme.value = "";

if (isMarketDetail.value) {
const readmeUrl = getDocumentUrl("readme_url");
if (!readmeUrl) {
const repo = repoUrl.value;
if (!repo) {
readmeEmpty.value = true;
readmeLoading.value = false;
return;
}
const ref =
props.marketPlugin?.commit_sha || props.marketPlugin?.branch || "";
const proxy =
props.state?.getSelectedGitHubProxy?.() || undefined;

try {
const content = await fetchRemoteMarkdown(readmeUrl);
const res = await pluginApi.marketReadme({
repo,
ref: ref || undefined,
proxy: proxy || undefined,
});

if (res.data.status !== "ok") {
// Missing README on the repo is a normal outcome — show the empty
// state rather than surfacing an error alert.
readmeEmpty.value = true;
return;
}

const content = res.data.data?.content || "";
if (!content.trim()) {
readmeEmpty.value = true;
return;
Expand Down Expand Up @@ -726,7 +743,7 @@ const fetchChangelog = async () => {
};

const showDocsSection = computed(
() => !isMarketDetail.value || !!getDocumentUrl("readme_url"),
() => !isMarketDetail.value || !!repoUrl.value,
);

const showChangelogSection = computed(
Expand All @@ -737,7 +754,8 @@ watch(
() => [
props.plugin?.name,
props.sourceTab,
props.marketPlugin?.readme_url,
props.marketPlugin?.repo,
props.marketPlugin?.commit_sha,
props.marketPlugin?.changelog_url,
],
async () => {
Expand Down
29 changes: 29 additions & 0 deletions openspec/openapi-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1862,6 +1862,35 @@ paths:
"200":
$ref: "#/components/responses/Text"

/api/v1/plugins/market/readme:
get:
tags: [Plugins]
summary: Get a marketplace plugin's README from its GitHub repo
operationId: getPluginMarketReadme
x-astrbot-scope: plugin
parameters:
- name: repo
in: query
required: true
schema:
type: string
description: Full GitHub repo URL (https://github.com/{author}/{repo}).
- name: ref
in: query
required: false
schema:
type: string
description: Git ref (commit_sha preferred; falls back to master).
- name: proxy
in: query
required: false
schema:
type: string
description: Optional GitHub proxy URL prefix (e.g. https://gh-proxy.com).
responses:
"200":
$ref: "#/components/responses/Text"

/api/v1/plugins/changelog:
get:
tags: [Plugins]
Expand Down
45 changes: 45 additions & 0 deletions tests/test_fastapi_v1_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -2555,3 +2555,48 @@ async def test_v1_platform_webhook_is_public_route(
"method": "POST",
"payload": {"challenge": "ping"},
}


@pytest.mark.asyncio
async def test_v1_plugin_market_readme_happy_path(
asgi_app: FastAPI,
asgi_client: httpx.AsyncClient,
monkeypatch: pytest.MonkeyPatch,
):
"""市场插件 README 接口正常路径:返回 200 + 内容,缓存命中返回缓存版本。"""
plugin_service = asgi_app.state.services.plugins
call_count = 0

async def fake_get_market_plugin_readme(*, repo, ref, proxy):
nonlocal call_count
call_count += 1
return {"content": f"# README of {repo}"}, "success"

monkeypatch.setattr(
plugin_service, "get_market_plugin_readme", fake_get_market_plugin_readme
)

headers = _jwt_headers()
response = await asgi_client.get(
"/api/v1/plugins/market/readme",
params={"repo": "https://github.com/a/b", "ref": "main"},
headers=headers,
)

assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["data"]["content"] == "# README of https://github.com/a/b"
assert call_count == 1


@pytest.mark.asyncio
async def test_v1_plugin_market_readme_requires_auth(
asgi_client: httpx.AsyncClient,
):
"""未经认证的请求应被拒绝(401)。"""
response = await asgi_client.get(
"/api/v1/plugins/market/readme",
params={"repo": "https://github.com/a/b"},
)
assert response.status_code == 401