refactor(core): migrate backend backbone from Quart to FastAPI and introduce more OpenAPI (#8688)

* refactor: migrate to fastapi

* structure refactor

* fix: pyright fix

* refactor: improve error handling and public messages in plugin services

* feat(api): refactor API client integration and enhance request handling

- Updated API client configuration to use a dedicated HTTP client.
- Introduced utility functions for generating options, queries, and form data for API requests.
- Refactored multiple API methods to utilize the new utility functions for improved consistency and readability.
- Renamed types for clarity and updated import statements accordingly.

feat(docs): add script to update OpenAPI JSON from YAML spec

- Created a Python script to convert OpenAPI YAML specification to JSON format.
- The script supports customizable input and output paths.
- Ensured the script handles directory creation for output paths and validates the YAML structure.

* fix

* feat(auth): implement rate limiting for v1 login endpoint and enhance request handling

* Refactor dashboard API routers to use legacy_router for backward compatibility

- Changed all instances of dashboard_router to legacy_router across multiple API modules including platform, plugins, providers, sessions, skills, stats, subagents, t2i, tools, updates, and asgi_runtime.
- Updated route definitions to ensure existing endpoints remain functional under the new router structure.
- Introduced support for Quart request context in asgi_runtime to enhance compatibility with existing Quart-based plugins.
- Added a test case to validate the functionality of the new Quart request context handling in plugin extensions.

* chore: remove cli test

* fix: update dashboard tests for fastapi migration

* chore: satisfy ruff checks

* fix: update openapi api key scopes

* fix: sync config scope chip selection

* fix: restore quart dependency

* docs: clarify quart plugin api compatibility

* docs: update openapi scope documentation

* fix: use singular skill openapi scope

* fix: hide update service exception details

* fix: address fastapi review comments

* fix: address dashboard review findings

* docs: revert unrelated package deployment changes

* docs: update agent api generation guidance

* feat: add plugin page web api helpers

* docs: add plugin page bridge demo

* fix: type plugin upload files

* fix: stabilize plugin page uploads

* fix: type plugin web request proxy

* docs: remove plugin page docs example

* fix: authenticate plugin page SSE bridge
This commit is contained in:
Weilong Liao
2026-06-14 15:03:26 +08:00
committed by GitHub
parent 2eee833832
commit 0d8e8682db
254 changed files with 52492 additions and 16582 deletions

View File

@@ -26,19 +26,30 @@ X-API-Key: abk_xxx
- `POST /api/v1/chat`: request body must include `username`
- `GET /api/v1/chat/sessions`: query params must include `username`
The local OpenAPI schema is available at `http://localhost:6185/api/v1/openapi.json`, and the interactive docs are available at `http://localhost:6185/api/v1/docs`.
## Scope Permissions
When creating an API Key, you can configure `scopes`. Each scope controls the range of accessible endpoints:
| Scope | Purpose | Accessible Endpoints |
| --- | --- | --- |
| `bot` | Manage bot/platform configurations | `GET /api/v1/bot-types`, `GET/POST /api/v1/bots`, `PATCH /api/v1/bots/enabled` |
| `provider` | Manage model providers and provider sources | `GET/POST /api/v1/providers`, `GET/PUT/DELETE /api/v1/provider-sources/by-id` |
| `persona` | Manage personas and persona folders | `GET/POST /api/v1/personas`, `GET/POST /api/v1/persona-folders` |
| `im` | Send proactive IM messages and query bot/platform list | `POST /api/v1/im/message`, `GET /api/v1/im/bots` |
| `config` | Manage config profiles, system config, and shared configuration. This scope also includes `bot` and `provider` access. | `GET /api/v1/configs`, `GET/PUT /api/v1/system-config`, `GET/POST /api/v1/config-profiles` |
| `chat` | Access chat capabilities and query sessions | `POST /api/v1/chat`, `GET /api/v1/chat/sessions` |
| `config` | Retrieve available config file list | `GET /api/v1/configs` |
| `file` | Upload attachment files and get `attachment_id` | `POST /api/v1/file` |
| `im` | Send proactive IM messages, query bot/platform list | `POST /api/v1/im/message`, `GET /api/v1/im/bots` |
| `plugin` | Manage plugins, plugin config, plugin sources, and marketplace entries | `GET /api/v1/plugins`, `GET/PUT /api/v1/plugins/config`, `POST /api/v1/plugins/install/url` |
| `mcp` | Manage MCP server configurations and provider sync | `GET/POST /api/v1/mcp/servers`, `PATCH /api/v1/mcp/servers/{server_name}/enabled`, `POST /api/v1/mcp/providers/modelscope/sync` |
| `skill` | Manage skills, skill archives, skill files, and Shipyard Neo skill workflows | `GET/POST /api/v1/skills`, `PUT /api/v1/skills/{skill_name}/files/{file_path}`, `POST /api/v1/skills/neo/sync` |
If the API Key does not include the required scope for the target endpoint, the request will return `403 Insufficient API key scope`.
`config` is a broad management scope. When an API key is created with `config`, AstrBot grants the key `config`, `bot`, and `provider` access together. The WebUI mirrors this dependency: selecting `config` selects `bot` and `provider`; deselecting `bot` or `provider` removes `config`.
Developer API keys currently support only the 9 scopes listed above. `file`, `tool`, `skills`, `kb`, `data`, and `system` are not valid developer API key scopes. Use the singular `skill` scope for `/api/v1/skills/*` endpoints. Related endpoints may still appear in the `/api/v1` reference, but they are not available to developer API keys unless their scope is one of the supported scopes above.
## Common Endpoints
**Chat**
@@ -49,9 +60,19 @@ Interact with AstrBot's built-in Agent. Supports plugin calls, tool calls, and o
- `GET /api/v1/chat/sessions`: list sessions for a specific `username` with pagination
- `GET /api/v1/configs`: list available config files
**File Upload**
**Bots and Providers**
- `POST /api/v1/file`: upload attachment
- `GET /api/v1/bots`: list bot/platform configurations
- `POST /api/v1/bots`: create a bot/platform configuration
- `GET /api/v1/providers`: list model provider configurations
- `GET /api/v1/provider-sources`: list provider source configurations
**Personas, Plugins, MCP, and Skills**
- `GET /api/v1/personas`: list personas
- `GET /api/v1/plugins`: list plugins
- `GET /api/v1/mcp/servers`: list MCP servers
- `GET /api/v1/skills`: list skills
**Proactive IM Messages**
@@ -99,7 +120,7 @@ Supported `type` values:
Notes:
- `attachment_id` comes from the upload result of `POST /api/v1/file`.
- `attachment_id` comes from an existing attachment record. Developer API keys cannot currently upload attachments via `POST /api/v1/file`.
- `reply` cannot be the only segment; at least one content segment (e.g. `plain/image/file/...`) is required.
- A request with only `reply` or empty content will return an error.

View File

@@ -1,6 +1,12 @@
# Plugin Pages
AstrBot lets a plugin expose Dashboard pages by placing static assets under `pages/`. Each direct child directory is one Page:
Plugin Pages let a plugin provide its own pages inside the AstrBot WebUI. Page files live under the plugin's `pages/` directory and are loaded by the Dashboard in a restricted iframe. Page scripts communicate with the Dashboard through the `window.AstrBotPluginPage` bridge, and the Dashboard forwards backend calls to Web APIs registered by the plugin.
If you only need a small set of editable settings, prefer [`_conf_schema.json`](./plugin-config.md). Pages are a better fit for complex forms, runtime dashboards, logs, file upload/download, SSE streams, charts, and other custom workflows.
## Directory Layout
Each direct child directory under `pages/` is one Page. AstrBot only discovers `pages/<page_name>/index.html`; directories without `index.html` are ignored.
```text
astrbot_plugin_page_demo/
@@ -16,13 +22,65 @@ astrbot_plugin_page_demo/
└─ index.html
```
AstrBot scans `pages/<page_name>/index.html`; directories without `index.html` are ignored.
Use simple directory names for `page_name`, such as `settings` or `bridge-demo`. Do not use an empty name, `.`, `..`, a name starting with `.`, or a name containing `/` or `\`.
If you only need a few editable settings, prefer [`_conf_schema.json`](./plugin-config.md). Plugin Pages are more suitable for complex forms, dashboards, logs, file transfer, SSE, and custom interaction flows.
Users open Pages from the plugin detail page in the WebUI.
Once Pages are registered, users can open the AstrBot WebUI Plugins page, click the plugin card to enter the plugin detail page, and then view and open the registered Pages from that detail page.
## Development Flow
## Minimal Frontend Example
1. Create `pages/<page_name>/index.html` in the plugin directory.
2. Use the `window.AstrBotPluginPage` bridge from the Page.
3. Register backend APIs with `context.register_web_api()` in `main.py`.
4. Read requests and return responses with `astrbot.api.web`.
5. Reload the plugin after adding or removing Page directories; refreshing the Page is usually enough for static asset edits.
## Minimal Complete Example
### Backend
Plugin backend code should use `astrbot.api.web`. Avoid exposing raw FastAPI, Starlette, or Quart request objects as the public API for your plugin business logic.
```python
from astrbot.api.star import Context, Star
from astrbot.api.web import error_response, json_response, request
PLUGIN_NAME = "astrbot_plugin_page_demo"
class MyPlugin(Star):
def __init__(self, context: Context):
super().__init__(context)
context.register_web_api(
f"/{PLUGIN_NAME}/ping",
self.page_ping,
["GET"],
"Page ping",
)
context.register_web_api(
f"/{PLUGIN_NAME}/settings/save",
self.save_settings,
["POST"],
"Save Page settings",
)
async def page_ping(self):
limit = request.query.get("limit", 20, type=int)
return json_response(
{
"message": "pong",
"limit": limit,
"username": request.username,
}
)
async def save_settings(self):
payload = await request.json(default={})
if not isinstance(payload.get("enabled"), bool):
return error_response("enabled must be a boolean")
return json_response({"saved": True})
```
### Frontend
`pages/bridge-demo/index.html`
@@ -52,228 +110,215 @@ const context = await bridge.ready();
output.textContent = JSON.stringify(context, null, 2);
document.getElementById("ping").addEventListener("click", async () => {
const result = await bridge.apiGet("ping");
const result = await bridge.apiGet("ping", { limit: 20 });
output.textContent = JSON.stringify(result, null, 2);
});
```
You do not need to import the bridge SDK manually. AstrBot injects `/api/plugin/page/bridge-sdk.js` into returned HTML.
## Register Backend APIs
When the frontend calls `bridge.apiGet("ping")`, the Dashboard forwards it to:
```text
/api/plug/<plugin_name>/ping
```
The registered Web API route must include the plugin name as a prefix:
```python
from quart import jsonify
from astrbot.api.star import Context, Star
PLUGIN_NAME = "astrbot_plugin_page_demo"
class MyPlugin(Star):
def __init__(self, context: Context):
super().__init__(context)
context.register_web_api(
f"/{PLUGIN_NAME}/ping",
self.page_ping,
["GET"],
"Page ping",
)
async def page_ping(self):
return jsonify({"message": "pong"})
```
## Bridge API
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`
- `download(endpoint, params, filename)`: Download a backend response
- `subscribeSSE(endpoint, handlers, params)`: Subscribe to SSE
- `unsubscribeSSE(subscriptionId)`: Cancel an SSE subscription
The current `ready()` context looks like this:
```json
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo",
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "en-US",
"i18n": {},
"isDark": false
}
```
`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:
You do not need to import the bridge SDK manually. AstrBot injects `/api/plugin/page/bridge-sdk.js` into returned HTML. If an inline script must access `window.AstrBotPluginPage` synchronously, move it to an external module file or explicitly include the SDK before your script:
```html
<script src="/api/plugin/page/bridge-sdk.js"></script>
```
## Light/Dark Theme Adaptation
## Backend Web APIs
When the user toggles light/dark mode in AstrBot, the theme state is automatically synced to Plugin Pages. The bridge SDK maintains a `data-theme` attribute on the `<html>` element:
### Route Registration
- Light mode: `<html data-theme="light">`
- Dark mode: `<html data-theme="dark">`
Register plugin APIs with `context.register_web_api(route, view_handler, methods, desc)`.
> When **Follow System** is selected, AstrBot automatically resolves the theme based on the system's color scheme. The `data-theme` value received by the plugin page is always either `light` or `dark` — no extra handling needed.
### CSS Adaptation
CSS variables are the recommended approach:
```css
:root {
--bg: #ffffff;
--text: #1a1a1a;
}
[data-theme="dark"] {
--bg: #1a1a1a;
--text: #e0e0e0;
}
body {
background: var(--bg);
color: var(--text);
}
```python
context.register_web_api(
f"/{PLUGIN_NAME}/items/<item_id>",
self.get_item,
["GET"],
"Get item",
)
```
AstrBot injects the `data-theme` attribute on the `<html>` tag server-side when serving HTML, so there is no initial flash.
### Responding to Theme Changes in JavaScript
The `isDark` field in the context indicates whether dark mode is active. Use `onContext()` to listen for theme changes:
The registered route must include the plugin name prefix. The bridge endpoint used by the Page does not include the plugin name:
```js
const bridge = window.AstrBotPluginPage;
function render() {
if (bridge.getContext()?.isDark) {
// JS logic for dark mode (e.g. chart filters)
}
}
await bridge.ready();
render();
bridge.onContext(render);
await bridge.apiGet("items/123");
```
When the theme is toggled, the Dashboard sends the updated context to the iframe via the bridge. As long as the Page listens with `onContext()`, it will be notified.
## 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.
AstrBot rewrites:
- HTML `src` and `href`
- CSS `url(...)`
- JavaScript `import`
- JavaScript `export ... from`
- JavaScript dynamic `import()`
Keep static assets on relative paths such as `./style.css` and `./assets/logo.svg`. Do not manually append `asset_token`, and do not rely on `..` to escape the Page root directory.
If you build a SPA, prefer hash routing. The static asset server resolves real file paths; with history routing, refreshing a page requires an actual file to exist at that path.
## Security Constraints
Plugin Pages run inside a restricted iframe:
The Dashboard forwards it to:
```text
allow-scripts allow-forms allow-downloads
/api/v1/plugins/extensions/<plugin_name>/items/123
```
The page cannot directly access Dashboard cookies, LocalStorage, or same-origin DOM, and it cannot bypass the bridge to reuse Dashboard auth directly.
The registered route `/<plugin_name>/items/<item_id>` matches the request, and `item_id` is passed to the handler as a keyword argument:
AstrBot also adds security headers to asset responses, including:
```python
async def get_item(self, item_id: str):
return json_response({"item_id": item_id})
```
- `X-Frame-Options: SAMEORIGIN`
- `Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'`
- `Cache-Control: no-store`
Supported dynamic segments:
## Debugging Tips
- `<name>`: matches one path segment.
- `<path:name>`: matches the remaining multi-segment path.
- 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
### Request Object
## Appendix: Bridge API Details
Recommended import:
Start page scripts by keeping a bridge reference:
```python
from astrbot.api.web import request
```
`request` is a context proxy for the current request and is only available while a plugin Web API handler is running. Common fields and methods:
| API | Description |
| --- | --- |
| `request.method` | HTTP method, such as `GET` or `POST` |
| `request.path` | Current Dashboard API path |
| `request.plugin_name` | Plugin name parsed from the extension path |
| `request.username` | Current Dashboard username, possibly `None` |
| `request.headers` | Request headers |
| `request.cookies` | Request cookies |
| `request.content_type` | Request Content-Type |
| `request.client_host` | Client address |
| `request.path_params` | Dynamic route parameters |
| `request.query` | Query parameters with `get()` and `getlist()` |
| `await request.body()` | Raw request body bytes |
| `await request.json(default={})` | JSON body, returning default on parse failure |
| `await request.form()` | Form fields without uploaded files |
| `await request.files()` | Uploaded files |
Query example:
```python
limit = request.query.get("limit", 20, type=int)
tags = request.query.getlist("tag")
```
JSON example:
```python
payload = await request.json(default={})
enabled = bool(payload.get("enabled"))
```
Upload example:
```python
from pathlib import Path
from astrbot.core.utils.astrbot_path import get_astrbot_plugin_data_path
from astrbot.api.web import PluginUploadFile, error_response, json_response, request
async def import_file(self):
form = await request.form()
files = await request.files()
upload: PluginUploadFile | None = files.get("file")
if not isinstance(upload, PluginUploadFile):
return error_response("missing file")
target_dir = (
Path(get_astrbot_plugin_data_path())
/ (request.plugin_name or "unknown_plugin")
/ "imports"
)
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / Path(upload.filename).name
await upload.save(target)
return json_response(
{
"filename": upload.filename,
"content_type": upload.content_type,
"tag": form.get("tag"),
}
)
```
`request.form()` and `request.files()` cache the parsed multipart data, so calling both in the same handler is fine.
### Responses
Recommended response helpers:
```python
from astrbot.api.web import (
error_response,
file_response,
json_response,
stream_response,
)
```
JSON response:
```python
return json_response({"saved": True})
```
Error response:
```python
return error_response("invalid threshold", status_code=400)
```
File download response:
```python
return file_response(
export_path,
filename="export.json",
content_type="application/json",
)
```
SSE response:
```python
import json
from astrbot.api.web import stream_response
async def stream_events(self):
async def events():
yield f"data: {json.dumps({'state': 'started'})}\n\n"
yield f"data: {json.dumps({'state': 'done'})}\n\n"
return stream_response(events())
```
Returning a `dict`, `list`, `(body, status_code)`, or a lower-level Response object still works. New plugins should prefer `astrbot.api.web` helpers so plugin code remains decoupled from the Dashboard's internal web framework.
### Quart Compatibility
For backward compatibility, handlers registered through `context.register_web_api()` still run inside a Quart-compatible request context. Existing plugins can continue to use:
```python
from quart import jsonify, request
```
New plugins and new documentation should use:
```python
from astrbot.api.web import json_response, request
```
Do not mix the two `request` proxies in the same handler. Migrate one handler at a time.
## Bridge API
The Page iframe cannot directly access Dashboard cookies, LocalStorage, or the parent DOM. Page scripts must use `window.AstrBotPluginPage` to call backend APIs and read context.
```js
const bridge = window.AstrBotPluginPage;
```
### `ready()`
### Context
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.
`ready()` waits for the parent page to send the initial context and returns `Promise<context>`. Wait for it during page initialization.
```js
const context = await bridge.ready();
console.log(context.pluginName, context.pageName, context.locale);
```
The context usually contains:
@@ -290,93 +335,111 @@ The context usually contains:
}
```
### `getContext()`
Context APIs:
Synchronously reads the latest context. Use it after `ready()` or inside an `onContext()` callback.
| API | Returns | Description |
| --- | --- | --- |
| `ready()` | `Promise<context>` | Waits until the bridge is ready and returns the initial context |
| `getContext()` | `context \| null` | Synchronously reads the latest context |
| `getLocale()` | `string` | Current WebUI locale, defaulting to `zh-CN` |
| `getI18n()` | `object` | Current plugin i18n resources |
| `t(key, fallback)` | `string` | Reads a dot-separated translation key, returning fallback when missing |
| `onContext(handler)` | `() => void` | Listens for context changes and returns an unsubscribe function |
```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.
Respond to locale or theme changes:
```js
function render() {
document.title = bridge.t("pages.settings.title", "Settings");
document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo");
document.getElementById("locale").textContent = bridge.getLocale();
}
await bridge.ready();
render();
const off = bridge.onContext(render);
window.addEventListener("beforeunload", off);
```
window.addEventListener("beforeunload", () => {
off();
});
### Request and Return Rules
The `endpoint` used by `apiGet`, `apiPost`, `upload`, `download`, and `subscribeSSE` is a plugin-local relative path, such as `stats`, `settings/save`, or `files/export`. Prefer not to start it with `/`; the current bridge strips leading `/` for compatibility.
`endpoint` must not be empty, contain `\`, contain a URL scheme, contain query strings or fragments, or contain empty, `.`, or `..` path segments.
Do not append query strings to endpoint:
```js
await bridge.apiGet("stats", { limit: 20 });
```
Bridge JSON calls use this compatibility rule:
- If the backend returns `{ "status": "ok", "data": value }`, the Promise resolves to `value`.
- If the backend returns plain JSON, such as `{ "message": "pong" }`, the Promise resolves to that full JSON body.
- If the backend returns `{ "status": "error", "message": "..." }`, or the HTTP request fails, the Promise rejects with `Error`.
For Page-only APIs, prefer returning plain business JSON:
```python
return json_response({"message": "pong"})
```
Use this for errors:
```python
return error_response("missing file", status_code=400)
```
Handle errors on the Page:
```js
try {
await bridge.apiPost("settings/save", { enabled: true });
} catch (error) {
console.error(error.message);
}
```
### `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>`.
Sends a GET request. `params` are sent as query parameters.
```js
const stats = await bridge.apiGet("stats", { limit: 20 });
const stats = await bridge.apiGet("stats", { limit: 20, tag: "today" });
```
Backend registration example:
Backend:
```python
context.register_web_api(
f"/{PLUGIN_NAME}/stats",
self.get_stats,
["GET"],
"Get stats",
)
async def stats(self):
limit = request.query.get("limit", 20, type=int)
tag = request.query.get("tag")
return json_response({"limit": limit, "tag": tag})
```
### `apiPost(endpoint, body)`
Sends a POST request to the plugin backend. `body` is sent as JSON and the method returns `Promise<data>`.
Sends a POST JSON request.
```js
await bridge.apiPost("settings/save", {
const result = await bridge.apiPost("settings/save", {
enabled: true,
threshold: 0.8,
});
```
Backend:
```python
async def save_settings(self):
payload = await request.json(default={})
return json_response({"saved": True, "enabled": payload.get("enabled")})
```
### `upload(endpoint, file)`
Uploads one file as `multipart/form-data` with the field name `file`, returning `Promise<data>`.
Uploads one file as `multipart/form-data`. The field name is always `file`.
```js
const input = document.querySelector("input[type=file]");
@@ -384,14 +447,48 @@ const file = input.files[0];
const result = await bridge.upload("files/import", file);
```
Backend:
```python
from astrbot.api.web import PluginUploadFile, error_response, json_response, request
async def import_file(self):
files = await request.files()
upload: PluginUploadFile | None = files.get("file")
if not isinstance(upload, PluginUploadFile):
return error_response("missing file", status_code=400)
return json_response({"filename": upload.filename})
```
If you need extra structured fields, send them through a separate `apiPost` call or use query parameters to select import behavior. The current `upload()` bridge method sends one 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.
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 read it from response headers.
```js
await bridge.download("files/export", { format: "json" }, "export.json");
```
Backend:
```python
async def export_file(self):
fmt = request.query.get("format", "json")
return file_response(
export_path,
filename=f"export.{fmt}",
content_type="application/json",
)
```
`download()` resolves to:
```json
{ "filename": "export.json" }
```
### `subscribeSSE(endpoint, handlers, params)`
Subscribes to plugin backend SSE and returns `Promise<subscriptionId>`. `handlers` may include `onOpen`, `onMessage`, and `onError`.
@@ -414,17 +511,25 @@ const subscriptionId = await bridge.subscribeSSE(
);
```
`event.parsed` is automatically parsed when the message is a JSON string; otherwise it equals the raw string.
`event.raw` is the raw string. If the message is a JSON string, `event.parsed` is parsed automatically; otherwise it equals the raw string. `event.eventType` matches the SSE `event:` field and defaults to `message`.
### `unsubscribeSSE(subscriptionId)`
The backend must return `text/event-stream`:
Cancels an SSE subscription.
```python
async def events(self):
async def stream():
yield 'data: {"message": "ready"}\n\n'
return stream_response(stream())
```
Unsubscribe:
```js
await bridge.unsubscribeSSE(subscriptionId);
```
Clean up subscriptions when the page unloads:
Clean up on unload:
```js
window.addEventListener("beforeunload", () => {
@@ -432,11 +537,116 @@ window.addEventListener("beforeunload", () => {
});
```
### endpoint Rules
## Page Internationalization
The `endpoint` used by `apiGet`, `apiPost`, `upload`, `download`, and `subscribeSSE` must be a plugin-local relative path:
Plugin Pages reuse plugin i18n resource files. Add `pages.<page_name>` to `.astrbot-plugin/i18n/<locale>.json`:
- Allowed: `"stats"`, `"settings/save"`, `"files/export"`
- Not allowed: empty string, `"/stats"`, `"../stats"`, `"https://example.com"`, `"stats?x=1"`, `"stats#top"`
```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"
}
}
}
```
Pass query parameters through `params`; do not append them to `endpoint`.
`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. Inside the Page, render text with `bridge.t()` and react to locale changes with `onContext()`.
```js
function render() {
document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo");
document.getElementById("heading").textContent = bridge.t(
"pages.bridge-demo.heading",
"Plugin Page",
);
}
await bridge.ready();
render();
bridge.onContext(render);
```
## Light/Dark Theme
AstrBot syncs the current theme to Plugin Pages. The bridge SDK maintains a `data-theme` attribute on `<html>`:
- Light mode: `<html data-theme="light">`
- Dark mode: `<html data-theme="dark">`
When **Follow System** is selected, the Page still receives either `light` or `dark`.
CSS variables are recommended:
```css
:root {
--bg: #ffffff;
--text: #1a1a1a;
}
[data-theme="dark"] {
--bg: #1a1a1a;
--text: #e0e0e0;
}
body {
background: var(--bg);
color: var(--text);
}
```
The server injects `data-theme` into returned HTML to reduce initial flashing. If JavaScript needs to react to theme changes, read `bridge.getContext()?.isDark` and listen with `onContext()`.
## Static Asset Paths
Use normal relative paths:
```html
<link rel="stylesheet" href="./style.css" />
<script type="module" src="./app.js"></script>
<img src="./assets/logo.svg" alt="" />
```
AstrBot rewrites relative asset URLs and appends a short-lived `asset_token`. Do not hardcode `/api/plugin/page/content/...`, append `asset_token` yourself, or rely on `..` to escape the Page root.
AstrBot rewrites:
- HTML `src` and `href`
- CSS `url(...)`
- JavaScript `import`
- JavaScript `export ... from`
- JavaScript dynamic `import()`
If you build a SPA, prefer hash routing. The static asset server resolves real file paths; with history routing, refreshing a page requires a real file at that path.
## Security Constraints
Plugin Pages run inside a restricted iframe:
```text
allow-scripts allow-forms allow-downloads
```
The Page cannot directly access Dashboard cookies, LocalStorage, or the parent DOM, and it cannot bypass the bridge to reuse Dashboard auth. All operations that need Dashboard identity should go through the bridge.
Asset responses include security headers such as:
- `X-Frame-Options: SAMEORIGIN`
- `Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'`
- `Cache-Control: no-store`
- `X-Content-Type-Options: nosniff`
Backend handlers must still validate input. Do not trust paths, filenames, formats, or numeric ranges sent by the Page. Store files only in safe directories and prefer whitelisted or regenerated filenames.
## Debugging Tips
- Page is missing: check that `pages/<page_name>/index.html` exists, the plugin is enabled, and the plugin detail page has been refreshed.
- Bridge is missing: make sure your script runs after the bridge SDK is injected; external `type="module"` scripts are recommended.
- API is not matched: make sure the registered route includes the plugin name prefix, such as `/{PLUGIN_NAME}/stats`, while the Page endpoint is `stats`.
- Query or JSON is empty: pass GET values through `apiGet(endpoint, params)` and POST JSON through `apiPost(endpoint, body)`.
- Upload is empty: `upload()` always uses the field name `file`; read it with `(await request.files()).get("file")`.
- SSE has no messages: make sure the backend response is `text/event-stream` and each message ends with a blank line, such as `data: ...\n\n`.
- SSE returns 401: do not call `new EventSource("/api/v1/...")` directly from the Page. Native `EventSource` cannot send the `Authorization` header; call through `bridge.subscribeSSE()` instead.