Skip to content
Closed
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
6 changes: 6 additions & 0 deletions astrbot/core/star/star.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ class StarMetadata:
views: list[dict] = field(default_factory=list)
"""插件注册的 Views 元数据。"""

readmes: dict[str, str] = field(default_factory=dict)
"""插件声明的多语言 README:locale -> 相对于插件根目录的 Markdown 路径。

"default" 键表示默认版本,其余键为 locale(如 "zh-CN")。
"""

@property
def plugin_id(self) -> str:
p_name = (self.name or "unknown").lower().replace("/", "_")
Expand Down
25 changes: 25 additions & 0 deletions astrbot/core/star/star_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | N
if isinstance(metadata.get("pages"), list)
else [],
i18n=PluginManager._load_plugin_i18n(plugin_path),
readmes=PluginManager._load_plugin_readmes(metadata.get("readmes")),
)

return metadata
Expand Down Expand Up @@ -670,6 +671,30 @@ def _get_plugin_dir_name_from_metadata(plugin_path: str) -> str:
PluginManager._validate_importable_name(plugin_dir_name)
return plugin_dir_name

@staticmethod
def _load_plugin_readmes(raw_readmes: object) -> dict[str, str]:
"""Normalize the optional metadata.yaml "readmes" mapping.

Args:
raw_readmes: Parsed "readmes" value from metadata.yaml.

Returns:
Mapping of locale (or "default") to a relative Markdown path.
Invalid entries are dropped so a typo cannot break plugin loading.
"""
if not isinstance(raw_readmes, dict):
return {}
readmes: dict[str, str] = {}
for locale, path in raw_readmes.items():
if not isinstance(locale, str) or not isinstance(path, str):
continue
locale = locale.strip()
path = path.strip()
if not locale or not path:
continue
readmes[locale] = path
return readmes

@staticmethod
def _validate_astrbot_version_specifier(
version_spec: str | None,
Expand Down
11 changes: 8 additions & 3 deletions astrbot/dashboard/api/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -832,11 +832,12 @@ async def delete_plugin_config_file_by_id(
@router.get("/plugins/readme")
async def get_plugin_readme_by_id(
plugin_id: str = Query(...),
locale: str | None = Query(None),
_auth: AuthContext = Depends(require_plugin_scope),
service: PluginService = Depends(get_service),
):
return await _run_service(
lambda: service.get_plugin_readme(plugin_id),
lambda: service.get_plugin_readme(plugin_id, locale),
log_label="/api/plugin/readme",
)

Expand Down Expand Up @@ -1082,11 +1083,12 @@ async def delete_plugin_config_file(
@router.get("/plugins/{plugin_id}/readme")
async def get_plugin_readme(
plugin_id: str,
locale: str | None = Query(None),
_auth: AuthContext = Depends(require_plugin_scope),
service: PluginService = Depends(get_service),
):
return await _run_service(
lambda: service.get_plugin_readme(plugin_id),
lambda: service.get_plugin_readme(plugin_id, locale),
log_label="/api/plugin/readme",
)

Expand Down Expand Up @@ -1424,7 +1426,10 @@ async def dashboard_get_plugin_readme(
service: PluginService = Depends(get_service),
):
return await _run_service(
lambda: service.get_plugin_readme(request.query_params.get("name")),
lambda: service.get_plugin_readme(
request.query_params.get("name"),
request.query_params.get("locale"),
),
log_label="/api/plugin/readme",
)

Expand Down
124 changes: 119 additions & 5 deletions astrbot/dashboard/services/plugin_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1931,21 +1931,134 @@ def resolve_plugin_dir(self, plugin_name: str) -> Path:
raise PluginServiceError(f"无法找到插件 {plugin_name} 的目录")
return plugin_dir

def get_plugin_readme(self, plugin_name: str | None) -> tuple[dict, str]:
def _get_plugin_readmes(self, plugin_name: str) -> dict[str, str]:
"""Return the locale -> README path mapping declared by one plugin.

Args:
plugin_name: Plugin name (metadata.name).

Returns:
The declared mapping, or an empty dict for plugins that only ship
a single README.md.
"""
for plugin in self.plugin_manager.context.get_all_stars():
if plugin.name == plugin_name:
return dict(getattr(plugin, "readmes", None) or {})
return {}

@staticmethod
def _normalize_locale(locale: str | None) -> str:
"""Normalize a locale tag to lowercase with a hyphen separator.

Args:
locale: Raw locale such as "zh_CN" or "zh-CN".

Returns:
The normalized tag, or an empty string when no locale is given.
"""
return (locale or "").strip().replace("_", "-").lower()

@staticmethod
def _resolve_declared_readme(plugin_dir: Path, filename: str) -> Path | None:
"""Resolve a README path declared in metadata.yaml inside the plugin directory.

Args:
plugin_dir: Root directory of the plugin.
filename: Declared relative Markdown path.

Returns:
The resolved file path, or None when the entry escapes the plugin
directory, is not a file, or is not Markdown.
"""
relative = Path(filename.strip())
if not relative.name or relative.is_absolute() or ".." in relative.parts:
return None
if relative.suffix.lower() not in {".md", ".markdown"}:
return None
try:
resolved = (plugin_dir / relative).resolve(strict=True)
root = plugin_dir.resolve(strict=True)
except (OSError, RuntimeError, ValueError):
# ValueError covers paths with embedded NUL bytes.
return None
if not resolved.is_file():
return None
try:
resolved.relative_to(root)
except ValueError:
return None
return resolved

def get_plugin_readme(
self,
plugin_name: str | None,
locale: str | None = None,
) -> tuple[dict, str]:
"""Read the plugin README that best matches the requested locale.

Resolution order is exact locale, base language (zh-TW -> zh), then the
declared default entry. Plugins that do not declare "readmes" in
metadata.yaml keep the legacy README.md behavior.

Args:
plugin_name: Plugin name (metadata.name).
locale: WebUI locale used to pick a language variant.

Returns:
A tuple of the response payload (content, resolved_locale,
available_locales) and a human readable message.

Raises:
PluginServiceError: If the plugin is unknown or no README exists.
"""
if not plugin_name:
logger.warning("The plugin name is empty.")
raise PluginServiceError("插件名称不能为空")

plugin_dir = self.resolve_plugin_dir(plugin_name)
readme_path = plugin_dir / "README.md"
declared = self._get_plugin_readmes(plugin_name)
available_locales = [key for key in declared if key != "default"]
requested = self._normalize_locale(locale)
# Map the normalized tag to the originally declared locale key so the
# resolved locale matches the spelling used in available_locales.
locale_paths = {
self._normalize_locale(key): (key, value)
for key, value in declared.items()
if key != "default"
}

candidates: list[tuple[str | None, str]] = []
if requested:
matched_locale = None
if requested in locale_paths:
matched_locale = requested
elif "-" in requested:
base_language = requested.split("-", 1)[0]
if base_language in locale_paths:
matched_locale = base_language
if matched_locale:
declared_locale, path = locale_paths[matched_locale]
candidates.append((declared_locale, path))
candidates.append((None, declared.get("default") or "README.md"))

readme_path = None
resolved_locale = None
for candidate_locale, filename in candidates:
candidate_path = self._resolve_declared_readme(plugin_dir, filename)
if candidate_path:
readme_path = candidate_path
resolved_locale = candidate_locale
break

if not readme_path.is_file():
if readme_path is None:
logger.warning(f"Plugin {plugin_name} has no README file.")
raise PluginServiceError(f"插件 {plugin_name} 没有README文件")

try:
return {
"content": readme_path.read_text(encoding="utf-8")
"content": readme_path.read_text(encoding="utf-8"),
"resolved_locale": resolved_locale,
"available_locales": available_locales,
}, "成功获取README内容"
except Exception as exc:
logger.warning(f"Failed to read README for plugin {plugin_name}: {exc}")
Expand All @@ -1957,8 +2070,9 @@ def get_plugin_readme(self, plugin_name: str | None) -> tuple[dict, str]:
def get_plugin_readme_from_dashboard_query(
self,
plugin_name: str | None,
locale: str | None = None,
) -> tuple[dict, str]:
return self.get_plugin_readme(plugin_name)
return self.get_plugin_readme(plugin_name, locale)

def get_plugin_changelog(self, plugin_name: str | None) -> tuple[dict, str]:
logger.debug(f"Getting the changelog for plugin {plugin_name}")
Expand Down
10 changes: 10 additions & 0 deletions dashboard/src/api/generated/openapi-v1/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1907,6 +1907,10 @@ export type DeletePluginConfigFileByIdError = unknown;

export type GetPluginReadmeByIdData = {
query: {
/**
* Preferred README locale such as zh-CN or en-US
*/
locale?: string;
plugin_id: string;
};
};
Expand Down Expand Up @@ -2070,6 +2074,12 @@ export type GetPluginReadmeData = {
path: {
plugin_id: string;
};
query?: {
/**
* Preferred README locale such as zh-CN or en-US
*/
locale?: string;
};
};

export type GetPluginReadmeResponse = (string);
Expand Down
6 changes: 4 additions & 2 deletions dashboard/src/api/v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1353,9 +1353,11 @@ export const pluginApi = {
}),
);
},
readme(pluginId: string) {
readme(pluginId: string, locale?: string) {
return typed<OpenConfig>(
openApiV1.getPluginReadmeById({ query: { plugin_id: pluginId } }),
openApiV1.getPluginReadmeById({
query: { plugin_id: pluginId, locale },
}),
);
},
changelog(pluginId: string) {
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/i18n/locales/en-US/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@
"notFound": "Plugin not found",
"docsTitle": "Documentation",
"docsEmpty": "No documentation",
"readmeLanguage": "Documentation language",
"readmeLanguageAuto": "Follow interface language",
"changelogTitle": "Changelog",
"changelogEmpty": "No changelog",
"subCommandsCount": "{count} sub-commands",
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/i18n/locales/ja-JP/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@
"notFound": "プラグインが見つかりません",
"docsTitle": "ドキュメント",
"docsEmpty": "ドキュメントはありません",
"readmeLanguage": "ドキュメントの言語",
"readmeLanguageAuto": "UI 言語に合わせる",
"changelogTitle": "変更履歴",
"changelogEmpty": "変更履歴はありません",
"subCommandsCount": "{count} 件のサブコマンド",
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/i18n/locales/ru-RU/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@
"notFound": "Плагин не найден",
"docsTitle": "Документация",
"docsEmpty": "Документация отсутствует",
"readmeLanguage": "Язык документации",
"readmeLanguageAuto": "Как в интерфейсе",
"changelogTitle": "Журнал изменений",
"changelogEmpty": "Журнал изменений отсутствует",
"subCommandsCount": "{count} подкоманд",
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/i18n/locales/zh-CN/features/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@
"notFound": "未找到该插件",
"docsTitle": "文档",
"docsEmpty": "暂无文档",
"readmeLanguage": "文档语言",
"readmeLanguageAuto": "跟随界面语言",
"changelogTitle": "更新日志",
"changelogEmpty": "暂无更新日志",
"subCommandsCount": "{count} 个子指令",
Expand Down
Loading