fix: restore OpenAPI file uploads

Expose /api/v1/file in OpenAPI, enable file-scoped API keys, and regenerate docs/client artifacts.
This commit is contained in:
Weilong Liao
2026-06-18 12:37:38 +08:00
committed by GitHub
parent 2c8f38c886
commit 264e7eaaa3
12 changed files with 411 additions and 29 deletions

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Request, WebSocket
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile, WebSocket
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from astrbot.dashboard.responses import ApiError, error, ok
@@ -19,7 +19,7 @@ from astrbot.dashboard.services.open_api_service import (
)
from .auth import AuthContext, require_scope
from .multipart import single_upload
from .multipart import UploadFileAdapter
router = APIRouter(tags=["Open API"])
@@ -230,31 +230,46 @@ async def get_chat_configs(
return ok(service.get_chat_configs())
@router.post("/file", include_in_schema=False)
@router.post(
"/file",
summary="Upload a file",
operation_id="uploadOpenApiFile",
openapi_extra={"x-astrbot-scope": "file"},
)
async def upload_open_api_file(
request: Request,
file: UploadFile = File(...),
_auth: AuthContext = Depends(require_file_scope),
chat_service: ChatService = Depends(get_chat_service),
):
try:
upload = await single_upload(request)
if upload is None:
raise ChatServiceError("Missing key: file")
return ok(await chat_service.save_uploaded_file(upload))
return ok(await chat_service.save_uploaded_file(UploadFileAdapter(file)))
except ChatServiceError as exc:
return error(str(exc))
@router.get("/file", include_in_schema=False)
@router.get(
"/file",
summary="Download an uploaded file by attachment ID",
operation_id="downloadOpenApiFile",
responses={
200: {
"description": "File content or an error envelope",
"content": {
"application/octet-stream": {
"schema": {"type": "string", "format": "binary"}
}
},
},
},
openapi_extra={"x-astrbot-scope": "file"},
)
async def get_open_api_file(
request: Request,
attachment_id: str = Query(...),
_auth: AuthContext = Depends(require_file_scope),
chat_service: ChatService = Depends(get_chat_service),
):
try:
file_path, mimetype = await chat_service.resolve_attachment_file(
request.query_params.get("attachment_id")
)
file_path, mimetype = await chat_service.resolve_attachment_file(attachment_id)
return FileResponse(file_path, media_type=mimetype)
except ChatServiceError as exc:
return _open_api_error(str(exc))

View File

@@ -54,6 +54,7 @@ ALL_OPEN_API_SCOPES = (
"im",
"config",
"chat",
"file",
"plugin",
"mcp",
"skill",

File diff suppressed because one or more lines are too long

View File

@@ -191,7 +191,7 @@ export type ConversationRef = {
export type CreateApiKeyRequest = {
name: string;
scopes?: Array<('bot' | 'provider' | 'persona' | 'im' | 'config' | 'chat' | 'plugin' | 'mcp' | 'skill')>;
scopes?: Array<('bot' | 'provider' | 'persona' | 'im' | 'config' | 'chat' | 'file' | 'plugin' | 'mcp' | 'skill')>;
expires_at?: string;
expires_in_days?: number;
};
@@ -340,6 +340,8 @@ export type NeoReleaseActionRequest = {
export type ParameterAttachmentId = string;
export type ParameterAttachmentIdQuery = string;
export type ParameterBotId = string;
export type ParameterChunkId = string;
@@ -1496,6 +1498,24 @@ export type UploadFileResponse = (SuccessEnvelope);
export type UploadFileError = unknown;
export type UploadOpenApiFileData = {
body: FileUploadRequest;
};
export type UploadOpenApiFileResponse = (SuccessEnvelope);
export type UploadOpenApiFileError = unknown;
export type DownloadOpenApiFileData = {
query: {
attachment_id: string;
};
};
export type DownloadOpenApiFileResponse = ((Blob | File));
export type DownloadOpenApiFileError = unknown;
export type GetFileByNameData = {
query: {
filename: string;

View File

@@ -482,7 +482,7 @@ const apiKeys = ref([]);
const apiKeyCreating = ref(false);
const newApiKeyName = ref('');
const newApiKeyExpiresInDays = ref(30);
const newApiKeyScopes = ref(['bot', 'provider', 'im', 'config', 'chat']);
const newApiKeyScopes = ref(['bot', 'provider', 'im', 'config', 'chat', 'file']);
const createdApiKeyPlaintext = ref('');
const systemConfigData = ref({});
const systemConfigMetadata = ref({});
@@ -513,6 +513,7 @@ const availableScopes = [
{ value: 'im', label: 'im' },
{ value: 'config', label: 'config' },
{ value: 'chat', label: 'chat' },
{ value: 'file', label: 'file' },
{ value: 'plugin', label: 'plugin' },
{ value: 'mcp', label: 'mcp' },
{ value: 'skill', label: 'skill' }

View File

@@ -40,6 +40,7 @@ When creating an API Key, you can configure `scopes`. Each scope controls the ra
| `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` |
| `file` | Upload and download chat attachments | `POST /api/v1/file`, `GET /api/v1/file`, `POST /api/v1/files` |
| `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` |
@@ -48,7 +49,7 @@ If the API Key does not include the required scope for the target endpoint, the
`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. The public OpenAPI reference only includes endpoints covered by supported developer API key scopes.
Developer API keys currently support only the 10 scopes listed above. `tool`, `skills`, `kb`, `data`, and `system` are not valid developer API key scopes. Use the singular `skill` scope for `/api/v1/skills/*` endpoints. The public OpenAPI reference only includes endpoints covered by supported developer API key scopes.
## Common Endpoints
@@ -59,6 +60,7 @@ Interact with AstrBot's built-in Agent. Supports plugin calls, tool calls, and o
- `POST /api/v1/chat`: send chat message (SSE stream, server generates UUID when `session_id` is omitted)
- `GET /api/v1/chat/sessions`: list sessions for a specific `username` with pagination
- `GET /api/v1/configs`: list available config files
- `POST /api/v1/file`: upload an attachment for later use in message segments
**Bots and Providers**
@@ -120,7 +122,7 @@ Supported `type` values:
Notes:
- `attachment_id` comes from an existing attachment record. Developer API keys cannot currently upload attachments via `POST /api/v1/file`.
- `attachment_id` comes from an existing attachment record, or from `POST /api/v1/file` after uploading an attachment with the `file` scope.
- `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

@@ -3,7 +3,7 @@
"info": {
"title": "AstrBot OpenAPI v1",
"version": "0.1.0",
"description": "Target REST contract for migrating AstrBot HTTP APIs to /api/v1. Dynamic AstrBot configuration payloads are intentionally modeled as open JSON objects because their schemas are provided at runtime by template endpoints. Developer API keys currently support these scopes only: bot, provider, persona, im, config, chat, plugin, mcp, skill. The config scope also grants bot and provider access.\n"
"description": "Target REST contract for migrating AstrBot HTTP APIs to /api/v1. Dynamic AstrBot configuration payloads are intentionally modeled as open JSON objects because their schemas are provided at runtime by template endpoints. Developer API keys currently support these scopes only: bot, provider, persona, im, config, chat, file, plugin, mcp, skill. The config scope also grants bot and provider access.\n"
},
"servers": [
{
@@ -17,6 +17,9 @@
}
],
"tags": [
{
"name": "Open API"
},
{
"name": "System Config"
},
@@ -41,6 +44,9 @@
{
"name": "IM"
},
{
"name": "Files"
},
{
"name": "Plugins"
},
@@ -2224,6 +2230,214 @@
}
}
},
"/api/v1/files": {
"post": {
"tags": [
"Files"
],
"summary": "Upload a file",
"operationId": "uploadFile",
"x-astrbot-scope": "file",
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/FileUploadRequest"
}
}
}
},
"responses": {
"200": {
"$ref": "#/components/responses/Ok"
}
}
}
},
"/api/v1/file": {
"post": {
"tags": [
"Open API"
],
"summary": "Upload a file",
"operationId": "uploadOpenApiFile",
"x-astrbot-scope": "file",
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/FileUploadRequest"
}
}
}
},
"responses": {
"200": {
"$ref": "#/components/responses/Ok"
}
}
},
"get": {
"tags": [
"Open API"
],
"summary": "Download an uploaded file by attachment ID",
"operationId": "downloadOpenApiFile",
"x-astrbot-scope": "file",
"parameters": [
{
"$ref": "#/components/parameters/AttachmentIdQuery"
}
],
"responses": {
"200": {
"description": "File content or an error envelope",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
},
"/api/v1/files/content": {
"get": {
"tags": [
"Files"
],
"summary": "Get an uploaded file by stored filename",
"operationId": "getFileByName",
"x-astrbot-scope": "file",
"parameters": [
{
"name": "filename",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "File bytes or an error envelope",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
},
"/api/v1/files/tokens/{file_token}": {
"get": {
"tags": [
"Files"
],
"summary": "Get a tokenized public file",
"operationId": "getTokenFile",
"x-astrbot-scope": "file",
"parameters": [
{
"name": "file_token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Tokenized file bytes or an error envelope",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
},
"/api/v1/files/{attachment_id}": {
"get": {
"tags": [
"Files"
],
"summary": "Get attachment metadata",
"operationId": "getAttachment",
"x-astrbot-scope": "file",
"parameters": [
{
"$ref": "#/components/parameters/AttachmentId"
}
],
"responses": {
"200": {
"$ref": "#/components/responses/Ok"
}
}
},
"delete": {
"tags": [
"Files"
],
"summary": "Delete an attachment",
"operationId": "deleteAttachment",
"x-astrbot-scope": "file",
"parameters": [
{
"$ref": "#/components/parameters/AttachmentId"
}
],
"responses": {
"200": {
"$ref": "#/components/responses/Ok"
}
}
}
},
"/api/v1/files/{attachment_id}/content": {
"get": {
"tags": [
"Files"
],
"summary": "Download attachment content",
"operationId": "downloadAttachment",
"x-astrbot-scope": "file",
"parameters": [
{
"$ref": "#/components/parameters/AttachmentId"
}
],
"responses": {
"200": {
"description": "File content",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
},
"/api/v1/plugins": {
"get": {
"tags": [
@@ -5376,6 +5590,22 @@
}
},
"parameters": {
"AttachmentId": {
"name": "attachment_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
"AttachmentIdQuery": {
"name": "attachment_id",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
},
"BotId": {
"name": "bot_id",
"in": "path",
@@ -5961,6 +6191,18 @@
},
"additionalProperties": false
},
"FileUploadRequest": {
"type": "object",
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary"
}
}
},
"PluginUpdateRequest": {
"type": "object",
"properties": {

View File

@@ -12,6 +12,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_SPEC = REPO_ROOT / "openspec" / "openapi-v1.yaml"
DEFAULT_OUTPUT = REPO_ROOT / "docs" / "public" / "openapi.json"
PUBLIC_OPEN_API_TAGS = {
"Open API",
"System Config",
"Config Profiles",
"Bot Config Routes",
@@ -20,6 +21,7 @@ PUBLIC_OPEN_API_TAGS = {
"Providers",
"Chat",
"IM",
"Files",
"Plugins",
"Plugin Sources",
"Plugin Pages",

View File

@@ -40,6 +40,7 @@ X-API-Key: abk_xxx
| `im` | 主动发 IM 消息、查询 bot/platform 列表 | `POST /api/v1/im/message``GET /api/v1/im/bots` |
| `config` | 管理配置文件、系统配置和通用配置。该 scope 同时包含 `bot``provider` 访问权限。 | `GET /api/v1/configs``GET/PUT /api/v1/system-config``GET/POST /api/v1/config-profiles` |
| `chat` | 调用对话能力、查询对话会话 | `POST /api/v1/chat``GET /api/v1/chat/sessions` |
| `file` | 上传和下载对话附件 | `POST /api/v1/file``GET /api/v1/file``POST /api/v1/files` |
| `plugin` | 管理插件、插件配置、插件源和插件市场 | `GET /api/v1/plugins``GET/PUT /api/v1/plugins/config``POST /api/v1/plugins/install/url` |
| `mcp` | 管理 MCP 服务器配置和服务端同步 | `GET/POST /api/v1/mcp/servers``PATCH /api/v1/mcp/servers/{server_name}/enabled``POST /api/v1/mcp/providers/modelscope/sync` |
| `skill` | 管理 Skills、Skill 压缩包、Skill 文件和 Shipyard Neo Skill 流程 | `GET/POST /api/v1/skills``PUT /api/v1/skills/{skill_name}/files/{file_path}``POST /api/v1/skills/neo/sync` |
@@ -48,7 +49,7 @@ X-API-Key: abk_xxx
`config` 是较大的管理 scope。创建 API Key 时如果包含 `config`AstrBot 会同时授予该 Key `config``bot``provider` 访问权限。WebUI 的勾选逻辑也会体现这个依赖关系:选中 `config` 会同时选中 `bot``provider`;取消选中 `bot``provider` 时,会同步取消 `config`
当前开发者 API Key 仅开放以上 9 个 scope。`file``tool``skills``kb``data``system` 暂不支持作为开发者 API Key scope。`/api/v1/skills/*` 接口使用单数 `skill` scope不使用复数 `skills`。公开 OpenAPI 文档只包含这些开发者 API Key scope 覆盖的接口。
当前开发者 API Key 仅开放以上 10 个 scope。`tool``skills``kb``data``system` 暂不支持作为开发者 API Key scope。`/api/v1/skills/*` 接口使用单数 `skill` scope不使用复数 `skills`。公开 OpenAPI 文档只包含这些开发者 API Key scope 覆盖的接口。
## 常用接口
@@ -59,6 +60,7 @@ X-API-Key: abk_xxx
- `POST /api/v1/chat`发送对话消息SSE 流式返回,不传 `session_id` 会自动创建 UUID
- `GET /api/v1/chat/sessions`:分页获取指定 `username` 的会话
- `GET /api/v1/configs`:获取可用配置文件列表
- `POST /api/v1/file`:上传附件,之后可在消息段中引用
**机器人和模型提供商**
@@ -121,7 +123,7 @@ X-API-Key: abk_xxx
说明:
- `attachment_id` 来自已存在的附件记录。开发者 API Key 当前不能使`POST /api/v1/file` 上传附件。
- `attachment_id` 来自已存在的附件记录,或使用 `file` scope 调`POST /api/v1/file` 上传附件后的返回值
- `reply` 不能单独作为唯一内容,至少需要一个有实际内容的段(如 `plain/image/file/...`)。
-`reply` 或空内容会返回错误。

View File

@@ -8,7 +8,7 @@ info:
JSON objects because their schemas are provided at runtime by template
endpoints.
Developer API keys currently support these scopes only: bot, provider,
persona, im, config, chat, plugin, mcp, skill. The config scope also
persona, im, config, chat, file, plugin, mcp, skill. The config scope also
grants bot and provider access.
servers:
- url: http://localhost:6185
@@ -18,6 +18,7 @@ security:
- ApiKeyAuth: []
tags:
- name: Open API
- name: Auth
- name: API Keys
- name: System Config
@@ -1551,6 +1552,37 @@ paths:
"200":
$ref: "#/components/responses/Ok"
/api/v1/file:
post:
tags: [Open API]
summary: Upload a file
operationId: uploadOpenApiFile
x-astrbot-scope: file
requestBody:
required: true
content:
multipart/form-data:
schema:
$ref: "#/components/schemas/FileUploadRequest"
responses:
"200":
$ref: "#/components/responses/Ok"
get:
tags: [Open API]
summary: Download an uploaded file by attachment ID
operationId: downloadOpenApiFile
x-astrbot-scope: file
parameters:
- $ref: "#/components/parameters/AttachmentIdQuery"
responses:
"200":
description: File content or an error envelope
content:
application/octet-stream:
schema:
type: string
format: binary
/api/v1/files/content:
get:
tags: [Files]
@@ -4823,6 +4855,12 @@ components:
required: true
schema:
type: string
AttachmentIdQuery:
name: attachment_id
in: query
required: true
schema:
type: string
BotId:
name: bot_id
in: path
@@ -5089,8 +5127,8 @@ components:
type: array
items:
type: string
enum: [bot, provider, persona, im, config, chat, plugin, mcp, skill]
example: [bot, provider, persona, im, config, chat, plugin, mcp, skill]
enum: [bot, provider, persona, im, config, chat, file, plugin, mcp, skill]
example: [bot, provider, persona, im, config, chat, file, plugin, mcp, skill]
expires_at:
type: string
format: date-time

View File

@@ -1,9 +1,11 @@
import asyncio
import io
import uuid
from unittest.mock import AsyncMock
import pytest
import pytest_asyncio
from werkzeug.datastructures import FileStorage
from astrbot.core import LogBroker
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -145,7 +147,13 @@ async def test_api_key_scope_and_revoke(
denied_res = await test_client.post(
"/api/v1/file",
data={},
files={
"file": FileStorage(
stream=io.BytesIO(b"scope denied"),
filename="denied.txt",
content_type="text/plain",
),
},
headers={"X-API-Key": raw_key},
)
assert denied_res.status_code == 403
@@ -787,7 +795,7 @@ async def test_open_api_key_scope_normalization(
@pytest.mark.asyncio
async def test_file_scope_is_not_available_for_developer_api_key(
async def test_file_scope_is_available_for_developer_api_key(
app: FastAPIAppAdapter,
authenticated_header: dict,
):
@@ -799,6 +807,25 @@ async def test_file_scope_is_not_available_for_developer_api_key(
)
create_data = await create_res.get_json()
assert create_res.status_code == 400
assert create_data["status"] == "error"
assert create_data["message"] == "Invalid scopes: file"
assert create_res.status_code == 200
assert create_data["status"] == "ok"
assert set(create_data["data"]["scopes"]) == {"file"}
upload_res = await test_client.post(
"/api/v1/file",
files={
"file": FileStorage(
stream=io.BytesIO(b"hello from api key"),
filename="api-key-upload.txt",
content_type="text/plain",
),
},
headers={"X-API-Key": create_data["data"]["api_key"]},
)
upload_data = await upload_res.get_json()
assert upload_res.status_code == 200
assert upload_data["status"] == "ok"
assert upload_data["data"]["filename"] == "api-key-upload.txt"
assert upload_data["data"]["type"] == "file"
assert upload_data["data"]["attachment_id"]

View File

@@ -1023,6 +1023,7 @@ async def test_v1_openapi_is_served_by_fastapi(asgi_client: httpx.AsyncClient):
assert "/api/v1/conversations" in spec["paths"]
assert "/api/v1/mcp/servers" in spec["paths"]
assert "/api/v1/skills" in spec["paths"]
assert "/api/v1/file" in spec["paths"]
def test_static_openapi_v1_paths_include_api_version():
@@ -1140,6 +1141,12 @@ async def test_v1_openapi_uses_pydantic_request_bodies(
"$ref"
].endswith("/ConfigContentRequest")
open_api_file_upload = spec["paths"]["/api/v1/file"]["post"]
assert open_api_file_upload["requestBody"]["content"]["multipart/form-data"][
"schema"
]["$ref"].endswith("/Body_uploadOpenApiFile")
assert open_api_file_upload["x-astrbot-scope"] == "file"
@pytest.mark.asyncio
async def test_v1_conversation_path_id_allows_slash(asgi_client: httpx.AsyncClient):