Compare commits

...

2 Commits

Author SHA1 Message Date
Soulter
22bf09e375 fix test 2026-05-04 20:12:58 +08:00
Soulter
faf8efa01f feat: enhance plugin page internationalization
- Updated PluginRoute to read initial context from JWT and set it in the bridge SDK.
- Added methods to retrieve locale and plugin metadata for better i18n support.
- Enhanced pluginI18n utility to resolve page-specific translations and added new functions for page titles and descriptions.
- Modified PluginPagePage and PluginDetailPage to utilize new i18n features for dynamic content rendering.
- Improved documentation for plugin page i18n structure and usage.
- Added tests to verify the correct integration of i18n in plugin pages and context handling.
2026-05-04 20:05:44 +08:00
12 changed files with 952 additions and 72 deletions

View File

@@ -3,6 +3,7 @@
const SELF_ORIGIN = window.location.origin;
const pendingRequests = new Map();
const sseHandlers = new Map();
const contextHandlers = new Set();
let requestCounter = 0;
let subscriptionCounter = 0;
let context = null;
@@ -13,7 +14,11 @@
});
function getTargetOrigin() {
if (typeof parentOrigin === "string" && parentOrigin && parentOrigin !== "null") {
if (
typeof parentOrigin === "string" &&
parentOrigin &&
parentOrigin !== "null"
) {
return parentOrigin;
}
if (SELF_ORIGIN !== "null") {
@@ -70,6 +75,63 @@
}
}
function getByPath(source, key) {
if (!source || typeof source !== "object" || !key) {
return undefined;
}
return String(key)
.split(".")
.reduce((current, part) => {
if (!current || typeof current !== "object" || !(part in current)) {
return undefined;
}
return current[part];
}, source);
}
function translate(key, fallback) {
const locale = context?.locale;
const messages = context?.i18n;
const locales = [locale, "zh-CN", "en-US"].filter(Boolean);
let value;
for (const candidateLocale of locales) {
value = getByPath(messages?.[candidateLocale], key);
if (value !== undefined && value !== null) {
break;
}
}
if (value === undefined || value === null) {
return fallback || "";
}
return typeof value === "string" ? value : String(value);
}
function notifyContextHandlers() {
contextHandlers.forEach((handler) => {
try {
handler(context);
} catch (error) {
console.error("AstrBotPluginPage context handler failed:", error);
}
});
}
function applyContext(nextContext) {
if (!nextContext || typeof nextContext !== "object") {
return;
}
context = {
...(context || {}),
...nextContext,
};
if (resolveReady) {
resolveReady(context);
resolveReady = null;
}
notifyContextHandlers();
}
window.addEventListener("message", (event) => {
if (event.source !== window.parent) {
return;
@@ -87,11 +149,7 @@
}
if (message.kind === "context") {
context = message.context || null;
if (resolveReady) {
resolveReady(context);
resolveReady = null;
}
applyContext(message.context);
return;
}
@@ -104,7 +162,9 @@
if (message.ok) {
pending.resolve(message.data);
} else {
pending.reject(new Error(message.error || "Plugin bridge request failed."));
pending.reject(
new Error(message.error || "Plugin bridge request failed."),
);
}
return;
}
@@ -139,6 +199,30 @@
getContext() {
return context;
},
getLocale() {
return context?.locale || "zh-CN";
},
getI18n() {
return context?.i18n || {};
},
t(key, fallback) {
return translate(key, fallback);
},
onContext(handler) {
if (typeof handler !== "function") {
return () => {};
}
contextHandlers.add(handler);
if (context) {
handler(context);
}
return () => {
contextHandlers.delete(handler);
};
},
__setInitialContext(nextContext) {
applyContext(nextContext);
},
apiGet(endpoint, params) {
return makeRequest("api:get", { endpoint, params });
},

View File

@@ -189,7 +189,13 @@ class PluginRoute(Route):
return await self._plugin_page_error_response(
404, "Plugin Page bridge SDK not found"
)
bridge_js = await self._read_plugin_page_binary(_PLUGIN_PAGE_BRIDGE_FILE)
bridge_js = await self._read_plugin_page_text(_PLUGIN_PAGE_BRIDGE_FILE)
initial_context = self._get_plugin_page_initial_context()
if initial_context:
context_json = json.dumps(initial_context, ensure_ascii=False)
bridge_js += (
f"\n;window.AstrBotPluginPage?.__setInitialContext({context_json});\n"
)
response = cast(
QuartResponse,
await make_response(
@@ -204,6 +210,82 @@ class PluginRoute(Route):
return plugin
return None
@staticmethod
def _get_by_path(source: dict | None, key: str):
if not isinstance(source, dict) or not key:
return None
current = source
for part in key.split("."):
if not isinstance(current, dict) or part not in current:
return None
current = current[part]
return current
@staticmethod
def _get_request_locale(default: str = "zh-CN") -> str:
raw_locale = request.headers.get("Accept-Language", "").strip()
locale = raw_locale.split(",", 1)[0].split(";", 1)[0].strip()
if not locale or len(locale) > 32:
return default
return locale
def _get_plugin_page_initial_context(self) -> dict | None:
asset_token = request.args.get("asset_token", "").strip()
if not asset_token:
return None
jwt_secret = self.config.get("dashboard", {}).get("jwt_secret")
if not isinstance(jwt_secret, str) or not jwt_secret.strip():
return None
try:
payload = jwt.decode(asset_token, jwt_secret, algorithms=["HS256"])
except jwt.InvalidTokenError:
return None
if payload.get("token_type") != _PLUGIN_PAGE_ASSET_TOKEN_TYPE:
return None
plugin_name = payload.get("plugin_name")
page_name = payload.get("page_name")
if not isinstance(plugin_name, str) or not isinstance(page_name, str):
return None
plugin = self._get_plugin_metadata_by_name(plugin_name)
if not plugin:
return None
locale = (
payload.get("locale")
if isinstance(payload.get("locale"), str)
else self._get_request_locale()
)
plugin_i18n = plugin.i18n or {}
try:
plugin_root = self._get_plugin_root_dir(plugin)
fresh_i18n = PluginManager._load_plugin_i18n(str(plugin_root))
if fresh_i18n:
plugin_i18n = fresh_i18n
except (OSError, ValueError):
pass
locale_data = plugin_i18n.get(locale)
display_name = (
self._get_by_path(locale_data, "metadata.display_name")
or plugin.display_name
or plugin.name
)
page_title = (
self._get_by_path(locale_data, f"pages.{page_name}.title") or page_name
)
return {
"pluginName": plugin.name,
"displayName": display_name,
"pageName": page_name,
"pageTitle": page_title,
"locale": locale,
"i18n": plugin_i18n,
}
@staticmethod
def _normalize_plugin_page_path(
raw_path: str,
@@ -634,6 +716,7 @@ class PluginRoute(Route):
page_data = {
"name": page.name,
"title": page.title,
"i18n_key": f"pages.{page.name}",
}
if include_content_path:
asset_token = (
@@ -675,6 +758,7 @@ class PluginRoute(Route):
"token_type": _PLUGIN_PAGE_ASSET_TOKEN_TYPE,
"plugin_name": plugin_name,
"page_name": page_name,
"locale": self._get_request_locale(),
"iat": now,
"exp": now + timedelta(seconds=_PLUGIN_PAGE_ASSET_TOKEN_TTL_SECONDS),
}
@@ -1285,6 +1369,7 @@ class PluginRoute(Route):
"name": page["title"],
"title": page["title"],
"page_name": page["name"],
"i18n_key": page["i18n_key"],
"description": "Plugin Page entry",
"plugin_name": plugin.name,
}

View File

@@ -1,62 +1,106 @@
import { useI18n } from '@/i18n/composables'
import { useI18n } from "@/i18n/composables";
function getLocaleData(i18n, locale) {
if (!i18n || typeof i18n !== 'object' || !locale) return null
return i18n[locale] || null
if (!i18n || typeof i18n !== "object" || !locale) return null;
return i18n[locale] || null;
}
function getByPath(source, key) {
if (!source || typeof source !== 'object' || !key) return undefined
if (!source || typeof source !== "object" || !key) return undefined;
const parts = key.split('.')
let current = source
const parts = key.split(".");
let current = source;
for (const part of parts) {
if (!current || typeof current !== 'object' || !(part in current)) {
return undefined
if (!current || typeof current !== "object" || !(part in current)) {
return undefined;
}
current = current[part]
current = current[part];
}
return current
return current;
}
export function resolvePluginI18n(i18n, locale, key, fallback = '') {
const localeData = getLocaleData(i18n, locale)
const value = getByPath(localeData, key)
return value === undefined || value === null ? fallback : value
export function resolvePluginI18n(i18n, locale, key, fallback = "") {
const localeData = getLocaleData(i18n, locale);
const value = getByPath(localeData, key);
return value === undefined || value === null ? fallback : value;
}
function getPluginPageI18nBase(page) {
if (page && typeof page === "object") {
if (typeof page.i18n_key === "string" && page.i18n_key.trim()) {
return page.i18n_key.trim();
}
const pageName = page.page_name || page.name;
return pageName ? `pages.${pageName}` : "";
}
return page ? `pages.${page}` : "";
}
export function usePluginI18n() {
const { locale } = useI18n()
const { locale } = useI18n();
const resolve = (i18n, key, fallback = '') => {
return resolvePluginI18n(i18n, locale.value, key, fallback)
}
const resolve = (i18n, key, fallback = "") => {
return resolvePluginI18n(i18n, locale.value, key, fallback);
};
const pluginName = (plugin) => {
const fallback = plugin?.display_name?.length ? plugin.display_name : plugin?.name
return resolve(plugin?.i18n, 'metadata.display_name', fallback || '')
}
const fallback = plugin?.display_name?.length
? plugin.display_name
: plugin?.name;
return resolve(plugin?.i18n, "metadata.display_name", fallback || "");
};
const pluginDesc = (plugin, fallback = '') => {
const pluginDesc = (plugin, fallback = "") => {
return resolve(
plugin?.i18n,
'metadata.desc',
fallback || plugin?.desc || plugin?.description || '',
)
}
"metadata.desc",
fallback || plugin?.desc || plugin?.description || "",
);
};
const pluginShortDesc = (plugin, fallback = '') => {
const pluginShortDesc = (plugin, fallback = "") => {
return resolve(
plugin?.i18n,
'metadata.short_desc',
fallback || plugin?.short_desc || plugin?.desc || plugin?.description || '',
)
}
"metadata.short_desc",
fallback ||
plugin?.short_desc ||
plugin?.desc ||
plugin?.description ||
"",
);
};
const configText = (i18n, path, attr, fallback = '') => {
const key = path ? `config.${path}.${attr}` : `config.${attr}`
return resolve(i18n, key, fallback)
}
const pluginPageText = (plugin, page, attr, fallback = "") => {
const base = getPluginPageI18nBase(page);
if (!base || !attr) {
return fallback;
}
return resolve(plugin?.i18n, `${base}.${attr}`, fallback);
};
const pluginPageTitle = (plugin, page, fallback = "") => {
const pageFallback =
fallback ||
(page && typeof page === "object"
? page.title || page.name || page.page_name
: page) ||
"";
return pluginPageText(plugin, page, "title", pageFallback);
};
const pluginPageDescription = (plugin, page, fallback = "") => {
const pageFallback =
fallback ||
(page && typeof page === "object" ? page.description || page.desc : "") ||
"";
return pluginPageText(plugin, page, "description", pageFallback);
};
const configText = (i18n, path, attr, fallback = "") => {
const key = path ? `config.${path}.${attr}` : `config.${attr}`;
return resolve(i18n, key, fallback);
};
return {
locale,
@@ -64,6 +108,9 @@ export function usePluginI18n() {
pluginName,
pluginDesc,
pluginShortDesc,
pluginPageText,
pluginPageTitle,
pluginPageDescription,
configText,
}
};
}

View File

@@ -1,14 +1,20 @@
<script setup>
import axios from "axios";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useModuleI18n } from "@/i18n/composables";
import { usePluginI18n } from "@/utils/pluginI18n";
const BRIDGE_CHANNEL = "astrbot-plugin-page";
const route = useRoute();
const router = useRouter();
const { tm } = useModuleI18n("features/extension");
const {
locale,
pluginName: pluginDisplayName,
pluginPageTitle,
} = usePluginI18n();
const loading = ref(true);
const errorMessage = ref("");
@@ -22,8 +28,23 @@ let iframeMessageOrigin = null;
const pluginName = computed(() => String(route.params.pluginName || ""));
const pageName = computed(() => String(route.params.pageName || ""));
const localizedPageTitle = computed(() =>
pluginPageTitle(
plugin.value,
page.value || pageName.value,
page.value?.title || pageName.value || tm("buttons.openPages"),
),
);
const getIframeWindow = () => iframeRef.value?.contentWindow || null;
const toPostMessageData = (value, fallback = null) => {
try {
return JSON.parse(JSON.stringify(toRaw(value)));
} catch {
return fallback;
}
};
const goBack = () => {
if (window.history.length > 1) {
router.back();
@@ -84,12 +105,19 @@ const normalizePluginEndpoint = (endpoint) => {
if (!trimmed) {
throw new Error("Plugin bridge endpoint cannot be empty.");
}
if (trimmed.includes("\\") || trimmed.includes("://") || trimmed.includes("?") || trimmed.includes("#")) {
if (
trimmed.includes("\\") ||
trimmed.includes("://") ||
trimmed.includes("?") ||
trimmed.includes("#")
) {
throw new Error("Plugin bridge endpoint is invalid.");
}
const segments = trimmed.split("/");
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
if (
segments.some((segment) => !segment || segment === "." || segment === "..")
) {
throw new Error("Plugin bridge endpoint is invalid.");
}
return segments.map((segment) => encodeURIComponent(segment)).join("/");
@@ -114,7 +142,9 @@ const isBridgeUploadFile = (value) => {
if (tag === "[object File]" || tag === "[object Blob]") {
return true;
}
return typeof value.arrayBuffer === "function" && typeof value.size === "number";
return (
typeof value.arrayBuffer === "function" && typeof value.size === "number"
);
};
const coerceBridgeUploadFile = async (value, fileName) => {
@@ -134,7 +164,9 @@ const coerceBridgeUploadFile = async (value, fileName) => {
return new File([buffer], fileName, {
type: fileType,
lastModified:
typeof value.lastModified === "number" ? value.lastModified : Date.now(),
typeof value.lastModified === "number"
? value.lastModified
: Date.now(),
});
}
return new Blob([buffer], { type: fileType });
@@ -165,9 +197,11 @@ const sendIframeContext = () => {
kind: "context",
context: {
pluginName: plugin.value.name,
displayName: plugin.value.display_name || plugin.value.name,
displayName: pluginDisplayName(plugin.value),
pageName: page.value.name,
pageTitle: page.value.title,
pageTitle: localizedPageTitle.value,
locale: locale.value,
i18n: toPostMessageData(plugin.value.i18n, {}),
},
});
};
@@ -221,7 +255,9 @@ const handleBridgeRequest = async (message) => {
},
);
if (response.data?.status === "error") {
throw new Error(response.data.message || "Plugin upload request failed.");
throw new Error(
response.data.message || "Plugin upload request failed.",
);
}
sendBridgeResponse(requestId, true, response.data?.data ?? response.data);
return;
@@ -237,7 +273,9 @@ const handleBridgeRequest = async (message) => {
anchor.href = blobUrl;
anchor.download =
(typeof message.filename === "string" && message.filename) ||
parseContentDispositionFilename(response.headers["content-disposition"]);
parseContentDispositionFilename(
response.headers["content-disposition"],
);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
@@ -254,11 +292,16 @@ const handleBridgeRequest = async (message) => {
throw new Error("Missing SSE subscription id.");
}
closeSSEConnection(subscriptionId);
const url = new URL(buildPluginApiPath(message.endpoint), window.location.origin);
const url = new URL(
buildPluginApiPath(message.endpoint),
window.location.origin,
);
Object.entries(message.params || {}).forEach(([key, value]) => {
url.searchParams.set(key, String(value));
});
const eventSource = new EventSource(url.toString(), { withCredentials: true });
const eventSource = new EventSource(url.toString(), {
withCredentials: true,
});
sseConnections.set(subscriptionId, eventSource);
eventSource.onopen = () => {
postToIframe({ kind: "sse_state", subscriptionId, state: "open" });
@@ -285,13 +328,19 @@ const handleBridgeRequest = async (message) => {
if (action === "sse:unsubscribe") {
closeSSEConnection(String(message.subscriptionId || ""));
sendBridgeResponse(requestId, true, { subscriptionId: message.subscriptionId });
sendBridgeResponse(requestId, true, {
subscriptionId: message.subscriptionId,
});
return;
}
throw new Error(`Unsupported plugin bridge action: ${action}`);
} catch (error) {
sendBridgeResponse(requestId, false, error?.message || "Plugin bridge request failed.");
sendBridgeResponse(
requestId,
false,
error?.message || "Plugin bridge request failed.",
);
}
};
@@ -386,7 +435,9 @@ const loadPluginPage = async () => {
iframeSrc.value = pageEntry.content_path;
} catch (error) {
errorMessage.value =
error?.response?.data?.message || error?.message || tm("messages.pluginPageLoadFailed");
error?.response?.data?.message ||
error?.message ||
tm("messages.pluginPageLoadFailed");
} finally {
loading.value = false;
}
@@ -402,6 +453,9 @@ onBeforeUnmount(() => {
});
watch([pluginName, pageName], loadPluginPage, { immediate: true });
watch(locale, () => {
sendIframeContext();
});
</script>
<template>
@@ -418,7 +472,7 @@ watch([pluginName, pageName], loadPluginPage, { immediate: true });
<div>
<div class="text-h2 mb-1">
{{ page?.title || pageName || tm("buttons.openPages") }}
{{ localizedPageTitle }}
</div>
</div>
</div>

View File

@@ -34,7 +34,12 @@ const props = defineProps({
});
const { tm, router } = props.state;
const { pluginName, pluginDesc: resolvePluginDesc } = usePluginI18n();
const {
pluginName,
pluginDesc: resolvePluginDesc,
pluginPageTitle,
pluginPageDescription,
} = usePluginI18n();
const markdown = new MarkdownIt({
html: true,
@@ -426,6 +431,16 @@ const getHandlerCommand = (handler) =>
).trim();
const getHandlerDisplayName = (handler, groupKey) => {
if (groupKey === "page") {
return pluginPageTitle(
pluginData.value,
handler,
handler?.title ||
handler?.name ||
handler?.page_name ||
tm("status.unknown"),
);
}
if (handler?.name) {
return handler.name;
}
@@ -450,10 +465,16 @@ const toggleCommandGroup = (key) => {
expandedCommandGroups.value = next;
};
const getComponentDescription = (component) =>
String(
component?.description || component?.desc || tm("status.unknown"),
).trim();
const getComponentDescription = (component) => {
const fallback =
component?.description || component?.desc || tm("status.unknown");
if (getComponentGroupKey(component) === "page") {
return String(
pluginPageDescription(pluginData.value, component, fallback),
).trim();
}
return String(fallback).trim();
};
const openComponentPage = (component) => {
const targetPluginName = component?.plugin_name || pluginData.value?.name;
@@ -691,8 +712,7 @@ const fetchChangelog = async () => {
});
if (res.data.status !== "ok") {
changelogError.value =
res.data.message || tm("messages.operationFailed");
changelogError.value = res.data.message || tm("messages.operationFailed");
return;
}

View File

@@ -20,6 +20,7 @@ When the current locale has no translation, a field is missing, or the locale fi
- Plugin names, card short descriptions, and descriptions fall back to `display_name`, `short_desc`, and `desc` in `metadata.yaml`.
- Configuration text falls back to `description`, `hint`, and `labels` in `_conf_schema.json`.
- Page text falls back to the Page directory name, default Page title, or fallback text provided by page code.
## Metadata
@@ -77,6 +78,52 @@ Corresponding `.astrbot-plugin/i18n/zh-CN.json`:
`options` are stored configuration values and should usually not be translated. Use `labels` for select display text.
## Plugin Pages
`pages` overrides plugin Dashboard Page titles, descriptions, and custom text inside plugin pages. The structure is nested by Page directory name.
Example plugin page directory:
```text
pages/
settings/
index.html
```
Corresponding `.astrbot-plugin/i18n/en-US.json`:
```json
{
"pages": {
"settings": {
"title": "Settings",
"description": "Manage advanced settings for this plugin.",
"save": "Save",
"reset": "Reset"
}
}
}
```
`title` is used by the WebUI shell title and the Page component name on the plugin detail page. `description` is used by the Page component description on the plugin detail page. Other fields are read by the page through the bridge:
```js
const bridge = window.AstrBotPluginPage;
function render() {
document.getElementById("save").textContent = bridge.t(
"pages.settings.save",
"Save",
);
}
await bridge.ready();
render();
bridge.onContext(render);
```
Use `onContext()` to react to WebUI language changes; with this listener, the Page usually does not need a refresh.
## Nested Configuration
For `object` items in `_conf_schema.json`, translations use the same nested field structure.

View File

@@ -96,6 +96,10 @@ Inside a plugin Page, use `window.AstrBotPluginPage` directly:
- `ready()`: Wait until the bridge is ready and return the context
- `getContext()`: Read the current context
- `getLocale()`: Read the current WebUI locale
- `getI18n()`: Read the current plugin i18n resources
- `t(key, fallback)`: Read text from plugin i18n resources by key, returning fallback when missing
- `onContext(handler)`: Listen for context changes, such as rerendering after the WebUI locale changes
- `apiGet(endpoint, params)`: Send a GET request
- `apiPost(endpoint, body)`: Send a POST request
- `upload(endpoint, file)`: Upload one file as `multipart/form-data`
@@ -108,12 +112,62 @@ The current `ready()` context looks like this:
```json
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo"
"displayName": "Plugin Page Demo",
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "en-US",
"i18n": {}
}
```
`endpoint` must be a plugin-local path. It must not be empty, contain `\`, contain a URL scheme, contain query strings or fragments, or contain `.` / `..` path segments.
## Page Internationalization
Plugin Pages reuse plugin i18n resource files. Add `pages.<page_name>` to `.astrbot-plugin/i18n/<locale>.json`:
```json
{
"pages": {
"bridge-demo": {
"title": "Bridge Demo",
"description": "Shows how a plugin page reads the WebUI locale and translations.",
"heading": "Plugin Page",
"refresh": "Render again"
}
}
}
```
`title` is used by the WebUI shell title and the Page component name on the plugin detail page. `description` is used by the Page component description on the plugin detail page.
Inside the Page, render text with `t()` and react to language changes with `onContext()`:
```js
const bridge = window.AstrBotPluginPage;
function render() {
document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo");
document.getElementById("heading").textContent = bridge.t(
"pages.bridge-demo.heading",
"Plugin Page",
);
document.getElementById("locale").textContent = bridge.getLocale();
}
await bridge.ready();
render();
bridge.onContext(render);
```
After the WebUI locale changes, the Dashboard sends the new `locale` and plugin i18n resources to the iframe through the bridge. If the Page listens with `onContext()`, it usually does not need a refresh.
If an inline script needs to access `window.AstrBotPluginPage` synchronously, move the code to an external module file or explicitly include the bridge SDK before your script:
```html
<script src="/api/plugin/page/bridge-sdk.js"></script>
```
## Asset Path Rules
AstrBot rewrites relative asset URLs and appends a short-lived `asset_token`. Write normal relative paths and do not hardcode `/api/plugin/page/content/...` yourself.
@@ -151,3 +205,184 @@ AstrBot also adds security headers to asset responses, including:
- Reload the plugin after adding or removing a Page directory
- For most edits under `pages/<page_name>/`, refreshing the Page is enough
- If a Page does not appear, check that `pages/<page_name>/index.html` exists and the plugin is enabled
## Appendix: Bridge API Details
Start page scripts by keeping a bridge reference:
```js
const bridge = window.AstrBotPluginPage;
```
### `ready()`
Waits for the parent page to send the initial context and returns `Promise<context>`. Page initialization should wait for this before reading context-dependent values.
```js
const context = await bridge.ready();
console.log(context.pluginName, context.pageName, context.locale);
```
The context usually contains:
```json
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo",
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "en-US",
"i18n": {}
}
```
### `getContext()`
Synchronously reads the latest context. Use it after `ready()` or inside an `onContext()` callback.
```js
function renderHeader() {
const context = bridge.getContext();
document.getElementById("title").textContent = context.pageTitle;
}
```
### `getLocale()`
Synchronously reads the current WebUI locale. It returns `zh-CN` when no context exists yet.
```js
document.documentElement.lang = bridge.getLocale();
```
### `getI18n()`
Synchronously reads the full plugin i18n resource object. Prefer `t()` for normal rendering; use this for custom traversal or debugging.
```js
console.log(Object.keys(bridge.getI18n()));
```
### `t(key, fallback)`
Reads text from plugin i18n by dot-separated key. If the current locale is missing, the bridge tries fallbacks; if still missing, it returns `fallback`.
```js
saveButton.textContent = bridge.t("pages.settings.save", "Save");
```
### `onContext(handler)`
Listens for context changes and returns an unsubscribe function. The callback runs when the WebUI locale changes, so pages that need live language switching should rerender here.
```js
function render() {
document.title = bridge.t("pages.settings.title", "Settings");
}
await bridge.ready();
render();
const off = bridge.onContext(render);
window.addEventListener("beforeunload", () => {
off();
});
```
### `apiGet(endpoint, params)`
Sends a GET request to the plugin backend and returns `Promise<data>`. `endpoint` is a plugin-local relative path; the Dashboard forwards it to `/api/plug/<plugin_name>/<endpoint>`.
```js
const stats = await bridge.apiGet("stats", { limit: 20 });
```
Backend registration example:
```python
context.register_web_api(
f"/{PLUGIN_NAME}/stats",
self.get_stats,
["GET"],
"Get stats",
)
```
### `apiPost(endpoint, body)`
Sends a POST request to the plugin backend. `body` is sent as JSON and the method returns `Promise<data>`.
```js
await bridge.apiPost("settings/save", {
enabled: true,
threshold: 0.8,
});
```
### `upload(endpoint, file)`
Uploads one file as `multipart/form-data` with the field name `file`, returning `Promise<data>`.
```js
const input = document.querySelector("input[type=file]");
const file = input.files[0];
const result = await bridge.upload("files/import", file);
```
### `download(endpoint, params, filename)`
Requests a plugin backend file endpoint and triggers a browser download. `params` are sent as query parameters. `filename` is optional; when omitted, the bridge tries to use the response filename header.
```js
await bridge.download("files/export", { format: "json" }, "export.json");
```
### `subscribeSSE(endpoint, handlers, params)`
Subscribes to plugin backend SSE and returns `Promise<subscriptionId>`. `handlers` may include `onOpen`, `onMessage`, and `onError`.
```js
const subscriptionId = await bridge.subscribeSSE(
"events",
{
onOpen() {
console.log("SSE opened");
},
onMessage(event) {
console.log(event.raw, event.parsed, event.lastEventId);
},
onError() {
console.warn("SSE error");
},
},
{ topic: "logs" },
);
```
`event.parsed` is automatically parsed when the message is a JSON string; otherwise it equals the raw string.
### `unsubscribeSSE(subscriptionId)`
Cancels an SSE subscription.
```js
await bridge.unsubscribeSSE(subscriptionId);
```
Clean up subscriptions when the page unloads:
```js
window.addEventListener("beforeunload", () => {
bridge.unsubscribeSSE(subscriptionId);
});
```
### endpoint Rules
The `endpoint` used by `apiGet`, `apiPost`, `upload`, `download`, and `subscribeSSE` must be a plugin-local relative path:
- Allowed: `"stats"`, `"settings/save"`, `"files/export"`
- Not allowed: empty string, `"/stats"`, `"../stats"`, `"https://example.com"`, `"stats?x=1"`, `"stats#top"`
Pass query parameters through `params`; do not append them to `endpoint`.

View File

@@ -20,6 +20,7 @@ your_plugin/
- 插件名称、卡片短描述和描述回退到 `metadata.yaml` 中的 `display_name``short_desc``desc`
- 配置项文案回退到 `_conf_schema.json` 中的 `description``hint``labels`
- Page 文案回退到 Page 目录名、Page 默认标题或页面代码中提供的 fallback。
## 元数据
@@ -77,6 +78,52 @@ your_plugin/
`options` 是配置保存值,不建议翻译。下拉框的展示文本请使用 `labels`
## 插件 Pages
`pages` 用于覆盖插件 Dashboard Page 的标题、描述和页面内自定义文案。结构按 Page 目录名嵌套。
例如插件页面目录:
```text
pages/
settings/
index.html
```
对应 `.astrbot-plugin/i18n/zh-CN.json`
```json
{
"pages": {
"settings": {
"title": "设置",
"description": "管理这个插件的高级设置。",
"save": "保存",
"reset": "重置"
}
}
}
```
`title` 会用于 WebUI 外壳标题和插件详情页中的 Page 组件名称,`description` 会用于插件详情页中的 Page 组件描述。其他字段由页面通过 bridge 自行读取:
```js
const bridge = window.AstrBotPluginPage;
function render() {
document.getElementById("save").textContent = bridge.t(
"pages.settings.save",
"Save",
);
}
await bridge.ready();
render();
bridge.onContext(render);
```
`onContext()` 用于响应 WebUI 语言切换;监听后通常不需要刷新 Page。
## 嵌套配置
如果 `_conf_schema.json` 中有 `object` 类型配置,翻译也按同样的字段结构继续嵌套。

View File

@@ -96,6 +96,10 @@ class MyPlugin(Star):
- `ready()`: 等待 bridge 就绪并返回上下文
- `getContext()`: 读取当前上下文
- `getLocale()`: 读取当前 WebUI 语言
- `getI18n()`: 读取当前插件的 i18n 资源
- `t(key, fallback)`: 从插件 i18n 资源中按 key 获取文案,缺失时返回 fallback
- `onContext(handler)`: 监听上下文变化,例如 WebUI 切换语言后重新渲染页面
- `apiGet(endpoint, params)`: 发送 GET 请求
- `apiPost(endpoint, body)`: 发送 POST 请求
- `upload(endpoint, file)`: 以 `multipart/form-data` 上传单个文件
@@ -108,12 +112,62 @@ class MyPlugin(Star):
```json
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo"
"displayName": "Plugin Page Demo",
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "zh-CN",
"i18n": {}
}
```
`endpoint` 必须是插件内相对路径,不能为空,不能包含 `\`、URL scheme、query、hash也不能包含 `.``..` 路径片段。
## Page 国际化
插件 Page 复用插件 i18n 资源文件。给 `.astrbot-plugin/i18n/<locale>.json` 增加 `pages.<page_name>` 即可:
```json
{
"pages": {
"bridge-demo": {
"title": "Bridge 演示页",
"description": "演示插件页面如何读取 WebUI 语言和翻译资源。",
"heading": "插件页面",
"refresh": "重新渲染"
}
}
}
```
`title` 会用于 WebUI 外壳标题和插件详情页的 Page 组件名称,`description` 会用于插件详情页的 Page 组件描述。
在 Page 内部使用 `t()` 渲染文案,并用 `onContext()` 响应语言切换:
```js
const bridge = window.AstrBotPluginPage;
function render() {
document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo");
document.getElementById("heading").textContent = bridge.t(
"pages.bridge-demo.heading",
"Plugin Page",
);
document.getElementById("locale").textContent = bridge.getLocale();
}
await bridge.ready();
render();
bridge.onContext(render);
```
切换 WebUI 语言后Dashboard 会把新的 `locale` 和插件 i18n 资源通过 bridge 发送给 iframe只要 Page 监听了 `onContext()`,通常不需要刷新页面。
如果你的内联脚本需要同步访问 `window.AstrBotPluginPage`,请把脚本放到外部 module 文件中,或在自己的脚本前显式引入:
```html
<script src="/api/plugin/page/bridge-sdk.js"></script>
```
## 静态资源路径规则
AstrBot 会重写相对资源路径,并自动补上短期 `asset_token`。你只需要正常写相对路径,不要自己拼接 `/api/plugin/page/content/...`
@@ -151,3 +205,184 @@ AstrBot 还会给资源响应添加安全头,包括:
- 新增或删除 Page 目录后重载插件
- 修改 `pages/<page_name>/` 下的大多数静态资源后,刷新 Page 即可
- 如果 Page 没出现,检查 `pages/<page_name>/index.html` 是否存在,以及插件是否启用
## 附录Bridge API 详解
建议在页面脚本开始处保存 bridge 引用:
```js
const bridge = window.AstrBotPluginPage;
```
### `ready()`
等待父页面发送初始上下文,返回 `Promise<context>`。页面初始化时应先等待它,避免过早读取空上下文。
```js
const context = await bridge.ready();
console.log(context.pluginName, context.pageName, context.locale);
```
上下文通常包含:
```json
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo",
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "zh-CN",
"i18n": {}
}
```
### `getContext()`
同步读取最近一次上下文。适合在 `ready()` 之后或 `onContext()` 回调中使用。
```js
function renderHeader() {
const context = bridge.getContext();
document.getElementById("title").textContent = context.pageTitle;
}
```
### `getLocale()`
同步读取当前 WebUI 语言。没有上下文时默认返回 `zh-CN`
```js
document.documentElement.lang = bridge.getLocale();
```
### `getI18n()`
同步读取当前插件的完整 i18n 资源对象。一般优先用 `t()`,只有需要自定义遍历或调试时才直接读取。
```js
console.log(Object.keys(bridge.getI18n()));
```
### `t(key, fallback)`
按点分隔 key 从插件 i18n 中取文案。当前语言缺失时会尝试回退,仍缺失则返回 `fallback`
```js
saveButton.textContent = bridge.t("pages.settings.save", "Save");
```
### `onContext(handler)`
监听上下文变化返回取消监听函数。WebUI 切换语言时会触发该回调,所以需要响应语言切换的页面应在这里重新渲染。
```js
function render() {
document.title = bridge.t("pages.settings.title", "Settings");
}
await bridge.ready();
render();
const off = bridge.onContext(render);
window.addEventListener("beforeunload", () => {
off();
});
```
### `apiGet(endpoint, params)`
向插件后端发送 GET 请求,返回 `Promise<data>``endpoint` 是插件内相对路径Dashboard 会转发到 `/api/plug/<plugin_name>/<endpoint>`
```js
const stats = await bridge.apiGet("stats", { limit: 20 });
```
后端注册示例:
```python
context.register_web_api(
f"/{PLUGIN_NAME}/stats",
self.get_stats,
["GET"],
"Get stats",
)
```
### `apiPost(endpoint, body)`
向插件后端发送 POST 请求,`body` 会作为 JSON 请求体发送,返回 `Promise<data>`
```js
await bridge.apiPost("settings/save", {
enabled: true,
threshold: 0.8,
});
```
### `upload(endpoint, file)`
`multipart/form-data` 上传单个文件,字段名为 `file`,返回 `Promise<data>`
```js
const input = document.querySelector("input[type=file]");
const file = input.files[0];
const result = await bridge.upload("files/import", file);
```
### `download(endpoint, params, filename)`
请求插件后端文件接口并触发浏览器下载。`params` 会作为 query string`filename` 可选;不传时会尝试使用响应头里的文件名。
```js
await bridge.download("files/export", { format: "json" }, "export.json");
```
### `subscribeSSE(endpoint, handlers, params)`
订阅插件后端 SSE返回 `Promise<subscriptionId>``handlers` 可包含 `onOpen``onMessage``onError`
```js
const subscriptionId = await bridge.subscribeSSE(
"events",
{
onOpen() {
console.log("SSE opened");
},
onMessage(event) {
console.log(event.raw, event.parsed, event.lastEventId);
},
onError() {
console.warn("SSE error");
},
},
{ topic: "logs" },
);
```
`event.parsed` 会在消息内容是 JSON 字符串时自动解析,否则等于原始字符串。
### `unsubscribeSSE(subscriptionId)`
取消 SSE 订阅。
```js
await bridge.unsubscribeSSE(subscriptionId);
```
页面卸载时建议清理订阅:
```js
window.addEventListener("beforeunload", () => {
bridge.unsubscribeSSE(subscriptionId);
});
```
### endpoint 规则
`apiGet``apiPost``upload``download``subscribeSSE``endpoint` 必须是插件内相对路径:
- 允许:`"stats"``"settings/save"``"files/export"`
- 不允许:空字符串、`"/stats"``"../stats"``"https://example.com"``"stats?x=1"``"stats#top"`
query 参数请通过 `params` 传递,不要拼进 `endpoint`

View File

@@ -4,9 +4,10 @@
"""
import shutil
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from astrbot.core.message.components import BaseMessageComponent
@@ -573,8 +574,9 @@ def create_mock_updater_update(
Callable: 异步函数,可用于 monkeypatch.setattr
"""
async def mock_update(plugin, proxy: str = "") -> None:
async def mock_update(plugin, proxy: str = "", download_url: str = "") -> None:
"""Mock updater.update 方法。"""
del proxy, download_url
plugin_dir = plugin_builder.get_plugin_path(plugin.name)
# 创建更新标记文件

View File

@@ -47,10 +47,12 @@ def registered_plugin_page(core_lifecycle_td: AstrBotCoreLifecycle, monkeypatch)
Path(core_lifecycle_td.plugin_manager.plugin_store_path) / PLUGIN_PAGE_DEMO_NAME
)
page_root = plugin_root / "pages" / PLUGIN_PAGE_DEMO_PAGE_NAME
i18n_root = plugin_root / ".astrbot-plugin" / "i18n"
shared_root = page_root / "shared"
images_root = page_root / "images"
shared_root.mkdir(parents=True, exist_ok=True)
images_root.mkdir(parents=True, exist_ok=True)
i18n_root.mkdir(parents=True, exist_ok=True)
(page_root / "index.html").write_text(
"""
@@ -94,6 +96,21 @@ window.renderTabs = renderTabs;
'<svg xmlns="http://www.w3.org/2000/svg"></svg>\n',
encoding="utf-8",
)
(i18n_root / "zh-CN.json").write_text(
"""
{
"metadata": {
"display_name": "插件页面演示"
},
"pages": {
"bridge-demo": {
"title": "Bridge 演示页"
}
}
}
""".strip(),
encoding="utf-8",
)
plugin = StarMetadata(
name=PLUGIN_PAGE_DEMO_NAME,
@@ -326,6 +343,7 @@ async def test_plugin_detail_includes_scanned_page_component(
"name": PLUGIN_PAGE_DEMO_PAGE_NAME,
"title": PLUGIN_PAGE_DEMO_PAGE_NAME,
"page_name": PLUGIN_PAGE_DEMO_PAGE_NAME,
"i18n_key": f"pages.{PLUGIN_PAGE_DEMO_PAGE_NAME}",
"description": "Plugin Page entry",
"plugin_name": PLUGIN_PAGE_DEMO_NAME,
}
@@ -351,6 +369,7 @@ async def test_plugin_page_entry_returns_signed_content_path(
assert data["status"] == "ok"
assert data["data"]["name"] == PLUGIN_PAGE_DEMO_PAGE_NAME
assert data["data"]["title"] == PLUGIN_PAGE_DEMO_PAGE_NAME
assert data["data"]["i18n_key"] == f"pages.{PLUGIN_PAGE_DEMO_PAGE_NAME}"
assert data["data"]["content_path"].startswith(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
)
@@ -467,6 +486,11 @@ async def test_plugin_page_content_issues_scoped_asset_token(
assert app_js_response.status_code == 200
bridge_response = await anonymous_client.get(bridge_sdk_url.group(1))
assert bridge_response.status_code == 200
bridge_js = (await bridge_response.get_data()).decode("utf-8")
assert "window.AstrBotPluginPage?.__setInitialContext" in bridge_js
assert '"locale": "zh-CN"' in bridge_js
assert '"displayName": "插件页面演示"' in bridge_js
assert '"pageTitle": "Bridge 演示页"' in bridge_js
css_response = await anonymous_client.get(css_url.group(1))
assert css_response.status_code == 200

View File

@@ -958,8 +958,8 @@ async def test_update_plugin_dependency_install_flow(
events = []
_mock_missing_requirements(monkeypatch, {"networkx"})
async def mock_update(plugin, proxy=""):
del proxy
async def mock_update(plugin, proxy="", download_url=""):
del proxy, download_url
events.append(("update", plugin.name))
monkeypatch.setattr(plugin_manager_pm.updator, "update", mock_update)