diff --git a/.dockerignore b/.dockerignore index 7a61edd14..a8bebec2c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,8 +16,11 @@ venv*/ ENV/ .conda/ dashboard/ +!astrbot/dashboard/ +!astrbot/dashboard/dist/ +!astrbot/dashboard/dist/** data/ tests/ .ruff_cache/ .astrbot -astrbot.lock \ No newline at end of file +astrbot.lock diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 54ce79f9e..cfcb51fba 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -1,4 +1,4 @@ -name: release +name: Build and Deploy AstrBot Docs on: push: @@ -11,9 +11,9 @@ jobs: runs-on: ubuntu-latest # 运行环境 steps: - name: checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup pnpm - uses: pnpm/action-setup@v6.0.8 + uses: pnpm/action-setup@v6.0.9 with: version: 10.28.2 - name: Setup Node.js diff --git a/.github/workflows/code-format.yml b/.github/workflows/code-format.yml index 3de1bea55..d3c5b520a 100644 --- a/.github/workflows/code-format.yml +++ b/.github/workflows/code-format.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5aeef1eff..a47a90e49 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/coverage_test.yml b/.github/workflows/coverage_test.yml index 9d4c6a8e8..40cd351bd 100644 --- a/.github/workflows/coverage_test.yml +++ b/.github/workflows/coverage_test.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -41,6 +41,6 @@ jobs: - name: Upload results to Codecov if: github.repository == 'AstrBotDevs/AstrBot' - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml index 61b455fd9..cc282a94d 100644 --- a/.github/workflows/dashboard_ci.yml +++ b/.github/workflows/dashboard_ci.yml @@ -12,10 +12,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup pnpm - uses: pnpm/action-setup@v6.0.8 + uses: pnpm/action-setup@v6.0.9 with: version: 10.28.2 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index ebc728b36..143f9f7e0 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 1 fetch-tag: true @@ -46,14 +46,21 @@ jobs: - name: Build Dashboard run: | + dashboard_version=$(python3 - <<'PY' + import tomllib + with open("pyproject.toml", "rb") as f: + print("v" + tomllib.load(f)["project"]["version"]) + PY + ) cd dashboard npm install npm run build mkdir -p dist/assets - echo $(git rev-parse HEAD) > dist/assets/version + echo "$dashboard_version" > dist/assets/version cd .. - mkdir -p data - cp -r dashboard/dist data/ + mkdir -p astrbot/dashboard + rm -rf astrbot/dashboard/dist + cp -r dashboard/dist astrbot/dashboard/dist - name: Determine test image tags id: test-meta @@ -118,7 +125,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 1 fetch-tag: true @@ -157,10 +164,11 @@ jobs: npm install npm run build mkdir -p dist/assets - echo $(git rev-parse HEAD) > dist/assets/version + echo "${{ steps.release-meta.outputs.version }}" > dist/assets/version cd .. - mkdir -p data - cp -r dashboard/dist data/ + mkdir -p astrbot/dashboard + rm -rf astrbot/dashboard/dist + cp -r dashboard/dist astrbot/dashboard/dist - name: Set QEMU uses: docker/setup-qemu-action@v4.1.0 diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml deleted file mode 100644 index c7d6024b6..000000000 --- a/.github/workflows/pr-title-check.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: PR Title Check - -on: - pull_request_target: - types: [opened, edited, reopened, synchronize] - -jobs: - title-format: - if: github.repository == 'AstrBotDevs/AstrBot' - runs-on: ubuntu-latest - permissions: - pull-requests: write - issues: write - - steps: - - name: Validate PR title - uses: actions/github-script@v9 - with: - script: | - const title = (context.payload.pull_request.title || "").trim(); - // allow only: - // feat: xxx - // feat(scope): xxx - const pattern = /^(feat)(\([a-z0-9-]+\))?:\s.+$/i; - const isValid = pattern.test(title); - const isSameRepo = - context.payload.pull_request.head.repo.full_name === context.payload.repository.full_name; - - if (!isValid) { - if (isSameRepo) { - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body: [ - "⚠️ PR title format check failed.", - "Required formats:", - "- `feat: xxx`", - "- `feat(scope): xxx`", - "Please update your PR title and push again." - ].join("\n") - }); - } catch (e) { - core.warning(`Failed to post PR title comment: ${e.message}`); - } - } else { - core.warning("Fork PR: comment permission is restricted; skip posting review comment."); - } - } - - if (!isValid) { - core.setFailed("Invalid PR title. Expected format: feat: xxx or feat(scope): xxx."); - } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b28976bc..f7af00304 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release +name: Release AstrBot on: push: @@ -28,7 +28,7 @@ jobs: R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 ref: ${{ inputs.ref || github.ref }} @@ -51,7 +51,7 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Setup pnpm - uses: pnpm/action-setup@v6.0.8 + uses: pnpm/action-setup@v6.0.9 with: version: 10.28.2 @@ -71,6 +71,15 @@ jobs: echo "${{ steps.tag.outputs.tag }}" > dist/assets/version zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist + - name: Build core package + shell: bash + run: | + git archive \ + --format=zip \ + --prefix="AstrBot-${{ steps.tag.outputs.tag }}/" \ + --output="AstrBot-${{ steps.tag.outputs.tag }}-core.zip" \ + HEAD + - name: Upload dashboard artifact uses: actions/upload-artifact@v7 with: @@ -78,11 +87,12 @@ jobs: if-no-files-found: error path: dashboard/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip - - name: Upload dashboard package to Cloudflare R2 + - name: Upload release packages to Cloudflare R2 if: ${{ env.R2_ACCOUNT_ID != '' && env.R2_ACCESS_KEY_ID != '' && env.R2_SECRET_ACCESS_KEY != '' }} env: R2_BUCKET_NAME: "astrbot" - R2_OBJECT_NAME: "astrbot-webui-latest.zip" + DASHBOARD_LATEST_OBJECT_NAME: "astrbot-webui-latest.zip" + CORE_LATEST_OBJECT_NAME: "astrbot-core-latest.zip" VERSION_TAG: ${{ steps.tag.outputs.tag }} shell: bash run: | @@ -98,11 +108,18 @@ jobs: endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com EOF - cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${R2_OBJECT_NAME}" - rclone copy "dashboard/${R2_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress + cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}" + rclone copy "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip" rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress + cp "AstrBot-${VERSION_TAG}-core.zip" "${CORE_LATEST_OBJECT_NAME}" + rclone copy "${CORE_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress + cp "AstrBot-${VERSION_TAG}-core.zip" "astrbot-core-${VERSION_TAG}.zip" + rclone copy "astrbot-core-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress + rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/astrbot-core/${VERSION_TAG}/source.zip" --progress + rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/download/astrbot-core/${VERSION_TAG}/source.zip" --progress + publish-release: name: Publish GitHub Release if: github.repository == 'AstrBotDevs/AstrBot' @@ -111,7 +128,7 @@ jobs: - build-dashboard steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 ref: ${{ inputs.ref || github.ref }} @@ -190,7 +207,7 @@ jobs: - publish-release steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 ref: ${{ inputs.ref || github.ref }} diff --git a/.github/workflows/smoke_test.yml b/.github/workflows/smoke_test.yml index 71996b569..af9b45da8 100644 --- a/.github/workflows/smoke_test.yml +++ b/.github/workflows/smoke_test.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/sync-wiki.yml b/.github/workflows/sync-wiki.yml index 78052c2ed..b256e43f8 100644 --- a/.github/workflows/sync-wiki.yml +++ b/.github/workflows/sync-wiki.yml @@ -1,4 +1,4 @@ -name: sync wiki +name: Sync AstrBot Docs to GitHub Wiki on: workflow_dispatch: @@ -31,7 +31,7 @@ jobs: exit 1 - name: Check out docs repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index dfaf43d68..500902229 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v6 diff --git a/README.md b/README.md index 3a6b56554..dbce7360d 100644 --- a/README.md +++ b/README.md @@ -234,10 +234,6 @@ pre-commit install ### QQ Groups -- Group 12: 916228568 (New) -- Group 9: 1076659624 (Full) -- Group 10: 1078079676 (Full) -- Group 11: 704659519 (Full) - Group 1: 322154837 (Full) - Group 3: 630166526 (Full) - Group 4: 1077826412 (Full) @@ -245,6 +241,12 @@ pre-commit install - Group 6: 753075035 (Full) - Group 7: 743746109 (Full) - Group 8: 1030353265 (Full) +- Group 9: 1076659624 (Full) +- Group 10: 1078079676 (Full) +- Group 11: 704659519 (Full) +- Group 12: 916228568 (Full) +- Group 13: 1092185289 +- Group 14: 1103419483 - Developer Group(Chit-chat): 975206796 - Developer Group(Formal): 1039761811 diff --git a/README_zh.md b/README_zh.md index f07972637..bdcbd4f7b 100644 --- a/README_zh.md +++ b/README_zh.md @@ -253,10 +253,6 @@ bun dev ### QQ 群组 -- 12 群:916228568 (新) -- 9 群:1076659624 (人满) -- 10 群:1078079676 (人满) -- 11 群:704659519 (人满) - 1 群:322154837 (人满) - 3 群:630166526 (人满) - 4 群:1077826412 (人满) @@ -264,6 +260,14 @@ bun dev - 6 群:753075035 (人满) - 7 群:743746109 (人满) - 8 群:1030353265 (人满) +- 9 群:1076659624 (人满) +- 10 群:1078079676 (人满) +- 11 群:704659519 (人满) +- 12 群:916228568 (人满) +- 13 群:1092185289 +- 14 群:1103419483 + + - 开发者群(偏闲聊吹水):975206796 - 开发者群(正式):1039761811 diff --git a/astrbot/__init__.py b/astrbot/__init__.py index 187bf00fc..bcb49affb 100644 --- a/astrbot/__init__.py +++ b/astrbot/__init__.py @@ -1,7 +1,15 @@ from __future__ import annotations +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version from typing import TYPE_CHECKING, Any +try: + __version__ = _pkg_version("astrbot") +except PackageNotFoundError: + __version__ = "4.26.0" + + if TYPE_CHECKING: from .core import logger as logger @@ -18,4 +26,5 @@ def __getattr__(name: str) -> Any: from .core import logger return logger + raise AttributeError(name) diff --git a/astrbot/api/all.py b/astrbot/api/all.py index 7c5f9c061..e3d5f4421 100644 --- a/astrbot/api/all.py +++ b/astrbot/api/all.py @@ -1,3 +1,4 @@ +# ruff: noqa: F401, F403, F811, I001 from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot import logger from astrbot.core import html_renderer @@ -51,4 +52,4 @@ from astrbot.core.platform import ( from astrbot.core.platform.register import register_platform_adapter -from .message_components import * \ No newline at end of file +from .message_components import * diff --git a/astrbot/api/web.py b/astrbot/api/web.py new file mode 100644 index 000000000..6fb46e69d --- /dev/null +++ b/astrbot/api/web.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +import contextvars +from collections.abc import Callable, KeysView +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Generic, TypeVar, overload + +from fastapi.encoders import jsonable_encoder +from fastapi.responses import FileResponse, JSONResponse +from starlette.datastructures import Headers +from starlette.datastructures import UploadFile as StarletteUploadFile +from starlette.responses import StreamingResponse + +ValueT = TypeVar("ValueT") +DefaultT = TypeVar("DefaultT") +ConvertedT = TypeVar("ConvertedT") + + +class PluginMultiDict(Generic[ValueT]): + """Dictionary-like request values that preserves duplicate keys.""" + + def __init__(self, pairs: list[tuple[str, ValueT]]) -> None: + self._pairs = pairs + + @overload + def get(self, key: str) -> ValueT | None: ... + + @overload + def get(self, key: str, default: DefaultT) -> ValueT | DefaultT: ... + + @overload + def get( + self, + key: str, + default: DefaultT, + type: Callable[[ValueT], ConvertedT], + ) -> ConvertedT | DefaultT: ... + + def get(self, key: str, default: Any = None, type: Callable | None = None): + """Return the last value for a key. + + Args: + key: Value key to read. + default: Value returned when the key is missing or conversion fails. + type: Optional callable used to convert the value. + + Returns: + The matching value, converted value, or default. + """ + for item_key, item_value in reversed(self._pairs): + if item_key != key: + continue + if type is None: + return item_value + try: + return type(item_value) + except (TypeError, ValueError): + return default + return default + + def getlist(self, key: str) -> list[ValueT]: + """Return all values for a key. + + Args: + key: Value key to read. + + Returns: + Values in request order. + """ + return [item_value for item_key, item_value in self._pairs if item_key == key] + + def keys(self) -> KeysView[str]: + return dict.fromkeys(item_key for item_key, _ in self._pairs).keys() + + def values(self) -> list[ValueT]: + return [self[key] for key in self.keys()] + + def items(self) -> list[tuple[str, ValueT]]: + return [(key, self[key]) for key in self.keys()] + + def __contains__(self, key: str) -> bool: + return any(item_key == key for item_key, _ in self._pairs) + + def __getitem__(self, key: str) -> ValueT: + value = self.get(key) + if value is None and key not in self: + raise KeyError(key) + return value + + def __bool__(self) -> bool: + return bool(self._pairs) + + +class PluginUploadFile: + """Uploaded file wrapper exposed to plugin Web API handlers.""" + + def __init__(self, upload_file: StarletteUploadFile) -> None: + self._upload_file: StarletteUploadFile = upload_file + self.filename: str | None = upload_file.filename + self.content_type: str | None = upload_file.content_type + self.headers: Headers = upload_file.headers + self.content_length: int | None = self._resolve_content_length() + + def _resolve_content_length(self) -> int | None: + try: + raw = self.headers.get("content-length") + return int(raw) if raw else None + except (TypeError, ValueError): + return None + + async def save(self, destination: str | Path) -> None: + """Save the uploaded file to disk. + + Args: + destination: Destination file path. + """ + path = Path(destination) + try: + await self._upload_file.seek(0) + except Exception: + pass + with path.open("wb") as output: + while True: + chunk = await self._upload_file.read(1024 * 1024) + if not chunk: + break + output.write(chunk) + + async def read(self, size: int = -1) -> bytes: + """Read bytes from the uploaded file. + + Args: + size: Maximum number of bytes to read. Use -1 to read all bytes. + + Returns: + File bytes. + """ + return await self._upload_file.read(size) + + async def write(self, data: bytes) -> None: + """Write bytes to the uploaded file object. + + Args: + data: Bytes to write. + """ + await self._upload_file.write(data) + + async def seek(self, offset: int) -> None: + """Move the uploaded file cursor. + + Args: + offset: Absolute byte offset. + """ + await self._upload_file.seek(offset) + + async def close(self) -> None: + """Close the uploaded file.""" + await self._upload_file.close() + + def __getattr__(self, key: str) -> Any: + return getattr(self._upload_file, key) + + +class PluginRequest: + """Request object exposed to plugin Web API handlers.""" + + def __init__( + self, + request_: Any, + *, + path_params: dict[str, Any] | None = None, + plugin_name: str | None = None, + username: str | None = None, + ) -> None: + self._request: Any = request_ + self.method: str = request_.method + self.path: str = request_.url.path + self.headers: Headers = request_.headers + self.cookies: dict[str, str] = request_.cookies + self.content_type: str | None = request_.headers.get("content-type") + self.client_host: str | None = request_.client.host if request_.client else None + self.path_params: dict[str, Any] = path_params or {} + self.plugin_name: str | None = plugin_name + self.username: str | None = username + self.query: PluginMultiDict[str] = PluginMultiDict[str]( + list(request_.query_params.multi_items()) + ) + self._form_cache: PluginMultiDict[str] | None = None + self._files_cache: PluginMultiDict[PluginUploadFile] | None = None + + async def body(self) -> bytes: + """Read the raw request body. + + Returns: + Raw request body bytes. + """ + return await self._request.body() + + async def json(self, default: DefaultT | None = None) -> Any | DefaultT | None: + """Read the JSON request body. + + Args: + default: Value returned when the request body cannot be parsed as JSON. + + Returns: + Parsed JSON data or default. + """ + try: + return await self._request.json() + except Exception: + return default + + async def _load_form_parts(self) -> None: + if self._form_cache is not None and self._files_cache is not None: + return + form = await self._request.form() + form_pairs: list[tuple[str, str]] = [] + file_pairs: list[tuple[str, PluginUploadFile]] = [] + for key, value in form.multi_items(): + if isinstance(value, StarletteUploadFile): + file_pairs.append((key, PluginUploadFile(value))) + else: + form_pairs.append((key, value)) + self._form_cache = PluginMultiDict(form_pairs) + self._files_cache = PluginMultiDict(file_pairs) + + async def form(self) -> PluginMultiDict[str]: + """Read form fields from a multipart or form-urlencoded request. + + Returns: + Form values without uploaded files. + """ + await self._load_form_parts() + assert self._form_cache is not None + return self._form_cache + + async def files(self) -> PluginMultiDict[PluginUploadFile]: + """Read uploaded files from a multipart request. + + Returns: + Uploaded file values. + """ + await self._load_form_parts() + assert self._files_cache is not None + return self._files_cache + + +_request_var: contextvars.ContextVar[PluginRequest] = contextvars.ContextVar( + "astrbot_plugin_web_request" +) + + +class PluginRequestProxy: + """Typed proxy for the request bound to the current plugin Web handler.""" + + def _get_current(self) -> PluginRequest: + try: + return _request_var.get() + except LookupError as exc: + raise RuntimeError( + "astrbot.api.web.request is only available inside a plugin Web API " + "handler." + ) from exc + + @property + def method(self) -> str: + return self._get_current().method + + @property + def path(self) -> str: + return self._get_current().path + + @property + def headers(self) -> Headers: + return self._get_current().headers + + @property + def cookies(self) -> dict[str, str]: + return self._get_current().cookies + + @property + def content_type(self) -> str | None: + return self._get_current().content_type + + @property + def client_host(self) -> str | None: + return self._get_current().client_host + + @property + def path_params(self) -> dict[str, Any]: + return self._get_current().path_params + + @property + def plugin_name(self) -> str | None: + return self._get_current().plugin_name + + @property + def username(self) -> str | None: + return self._get_current().username + + @property + def query(self) -> PluginMultiDict[str]: + return self._get_current().query + + async def body(self) -> bytes: + return await self._get_current().body() + + async def json(self, default: DefaultT | None = None) -> Any | DefaultT | None: + return await self._get_current().json(default=default) + + async def form(self) -> PluginMultiDict[str]: + return await self._get_current().form() + + async def files(self) -> PluginMultiDict[PluginUploadFile]: + return await self._get_current().files() + + def __getattr__(self, key: str) -> Any: + return getattr(self._get_current(), key) + + +request: PluginRequestProxy = PluginRequestProxy() + + +@contextmanager +def bind_request_context(request_: PluginRequest): + """Bind a plugin Web request for the current async context. + + Args: + request_: Request object exposed through the module-level request proxy. + + Yields: + The bound request object. + """ + token = _request_var.set(request_) + try: + yield request_ + finally: + _request_var.reset(token) + + +def json_response( + data: Any = None, + *, + status_code: int = 200, + headers: dict[str, str] | None = None, +) -> JSONResponse: + """Build a JSON response for plugin Web API handlers. + + Args: + data: JSON-serializable response body. + status_code: HTTP status code. + headers: Optional response headers. + + Returns: + A Starlette/FastAPI JSON response. + """ + return JSONResponse( + jsonable_encoder({} if data is None else data), + status_code=status_code, + headers=headers, + ) + + +def error_response( + message: str, + *, + status_code: int = 400, + data: Any = None, + headers: dict[str, str] | None = None, +) -> JSONResponse: + """Build a standard error response for plugin bridge calls. + + Args: + message: Public error message. + status_code: HTTP status code. + data: Optional error details that are safe to expose. + headers: Optional response headers. + + Returns: + A JSON response with the AstrBot error envelope. + """ + return json_response( + {"status": "error", "message": message, "data": data}, + status_code=status_code, + headers=headers, + ) + + +def file_response( + path: str | Path, + *, + filename: str | None = None, + content_type: str | None = None, + headers: dict[str, str] | None = None, +) -> FileResponse: + """Build a file download response for plugin Web API handlers. + + Args: + path: File path to send. + filename: Optional download filename. + content_type: Optional response media type. + headers: Optional response headers. + + Returns: + A Starlette/FastAPI file response. + """ + return FileResponse( + path, + filename=filename, + media_type=content_type, + headers=headers, + ) + + +def stream_response( + content: Any, + *, + content_type: str = "text/event-stream", + status_code: int = 200, + headers: dict[str, str] | None = None, +) -> StreamingResponse: + """Build a streaming response for plugin Web API handlers. + + Args: + content: Sync or async iterable that yields response chunks. + content_type: Response media type. + status_code: HTTP status code. + headers: Optional response headers. + + Returns: + A Starlette/FastAPI streaming response. + """ + return StreamingResponse( + content, + media_type=content_type, + status_code=status_code, + headers=headers, + ) + + +__all__ = [ + "PluginMultiDict", + "PluginRequest", + "PluginRequestProxy", + "PluginUploadFile", + "bind_request_context", + "error_response", + "file_response", + "json_response", + "request", + "stream_response", +] diff --git a/astrbot/builtin_stars/astrbot/group_chat_context.py b/astrbot/builtin_stars/astrbot/group_chat_context.py index 7fee3c0df..0dae34842 100644 --- a/astrbot/builtin_stars/astrbot/group_chat_context.py +++ b/astrbot/builtin_stars/astrbot/group_chat_context.py @@ -7,7 +7,18 @@ from collections import defaultdict, deque from astrbot import logger from astrbot.api import star from astrbot.api.event import AstrMessageEvent -from astrbot.api.message_components import At, Image, Plain +from astrbot.api.message_components import ( + At, + AtAll, + Face, + File, + Forward, + Image, + Plain, + Record, + Reply, + Video, +) from astrbot.api.platform import MessageType from astrbot.api.provider import Provider, ProviderRequest from astrbot.core.agent.message import TextPart @@ -214,10 +225,60 @@ class GroupChatContext: if is_at_self: parts.insert(1, "⚠️[DIRECTED AT YOU] ") parts.append(f" [At: {comp.name}]") + elif isinstance(comp, Reply): + if comp.message_str: + parts.append( + f" [Quote({comp.sender_nickname}: {_truncate_reply_text(comp.message_str)})]" + ) + elif comp.chain: + chain_desc = _describe_chain(comp.chain) + parts.append(f" [Quote({comp.sender_nickname}: {chain_desc})]") + else: + parts.append(" [Quote]") return "".join(parts) +_MAX_REPLY_TEXT_LENGTH = 200 + + +def _describe_chain(chain: list) -> str: + """Summarize message chain content for quoted reply display.""" + desc = [] + for c in chain: + if isinstance(c, Plain) and getattr(c, "text", None): + desc.append(c.text) + elif isinstance(c, Image): + desc.append("[Image]") + elif isinstance(c, At): + name = getattr(c, "name", "") or getattr(c, "qq", "") + desc.append(f"[At: {name}]") + elif isinstance(c, Record): + desc.append("[Voice]") + elif isinstance(c, Video): + desc.append("[Video]") + elif isinstance(c, File): + desc.append(f"[File: {getattr(c, 'name', '') or ''}]") + elif isinstance(c, Forward): + desc.append("[Forward]") + elif isinstance(c, AtAll): + desc.append("[At: All]") + elif isinstance(c, Face): + desc.append(f"[Sticker: {getattr(c, 'id', '')}]") + elif isinstance(c, Reply): + desc.append("[Quote]") + else: + desc.append(f"[{c.__class__.__name__}]") + return "".join(desc) or "[Unknown]" + + +def _truncate_reply_text(text: str) -> str: + """Truncate overly long quoted reply text.""" + if len(text) <= _MAX_REPLY_TEXT_LENGTH: + return text + return text[:_MAX_REPLY_TEXT_LENGTH] + "..." + + def _positive_int(value, fallback: int) -> int: try: parsed = int(value) diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py index 5e6913fc1..f9b7d066c 100644 --- a/astrbot/cli/__init__.py +++ b/astrbot/cli/__init__.py @@ -1 +1,3 @@ -__version__ = "4.25.3" +from astrbot import __version__ + +__all__ = ["__version__"] diff --git a/astrbot/cli/utils/basic.py b/astrbot/cli/utils/basic.py new file mode 100644 index 000000000..246db0a68 --- /dev/null +++ b/astrbot/cli/utils/basic.py @@ -0,0 +1,83 @@ +from pathlib import Path + +import click + +# Static assets bundled inside the installed wheel (built by hatch_build.py). +_BUNDLED_DIST = Path(__file__).parent.parent.parent / "dashboard" / "dist" + + +def check_astrbot_root(path: str | Path) -> bool: + """Check if the path is an AstrBot root directory""" + if not isinstance(path, Path): + path = Path(path) + if not path.exists() or not path.is_dir(): + return False + if not (path / ".astrbot").exists(): + return False + return True + + +def get_astrbot_root() -> Path: + """Get the AstrBot root directory path""" + return Path.cwd() + + +async def check_dashboard(astrbot_root: Path) -> None: + """Check if the dashboard is installed""" + from astrbot.core.config.default import VERSION + from astrbot.core.utils.io import download_dashboard, get_dashboard_version + + from .version_comparator import VersionComparator + + # If the wheel ships bundled dashboard assets, no network download is needed. + if _BUNDLED_DIST.exists(): + click.echo("Dashboard is bundled with the package – skipping download.") + return + + try: + dashboard_version = await get_dashboard_version() + match dashboard_version: + case None: + click.echo("Dashboard is not installed") + if click.confirm( + "Install dashboard?", + default=True, + ): + click.echo("Installing dashboard...") + await download_dashboard( + path="data/dashboard.zip", + extract_path=str(astrbot_root), + version=f"v{VERSION}", + latest=False, + ) + click.echo("Dashboard installed successfully") + + case str(): + if VersionComparator.compare_version(VERSION, dashboard_version) <= 0: + click.echo("Dashboard is already up to date") + return + try: + version = dashboard_version.split("v")[1] + click.echo(f"Dashboard version: {version}") + await download_dashboard( + path="data/dashboard.zip", + extract_path=str(astrbot_root), + version=f"v{VERSION}", + latest=False, + ) + except Exception as e: + click.echo(f"Failed to download dashboard: {e}") + return + except FileNotFoundError: + click.echo("Initializing dashboard directory...") + try: + await download_dashboard( + path=str(astrbot_root / "dashboard.zip"), + extract_path=str(astrbot_root), + version=f"v{VERSION}", + latest=False, + ) + click.echo("Dashboard initialized successfully") + except Exception as e: + click.echo(f"Failed to download dashboard: {e}") + return diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index 72bb84fa5..3a7885897 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -11,6 +11,7 @@ from contextlib import AsyncExitStack from datetime import timedelta from typing import Any, Generic, TextIO +import httpx from tenacity import ( before_sleep_log, retry, @@ -36,12 +37,22 @@ except (ModuleNotFoundError, ImportError): "Warning: Missing 'mcp' dependency, MCP services will be unavailable.", ) +streamable_http_client_legacy = None +streamable_http_client = None + try: - from mcp.client.streamable_http import streamablehttp_client -except (ModuleNotFoundError, ImportError): - logger.warning( - "Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.", + from mcp.client.streamable_http import ( + streamablehttp_client as streamable_http_client_legacy, ) +except (ModuleNotFoundError, ImportError): + try: + from mcp.client.streamable_http import ( + streamable_http_client as streamable_http_client, + ) + except (ModuleNotFoundError, ImportError): + logger.warning( + "Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.", + ) class TenacityLogger: @@ -73,6 +84,23 @@ def _prepare_config(config: dict) -> dict: return config +def validate_mcp_stdio_config(config: dict) -> None: + """Validate MCP stdio configuration in a backward-compatible way.""" + cfg = _prepare_config(config.copy()) + if "url" in cfg: + return + + command = cfg.get("command") + if not isinstance(command, str) or not command.strip(): + raise ValueError("MCP stdio server requires a non-empty command.") + + args = cfg.get("args") + if args is not None and ( + not isinstance(args, list) or not all(isinstance(arg, str) for arg in args) + ): + raise ValueError("MCP stdio args must be a list of strings.") + + def _prepare_stdio_env(config: dict) -> dict: """Preserve Windows executable resolution for stdio subprocesses.""" if sys.platform != "win32": @@ -183,11 +211,9 @@ def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]: properties = normalized.get("properties") if isinstance(properties, dict): - original_properties = ( - node.get("properties") - if isinstance(node.get("properties"), dict) - else {} - ) + original_properties = node.get("properties") + if not isinstance(original_properties, dict): + original_properties = {} required = normalized.get("required") required_list = required[:] if isinstance(required, list) else [] @@ -318,17 +344,38 @@ class MCPClient: ) self.session = session else: - timeout = timedelta(seconds=cfg.get("timeout", 30)) - sse_read_timeout = timedelta( - seconds=cfg.get("sse_read_timeout", 60 * 5), - ) - self._streams_context = streamablehttp_client( - url=cfg["url"], - headers=cfg.get("headers", {}), - timeout=timeout, - sse_read_timeout=sse_read_timeout, - terminate_on_close=cfg.get("terminate_on_close", True), - ) + timeout_seconds = cfg.get("timeout", 30) + sse_read_timeout_seconds = cfg.get("sse_read_timeout", 60 * 5) + if streamable_http_client_legacy: + timeout = timedelta(seconds=timeout_seconds) + sse_read_timeout = timedelta(seconds=sse_read_timeout_seconds) + self._streams_context = streamable_http_client_legacy( + url=cfg["url"], + headers=cfg.get("headers", {}), + timeout=timeout, + sse_read_timeout=sse_read_timeout, + terminate_on_close=cfg.get("terminate_on_close", True), + ) + elif streamable_http_client: + http_client = await self.exit_stack.enter_async_context( + httpx.AsyncClient( + headers=cfg.get("headers", {}), + timeout=httpx.Timeout( + timeout_seconds, + read=sse_read_timeout_seconds, + ), + follow_redirects=True, + ), + ) + self._streams_context = streamable_http_client( + url=cfg["url"], + http_client=http_client, + terminate_on_close=cfg.get("terminate_on_close", True), + ) + else: + raise RuntimeError( + "Streamable HTTP transport is not available in the installed MCP library version." + ) read_s, write_s, _ = await self.exit_stack.enter_async_context( self._streams_context, ) diff --git a/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py b/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py index de7ecc0ef..19f952bbe 100644 --- a/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py +++ b/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py @@ -28,6 +28,7 @@ from .deerflow_api_client import DeerFlowAPIClient from .deerflow_content_mapper import ( build_chain_from_ai_content, build_user_content, + build_user_content_resolved, image_component_from_url, ) from .deerflow_stream_utils import ( @@ -420,6 +421,34 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]): ) return messages + async def _build_messages_resolved( + self, + prompt: str, + image_urls: list[str], + system_prompt: str | None, + ) -> list[dict[str, T.Any]]: + """Build DeerFlow messages after materializing image references. + + Args: + prompt: User prompt text. + image_urls: Image references accepted by MediaResolver. + system_prompt: Optional system prompt prepended to the request. + + Returns: + Messages payload for DeerFlow. + """ + + messages: list[dict[str, T.Any]] = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append( + { + "role": "user", + "content": await build_user_content_resolved(prompt, image_urls), + }, + ) + return messages + def _build_runtime_configurable(self, thread_id: str) -> dict[str, T.Any]: runtime_configurable: dict[str, T.Any] = { "thread_id": thread_id, @@ -458,6 +487,43 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]): }, } + async def _build_payload_resolved( + self, + thread_id: str, + prompt: str, + image_urls: list[str], + system_prompt: str | None, + ) -> dict[str, T.Any]: + """Build a DeerFlow request payload with resolved media refs. + + Args: + thread_id: DeerFlow thread id. + prompt: User prompt text. + image_urls: Image references accepted by MediaResolver. + system_prompt: Optional system prompt prepended to the request. + + Returns: + Complete DeerFlow stream request payload. + """ + + runtime_configurable = self._build_runtime_configurable(thread_id) + return { + "assistant_id": self.assistant_id, + "input": { + "messages": await self._build_messages_resolved( + prompt, + image_urls, + system_prompt, + ), + }, + "stream_mode": ["values", "messages-tuple", "custom"], + "context": dict(runtime_configurable), + "config": { + "recursion_limit": self.recursion_limit, + "configurable": runtime_configurable, + }, + } + def _update_text_and_maybe_stream( self, *, @@ -642,7 +708,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]): system_prompt = self.req.system_prompt thread_id = await self._ensure_thread_id(session_id) - payload = self._build_payload( + payload = await self._build_payload_resolved( thread_id=thread_id, prompt=prompt, image_urls=image_urls, diff --git a/astrbot/core/agent/runners/deerflow/deerflow_content_mapper.py b/astrbot/core/agent/runners/deerflow/deerflow_content_mapper.py index dbb7e893e..0240cd599 100644 --- a/astrbot/core/agent/runners/deerflow/deerflow_content_mapper.py +++ b/astrbot/core/agent/runners/deerflow/deerflow_content_mapper.py @@ -5,6 +5,10 @@ from typing import Any import astrbot.core.message.components as Comp from astrbot import logger from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.utils.media_utils import ( + describe_media_ref, + resolve_media_ref_to_base64_data, +) from .deerflow_stream_utils import extract_text @@ -94,6 +98,88 @@ def build_user_content(prompt: str, image_urls: list[str]) -> Any: return content +async def build_user_content_resolved(prompt: str, image_urls: list[str]) -> Any: + """Build DeerFlow user content after resolving all supported image refs. + + Args: + prompt: User text to include before image blocks. + image_urls: Image references from plugins or message attachments. Supports + local paths, HTTP(S), file URIs, base64://, data URIs, and bare base64. + + Returns: + Plain text when no images are present; otherwise a multimodal content list. + """ + + if not image_urls: + return prompt + + content: list[dict[str, Any]] = [] + skipped_invalid_images = 0 + any_valid_image = False + if prompt: + content.append({"type": "text", "text": prompt}) + + for image_url in image_urls: + if not isinstance(image_url, str): + skipped_invalid_images += 1 + logger.debug( + "Skipped DeerFlow image input because value is not a string: %r", + type(image_url).__name__, + ) + continue + image_ref = image_url.strip() + if not image_ref: + skipped_invalid_images += 1 + logger.debug("Skipped DeerFlow image input because value is empty.") + continue + try: + image_data = await resolve_media_ref_to_base64_data( + image_ref, + media_type="image", + ) + except Exception as exc: + skipped_invalid_images += 1 + logger.debug( + "Skipped DeerFlow image input %s: %s", + describe_media_ref(image_ref), + exc, + ) + continue + if not image_data: + skipped_invalid_images += 1 + logger.debug( + "Skipped DeerFlow image input %s because it could not be resolved.", + describe_media_ref(image_ref), + ) + continue + content.append( + { + "type": "image_url", + "image_url": {"url": image_data.to_data_url()}, + }, + ) + any_valid_image = True + + if skipped_invalid_images: + note_text = ( + "Note: some images could not be processed and were ignored." + if any_valid_image + else "Note: none of the provided images could be processed." + ) + content.insert(0, {"type": "text", "text": note_text}) + if not any_valid_image: + logger.warning( + "All %d provided DeerFlow image inputs were rejected as invalid or unsupported.", + skipped_invalid_images, + ) + else: + logger.info( + "%d DeerFlow image input(s) were rejected as invalid or unsupported.", + skipped_invalid_images, + ) + return content + + def image_component_from_url(url: Any) -> Comp.Image | None: if not isinstance(url, str): return None diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 3f74f0ec9..b56d7e62f 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -5,7 +5,6 @@ import time import traceback import typing as T import uuid -from collections.abc import AsyncIterator from contextlib import suppress from dataclasses import dataclass, field, replace from pathlib import Path @@ -225,6 +224,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): custom_compressor: ContextCompressor | None = None, tool_schema_mode: str | None = "full", fallback_providers: list[Provider] | None = None, + request_max_retries: int | None = None, tool_result_overflow_dir: str | None = None, read_tool: FunctionTool | None = None, **kwargs: T.Any, @@ -238,6 +238,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): self.truncate_turns = truncate_turns self.custom_token_counter = custom_token_counter self.custom_compressor = custom_compressor + self.request_max_retries = request_max_retries self.tool_result_overflow_dir = tool_result_overflow_dir self.read_tool = read_tool self._tool_result_token_counter = EstimateTokenCounter() @@ -464,6 +465,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): "session_id": self.req.session_id, "extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart] "abort_signal": self._abort_signal, + "request_max_retries": self.request_max_retries, } if include_model: # For primary provider we keep explicit model selection if provided. @@ -1306,6 +1308,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): extra_user_content_parts=self.req.extra_user_content_parts, # tool_choice="required", abort_signal=self._abort_signal, + request_max_retries=self.request_max_retries, ) if requery_resp: llm_resp = requery_resp @@ -1332,6 +1335,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): extra_user_content_parts=self.req.extra_user_content_parts, # tool_choice="required", abort_signal=self._abort_signal, + request_max_retries=self.request_max_retries, ) if repair_resp: llm_resp = repair_resp @@ -1404,7 +1408,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): async def _iter_tool_executor_results( self, - executor: AsyncIterator[ToolExecutorResultT], + executor: T.AsyncGenerator[ToolExecutorResultT, None], ) -> T.AsyncGenerator[ToolExecutorResultT, None]: async def _next_executor_result() -> ToolExecutorResultT: return await anext(executor) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 274c905ac..07ea3639d 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -277,8 +277,13 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): ) if tools is None: toolset = ToolSet() - for registered_tool in llm_tools.func_list: - if isinstance(registered_tool, HandoffTool): + handoff_names = { + tool.name + for tool in tool_mgr.func_list + if isinstance(tool, HandoffTool) + } + for registered_tool in tool_mgr.get_full_tool_set(): + if registered_tool.name in handoff_names: continue if registered_tool.active: toolset.add_tool(registered_tool) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index ee007140b..16ebac7a8 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -85,6 +85,8 @@ from astrbot.core.tools.web_search_tools import ( BaiduWebSearchTool, BochaWebSearchTool, BraveWebSearchTool, + ExaGetContentsTool, + ExaWebSearchTool, FirecrawlExtractWebPageTool, FirecrawlWebSearchTool, TavilyExtractWebPageTool, @@ -115,12 +117,22 @@ from astrbot.core.utils.quoted_message_parser import ( from astrbot.core.utils.string_utils import normalize_and_dedupe_strings LLM_ERROR_MESSAGE_EXTRA_KEY = "_llm_error_message" +WEEKDAY_NAMES = ( + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +) WEB_SEARCH_CITATION_TOOL_NAMES = frozenset( { "web_search_baidu", "web_search_tavily", "web_search_bocha", "web_search_brave", + "web_search_exa", } ) WEB_SEARCH_CITATION_PROMPT = ( @@ -269,10 +281,11 @@ async def _apply_kb( ) if not kb_result: return - if req.system_prompt is not None: - req.system_prompt += ( - f"\n\n[Related Knowledge Base Results]:\n{kb_result}" - ) + req.extra_user_content_parts.append( + TextPart( + text=f"[Related Knowledge Base Results]:\n{kb_result}", + ).mark_as_temp() + ) except Exception as exc: # noqa: BLE001 logger.error("Error occurred while retrieving knowledge base: %s", exc) else: @@ -485,14 +498,26 @@ async def _ensure_persona_and_skills( skill_manager = SkillManager() skills = skill_manager.list_skills(active_only=True, runtime=runtime) skills = _filter_skills_for_current_config(skills, cfg) + workspace_skills = ( + skill_manager.list_workspace_skills( + _get_workspace_path_for_umo(event.unified_msg_origin) + ) + if runtime == "local" + else [] + ) - if skills: + if skills or workspace_skills: if persona and persona.get("skills") is not None: if not persona["skills"]: skills = [] else: allowed = set(persona["skills"]) skills = [skill for skill in skills if skill.name in allowed] + if workspace_skills and (not persona or persona.get("skills") != []): + skills_by_name = {skill.name: skill for skill in skills} + for skill in workspace_skills: + skills_by_name[skill.name] = skill + skills = [skills_by_name[name] for name in sorted(skills_by_name)] if skills: req.system_prompt += f"\n{build_skills_prompt(skills)}\n" if runtime == "none": @@ -680,7 +705,20 @@ async def _append_video_attachment( video_path = await video.convert_to_file_path() except Exception as exc: # noqa: BLE001 if quoted: - logger.error("Error processing quoted video attachment: %s", exc) + logger.debug( + "Quoted video attachment is not locally resolvable, preserving ref: %s", + exc, + ) + video_ref = video.path or video.url or video.file or "" + ref_name = os.path.basename(video_ref.split("?", 1)[0].rstrip("/")) + req.extra_user_content_parts.append( + TextPart( + text=( + "[Video Attachment in quoted message: " + f"name {ref_name or 'video'}, ref {video_ref}]" + ) + ) + ) else: logger.error("Error processing video attachment: %s", exc) return @@ -767,6 +805,7 @@ async def _process_quote_message( quoted_message_settings: QuotedMessageParserSettings = DEFAULT_QUOTED_MESSAGE_SETTINGS, config: MainAgentBuildConfig | None = None, main_provider_supports_image: bool = False, + skip_quote_image_caption: bool = False, ) -> None: quote = None for comp in event.message_obj.message: @@ -796,54 +835,63 @@ async def _process_quote_message( image_seg = comp break - if image_seg and main_provider_supports_image: - logger.debug( - "Skipping quote image captioning because the main provider supports image input." - ) - elif image_seg and not img_cap_prov_id: - logger.debug( - "No dedicated image caption provider configured. " - "Skipping quote image captioning." - ) - elif image_seg: - try: - prov = None - path = None - compress_path = None - prov = plugin_context.get_provider_by_id(img_cap_prov_id) - if prov is None: - prov = plugin_context.get_using_provider(event.unified_msg_origin) + if image_seg: + if skip_quote_image_caption: + logger.debug( + "Skipping quote image captioning because image captioning already handled this request." + ) + elif main_provider_supports_image: + logger.debug( + "Skipping quote image captioning because the main provider supports image input." + ) + elif not img_cap_prov_id: + logger.debug( + "No dedicated image caption provider configured. " + "Skipping quote image captioning." + ) + else: + try: + prov = None + path = None + compress_path = None + prov = plugin_context.get_provider_by_id(img_cap_prov_id) + if prov is None: + prov = plugin_context.get_using_provider(event.unified_msg_origin) - if prov and isinstance(prov, Provider): - path = await image_seg.convert_to_file_path() - compress_path = await _compress_image_for_provider( - path, - config.provider_settings if config else None, - ) - if path and _is_generated_compressed_image_path(path, compress_path): - event.track_temporary_local_file(compress_path) - llm_resp = await prov.text_chat( - prompt="Please describe the image content.", - image_urls=[compress_path], - ) - if llm_resp.completion_text: - content_parts.append( - f"[Image Caption in quoted message]: {llm_resp.completion_text}" + if prov and isinstance(prov, Provider): + path = await image_seg.convert_to_file_path() + compress_path = await _compress_image_for_provider( + path, + config.provider_settings if config else None, ) - else: - logger.warning("No provider found for image captioning in quote.") - except BaseException as exc: - logger.error("处理引用图片失败: %s", exc) - finally: - if ( - compress_path - and compress_path != path - and os.path.exists(compress_path) - ): - try: - os.remove(compress_path) - except Exception as exc: # noqa: BLE001 - logger.warning("Fail to remove temporary compressed image: %s", exc) + if path and _is_generated_compressed_image_path( + path, compress_path + ): + event.track_temporary_local_file(compress_path) + llm_resp = await prov.text_chat( + prompt="Please describe the image content.", + image_urls=[compress_path], + ) + if llm_resp.completion_text: + content_parts.append( + f"[Image Caption in quoted message]: {llm_resp.completion_text}" + ) + else: + logger.warning("No provider found for image captioning in quote.") + except BaseException as exc: + logger.error("处理引用图片失败: %s", exc) + finally: + if ( + compress_path + and compress_path != path + and os.path.exists(compress_path) + ): + try: + os.remove(compress_path) + except Exception as exc: # noqa: BLE001 + logger.warning( + "Fail to remove temporary compressed image: %s", exc + ) quoted_content = "\n".join(content_parts) quoted_text = f"\n{quoted_content}\n" @@ -874,18 +922,17 @@ def _append_system_reminders( system_parts.append(f"Group name: {group_name}") if cfg.get("datetime_system_prompt"): - current_time = None + now = None if timezone: try: now = datetime.datetime.now(zoneinfo.ZoneInfo(timezone)) - current_time = now.strftime("%Y-%m-%d %H:%M (%Z)") except Exception as exc: # noqa: BLE001 logger.error("时区设置错误: %s, 使用本地时区", exc) - if not current_time: - current_time = ( - datetime.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%Z)") - ) - system_parts.append(f"Current datetime: {current_time}") + if now is None: + now = datetime.datetime.now().astimezone() + current_time = now.strftime("%Y-%m-%d %H:%M (%Z)") + weekday = WEEKDAY_NAMES[now.weekday()] + system_parts.append(f"Current datetime: {current_time}, Weekday: {weekday}") if system_parts: system_content = ( @@ -910,11 +957,12 @@ async def _decorate_llm_request( main_provider_supports_image = provider is not None and _provider_supports_modality( provider, "image" ) + img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or "" + quote_images_already_captioned = False if req.conversation: await _ensure_persona_and_skills(req, cfg, plugin_context, event) - img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or "" if img_cap_prov_id and req.image_urls and not main_provider_supports_image: await _ensure_img_caption( event, @@ -923,8 +971,8 @@ async def _decorate_llm_request( plugin_context, img_cap_prov_id, ) + quote_images_already_captioned = True - img_cap_prov_id = cfg.get("default_image_caption_provider_id") or "" quoted_message_settings = _get_quoted_message_parser_settings(cfg) await _process_quote_message( event, @@ -934,6 +982,7 @@ async def _decorate_llm_request( quoted_message_settings, config, main_provider_supports_image=main_provider_supports_image, + skip_quote_image_caption=quote_images_already_captioned, ) tz = config.timezone @@ -1161,6 +1210,9 @@ async def _apply_web_search_tools( req.func_tool.add_tool(tool_mgr.get_builtin_tool(FirecrawlExtractWebPageTool)) elif provider == "baidu_ai_search": req.func_tool.add_tool(tool_mgr.get_builtin_tool(BaiduWebSearchTool)) + elif provider == "exa": + req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaWebSearchTool)) + req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaGetContentsTool)) def _apply_web_search_citation_prompt( @@ -1570,6 +1622,7 @@ async def build_main_agent( enforce_max_turns=config.max_context_length, tool_schema_mode=config.tool_schema_mode, fallback_providers=fallback_providers, + request_max_retries=config.provider_settings.get("request_max_retries", 5), tool_result_overflow_dir=( get_astrbot_system_tmp_path() if req.func_tool and req.func_tool.get_tool("astrbot_file_read_tool") diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py index ff3e5830a..f8ed339d6 100644 --- a/astrbot/core/computer/booters/local.py +++ b/astrbot/core/computer/booters/local.py @@ -134,14 +134,17 @@ class LocalPythonComponent(PythonComponent): kernel_id: str | None = None, timeout: int = 30, silent: bool = False, + cwd: str | None = None, ) -> dict[str, Any]: def _run() -> dict[str, Any]: try: + working_dir = os.path.abspath(cwd) if cwd else get_astrbot_root() result = subprocess.run( [os.environ.get("PYTHON", sys.executable), "-c", code], check=False, timeout=timeout, capture_output=True, + cwd=working_dir, ) stdout = "" if silent else _decode_shell_output(result.stdout) stderr = ( diff --git a/astrbot/core/computer/olayer/python.py b/astrbot/core/computer/olayer/python.py index 78bdad0bf..6bc9897e9 100644 --- a/astrbot/core/computer/olayer/python.py +++ b/astrbot/core/computer/olayer/python.py @@ -12,6 +12,7 @@ class PythonComponent(Protocol): kernel_id: str | None = None, timeout: int = 30, silent: bool = False, + cwd: str | None = None, ) -> dict[str, Any]: """Execute Python code""" ... diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 20ecb07e3..700a345cc 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -2,10 +2,12 @@ import os +from astrbot import __version__ from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG from astrbot.core.utils.astrbot_path import get_astrbot_data_path -VERSION = "4.25.4" +VERSION = __version__ + DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") PERSONAL_WECHAT_CONFIG_METADATA = { "weixin_oc_base_url": { @@ -101,6 +103,7 @@ DEFAULT_CONFIG = { "enable": True, "default_provider_id": "", "fallback_chat_models": [], + "request_max_retries": 5, "default_image_caption_provider_id": "", "image_caption_prompt": "Please describe the image using Chinese.", "provider_pool": ["*"], # "*" 表示使用所有可用的提供者 @@ -112,6 +115,7 @@ DEFAULT_CONFIG = { "websearch_brave_key": [], "websearch_baidu_app_builder_key": "", "websearch_firecrawl_key": [], + "websearch_exa_key": [], "web_search_link": False, "display_reasoning_text": False, "identifier": False, @@ -132,8 +136,8 @@ DEFAULT_CONFIG = { ), "llm_compress_keep_recent_ratio": 0.15, "llm_compress_provider_id": "", - "max_context_length": 50, - "dequeue_context_length": 10, + "max_context_length": -1, # 默认不限制 + "dequeue_context_length": 1, "streaming_response": False, "show_tool_use_status": False, "show_tool_call_result": False, @@ -328,7 +332,7 @@ CONFIG_METADATA_2 = { "description": "消息平台适配器", "type": "list", "config_template": { - "QQ 官方机器人(WebSocket)": { + "QQ 官方机器人(Websocket, 推荐)": { "id": "default", "type": "qq_official", "enable": True, @@ -1807,6 +1811,25 @@ CONFIG_METADATA_2 = { "gemini_tts_voice_name": "Leda", "proxy": "", }, + "ElevenLabs TTS(API)": { + "hint": "API Key 从 https://elevenlabs.io/app/settings/api-keys 获取。Voice ID 可在 https://elevenlabs.io/app/voice-library 浏览选择。", + "id": "elevenlabs_tts", + "type": "elevenlabs_tts_api", + "provider": "elevenlabs", + "provider_type": "text_to_speech", + "enable": False, + "api_key": "", + "api_base": "https://api.elevenlabs.io/v1", + "model": "eleven_multilingual_v2", + "elevenlabs-tts-voice-id": "JBFqnCBsd6RMkjVDRZzb", + "elevenlabs-tts-output-format": "mp3_44100_128", + "elevenlabs-tts-stability": "", + "elevenlabs-tts-similarity-boost": "", + "elevenlabs-tts-style": "", + "elevenlabs-tts-use-speaker-boost": True, + "timeout": "20", + "proxy": "", + }, "OpenAI Embedding": { "id": "openai_embedding", "type": "openai_embedding", @@ -2789,6 +2812,9 @@ CONFIG_METADATA_2 = { "type": "list", "items": {"type": "string"}, }, + "request_max_retries": { + "type": "int", + }, "wake_prefix": { "type": "string", }, @@ -3148,6 +3174,11 @@ CONFIG_METADATA_3 = { "_special": "select_providers", "hint": "主聊天模型请求失败时,按顺序切换到这些模型。", }, + "provider_settings.request_max_retries": { + "description": "请求最大重试次数", + "type": "int", + "hint": "单次模型请求遇到可重试错误时的最大尝试次数。", + }, "provider_settings.default_image_caption_provider_id": { "description": "默认图片转述模型", "type": "string", @@ -3265,6 +3296,7 @@ CONFIG_METADATA_3 = { "bocha", "brave", "firecrawl", + "exa", ], "condition": { "provider_settings.web_search": True, @@ -3319,6 +3351,16 @@ CONFIG_METADATA_3 = { "provider_settings.web_search": True, }, }, + "provider_settings.websearch_exa_key": { + "description": "Exa API Key", + "type": "list", + "items": {"type": "string"}, + "hint": "可添加多个 Key 进行轮询。Get a key at https://dashboard.exa.ai", + "condition": { + "provider_settings.websearch_provider": "exa", + "provider_settings.web_search": True, + }, + }, "provider_settings.web_search_link": { "description": "显示来源引用", "type": "bool", diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index ddce6f19a..6198e33da 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -753,6 +753,12 @@ class AstrBotCoreLifecycle: name = task.get_name() if hasattr(task, "get_name") else str(task) logger.error(f"任务 {name} 发生错误: {e}") + # 释放数据库引擎连接池 + try: + await self.db.engine.dispose() + except Exception as e: + logger.warning(f"释放数据库引擎失败: {e}") + async def restart(self) -> None: """重启 AstrBot 核心生命周期管理类, 终止各个管理器并重新加载平台实例""" await self.provider_manager.terminate() diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index f2cc92b79..63e5b16db 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -28,6 +28,7 @@ from astrbot.core.db.po import ( UmoAlias, WebChatThread, ) +from astrbot.core.sentinels import NOT_GIVEN @dataclass @@ -501,11 +502,23 @@ class BaseDatabase(abc.ABC): persona_id: str, system_prompt: str | None = None, begin_dialogs: list[str] | None = None, - tools: list[str] | None = None, - skills: list[str] | None = None, - custom_error_message: str | None = None, + tools: list[str] | None | object = NOT_GIVEN, + skills: list[str] | None | object = NOT_GIVEN, + custom_error_message: str | None | object = NOT_GIVEN, ) -> Persona | None: - """Update a persona's system prompt or begin dialogs.""" + """Update a persona record. + + Args: + persona_id: Persona ID to update. + system_prompt: Optional replacement system prompt. + begin_dialogs: Optional replacement begin dialogs. + tools: Tool names, None for all tools, or NOT_GIVEN to leave unchanged. + skills: Skill names, None for all skills, or NOT_GIVEN to leave unchanged. + custom_error_message: Custom fallback message, None to clear, or NOT_GIVEN to leave unchanged. + + Returns: + Updated persona, or None when no fields were updated. + """ ... @abc.abstractmethod diff --git a/astrbot/core/db/vec_db/faiss_impl/__init__.py b/astrbot/core/db/vec_db/faiss_impl/__init__.py index 41f60466c..d46823f5f 100644 --- a/astrbot/core/db/vec_db/faiss_impl/__init__.py +++ b/astrbot/core/db/vec_db/faiss_impl/__init__.py @@ -1,3 +1,9 @@ -from .vec_db import FaissVecDB +def __getattr__(name: str): + if name == "FaissVecDB": + from .vec_db import FaissVecDB + + return FaissVecDB + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + __all__ = ["FaissVecDB"] diff --git a/astrbot/core/file_token_service.py b/astrbot/core/file_token_service.py index 0837f6757..243963c67 100644 --- a/astrbot/core/file_token_service.py +++ b/astrbot/core/file_token_service.py @@ -2,6 +2,7 @@ import asyncio import platform import time import uuid +from typing import Any from urllib.parse import unquote, urlparse import anyio @@ -36,6 +37,7 @@ class FileTokenService: self, file_path: str, expire_seconds: float | None = None, + **kwargs: Any, ) -> str: """向令牌服务注册一个文件。 @@ -73,9 +75,19 @@ class FileTokenService: ) file_token = str(uuid.uuid4()) - expire_time = time.time() + ( - expire_seconds if expire_seconds is not None else self.default_timeout + timeout = kwargs.pop("timeout", None) + if kwargs: + unknown_kwargs = ", ".join(sorted(kwargs.keys())) + raise ValueError(f"Unexpected arguments: {unknown_kwargs}") + + resolved_expire = ( + expire_seconds + if expire_seconds is not None + else timeout + if timeout is not None + else self.default_timeout ) + expire_time = time.time() + resolved_expire # 存储转换后的真实路径 self.staged_files[file_token] = (local_path, expire_time) return file_token diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 99712613a..5e4eed166 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -170,7 +170,7 @@ class RespondStage(Stage): return False if event.get_platform_name() in [ - "qq_official", + "qq_official_webhook", "weixin_official_account", "dingtalk", ]: diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py index a7f15fa22..54290ef69 100644 --- a/astrbot/core/pipeline/result_decorate/stage.py +++ b/astrbot/core/pipeline/result_decorate/stage.py @@ -190,7 +190,6 @@ class ResultDecorateStage(Stage): # 流式输出不执行下面的逻辑 if is_stream: - logger.info("流式输出已启用,跳过结果装饰阶段") return # 需要再获取一次。插件可能直接对 chain 进行了替换。 @@ -208,7 +207,7 @@ class ResultDecorateStage(Stage): # 分段回复 if self.enable_segmented_reply and event.get_platform_name() not in [ - "qq_official", + "qq_official_webhook", "weixin_official_account", "dingtalk", ]: @@ -314,6 +313,8 @@ class ResultDecorateStage(Stage): tts_chain.append(comp) continue + event.track_temporary_local_file(audio_path) + use_file_service = self.ctx.astrbot_config[ "provider_tts_settings" ]["use_file_service"] diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py index f99adfcb2..df3b0268e 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py @@ -265,11 +265,22 @@ class AiocqhttpMessageEvent(AstrMessageEvent): session_id_int = ( int(session_id) if session_id and session_id.isdigit() else None ) + routing_params = {} + if isinstance(event, Event) and event.get("self_id"): + routing_params["self_id"] = event["self_id"] if is_group and isinstance(session_id_int, int): - await bot.send_group_msg(group_id=session_id_int, message=messages) + await bot.send_group_msg( + group_id=session_id_int, + message=messages, + **routing_params, + ) elif not is_group and isinstance(session_id_int, int): - await bot.send_private_msg(user_id=session_id_int, message=messages) + await bot.send_private_msg( + user_id=session_id_int, + message=messages, + **routing_params, + ) elif isinstance(event, Event): # 最后兜底 await bot.send(event=event, message=messages) else: @@ -341,9 +352,13 @@ class AiocqhttpMessageEvent(AstrMessageEvent): if is_group: payload["group_id"] = session_id + if isinstance(event, Event) and event.get("self_id"): + payload["self_id"] = event["self_id"] await bot.call_action("send_group_forward_msg", **payload) else: payload["user_id"] = session_id + if isinstance(event, Event) and event.get("self_id"): + payload["self_id"] = event["self_id"] await bot.call_action("send_private_forward_msg", **payload) elif isinstance(seg, File): # 使用 OneBot V11 文件 API 发送文件 @@ -440,14 +455,20 @@ class AiocqhttpMessageEvent(AstrMessageEvent): else: return None + routing_params = {} + if getattr(self.message_obj, "self_id", None): + routing_params["self_id"] = self.message_obj.self_id + info: dict = await self.bot.call_action( "get_group_info", group_id=group_id, + **routing_params, ) members: list[dict] = await self.bot.call_action( "get_group_member_list", group_id=group_id, + **routing_params, ) owner_id = None diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index c24a05b8b..c52704cdf 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -27,6 +27,7 @@ from astrbot.core.platform.register import register_platform_adapter from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.media_utils import ( + MediaResolver, convert_audio_format, convert_video_format, extract_video_cover, @@ -278,7 +279,12 @@ class DingtalkPlatformAdapter(Platform): voice_ext, ) if f_path: - abm.message.append(Record.fromFileSystem(f_path)) + path_wav = await MediaResolver( + f_path, + media_type="audio", + default_suffix=".wav", + ).to_path(target_format="wav") + abm.message.append(Record(file=path_wav, url=path_wav)) case "file": download_code = cast("str", raw_content.get("downloadCode") or "") if not download_code: @@ -755,17 +761,26 @@ class DingtalkPlatformAdapter(Platform): # at_str=at_str, ) - async def handle_msg(self, abm: AstrBotMessage) -> None: - event = DingtalkMessageEvent( - message_str=abm.message_str, - message_obj=abm, + def create_event(self, message: AstrBotMessage) -> DingtalkMessageEvent: + """Creates a Dingtalk message event. + + Args: + message: AstrBot message object to wrap. + + Returns: + Created Dingtalk message event. + """ + return DingtalkMessageEvent( + message_str=message.message_str, + message_obj=message, platform_meta=self.meta(), - session_id=abm.session_id, + session_id=message.session_id, client=self.client, adapter=self, ) - self._event_queue.put_nowait(event) + async def handle_msg(self, abm: AstrBotMessage) -> None: + self.commit_event(self.create_event(abm)) async def run(self) -> None: # await self.client_.start() diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index b86d2433a..22c3c23b7 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -15,6 +15,7 @@ from astrbot.api.platform import ( ) from astrbot.core.message.components import BaseMessageComponent, File, Record, Video from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.utils.media_utils import MediaResolver from .kook_client import KookClient from .kook_config import KookConfig @@ -71,13 +72,8 @@ class KookPlatformAdapter(Platform): inner_message = AstrBotMessage() inner_message.session_id = session.session_id inner_message.type = session.message_type - message_event = KookEvent( - message_str=message_chain.get_plain_text(), - message_obj=inner_message, - platform_meta=self.meta(), - session_id=session.session_id, - client=self.client, - ) + inner_message.message_str = message_chain.get_plain_text() + message_event = self.create_event(inner_message) await message_event.send(message_chain) def meta(self) -> PlatformMetadata: @@ -431,7 +427,12 @@ class KookPlatformAdapter(Platform): elif file_type == KookModuleType.VIDEO: message.append(Video(file=file_url)) elif file_type == KookModuleType.AUDIO: - message.append(Record(file=file_url)) + path_wav = await MediaResolver( + file_url, + media_type="audio", + default_suffix=".wav", + ).to_path(target_format="wav") + message.append(Record(file=path_wav, url=path_wav)) else: logger.warning(f"[KOOK] 跳过未知文件类型: {file_type.name}") @@ -507,12 +508,22 @@ class KookPlatformAdapter(Platform): return abm - async def handle_msg(self, message: AstrBotMessage): - message_event = KookEvent( + def create_event(self, message: AstrBotMessage) -> KookEvent: + """Creates a KOOK message event. + + Args: + message: AstrBot message object to wrap. + + Returns: + Created KOOK message event. + """ + return KookEvent( message_str=message.message_str, message_obj=message, platform_meta=self.meta(), session_id=message.session_id, client=self.client, ) - self.commit_event(message_event) + + async def handle_msg(self, message: AstrBotMessage): + self.commit_event(self.create_event(message)) diff --git a/astrbot/core/platform/sources/lark/server.py b/astrbot/core/platform/sources/lark/server.py index f7e715092..c1c549c35 100644 --- a/astrbot/core/platform/sources/lark/server.py +++ b/astrbot/core/platform/sources/lark/server.py @@ -136,7 +136,7 @@ class LarkWebhookServer: """处理 webhook 回调,可被统一 webhook 入口复用 Args: - request: Quart 请求对象 + request: webhook 请求对象 Returns: 响应数据 diff --git a/astrbot/core/platform/sources/mattermost/client.py b/astrbot/core/platform/sources/mattermost/client.py index 37ba33474..6ba63dfbb 100644 --- a/astrbot/core/platform/sources/mattermost/client.py +++ b/astrbot/core/platform/sources/mattermost/client.py @@ -10,6 +10,7 @@ from astrbot.api import logger from astrbot.api.event import MessageChain from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.utils.media_utils import MediaResolver class MattermostClient: @@ -245,7 +246,12 @@ class MattermostClient: if mime_type.startswith("image/"): components.append(Image.fromFileSystem(str(file_path))) elif mime_type.startswith("audio/"): - components.append(Record.fromFileSystem(str(file_path))) + path_wav = await MediaResolver( + str(file_path), + media_type="audio", + default_suffix=".wav", + ).to_path(target_format="wav") + components.append(Record(file=path_wav, url=path_wav)) elif mime_type.startswith("video/"): components.append(Video.fromFileSystem(str(file_path))) else: diff --git a/astrbot/core/platform/sources/mattermost/mattermost_adapter.py b/astrbot/core/platform/sources/mattermost/mattermost_adapter.py index cb2b5888f..4e52f48b3 100644 --- a/astrbot/core/platform/sources/mattermost/mattermost_adapter.py +++ b/astrbot/core/platform/sources/mattermost/mattermost_adapter.py @@ -305,15 +305,25 @@ class MattermostPlatformAdapter(Platform): return raw_value // 1000 if raw_value > 1_000_000_000_000 else raw_value return int(time.time()) - async def handle_msg(self, message: AstrBotMessage) -> None: - message_event = MattermostMessageEvent( + def create_event(self, message: AstrBotMessage) -> MattermostMessageEvent: + """Creates a Mattermost message event. + + Args: + message: AstrBot message object to wrap. + + Returns: + Created Mattermost message event. + """ + return MattermostMessageEvent( message_str=message.message_str, message_obj=message, platform_meta=self.meta(), session_id=message.session_id, client=self.client, ) - self.commit_event(message_event) + + async def handle_msg(self, message: AstrBotMessage) -> None: + self.commit_event(self.create_event(message)) async def terminate(self) -> None: self._running = False diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index 00f49f479..fe4266fc4 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -4,6 +4,7 @@ from typing import Any import astrbot.api.message_components as Comp from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType +from astrbot.core.utils.media_utils import MediaResolver class FileIDExtractor: @@ -266,7 +267,7 @@ def add_at_mention_if_needed( return text -def create_file_component(file_info: dict[str, Any]) -> tuple[Any, str]: +async def create_file_component(file_info: dict[str, Any]) -> tuple[Any, str]: """创建文件组件和描述文本""" file_url = file_info.get("url", "") file_name = file_info.get("name", "未知文件") @@ -275,13 +276,18 @@ def create_file_component(file_info: dict[str, Any]) -> tuple[Any, str]: if file_type.startswith("image/"): return Comp.Image(url=file_url, file=file_name), f"图片[{file_name}]" if file_type.startswith("audio/"): - return Comp.Record(url=file_url, file=file_name), f"音频[{file_name}]" + path_wav = await MediaResolver( + file_url, + media_type="audio", + default_suffix=".wav", + ).to_path(target_format="wav") + return Comp.Record(url=path_wav, file=path_wav), f"音频[{file_name}]" if file_type.startswith("video/"): return Comp.Video(url=file_url, file=file_name), f"视频[{file_name}]" return Comp.File(name=file_name, url=file_url), f"文件[{file_name}]" -def process_files( +async def process_files( message: AstrBotMessage, files: list, include_text_parts: bool = True, @@ -289,7 +295,7 @@ def process_files( """处理文件列表,添加到消息组件中并返回文本描述""" file_parts = [] for file_info in files: - component, part_text = create_file_component(file_info) + component, part_text = await create_file_component(file_info) message.message.append(component) if include_text_parts: file_parts.append(part_text) diff --git a/astrbot/core/platform/sources/qqofficial/login_registration.py b/astrbot/core/platform/sources/qqofficial/login_registration.py new file mode 100644 index 000000000..ebc13a216 --- /dev/null +++ b/astrbot/core/platform/sources/qqofficial/login_registration.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import base64 +import secrets +from dataclasses import dataclass +from typing import Any +from urllib.parse import quote + +import httpx +from Crypto.Cipher import AES + +DEFAULT_QQOFFICIAL_BIND_HOST = "q.qq.com" +DEFAULT_QQOFFICIAL_QR_POLL_INTERVAL = 2 +DEFAULT_QQOFFICIAL_API_TIMEOUT_MS = 10_000 + +QQOFFICIAL_BIND_STATUS_NONE = 0 +QQOFFICIAL_BIND_STATUS_PENDING = 1 +QQOFFICIAL_BIND_STATUS_COMPLETED = 2 +QQOFFICIAL_BIND_STATUS_EXPIRED = 3 + + +@dataclass +class QQOfficialLoginRegistration: + task_id: str + bind_key: str + qrcode: str + interval: int + + +def _string_field(data: dict[str, Any], key: str) -> str: + value = data.get(key) + if isinstance(value, str): + return value.strip() + return "" + + +def _int_config(value: Any, default: int, minimum: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = default + return max(parsed, minimum) + + +def _bind_host(platform_config: dict[str, Any]) -> str: + host = _string_field(platform_config, "qqofficial_bind_host") + if not host: + host = DEFAULT_QQOFFICIAL_BIND_HOST + host = host.removeprefix("https://").removeprefix("http://").rstrip("/") + return host or DEFAULT_QQOFFICIAL_BIND_HOST + + +def _connect_url(task_id: str, host: str) -> str: + return ( + f"https://{host}/qqbot/openclaw/connect.html" + f"?task_id={quote(task_id, safe='')}&_wv=2" + ) + + +async def _post_json( + *, + url: str, + payload: dict[str, Any], + timeout_ms: int, +) -> dict[str, Any]: + async with httpx.AsyncClient(timeout=timeout_ms / 1000) as client: + response = await client.post( + url, + json=payload, + headers={"Accept": "application/json"}, + ) + response.raise_for_status() + data = response.json() + if not isinstance(data, dict): + raise RuntimeError("QQ 机器人绑定接口响应格式异常") + retcode = data.get("retcode") + if retcode is not None: + try: + retcode_ok = int(retcode) == 0 + except (TypeError, ValueError): + retcode_ok = False + if retcode_ok: + return data + message = ( + _string_field(data, "msg") + or _string_field(data, "message") + or "QQ 机器人绑定接口返回失败" + ) + raise RuntimeError(message) + return data + + +def generate_qqofficial_bind_key() -> str: + """Generate a base64 AES-256 key for QQ bot binding. + + Returns: + A base64-encoded 32-byte key. + """ + + return base64.b64encode(secrets.token_bytes(32)).decode("ascii") + + +def decrypt_qqofficial_secret(encrypted_secret: str, bind_key: str) -> str: + """Decrypt the AppSecret returned by QQ bot QR binding. + + Args: + encrypted_secret: Base64 payload containing 12-byte nonce, ciphertext, + and 16-byte GCM tag. + bind_key: Base64 AES-256 key sent when creating the bind task. + + Returns: + The decrypted QQ bot AppSecret. + + Raises: + ValueError: If the encrypted payload is malformed or decryption fails. + """ + + try: + key = base64.b64decode(bind_key) + raw = base64.b64decode(encrypted_secret) + except Exception as exc: + raise ValueError("QQ 机器人凭证解码失败") from exc + if len(key) != 32 or len(raw) <= 28: + raise ValueError("QQ 机器人凭证密文格式异常") + + nonce = raw[:12] + tag = raw[-16:] + ciphertext = raw[12:-16] + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) + try: + return cipher.decrypt_and_verify(ciphertext, tag).decode("utf-8") + except Exception as exc: + raise ValueError("QQ 机器人凭证解密失败") from exc + + +def qqofficial_login_result(data: dict[str, Any], *, bind_key: str) -> dict[str, Any]: + """Map QQ bot bind polling payloads to AstrBot registration statuses. + + Args: + data: Response data from `/lite/poll_bind_result`. + bind_key: Base64 AES-256 key originally used for the bind task. + + Returns: + A registration status payload for the dashboard polling flow. + """ + + payload = data.get("data") + if not isinstance(payload, dict): + payload = {} + + try: + raw_status = int(payload.get("status", QQOFFICIAL_BIND_STATUS_NONE)) + except (TypeError, ValueError): + raw_status = QQOFFICIAL_BIND_STATUS_NONE + + if raw_status == QQOFFICIAL_BIND_STATUS_COMPLETED: + appid = str(payload.get("bot_appid") or "").strip() + encrypted_secret = str(payload.get("bot_encrypt_secret") or "").strip() + if not appid or not encrypted_secret: + return { + "status": "error", + "qr_status": raw_status, + "message": "扫码成功但未返回完整 QQ 机器人凭证", + } + try: + secret = decrypt_qqofficial_secret(encrypted_secret, bind_key) + except ValueError as exc: + return { + "status": "error", + "qr_status": raw_status, + "message": str(exc), + } + return { + "status": "created", + "qr_status": raw_status, + "appid": appid, + "secret": secret, + "platform_id_suffix": f"_{appid}", + } + + if raw_status == QQOFFICIAL_BIND_STATUS_EXPIRED: + return { + "status": "expired", + "qr_status": raw_status, + "message": "二维码已过期", + } + + return {"status": "pending", "qr_status": raw_status} + + +async def request_qqofficial_login_qr( + platform_config: dict[str, Any], +) -> QQOfficialLoginRegistration: + """Request a QR binding task for QQ Official Bot credentials. + + Args: + platform_config: Platform configuration from the dashboard. + + Returns: + QR binding registration data used by the dashboard. + """ + + host = _bind_host(platform_config) + timeout_ms = _int_config( + platform_config.get("qqofficial_api_timeout_ms"), + DEFAULT_QQOFFICIAL_API_TIMEOUT_MS, + 1_000, + ) + interval = _int_config( + platform_config.get("qqofficial_qr_poll_interval"), + DEFAULT_QQOFFICIAL_QR_POLL_INTERVAL, + 1, + ) + bind_key = generate_qqofficial_bind_key() + data = await _post_json( + url=f"https://{host}/lite/create_bind_task", + payload={"key": bind_key}, + timeout_ms=timeout_ms, + ) + + payload = data.get("data") + if not isinstance(payload, dict): + payload = {} + task_id = str(payload.get("task_id") or "").strip() + if not task_id: + raise RuntimeError("QQ 机器人绑定任务响应缺少 task_id") + + return QQOfficialLoginRegistration( + task_id=task_id, + bind_key=bind_key, + qrcode=_connect_url(task_id, host), + interval=interval, + ) + + +async def poll_qqofficial_login_once( + *, + platform_config: dict[str, Any], + task_id: str, + bind_key: str, +) -> dict[str, Any]: + """Poll a QQ Official Bot QR binding task once. + + Args: + platform_config: Platform configuration from the dashboard. + task_id: Task ID returned by `request_qqofficial_login_qr`. + bind_key: Base64 AES-256 key returned with the task. + + Returns: + A registration status payload for the dashboard polling flow. + + Raises: + ValueError: If `task_id` or `bind_key` is missing. + """ + + if not task_id: + raise ValueError("Missing task_id") + if not bind_key: + raise ValueError("Missing bind_key") + + host = _bind_host(platform_config) + timeout_ms = _int_config( + platform_config.get("qqofficial_api_timeout_ms"), + DEFAULT_QQOFFICIAL_API_TIMEOUT_MS, + 1_000, + ) + data = await _post_json( + url=f"https://{host}/lite/poll_bind_result", + payload={"task_id": task_id}, + timeout_ms=timeout_ms, + ) + return qqofficial_login_result(data, bind_key=bind_key) diff --git a/astrbot/core/platform/sources/webchat/message_parts_helper.py b/astrbot/core/platform/sources/webchat/message_parts_helper.py index 5dc255ca0..7f3f9fea6 100644 --- a/astrbot/core/platform/sources/webchat/message_parts_helper.py +++ b/astrbot/core/platform/sources/webchat/message_parts_helper.py @@ -20,6 +20,7 @@ from astrbot.core.message.components import ( Video, ) from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.utils.media_utils import MediaResolver AttachmentGetter = Callable[[str], Awaitable[Attachment | None]] AttachmentInserter = Callable[[str, str, str], Awaitable[Attachment | None]] @@ -156,7 +157,12 @@ async def parse_webchat_message_parts( if part_type == "image": components.append(Image.fromFileSystem(file_path_str)) elif part_type == "record": - components.append(Record.fromFileSystem(file_path_str)) + path_wav = await MediaResolver( + file_path_str, + media_type="audio", + default_suffix=".wav", + ).to_path(target_format="wav") + components.append(Record(file=path_wav, url=path_wav)) elif part_type == "video": components.append(Video.fromFileSystem(file_path_str)) else: diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py index e2fcc6ce1..8d2397950 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py @@ -6,9 +6,8 @@ import asyncio from collections.abc import Callable from typing import Any -import quart - from astrbot.api import logger +from astrbot.core.platform.webhook_server import FastAPIWebhookServer from .wecomai_api import WecomAIBotAPIClient from .wecomai_utils import WecomAIBotConstants @@ -38,14 +37,13 @@ class WecomAIBotServer: self.api_client = api_client self.message_handler = message_handler - self.app = quart.Quart(__name__) + self.app = FastAPIWebhookServer("wecom-ai-bot-webhook") self._setup_routes() self.shutdown_event = asyncio.Event() def _setup_routes(self) -> None: - """设置 Quart 路由""" - # 使用 Quart 的 add_url_rule 方法添加路由 + """设置 FastAPI 路由""" self.app.add_url_rule( "/webhook/wecom-ai-bot", view_func=self.verify_url, @@ -58,15 +56,15 @@ class WecomAIBotServer: methods=["POST"], ) - async def verify_url(self): + async def verify_url(self, request): """内部服务器的 GET 验证入口""" - return await self.handle_verify(quart.request) + return await self.handle_verify(request) async def handle_verify(self, request): """处理 URL 验证请求,可被统一 webhook 入口复用 Args: - request: Quart 请求对象 + request: FastAPI webhook request 对象 Returns: 验证响应元组 (content, status_code, headers) @@ -92,15 +90,15 @@ class WecomAIBotServer: result = self.api_client.verify_url(msg_signature, timestamp, nonce, echostr) return result, 200, {"Content-Type": "text/plain"} - async def handle_message(self): + async def handle_message(self, request): """内部服务器的 POST 消息回调入口""" - return await self.handle_callback(quart.request) + return await self.handle_callback(request) async def handle_callback(self, request): """处理消息回调,可被统一 webhook 入口复用 Args: - request: Quart 请求对象 + request: FastAPI webhook request 对象 Returns: 响应元组 (content, status_code, headers) @@ -188,5 +186,5 @@ class WecomAIBotServer: self.shutdown_event.set() def get_app(self): - """获取 Quart 应用实例""" - return self.app + """获取 FastAPI 应用实例""" + return self.app.app diff --git a/astrbot/core/platform/webhook_server.py b/astrbot/core/platform/webhook_server.py new file mode 100644 index 000000000..57fe52dfc --- /dev/null +++ b/astrbot/core/platform/webhook_server.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import inspect +from collections.abc import Callable +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, Response +from hypercorn.asyncio import serve +from hypercorn.config import Config as HyperConfig + + +class WebhookRequest: + def __init__(self, request: Request) -> None: + self._request = request + self.args = request.query_params + self.headers = request.headers + self.method = request.method + + @property + def json(self): + return self._request.json() + + async def get_data(self) -> bytes: + return await self._request.body() + + async def get_json(self, *, force: bool = False, silent: bool = False): + try: + return await self._request.json() + except Exception: + if silent: + return None + raise + + +def _response_from_result(result: Any): + if isinstance(result, Response): + return result + + if isinstance(result, tuple): + content = result[0] if result else "" + status_code = ( + result[1] if len(result) > 1 and isinstance(result[1], int) else 200 + ) + headers = result[2] if len(result) > 2 and isinstance(result[2], dict) else None + if isinstance(content, dict | list): + return JSONResponse(content, status_code=status_code, headers=headers) + return Response( + content=content, + status_code=status_code, + headers=headers, + media_type=headers.get("Content-Type") if headers else None, + ) + + if isinstance(result, dict | list): + return JSONResponse(result) + + return result + + +class FastAPIWebhookServer: + def __init__(self, name: str) -> None: + self.app = FastAPI(title=name, docs_url=None, redoc_url=None, openapi_url=None) + + def add_url_rule( + self, + path: str, + view_func: Callable, + methods: list[str] | None = None, + ) -> None: + has_params = bool(inspect.signature(view_func).parameters) + + async def endpoint(request: Request): + if has_params: + result = view_func(WebhookRequest(request)) + else: + result = view_func() + if inspect.isawaitable(result): + result = await result + return _response_from_result(result) + + self.app.add_api_route( + path, + endpoint, + methods=methods or ["GET"], + include_in_schema=False, + ) + + def route(self, path: str, methods: list[str] | None = None): + def decorator(view_func: Callable): + self.add_url_rule(path, view_func, methods) + return view_func + + return decorator + + async def run_task( + self, + *, + host: str, + port: int, + shutdown_trigger: Callable | None = None, + **_kwargs, + ) -> None: + config = HyperConfig() + config.bind = [f"{host}:{port}"] + await serve(self.app, config, shutdown_trigger=shutdown_trigger) + + async def shutdown(self) -> None: + return None diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index 21da1d77b..c7b8898ac 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -210,6 +210,78 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: return False, f"{e!s}" +class _PermissionGuardedTool(FunctionTool): + """Transparent proxy that checks per-tool permissions before delegating. + + Only wraps non-builtin tools. Builtin tools are added to the tool set + without wrapping, so their existing hardcoded permission logic + (``check_admin_permission`` / ``_is_restricted_env``) is unaffected. + + The ``handler`` field is intentionally kept ``None`` so that + ``FunctionToolExecutor._execute_local`` falls through to the + ``is_override_call`` branch and invokes our ``call()`` instead of + calling the raw handler directly. This ensures the permission + check runs for *every* invocation path. + """ + + def __init__( + self, + tool: FunctionTool, + manager: FunctionToolManager, + ) -> None: + # Do NOT pass handler to the parent — keep self.handler = None + # so the tool executor always routes through our call(). + super().__init__( + name=tool.name, + description=tool.description, + parameters=getattr(tool, "parameters", {}), + ) + self._wrapped = tool + self._mgr = manager + # Mirror mutable state from the underlying tool + self.active = getattr(tool, "active", True) + self.handler_module_path = getattr(tool, "handler_module_path", None) + + async def call(self, context: Any, **kwargs: Any) -> Any: + import inspect as _inspect + + error = self._mgr._check_tool_permission(self.name, context) + if error is not None: + return error + + # Delegate to handler first (plugin tools). + if self._wrapped.handler is not None: + result = self._wrapped.handler(context, **kwargs) + if _inspect.isasyncgen(result): + last: Any = None + async for item in result: + last = item + return last + if _inspect.isawaitable(result): + return await result + return result + + # Fall back to overridden call() on subclasses (e.g. MCPTool). + call_override = getattr(type(self._wrapped), "call", None) + if call_override is not None and call_override is not FunctionTool.call: + return await self._wrapped.call(context, **kwargs) + + run = getattr(self._wrapped, "run", None) + if run is not None: + event = context.context.event + result = run(event, **kwargs) + if _inspect.isasyncgen(result): + last: Any = None + async for item in result: + last = item + return last + if _inspect.isawaitable(result): + return await result + return result + + return "error: tool has no callable handler" + + class FunctionToolManager: def __init__(self) -> None: self.func_list: list[FuncTool] = [] @@ -374,6 +446,51 @@ class FunctionToolManager: ensure_builtin_tools_loaded() return get_builtin_tool_class(name) is not None + def _default_permission(self, tool_name: str) -> str: + """Compute the fallback permission for a non-builtin tool. + + All non-builtin tools default to ``"member"`` (no restriction). + Builtin tools are never routed through this method.""" + return "member" + + def _check_tool_permission( + self, + tool_name: str, + context: Any, + ) -> str | None: + """Return an error string if the caller lacks permission, or None. + + Only non-builtin tools are guarded. Permission is resolved from + ``tool_permissions`` in SharedPreferences (``_default`` key). When + no explicit entry exists the tool inherits the fallback + ``_default_permission``.""" + try: + perms_raw = sp.get( + "tool_permissions", {}, scope="global", scope_id="global" + ) + except Exception: + perms_raw = {} + defaults = perms_raw.get("_default", {}) if isinstance(perms_raw, dict) else {} + effective = defaults.get(tool_name) + if effective is None: + effective = self._default_permission(tool_name) + + if effective != "admin": + return None # member or unknown → pass + + try: + event = context.context.event + except AttributeError: + event = None + if event is None or not event.is_admin(): + sender_id = getattr(event, "get_sender_id", lambda: "unknown")() + return ( + f"error: Permission denied. The tool '{tool_name}' requires admin " + f"privileges. Your ID: {sender_id}. " + "Ask admin to configure in WebUI → Extension → Components." + ) + return None + def get_full_tool_set(self) -> ToolSet: """获取完整工具集 @@ -383,10 +500,14 @@ class FunctionToolManager: 因此,后加载的 inactive 工具不会覆盖已激活的工具; 同时,MCP 工具在需要时仍可覆盖被禁用的内置工具。 + + Non-builtin tools are wrapped with ``_PermissionGuardedTool`` so that + every invocation checks the per-tool permission configured via the + dashboard. """ tool_set = ToolSet() for tool in self.func_list: - tool_set.add_tool(tool) + tool_set.add_tool(_PermissionGuardedTool(tool, self)) return tool_set @staticmethod diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 608d1bd05..1764646d9 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -482,6 +482,10 @@ class ProviderManager: from .sources.gemini_tts_source import ( ProviderGeminiTTSAPI as ProviderGeminiTTSAPI, ) + case "elevenlabs_tts_api": + from .sources.elevenlabs_tts_source import ( + ProviderElevenLabsTTSAPI as ProviderElevenLabsTTSAPI, + ) case "openai_embedding": from .sources.openai_embedding_source import ( OpenAIEmbeddingProvider as OpenAIEmbeddingProvider, diff --git a/astrbot/core/provider/sources/elevenlabs_tts_source.py b/astrbot/core/provider/sources/elevenlabs_tts_source.py new file mode 100644 index 000000000..7ea0fcf61 --- /dev/null +++ b/astrbot/core/provider/sources/elevenlabs_tts_source.py @@ -0,0 +1,173 @@ +import uuid +from pathlib import Path + +import httpx + +from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path + +from ..entities import ProviderType +from ..provider import TTSProvider +from ..register import register_provider_adapter + +SUPPORTED_CONTAINER_OUTPUT_PREFIXES = ("mp3", "wav", "opus") +RAW_AUDIO_OUTPUT_PREFIXES = ("pcm", "ulaw", "alaw") + + +def _parse_optional_float( + provider_config: dict, + cfg_name: str, +) -> float | None: + value = provider_config.get(cfg_name, "") + if value in ("", None): + return None + try: + parsed = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{cfg_name} must be a number between 0 and 1.") from exc + if not 0 <= parsed <= 1: + raise ValueError(f"{cfg_name} must be between 0 and 1.") + return parsed + + +def _parse_bool(provider_config: dict, cfg_name: str) -> bool: + value = provider_config[cfg_name] + if isinstance(value, bool): + return value + if isinstance(value, int): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes", "y", "on"}: + return True + if normalized in {"false", "0", "no", "n", "off"}: + return False + raise ValueError(f"{cfg_name} must be a boolean value.") + + +def _normalize_timeout(value: int | str | None) -> int: + if value in ("", None): + return 20 + try: + timeout = int(value) + except (TypeError, ValueError) as exc: + raise ValueError("timeout must be a positive integer.") from exc + if timeout <= 0: + raise ValueError("timeout must be a positive integer.") + return timeout + + +def _validate_output_format(output_format: str) -> None: + fmt = output_format.lower() + if fmt.startswith(RAW_AUDIO_OUTPUT_PREFIXES): + raise ValueError( + "ElevenLabs raw audio output formats are not supported by this provider. " + "Use an mp3, wav, or opus output format instead." + ) + if not fmt.startswith(SUPPORTED_CONTAINER_OUTPUT_PREFIXES): + raise ValueError( + "Unsupported ElevenLabs output format. " + "Use an mp3, wav, or opus output format." + ) + + +@register_provider_adapter( + "elevenlabs_tts_api", + "ElevenLabs TTS API", + provider_type=ProviderType.TEXT_TO_SPEECH, +) +class ProviderElevenLabsTTSAPI(TTSProvider): + def __init__( + self, + provider_config: dict, + provider_settings: dict, + ) -> None: + super().__init__(provider_config, provider_settings) + self.api_key = provider_config.get("api_key", "") + self.api_base = provider_config.get( + "api_base", "https://api.elevenlabs.io/v1" + ).removesuffix("/") + self.voice_id = provider_config.get( + "elevenlabs-tts-voice-id", "JBFqnCBsd6RMkjVDRZzb" + ) + self.model_id = provider_config.get("model", "eleven_multilingual_v2") + self.set_model(self.model_id) + self.output_format = provider_config.get( + "elevenlabs-tts-output-format", "mp3_44100_128" + ) + _validate_output_format(self.output_format) + + # Only send explicitly configured voice settings so the API can apply defaults. + self.voice_settings: dict = {} + for key, cfg_name in ( + ("stability", "elevenlabs-tts-stability"), + ("similarity_boost", "elevenlabs-tts-similarity-boost"), + ("style", "elevenlabs-tts-style"), + ): + value = _parse_optional_float(provider_config, cfg_name) + if value is not None: + self.voice_settings[key] = value + if "elevenlabs-tts-use-speaker-boost" in provider_config: + self.voice_settings["use_speaker_boost"] = _parse_bool( + provider_config, + "elevenlabs-tts-use-speaker-boost", + ) + + timeout = _normalize_timeout(provider_config.get("timeout", 20)) + + proxy = provider_config.get("proxy", "") + if proxy: + logger.info(f"[ElevenLabs TTS] 使用代理: {proxy}") + self.client = httpx.AsyncClient( + timeout=timeout, + proxy=proxy or None, + trust_env=False, + ) + + def _output_extension(self) -> str: + """Infer the audio file extension from the configured output format.""" + fmt = self.output_format.lower() + if fmt.startswith("mp3"): + return "mp3" + if fmt.startswith("opus"): + return "opus" + if fmt.startswith("wav"): + return "wav" + return "mp3" + + async def get_audio(self, text: str) -> str: + url = f"{self.api_base}/text-to-speech/{self.voice_id}" + headers = { + "xi-api-key": self.api_key, + "Content-Type": "application/json", + } + payload: dict = { + "text": text, + "model_id": self.model_name, + } + if self.voice_settings: + payload["voice_settings"] = self.voice_settings + + response = await self.client.post( + url, + headers=headers, + params={"output_format": self.output_format}, + json=payload, + ) + if response.status_code != 200: + error_text = response.text[:1024] + raise Exception( + f"ElevenLabs TTS API 请求失败: {response.status_code}, {error_text}" + ) + + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + path = ( + temp_dir / f"elevenlabs_tts_api_{uuid.uuid4()}.{self._output_extension()}" + ) + path.write_bytes(response.content) + return str(path) + + async def terminate(self): + if self.client: + await self.client.aclose() diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index 618366c9c..73a6c71b0 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -1,3 +1,5 @@ +import re + import httpx from openai import AsyncOpenAI @@ -7,6 +9,13 @@ from astrbot.core.provider.provider import EmbeddingProvider from astrbot.core.provider.register import register_provider_adapter +def _normalize_api_base(api_base: str) -> str: + api_base = api_base.strip().removesuffix("/").removesuffix("/embeddings") + if api_base and not re.search(r"/v\d+$", api_base): + api_base = api_base + "/v1" + return api_base + + @register_provider_adapter( "openai_embedding", "OpenAI API Embedding 提供商适配器", @@ -23,15 +32,9 @@ class OpenAIEmbeddingProvider(EmbeddingProvider): if proxy: logger.info(f"[OpenAI Embedding] {provider_id} Using proxy: {proxy}") http_client = httpx.AsyncClient(proxy=proxy) - api_base = ( + api_base = _normalize_api_base( provider_config.get("embedding_api_base", "https://api.openai.com/v1") - .strip() - .removesuffix("/") - .removesuffix("/embeddings") ) - if api_base and not api_base.endswith("/v1") and not api_base.endswith("/v4"): - # /v4 see #5699 - api_base = api_base + "/v1" logger.info(f"[OpenAI Embedding] {provider_id} Using API Base: {api_base}") self.client = AsyncOpenAI( api_key=provider_config.get("embedding_api_key"), diff --git a/astrbot/core/provider/sources/request_retry.py b/astrbot/core/provider/sources/request_retry.py new file mode 100644 index 000000000..14dd57d7a --- /dev/null +++ b/astrbot/core/provider/sources/request_retry.py @@ -0,0 +1,163 @@ +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from typing import TypeVar + +from tenacity import ( + AsyncRetrying, + RetryCallState, + retry_if_exception, + stop_after_attempt, + wait_exponential, +) + +from astrbot import logger +from astrbot.core.utils.config_number import coerce_int_config +from astrbot.core.utils.network_utils import is_connection_error + +T = TypeVar("T") + +REQUEST_RETRY_ATTEMPTS = 5 # default value +REQUEST_RETRY_WAIT_MIN_S = 0.2 +REQUEST_RETRY_WAIT_MAX_S = 30 +REQUEST_RETRY_STATUS_CODES = {408, 409, 429, 500, 502, 503, 504, 529} + + +def _get_status_code(error: BaseException) -> int | None: + for attr in ("status_code", "status", "code"): + value = getattr(error, attr, None) + if isinstance(value, int): + return value + + response = getattr(error, "response", None) + if response is not None: + status_code = getattr(response, "status_code", None) + if isinstance(status_code, int): + return status_code + + return None + + +def _is_retryable_provider_request_error( + error: BaseException, + *, + retry_rate_limits: bool, +) -> bool: + if is_connection_error(error): + return True + + error_type_name = type(error).__name__ + if error_type_name in {"APIConnectionError", "APITimeoutError"}: + return True + + status_code = _get_status_code(error) + if status_code is None: + return False + + if status_code == 429 and not retry_rate_limits: + return False + + return status_code in REQUEST_RETRY_STATUS_CODES or 500 <= status_code <= 599 + + +def _log_retry( + provider_label: str, + retry_state: RetryCallState, + max_attempts: int, +) -> None: + error = retry_state.outcome.exception() if retry_state.outcome else None + logger.warning( + f"[{provider_label}] Request failed with retryable error; " + f"retrying ({retry_state.attempt_number + 1}/{max_attempts}): " + f"{error}" + ) + + +def _build_retrying( + provider_label: str, + *, + retry_rate_limits: bool, + max_attempts: int | None = None, +) -> AsyncRetrying: + max_attempts = coerce_int_config( + max_attempts if max_attempts is not None else REQUEST_RETRY_ATTEMPTS, + default=REQUEST_RETRY_ATTEMPTS, + min_value=1, + field_name="request_max_retries", + source=provider_label, + ) + + return AsyncRetrying( + retry=retry_if_exception( + lambda error: _is_retryable_provider_request_error( + error, + retry_rate_limits=retry_rate_limits, + ) + ), + stop=stop_after_attempt(max_attempts), + wait=wait_exponential( + multiplier=1, + min=REQUEST_RETRY_WAIT_MIN_S, + max=REQUEST_RETRY_WAIT_MAX_S, + ), + before_sleep=lambda retry_state: _log_retry( + provider_label, + retry_state, + max_attempts, + ), + reraise=True, + ) + + +async def retry_provider_request( + provider_label: str, + request_factory: Callable[[], Awaitable[T]], + *, + retry_rate_limits: bool = True, + max_attempts: int | None = None, +) -> T: + retrying = _build_retrying( + provider_label, + retry_rate_limits=retry_rate_limits, + max_attempts=max_attempts, + ) + + async for attempt in retrying: + with attempt: + return await request_factory() + + raise RuntimeError("Provider request retry loop exited unexpectedly.") + + +@asynccontextmanager +async def retry_provider_request_context( + provider_label: str, + context_manager_factory: Callable[[], AbstractAsyncContextManager[T]], + *, + retry_rate_limits: bool = True, + max_attempts: int | None = None, +) -> AsyncIterator[T]: + manager: AbstractAsyncContextManager[T] | None = None + + async def _enter_context() -> T: + nonlocal manager + manager = context_manager_factory() + return await manager.__aenter__() + + value = await retry_provider_request( + provider_label, + _enter_context, + retry_rate_limits=retry_rate_limits, + max_attempts=max_attempts, + ) + + if manager is None: + raise RuntimeError("Provider request context was not created.") + + try: + yield value + except BaseException as error: + if await manager.__aexit__(type(error), error, error.__traceback__): + return + raise + else: + await manager.__aexit__(None, None, None) diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index a14563cfe..861da130b 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -27,6 +27,8 @@ SANDBOX_SKILLS_CACHE_FILENAME = "sandbox_skills_cache.json" DEFAULT_SKILLS_CONFIG: dict[str, dict] = {"skills": {}} SANDBOX_SKILLS_ROOT = "skills" SANDBOX_WORKSPACE_ROOT = "/workspace" +WORKSPACE_SKILLS_ROOT = "skills" +WORKSPACE_SKILL_FRONTMATTER_MAX_CHARS = 64 * 1024 _SANDBOX_SKILLS_CACHE_VERSION = 1 _SKILL_NAME_RE = re.compile(r"^[\w.-]+$") @@ -256,7 +258,7 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str: display_name = _sanitize_skill_display_name(skill.name) description = skill.description or "No description" - if skill.source_type == "sandbox_only": + if skill.source_type in {"sandbox_only", "workspace"}: description = _sanitize_prompt_description(description) if not description: description = "Read SKILL.md for details." @@ -391,6 +393,83 @@ class SkillManager: return skill_dir return None + def list_workspace_skills( + self, workspace_root: str | Path | None + ) -> list[SkillInfo]: + """List request-scoped skills from a session workspace. + + Args: + workspace_root: The current session workspace directory. + + Returns: + Skills discovered under ``/skills``. + """ + if not workspace_root: + return [] + + raw_workspace_root = Path(workspace_root) + skills_root = raw_workspace_root / WORKSPACE_SKILLS_ROOT + if not skills_root.is_dir(): + return [] + + try: + resolved_workspace_root = raw_workspace_root.resolve(strict=True) + resolved_skills_root = skills_root.resolve(strict=True) + if not resolved_skills_root.is_relative_to(resolved_workspace_root): + return [] + skill_dirs = sorted( + resolved_skills_root.iterdir(), key=lambda item: item.name + ) + except OSError: + return [] + + skills: list[SkillInfo] = [] + for skill_dir in skill_dirs: + if not skill_dir.is_dir(): + continue + skill_name = skill_dir.name + if not _SKILL_NAME_RE.match(skill_name): + continue + try: + entry_names = {entry.name for entry in skill_dir.iterdir()} + except OSError: + continue + if "SKILL.md" not in entry_names: + continue + skill_md = skill_dir / "SKILL.md" + if not skill_md.is_file(): + continue + + try: + resolved_skill_md = skill_md.resolve(strict=True) + except OSError: + continue + if not resolved_skill_md.is_relative_to(resolved_skills_root): + continue + + description = "" + try: + with resolved_skill_md.open(encoding="utf-8") as f: + content = f.read(WORKSPACE_SKILL_FRONTMATTER_MAX_CHARS) + description = _parse_frontmatter_description(content) + except (OSError, UnicodeError): + description = "" + + skills.append( + SkillInfo( + name=skill_name, + description=description, + path=resolved_skill_md.as_posix(), + active=True, + source_type="workspace", + source_label="workspace", + local_exists=True, + readonly=True, + ) + ) + + return skills + def _load_config(self) -> dict: if not os.path.exists(self.config_path): self._save_config(DEFAULT_SKILLS_CONFIG.copy()) diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index fb4c861f7..27b09f202 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -54,10 +54,72 @@ if TYPE_CHECKING: WebApiHandler = Callable[..., Awaitable[Any]] RegisteredWebApi = tuple[str, WebApiHandler, list[str], str] +_PLUGIN_MODULE_FLAGS = {"builtin_stars", "plugins"} + + +def _split_module_path(module_path: Any) -> list[str]: + if not isinstance(module_path, str) or not module_path: + return [] + return module_path.split(".") + + +def _plugin_root_from_module_parts(parts: list[str]) -> tuple[str, str] | None: + for index, part in enumerate(parts): + if part in _PLUGIN_MODULE_FLAGS and index + 1 < len(parts): + return part, parts[index + 1] + return None + + +def _plugin_root_from_metadata(metadata: StarMetadata) -> str | None: + if metadata.root_dir_name: + return metadata.root_dir_name + + root_info = _plugin_root_from_module_parts(_split_module_path(metadata.module_path)) + return root_info[1] if root_info else None + + +def _registered_plugin_module_path(root_dir_name: str, flag: str | None) -> str | None: + for metadata in reversed(star_registry): + if not metadata.module_path: + continue + if _plugin_root_from_metadata(metadata) != root_dir_name: + continue + if flag and flag not in _split_module_path(metadata.module_path): + continue + return metadata.module_path + return None + + +def _legacy_plugin_module_path(parts: list[str]) -> str: + resolved_parts = [] + for index, part in enumerate(parts): + resolved_parts.append(part) + if part in _PLUGIN_MODULE_FLAGS and index + 1 < len(parts): + resolved_parts.append(parts[index + 1]) + resolved_parts.append("main") + break + return ".".join(resolved_parts) + + +def _resolve_tool_handler_module_path(tool: FunctionTool) -> str: + module_path = getattr(tool, "__module__", None) + module_parts = _split_module_path(module_path) + if not module_parts: + return module_path if isinstance(module_path, str) else "" + + root_info = _plugin_root_from_module_parts(module_parts) + if root_info: + flag, root_dir_name = root_info + registered_module_path = _registered_plugin_module_path(root_dir_name, flag) + return registered_module_path or _legacy_plugin_module_path(module_parts) + + registered_module_path = _registered_plugin_module_path(module_parts[0], "plugins") + return registered_module_path or ".".join(module_parts) class PlatformManagerProtocol(Protocol): platform_insts: list[Platform] + get_insts: Callable[[], list[Platform]] class Context: @@ -515,16 +577,7 @@ class Context: module_path = "" for tool in tools: if not module_path: - _parts = [] - module_part = tool.__module__.split(".") - flags = ["builtin_stars", "plugins"] - for i, part in enumerate(module_part): - _parts.append(part) - if part in flags and i + 1 < len(module_part): - _parts.append(module_part[i + 1]) - _parts.append("main") - break - tool.handler_module_path = ".".join(_parts) + tool.handler_module_path = _resolve_tool_handler_module_path(tool) module_path = tool.handler_module_path else: tool.handler_module_path = module_path diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 476b734f3..bf7e84bef 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -67,6 +67,10 @@ class PluginVersionIncompatibleError(Exception): """Raised when plugin astrbot_version is incompatible with current AstrBot.""" +class PluginVersionUnsupportedError(PluginVersionIncompatibleError): + """Backward-compatible alias for plugin version compatibility errors.""" + + class PluginDependencyInstallError(Exception): """Raised when plugin dependency installation fails.""" diff --git a/astrbot/core/star/updator.py b/astrbot/core/star/updator.py index b273708b1..646ff4203 100644 --- a/astrbot/core/star/updator.py +++ b/astrbot/core/star/updator.py @@ -57,7 +57,7 @@ class PluginUpdator(RepoZipUpdator): f"Downloading plugin update archive for {plugin.name}: {download_url}", ) await self._download_file(download_url, plugin_path + ".zip") - else: + elif repo_url: await self.download_from_repo_url(plugin_path, repo_url, proxy=proxy) try: diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py index 58adb320f..a144d24af 100644 --- a/astrbot/core/tools/computer_tools/fs.py +++ b/astrbot/core/tools/computer_tools/fs.py @@ -35,6 +35,7 @@ Local path resolution rule: import asyncio import os +import stat import uuid from dataclasses import dataclass, field from pathlib import Path @@ -70,6 +71,12 @@ _SANDBOX_RUNTIME_TOOL_CONFIG = { _IMAGE_FILE_SUFFIXES = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"} +def _remote_basename(path: str) -> str: + # Sandbox paths may come from POSIX or Windows runtimes; normalize separators + # without interpreting the path against the host filesystem. + return path.replace("\\", "/").rstrip("/").split("/")[-1] + + def _restricted_env_path_labels(umo: str, *, include_plugin_skills: bool) -> list[str]: """Labels for the allowed directories in a local(not sandbox) and restricted(not admin) environment""" normalized_umo = normalize_umo_for_workspace(umo) @@ -176,6 +183,25 @@ def _is_path_within_allowed_roots( ) +def _reject_multi_link_file(path: str) -> None: + try: + path_stat = os.stat(path) + except FileNotFoundError: + return + except OSError as exc: + raise PermissionError( + "Access denied: unable to inspect restricted path link count. " + f"Blocked path: {path}." + ) from exc + + if stat.S_ISREG(path_stat.st_mode) and path_stat.st_nlink > 1: + raise PermissionError( + "Access denied: file has multiple hard links and may alias content " + "outside allowed directories. " + f"Link count: {path_stat.st_nlink}. Blocked path: {path}." + ) + + def _normalize_rw_path( path: str, *, @@ -202,6 +228,8 @@ def _normalize_rw_path( f"{access} access is restricted for this user. " f"Allowed directories: {allowed}. Blocked path: {normalized_path}.", ) + if restricted: + _reject_multi_link_file(normalized_path) return normalized_path @@ -596,6 +624,8 @@ class GrepTool(FunctionTool): "Read access is restricted for this user. " f"Allowed directories: {allowed}. Blocked paths: {blocked}.", ) + for path in normalized: + _reject_multi_link_file(path) return normalized @@ -772,7 +802,7 @@ class FileDownloadTool(FunctionTool): context.context.event.unified_msg_origin, ) try: - name = os.path.basename(remote_path) + name = _remote_basename(remote_path) or os.path.basename(remote_path) local_path = os.path.join( get_astrbot_temp_path(), @@ -785,7 +815,7 @@ class FileDownloadTool(FunctionTool): if also_send_to_user: try: - name = os.path.basename(local_path) + name = _remote_basename(remote_path) or os.path.basename(local_path) if Path(local_path).suffix.lower() in _IMAGE_FILE_SUFFIXES: message_component = Image.fromFileSystem(local_path) sent_as = "image" diff --git a/astrbot/core/tools/cron_tools.py b/astrbot/core/tools/cron_tools.py index a5cc9d418..b357798fd 100644 --- a/astrbot/core/tools/cron_tools.py +++ b/astrbot/core/tools/cron_tools.py @@ -23,6 +23,23 @@ def _extract_job_session(job: Any) -> str | None: return str(session) if session is not None else None +def _extract_job_sender(job: Any) -> str | None: + payload = getattr(job, "payload", None) + if not isinstance(payload, dict): + return None + sender_id = payload.get("sender_id") + return str(sender_id) if sender_id is not None else None + + +def _job_belongs_to_current_sender( + job: Any, current_umo: str, current_sender_id: str +) -> bool: + return ( + _extract_job_session(job) == current_umo + and _extract_job_sender(job) == current_sender_id + ) + + def _parse_run_at(run_at: Any) -> datetime | None: if run_at in (None, ""): return None @@ -135,6 +152,7 @@ class FutureTaskTool(FunctionTool[AstrAgentContext]): return f"Scheduled future task {job.job_id} ({job.name}) {suffix}." current_umo = context.context.event.unified_msg_origin + current_sender_id = str(context.context.event.get_sender_id()) if action == "edit": job_id = kwargs.get("job_id") if not job_id: @@ -148,8 +166,8 @@ class FutureTaskTool(FunctionTool[AstrAgentContext]): job = await cron_mgr.db.get_cron_job(str(job_id)) if not job: return f"error: cron job {job_id} not found." - if _extract_job_session(job) != current_umo: - return "error: you can only edit future tasks in the current umo." + if not _job_belongs_to_current_sender(job, current_umo, current_sender_id): + return "error: you can only edit your own future tasks." payload = dict(job.payload) if isinstance(job.payload, dict) else {} @@ -216,8 +234,8 @@ class FutureTaskTool(FunctionTool[AstrAgentContext]): job = await cron_mgr.db.get_cron_job(str(job_id)) if not job: return f"error: cron job {job_id} not found." - if _extract_job_session(job) != current_umo: - return "error: you can only delete future tasks in the current umo." + if not _job_belongs_to_current_sender(job, current_umo, current_sender_id): + return "error: you can only delete your own future tasks." await cron_mgr.delete_job(str(job_id)) return f"Deleted cron job {job_id}." @@ -225,7 +243,7 @@ class FutureTaskTool(FunctionTool[AstrAgentContext]): jobs = [ job for job in await cron_mgr.list_jobs() - if _extract_job_session(job) == current_umo + if _job_belongs_to_current_sender(job, current_umo, current_sender_id) ] if not jobs: return "No cron jobs found." diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py index fd462c260..ee6963e52 100644 --- a/astrbot/core/tools/web_search_tools.py +++ b/astrbot/core/tools/web_search_tools.py @@ -21,6 +21,8 @@ WEB_SEARCH_TOOL_NAMES = [ "web_search_brave", "web_search_firecrawl", "firecrawl_extract_web_page", + "web_search_exa", + "exa_get_contents", ] _TAVILY_WEB_SEARCH_TOOL_CONFIG = { "provider_settings.web_search": True, @@ -42,6 +44,10 @@ _BAIDU_WEB_SEARCH_TOOL_CONFIG = { "provider_settings.web_search": True, "provider_settings.websearch_provider": "baidu_ai_search", } +_EXA_WEB_SEARCH_TOOL_CONFIG = { + "provider_settings.web_search": True, + "provider_settings.websearch_provider": "exa", +} @std_dataclass @@ -76,6 +82,7 @@ _TAVILY_KEY_ROTATOR = _KeyRotator("websearch_tavily_key", "Tavily") _BOCHA_KEY_ROTATOR = _KeyRotator("websearch_bocha_key", "BoCha") _BRAVE_KEY_ROTATOR = _KeyRotator("websearch_brave_key", "Brave") _FIRECRAWL_KEY_ROTATOR = _KeyRotator("websearch_firecrawl_key", "Firecrawl") +_EXA_KEY_ROTATOR = _KeyRotator("websearch_exa_key", "Exa") def normalize_legacy_web_search_config(cfg) -> None: @@ -99,6 +106,7 @@ def normalize_legacy_web_search_config(cfg) -> None: "websearch_bocha_key", "websearch_brave_key", "websearch_firecrawl_key", + "websearch_exa_key", ): value = provider_settings.get(setting_name) if isinstance(value, str): @@ -813,11 +821,232 @@ class BaiduWebSearchTool(FunctionTool[AstrAgentContext]): return _search_result_payload(results) +async def _exa_search( + provider_settings: dict, + payload: dict, +) -> list[SearchResult]: + """Call the Exa /search endpoint and return normalized results.""" + exa_key = await _EXA_KEY_ROTATOR.get(provider_settings) + headers = { + "x-api-key": exa_key, + "Content-Type": "application/json", + } + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.post( + "https://api.exa.ai/search", + json=payload, + headers=headers, + ) as response: + if response.status != 200: + reason = await response.text() + raise Exception( + f"Exa web search failed: {reason}, status: {response.status}", + ) + data = await response.json() + return [ + SearchResult( + title=item.get("title", ""), + url=item.get("url", ""), + snippet=( + item.get("text") + or (item.get("highlights") or [""])[0] + or item.get("summary", "") + ), + ) + for item in data.get("results", []) + if item.get("url") + ] + + +async def _exa_get_contents( + provider_settings: dict, + payload: dict, +) -> list[dict]: + """Call the Exa /contents endpoint and return raw result dicts.""" + exa_key = await _EXA_KEY_ROTATOR.get(provider_settings) + headers = { + "x-api-key": exa_key, + "Content-Type": "application/json", + } + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.post( + "https://api.exa.ai/contents", + json=payload, + headers=headers, + ) as response: + if response.status != 200: + reason = await response.text() + raise Exception( + f"Exa get contents failed: {reason}, status: {response.status}", + ) + data = await response.json() + return data.get("results", []) + + +@builtin_tool(config=_EXA_WEB_SEARCH_TOOL_CONFIG) +@pydantic_dataclass +class ExaWebSearchTool(FunctionTool[AstrAgentContext]): + """Web search tool powered by the Exa Search API.""" + + name: str = "web_search_exa" + description: str = ( + "A web search tool powered by Exa, an AI-native search engine. " + "Supports keyword and semantic search with domain, date, and category filters." + ) + parameters: dict = Field( + default_factory=lambda: { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Required. Search query."}, + "num_results": { + "type": "integer", + "description": "Optional. Number of results to return. Default is 10.", + }, + "type": { + "type": "string", + "description": ( + 'Optional. Search type. One of "auto", "keyword", "neural". ' + 'Default is "auto".' + ), + }, + "category": { + "type": "string", + "description": ( + "Optional. Category filter. One of " + '"company", "research paper", "news", "github", ' + '"tweet", "personal site", "pdf", "linkedin profile".' + ), + }, + "include_domains": { + "type": "string", + "description": "Optional. Comma-separated domains to restrict results to.", + }, + "exclude_domains": { + "type": "string", + "description": "Optional. Comma-separated domains to exclude from results.", + }, + "start_published_date": { + "type": "string", + "description": "Optional. Start date filter in ISO 8601 format (e.g. 2024-01-01T00:00:00.000Z).", + }, + "end_published_date": { + "type": "string", + "description": "Optional. End date filter in ISO 8601 format.", + }, + }, + "required": ["query"], + } + ) + + async def call(self, context, **kwargs) -> ToolExecResult: + _, provider_settings, _ = _get_runtime(context) + if not provider_settings.get("websearch_exa_key", []): + return "Error: Exa API key is not configured in AstrBot." + + try: + num_results = int(kwargs.get("num_results", 10)) + except (TypeError, ValueError): + num_results = 10 + if num_results < 1: + num_results = 1 + + search_type = kwargs.get("type", "auto") + if search_type not in ("auto", "keyword", "neural"): + search_type = "auto" + + payload: dict = { + "query": kwargs["query"], + "numResults": num_results, + "type": search_type, + "contents": {"text": {"maxCharacters": 500}}, + } + + category = kwargs.get("category", "") + if category: + payload["category"] = category + + include_domains = str(kwargs.get("include_domains", "")).strip() + if include_domains: + payload["includeDomains"] = [ + d.strip() for d in include_domains.split(",") if d.strip() + ] + + exclude_domains = str(kwargs.get("exclude_domains", "")).strip() + if exclude_domains: + payload["excludeDomains"] = [ + d.strip() for d in exclude_domains.split(",") if d.strip() + ] + + if kwargs.get("start_published_date"): + payload["startPublishedDate"] = kwargs["start_published_date"] + if kwargs.get("end_published_date"): + payload["endPublishedDate"] = kwargs["end_published_date"] + + results = await _exa_search(provider_settings, payload) + if not results: + return "Error: Exa web search does not return any results." + return _search_result_payload(results) + + +@builtin_tool(config=_EXA_WEB_SEARCH_TOOL_CONFIG) +@pydantic_dataclass +class ExaGetContentsTool(FunctionTool[AstrAgentContext]): + """Extract full page content from URLs using the Exa Contents API.""" + + name: str = "exa_get_contents" + description: str = "Extract the content of a web page using Exa." + parameters: dict = Field( + default_factory=lambda: { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Required. A URL to extract content from.", + }, + "max_characters": { + "type": "integer", + "description": "Optional. Maximum number of characters to return. Default is 3000.", + }, + }, + "required": ["url"], + } + ) + + async def call(self, context, **kwargs) -> ToolExecResult: + _, provider_settings, _ = _get_runtime(context) + if not provider_settings.get("websearch_exa_key", []): + return "Error: Exa API key is not configured in AstrBot." + + url = str(kwargs.get("url", "")).strip() + if not url: + return "Error: url must be a non-empty string." + + try: + max_characters = int(kwargs.get("max_characters", 3000)) + except (TypeError, ValueError): + max_characters = 3000 + results = await _exa_get_contents( + provider_settings, + { + "ids": [url], + "text": {"maxCharacters": max_characters}, + }, + ) + ret_ls = [] + for result in results: + ret_ls.append(f"URL: {result.get('url', 'No URL')}") + ret_ls.append(f"Content: {result.get('text', 'No content')}") + ret = "\n".join(ret_ls) + return ret or "Error: Exa get contents does not return any results." + + __all__ = [ "WEB_SEARCH_TOOL_NAMES", "BaiduWebSearchTool", "BochaWebSearchTool", "BraveWebSearchTool", + "ExaGetContentsTool", + "ExaWebSearchTool", "TavilyExtractWebPageTool", "TavilyWebSearchTool", "normalize_legacy_web_search_config", diff --git a/astrbot/core/utils/auth_password.py b/astrbot/core/utils/auth_password.py index c80317165..95bc82d67 100644 --- a/astrbot/core/utils/auth_password.py +++ b/astrbot/core/utils/auth_password.py @@ -69,6 +69,11 @@ def hash_legacy_dashboard_password(raw_password: str) -> str: return hashlib.md5(raw_password.encode("utf-8")).hexdigest() +def hash_md5_dashboard_password(raw_password: str) -> str: + """Backward-compatible alias for legacy md5 hashing.""" + return hash_legacy_dashboard_password(raw_password) + + def validate_dashboard_password(raw_password: str) -> None: """Validate whether dashboard password meets the minimal complexity policy.""" if not isinstance(raw_password, str) or raw_password == "": @@ -244,3 +249,8 @@ def is_legacy_dashboard_password(stored_hash: str) -> bool: ): return True return False + + +def is_md5_dashboard_password(stored_hash: str) -> bool: + """Backward-compatible alias for md5-format password check.""" + return _is_legacy_md5_hash(stored_hash) diff --git a/astrbot/core/utils/image_ref_utils.py b/astrbot/core/utils/image_ref_utils.py index 204e57631..1b58acc5e 100644 --- a/astrbot/core/utils/image_ref_utils.py +++ b/astrbot/core/utils/image_ref_utils.py @@ -3,7 +3,8 @@ from __future__ import annotations import os from collections.abc import Sequence from pathlib import Path -from urllib.parse import unquote, urlparse + +from astrbot.core.utils.media_utils import file_uri_to_path, is_file_uri ALLOWED_IMAGE_EXTENSIONS = { ".png", @@ -20,23 +21,9 @@ ALLOWED_IMAGE_EXTENSIONS = { def resolve_file_url_path(image_ref: str) -> str: - parsed = urlparse(image_ref) - if parsed.scheme != "file": + if not is_file_uri(image_ref): return image_ref - - path = unquote(parsed.path or "") - netloc = unquote(parsed.netloc or "") - - # Keep support for file:///path and file:// forms. - if netloc and netloc.lower() != "localhost": - path = f"//{netloc}{path}" if path else netloc - elif not path and netloc: - path = netloc - - if os.name == "nt" and len(path) > 2 and path[0] == "/" and path[2] == ":": - path = path[1:] - - return path or image_ref + return file_uri_to_path(image_ref) def _is_path_within_roots(path: str, roots: Sequence[str]) -> bool: @@ -69,7 +56,7 @@ def is_supported_image_ref( return True file_path = ( - resolve_file_url_path(image_ref) if lowered.startswith("file://") else image_ref + resolve_file_url_path(image_ref) if is_file_uri(image_ref) else image_ref ) ext = os.path.splitext(file_path)[1].lower() if ext in ALLOWED_IMAGE_EXTENSIONS: diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py index bc7cdcf85..5c17a7fb9 100644 --- a/astrbot/core/utils/io.py +++ b/astrbot/core/utils/io.py @@ -387,6 +387,11 @@ def _read_dashboard_dist_version(dist_dir: str | Path) -> str | None: return None +def get_dashboard_dist_version(dist_dir: str | Path) -> str | None: + """Read the version marker from a given dashboard dist directory.""" + return _read_dashboard_dist_version(dist_dir) + + def get_bundled_dashboard_dist_path() -> Path: return Path(get_astrbot_path()) / "astrbot" / "dashboard" / "dist" @@ -512,5 +517,10 @@ async def download_dashboard( show_progress=True, progress_callback=progress_callback, ) + extract_dashboard(zip_path, extract_path) + + +def extract_dashboard(zip_path: str | Path, extract_path: str | Path) -> None: + """Extract a downloaded dashboard archive.""" with zipfile.ZipFile(zip_path, "r") as z: z.extractall(extract_path) diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py index a8a482d7e..c53d148ed 100644 --- a/astrbot/core/utils/media_utils.py +++ b/astrbot/core/utils/media_utils.py @@ -1,42 +1,894 @@ -"""媒体文件处理工具 - -提供音视频格式转换。时长获取等功能。 +"""Media file utilities. +Provides shared media reference materialization, format conversion, duration +probing, and image compression helpers. """ import asyncio import base64 +import binascii import io +import mimetypes import os import subprocess import uuid +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field from pathlib import Path +from typing import TypeAlias +from urllib.parse import unquote, urlparse, urlsplit +from urllib.request import url2pathname -import anyio from PIL import Image as PILImage from astrbot import logger from astrbot.core.utils.astrbot_path import get_astrbot_temp_path -from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav +from astrbot.core.utils.io import download_file +from astrbot.core.utils.tencent_record_helper import ( + tencent_silk_to_wav, + wav_to_tencent_silk, +) IMAGE_COMPRESS_DEFAULT_MAX_SIZE = 1280 IMAGE_COMPRESS_DEFAULT_QUALITY = 95 IMAGE_COMPRESS_DEFAULT_OPTIMIZE = True IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_MB = 1.0 +MEDIA_MIME_EXTENSIONS = { + "audio/wav": ".wav", + "audio/wave": ".wav", + "audio/x-wav": ".wav", + "audio/mpeg": ".mp3", + "audio/mp3": ".mp3", + "audio/ogg": ".ogg", + "audio/opus": ".opus", + "audio/flac": ".flac", + "audio/aac": ".aac", + "audio/amr": ".amr", + "audio/silk": ".silk", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/bmp": ".bmp", + "image/tiff": ".tiff", + "image/avif": ".avif", + "video/mp4": ".mp4", + "video/webm": ".webm", + "video/quicktime": ".mov", +} -async def get_media_duration(file_path: str) -> int | None: - """使用ffprobe获取媒体文件时长 +IMAGE_FORMAT_MIME_TYPES = { + "JPEG": "image/jpeg", + "PNG": "image/png", + "GIF": "image/gif", + "WEBP": "image/webp", + "BMP": "image/bmp", + "TIFF": "image/tiff", + "AVIF": "image/avif", +} + +AUDIO_FORMAT_MIME_TYPES = { + "aac": "audio/aac", + "amr": "audio/amr", + "flac": "audio/flac", + "mp3": "audio/mp3", + "ogg": "audio/ogg", + "opus": "audio/opus", + "silk": "audio/silk", + "tencent_silk": "audio/silk", + "wav": "audio/wav", +} + +DEFAULT_MEDIA_SUFFIXES = { + "audio": ".wav", + "image": ".bin", + "video": ".mp4", + "file": ".bin", +} + + +MediaRefStr: TypeAlias = str +""" +A media reference string accepted by MediaResolver: local path, file URI, HTTP(S), +base64://, data URI, or legacy bare base64. + +Examples: + Local path: ``/tmp/image.png`` + File URI: ``file:///tmp/image.png`` + HTTP(S) URL: ``https://example.com/image.png`` + base64:// payload: ``base64://iVBORw0KGgo...`` + Data URI: ``data:image/png;base64,iVBORw0KGgo...`` + Legacy bare base64: ``iVBORw0KGgo...`` +""" + + +@dataclass(slots=True) +class ResolvedMediaData: + """Base64 media bytes plus the metadata needed by provider payloads. + + Attributes: + base64_data: Raw base64 payload without a ``data:`` URI prefix. + mime_type: MIME type to send with provider payloads. + format: Optional normalized media format, such as ``wav`` for audio. + """ + + base64_data: str + mime_type: str + format: str | None = None + + def to_bytes(self) -> bytes: + """Decode the base64 payload, accepting missing padding.""" + return _decode_base64_payload( + self.base64_data, + error_message="invalid resolved media base64 data", + ) + + def to_data_url(self) -> str: + """Return a ``data:;base64,...`` URL for multimodal providers.""" + return f"data:{self.mime_type};base64,{self.base64_data}" + + +@dataclass(slots=True) +class _LocalMediaFile: + path: Path + mime_type: str | None = None + cleanup_paths: list[Path] = field(default_factory=list) + + +@dataclass(slots=True) +class ResolvedMediaFile: + """A media reference resolved to a local path. + + ``cleanup_paths`` contains temporary files owned by the resolver. Callers that + use ``MediaResolver.as_path()`` get automatic cleanup; callers that need to + keep a path after the resolver returns should use ``MediaResolver.to_path()``. + """ + + source_ref: MediaRefStr + media_type: str + path: Path + mime_type: str | None = None + format: str | None = None + cleanup_paths: list[Path] = field(default_factory=list) + + def read_bytes(self) -> bytes: + """Read the resolved local file.""" + return self.path.read_bytes() + + def to_base64(self) -> str: + """Read the resolved local file and return raw base64 data.""" + return base64.b64encode(self.read_bytes()).decode("utf-8") + + def to_data_url(self) -> str: + """Read the resolved local file and return a data URL.""" + mime_type = self.mime_type or "application/octet-stream" + return f"data:{mime_type};base64,{self.to_base64()}" + + def open(self, mode: str = "rb"): + """Open the resolved local file.""" + return self.path.open(mode) + + def detach(self) -> None: + """Keep temporary files alive after resolver cleanup would normally run.""" + + self.cleanup_paths.clear() + + def cleanup(self) -> None: + _cleanup_paths(self.cleanup_paths) + + +def is_file_uri(value: object) -> bool: + """Return whether a value is a ``file:`` URI. Args: - file_path: 媒体文件路径 + value: Candidate media reference or local path. Returns: - 时长(毫秒),如果获取失败返回None + ``True`` only for string values whose parsed URI scheme is ``file``. + """ + if not isinstance(value, str): + return False + try: + return urlsplit(value).scheme.lower() == "file" + except ValueError: + return False + + +def file_uri_to_path(file_uri: MediaRefStr) -> str: + """Normalize file URIs to local filesystem paths. + + Args: + file_uri: A ``file:`` URI or a plain filesystem path. + + Returns: + The local filesystem path decoded with standard-library URL path rules. + Non-``file:`` inputs are returned unchanged for convenience. + """ + + if not is_file_uri(file_uri): + return file_uri + + parsed = urlparse(file_uri) + netloc = parsed.netloc or "" + path = parsed.path or "" + if netloc and netloc.lower() != "localhost": + if len(netloc) == 2 and netloc[1] == ":" and netloc[0].isalpha(): + return str(Path(url2pathname(f"{netloc}{path}"))) + return str(Path(url2pathname(f"//{netloc}{path}"))) + + path = url2pathname(path) + if len(path) >= 4 and path[0] == "/" and path[2] == ":" and path[1].isalpha(): + path = path[1:] + elif os.name != "nt" and path.startswith("//"): + # Older AstrBot builds generated file:////path for POSIX absolute paths. + path = "/" + path.lstrip("/") + return str(Path(path)) + + +def _extension_from_mime_type(mime_type: str | None) -> str | None: + """Return a filesystem suffix for a MIME type, if one is known.""" + if not mime_type: + return None + normalized = mime_type.split(";", 1)[0].strip().lower() + if not normalized: + return None + return MEDIA_MIME_EXTENSIONS.get(normalized) or mimetypes.guess_extension( + normalized + ) + + +def _temp_media_path(media_type: str, suffix: str) -> Path: + """Create a unique path under AstrBot's temp directory for materialized media.""" + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + safe_media_type = "".join( + char if char.isalnum() or char in {"_", "-"} else "_" for char in media_type + ) + return temp_dir / f"media_{safe_media_type}_{uuid.uuid4().hex}{suffix}" + + +def _parse_base64_data_uri(data_uri: str) -> tuple[str | None, bytes]: + """Parse a base64 data URI and return ``(mime_type, decoded_bytes)``.""" + header, separator, payload = data_uri.partition(",") + if not separator or not header.lower().startswith("data:"): + raise ValueError("invalid data URI") + + header_body = header[5:] + header_parts = header_body.split(";") if header_body else [] + mime_type = header_parts[0].strip() if header_parts and header_parts[0] else None + if not any(part.lower() == "base64" for part in header_parts[1:]): + raise ValueError("data URI is not base64 encoded") + + return mime_type, _decode_base64_payload( + payload, + error_message="invalid base64 data URI payload", + ) + + +def _decode_base64_payload( + payload: str, + *, + error_message: str, + validate: bool = False, +) -> bytes: + """Decode a base64 payload while tolerating omitted padding. + + Args: + payload: Base64 payload without a data URI header. + error_message: Message to use when decoding fails. + validate: Whether to ask ``base64.b64decode`` to reject non-base64 + characters. + + Returns: + Decoded bytes. + + Raises: + ValueError: Raised when the payload cannot be decoded. + """ + payload = "".join(payload.split()) + missing_padding = len(payload) % 4 + if missing_padding: + payload += "=" * (4 - missing_padding) + + try: + return base64.b64decode(payload, validate=validate) + except (binascii.Error, ValueError) as exc: + raise ValueError(error_message) from exc + + +def describe_media_ref(media_ref: object | None) -> str: + """Return a log-safe description of a media reference. + + Args: + media_ref: Original media reference from a platform, plugin, or provider + request. It may contain a signed URL or a large base64 payload. + + Returns: + A short description that avoids logging query strings, tokens, and base64 + payload contents. + """ + + if not media_ref: + return "" + if not isinstance(media_ref, str): + return f"media ref type={type(media_ref).__name__}" + + ref_len = len(media_ref) + if media_ref.startswith("data:"): + header, _, payload = media_ref.partition(",") + mime_type = header[5:].split(";", 1)[0] or "unknown" + return f"data URI mime={mime_type!r} payload_len={len(payload)}" + + if media_ref.startswith("base64://"): + return f"base64 media payload_len={len(media_ref.removeprefix('base64://'))}" + + parsed = urlparse(media_ref) + if parsed.scheme in {"http", "https"}: + filename = Path(unquote(parsed.path or "")).name + suffix = f" file={filename!r}" if filename else "" + return f"{parsed.scheme} URL host={parsed.netloc!r}{suffix} len={ref_len}" + + if is_file_uri(media_ref): + filename = Path(file_uri_to_path(media_ref)).name + return f"file URI name={filename!r} len={ref_len}" + + media_path_exists = False + try: + media_path_exists = Path(media_ref).exists() + except OSError: + pass + if not media_path_exists: + compact = "".join(media_ref.split()) + if compact: + try: + _decode_base64_payload( + compact, + error_message="invalid bare base64 media payload", + validate=True, + ) + except ValueError: + pass + else: + return f"bare base64 media payload_len={len(compact)}" + + return f"local media path name={Path(media_ref).name!r} len={ref_len}" + + +def detect_image_mime_type( + image_bytes: bytes, + *, + default_mime_type: str | None = "image/jpeg", +) -> str | None: + """Detect an image MIME type from bytes. + + Args: + image_bytes: Encoded image bytes to inspect. + default_mime_type: MIME type to return when detection fails. + + Returns: + The detected MIME type, or ``default_mime_type`` when detection fails or + the format is unknown. + """ + + try: + with PILImage.open(io.BytesIO(image_bytes)) as image: + image.verify() + image_format = str(image.format or "").upper() + except Exception: + return default_mime_type + + return IMAGE_FORMAT_MIME_TYPES.get(image_format, default_mime_type) + + +def _guess_mime_type(path: Path, fallback: str | None = None) -> str | None: + """Guess a MIME type from a filename, with an optional fallback.""" + return mimetypes.guess_type(path.name)[0] or fallback + + +def _cleanup_paths(cleanup_paths: list[Path] | None) -> None: + """Best-effort cleanup for temporary files created by the resolver.""" + for cleanup_path in cleanup_paths or []: + try: + cleanup_path.unlink(missing_ok=True) + except Exception as exc: + logger.warning("Failed to cleanup %s: %s", cleanup_path, exc) + + +async def _materialize_media_ref( + media_ref: MediaRefStr, + *, + media_type: str = "file", + default_suffix: str | None = None, +) -> _LocalMediaFile: + """Resolve a plugin-facing media reference to a local file. + + Supported references: local paths, file:// URIs, http(s) URLs, base64://, + data:*;base64,... URIs, and legacy bare base64 payloads. + + Args: + media_ref: Original media reference from a platform, plugin, or history. + media_type: Logical media family used for temp filenames and defaults. + default_suffix: Suffix to use when the reference does not carry one. + """ + + cleanup_paths: list[Path] = [] + suffix = default_suffix or DEFAULT_MEDIA_SUFFIXES.get(media_type, ".bin") + + if media_ref.startswith(("http://", "https://")): + parsed = urlparse(media_ref) + target_suffix = Path(parsed.path).suffix or suffix + target_path = _temp_media_path(media_type, target_suffix) + cleanup_paths.append(target_path) + try: + await download_file(media_ref, str(target_path)) + except Exception: + _cleanup_paths(cleanup_paths) + raise + return _LocalMediaFile( + path=target_path, + mime_type=_guess_mime_type(target_path), + cleanup_paths=cleanup_paths, + ) + + if is_file_uri(media_ref): + path = Path(file_uri_to_path(media_ref)) + return _LocalMediaFile(path=path, mime_type=_guess_mime_type(path)) + + if media_ref.startswith("data:"): + mime_type, media_bytes = _parse_base64_data_uri(media_ref) + target_suffix = _extension_from_mime_type(mime_type) or suffix + target_path = _temp_media_path(media_type, target_suffix) + cleanup_paths.append(target_path) + try: + target_path.write_bytes(media_bytes) + except Exception: + _cleanup_paths(cleanup_paths) + raise + return _LocalMediaFile( + path=target_path, + mime_type=mime_type, + cleanup_paths=cleanup_paths, + ) + + if media_ref.startswith("base64://"): + media_bytes = _decode_base64_payload( + media_ref.removeprefix("base64://"), + error_message="invalid base64 media payload", + ) + target_path = _temp_media_path(media_type, suffix) + cleanup_paths.append(target_path) + try: + target_path.write_bytes(media_bytes) + except Exception: + _cleanup_paths(cleanup_paths) + raise + return _LocalMediaFile(path=target_path, cleanup_paths=cleanup_paths) + + path = Path(media_ref) + path_exists = False + try: + path_exists = path.exists() + except OSError: + pass + if path_exists: + return _LocalMediaFile(path=path, mime_type=_guess_mime_type(path)) + + compact_media_ref = "".join(media_ref.split()) + if compact_media_ref: + try: + media_bytes = _decode_base64_payload( + compact_media_ref, + error_message="invalid bare base64 media payload", + validate=True, + ) + except ValueError: + pass + else: + target_path = _temp_media_path(media_type, suffix) + cleanup_paths.append(target_path) + try: + target_path.write_bytes(media_bytes) + except Exception: + _cleanup_paths(cleanup_paths) + raise + return _LocalMediaFile(path=target_path, cleanup_paths=cleanup_paths) + + return _LocalMediaFile(path=path, mime_type=_guess_mime_type(path)) + + +class MediaResolver: + """Resolve, convert, and export media references. + + The resolver accepts local paths, file:// URIs, http(s) URLs, base64:// payloads, + data:*;base64,... URIs, and legacy bare base64 payloads. Temporary paths are + cleaned when using as_path(), while to_path() intentionally leaves returned + paths alive for callers that need to hand them to platform SDKs. + + Args: + media_ref: Source media reference. It may be a local path, ``file://`` URI, + HTTP(S) URL, ``base64://`` payload, base64 data URI, or legacy bare + base64 payload. + media_type: Logical media family. ``audio`` enables format conversion and + defaults to WAV output; ``image`` enables image MIME detection. + default_suffix: Fallback suffix for temporary files when the source does + not expose one. + """ + + def __init__( + self, + media_ref: MediaRefStr, + *, + media_type: str = "file", + default_suffix: str | None = None, + ) -> None: + self.media_ref = media_ref + self.media_type = media_type + self.default_suffix = default_suffix + + async def _resolve_path( + self, + *, + target_format: str | None = None, + preserve_mp3: bool = False, + ) -> ResolvedMediaFile: + """Materialize the source and apply media-type-specific conversion. + + For audio, ``target_format`` controls the output format, including the + QQ / Wechat / Wecom ``tencent_silk`` upload format. When it is not set, audio + resolves to WAV unless ``preserve_mp3`` is true and the source already + appears to be MP3. + """ + local_file = await _materialize_media_ref( + self.media_ref, + media_type=self.media_type, + default_suffix=self.default_suffix, + ) + cleanup_paths = list(local_file.cleanup_paths) + resolved_path = local_file.path + mime_type = local_file.mime_type or _guess_mime_type(resolved_path) + resolved_format = resolved_path.suffix.lower().lstrip(".") or None + + try: + if self.media_type == "audio": + audio_format = target_format + if not audio_format: + audio_format = ( + "mp3" if preserve_mp3 and resolved_format == "mp3" else "wav" + ) + + if audio_format == "tencent_silk": + intermediate_cleanup_paths = list(cleanup_paths) + silk_path = _temp_media_path("audio", ".silk") + try: + wav_path = Path(await ensure_wav(str(resolved_path))) + if wav_path != resolved_path: + intermediate_cleanup_paths.append(wav_path) + duration = await wav_to_tencent_silk( + str(wav_path), str(silk_path) + ) + if duration <= 0: + raise ValueError( + "Tencent Silk conversion returned empty duration" + ) + except Exception: + _cleanup_paths([*intermediate_cleanup_paths, silk_path]) + raise + + _cleanup_paths(intermediate_cleanup_paths) + cleanup_paths = [silk_path] + resolved_path = silk_path + resolved_format = audio_format + mime_type = AUDIO_FORMAT_MIME_TYPES[resolved_format] + else: + if audio_format == "wav": + converted_audio_path = Path( + await ensure_wav(str(resolved_path)) + ) + elif resolved_format == audio_format: + converted_audio_path = resolved_path + else: + converted_audio_path = Path( + await convert_audio_format( + str(resolved_path), + output_format=audio_format, + ) + ) + + if converted_audio_path != resolved_path: + cleanup_paths.append(converted_audio_path) + resolved_path = converted_audio_path + resolved_format = audio_format + mime_type = AUDIO_FORMAT_MIME_TYPES.get( + resolved_format, "audio/wav" + ) + except Exception: + _cleanup_paths(cleanup_paths) + raise + + return ResolvedMediaFile( + source_ref=self.media_ref, + media_type=self.media_type, + path=resolved_path, + mime_type=mime_type, + format=resolved_format, + cleanup_paths=cleanup_paths, + ) + + @asynccontextmanager + async def as_path( + self, + *, + target_format: str | None = None, + preserve_mp3: bool = False, + ) -> AsyncIterator[ResolvedMediaFile]: + """Yield a resolved local file and clean resolver-owned temp files on exit. + + Use this when the consumer only needs the file during the context manager. + For audio, pass ``target_format`` to force a format such as ``wav`` or + ``tencent_silk``. + """ + resolved = await self._resolve_path( + target_format=target_format, + preserve_mp3=preserve_mp3, + ) + try: + yield resolved + finally: + resolved.cleanup() + + async def to_path( + self, + *, + target_format: str | None = None, + preserve_mp3: bool = False, + ) -> str: + """Return a resolved local path and keep temporary files alive. + + This is for message components and platform SDK calls that need a path + after the resolver method returns. Callers or event cleanup should remove + the returned temp file later. + """ + resolved = await self._resolve_path( + target_format=target_format, + preserve_mp3=preserve_mp3, + ) + resolved.detach() + return str(resolved.path.resolve()) + + async def to_bytes( + self, + *, + target_format: str | None = None, + preserve_mp3: bool = False, + ) -> bytes: + """Resolve media, read bytes, and clean resolver-owned temp files.""" + async with self.as_path( + target_format=target_format, + preserve_mp3=preserve_mp3, + ) as resolved: + return resolved.read_bytes() + + async def to_base64( + self, + *, + target_format: str | None = None, + preserve_mp3: bool = False, + ) -> str: + """Resolve media to raw base64 data without a data URI prefix.""" + return base64.b64encode( + await self.to_bytes( + target_format=target_format, + preserve_mp3=preserve_mp3, + ) + ).decode("utf-8") + + async def to_base64_data( + self, + *, + strict: bool = False, + target_format: str | None = None, + preserve_mp3: bool = False, + default_mime_type: str | None = "image/jpeg", + ) -> ResolvedMediaData | None: + """Resolve media to base64 data plus MIME metadata. + + Args: + strict: Raise on invalid or unreadable media instead of returning + ``None`` where the resolver can safely ignore the reference. + target_format: Optional output format for audio conversion. + preserve_mp3: Keep existing MP3 audio as MP3 when no target format is + provided; otherwise audio defaults to WAV. + default_mime_type: Fallback MIME type for legacy image base64 payloads + whose bytes cannot be identified. + """ + if self.media_type == "image": + async with self.as_path(target_format=target_format) as resolved: + try: + media_bytes = resolved.read_bytes() + except OSError: + if strict: + raise + return None + + mime_type = detect_image_mime_type( + media_bytes, + default_mime_type=None, + ) + if ( + not mime_type + and resolved.mime_type + and resolved.mime_type.startswith("image/") + ): + mime_type = resolved.mime_type + is_legacy_base64_ref = self.media_ref.startswith("base64://") + is_remote_or_data_ref = self.media_ref.startswith( + ("http://", "https://", "data:") + ) or is_file_uri(self.media_ref) + if not is_legacy_base64_ref and not is_remote_or_data_ref: + try: + _decode_base64_payload( + "".join(self.media_ref.split()), + error_message="invalid bare base64 media payload", + validate=True, + ) + except ValueError: + is_legacy_base64_ref = False + else: + is_legacy_base64_ref = True + if not mime_type and is_legacy_base64_ref: + mime_type = default_mime_type + if not mime_type: + if strict: + raise ValueError( + f"Invalid image file: {describe_media_ref(self.media_ref)}" + ) + return None + + return ResolvedMediaData( + base64_data=base64.b64encode(media_bytes).decode("utf-8"), + mime_type=mime_type, + ) + + async with self.as_path( + target_format=target_format, + preserve_mp3=preserve_mp3, + ) as resolved: + try: + media_bytes = resolved.read_bytes() + except OSError: + if strict: + raise + return None + + mime_type = resolved.mime_type or "application/octet-stream" + return ResolvedMediaData( + base64_data=base64.b64encode(media_bytes).decode("utf-8"), + mime_type=mime_type, + format=resolved.format, + ) + + async def to_data_url( + self, + *, + strict: bool = False, + target_format: str | None = None, + preserve_mp3: bool = False, + default_mime_type: str | None = "image/jpeg", + ) -> str | None: + """Resolve media directly to a provider-ready data URL.""" + resolved = await self.to_base64_data( + strict=strict, + target_format=target_format, + preserve_mp3=preserve_mp3, + default_mime_type=default_mime_type, + ) + return resolved.to_data_url() if resolved else None + + @asynccontextmanager + async def open( + self, + mode: str = "rb", + *, + target_format: str | None = None, + preserve_mp3: bool = False, + ): + """Open resolved media as a file object inside a cleanup context.""" + async with self.as_path( + target_format=target_format, + preserve_mp3=preserve_mp3, + ) as resolved: + with resolved.open(mode) as file_obj: + yield file_obj + + +async def resolve_image_ref_to_base64_data( + image_ref: MediaRefStr, + *, + strict: bool = False, + default_mime_type: str | None = "image/jpeg", +) -> ResolvedMediaData | None: + """Resolve an image reference to base64 data and a detected MIME type. + + ``strict=False`` returns ``None`` for invalid images so provider payload + assembly can skip bad image refs without failing the whole request. + """ + + return await MediaResolver( + image_ref, + media_type="image", + default_suffix=".bin", + ).to_base64_data( + strict=strict, + default_mime_type=default_mime_type, + ) + + +async def resolve_audio_ref_to_base64_data( + audio_ref: MediaRefStr, + *, + preserve_mp3: bool = False, + target_format: str | None = None, +) -> ResolvedMediaData: + """Resolve an audio reference to base64 data. + + Audio is converted to WAV by default. Pass preserve_mp3=True for legacy + provider payloads that intentionally keep MP3 input unchanged. + ``target_format`` overrides both defaults when provided. + """ + + audio_data = await MediaResolver( + audio_ref, + media_type="audio", + default_suffix=".wav", + ).to_base64_data( + target_format=target_format, + preserve_mp3=preserve_mp3, + strict=True, + ) + if audio_data is None: + raise ValueError(f"Invalid audio data: {describe_media_ref(audio_ref)}") + return audio_data + + +async def resolve_media_ref_to_base64_data( + media_ref: MediaRefStr, + *, + media_type: str, + strict: bool = False, +) -> ResolvedMediaData | None: + """Resolve a media reference to base64 data through one shared entrypoint. + + This helper keeps provider sources from knowing whether a reference is local, + HTTP(S), ``base64://``, a data URI, or a legacy bare base64 payload. + """ + + if media_type == "image": + return await resolve_image_ref_to_base64_data(media_ref, strict=strict) + if media_type == "audio": + return await resolve_audio_ref_to_base64_data(media_ref) + + return await MediaResolver( + media_ref, + media_type=media_type, + ).to_base64_data( + strict=strict, + ) + + +async def get_media_duration(file_path: str) -> int | None: + """Probe media duration with ffprobe. + + Args: + file_path: Local media file path. + + Returns: + Duration in milliseconds, or ``None`` when probing fails. """ try: - # 使用ffprobe获取时长 + # Probe duration with ffprobe. process = await asyncio.create_subprocess_exec( "ffprobe", "-v", @@ -50,28 +902,39 @@ async def get_media_duration(file_path: str) -> int | None: stderr=subprocess.PIPE, ) - stdout, _stderr = await process.communicate() + stdout, stderr = await process.communicate() if process.returncode == 0 and stdout: duration_seconds = float(stdout.decode().strip()) duration_ms = int(duration_seconds * 1000) - logger.debug(f"[Media Utils] 获取媒体时长: {duration_ms}ms") + logger.debug("Media duration detected: %sms", duration_ms) return duration_ms - logger.warning(f"[Media Utils] 无法获取媒体文件时长: {file_path}") - return None + else: + logger.warning("Failed to get media duration: %s", file_path) + return None except FileNotFoundError: logger.warning( - "[Media Utils] ffprobe未安装或不在PATH中,无法获取媒体时长。请安装ffmpeg: https://ffmpeg.org/", + "ffprobe is not installed or not in PATH. " + "Install ffmpeg: https://ffmpeg.org/" ) return None except Exception as e: - logger.warning(f"[Media Utils] 获取媒体时长时出错: {e}") + logger.warning("Error while probing media duration: %s", e) return None async def convert_audio_to_opus(audio_path: str, output_path: str | None = None) -> str: - """将音频转换为opus格式。""" + """Convert an audio file to Opus format. + + Args: + audio_path: Source audio file path. + output_path: Optional output file path. When omitted, a temporary path is + created under AstrBot's temp directory. + + Returns: + The converted Opus file path. + """ return await convert_audio_format( audio_path=audio_path, output_format="opus", @@ -80,39 +943,37 @@ async def convert_audio_to_opus(audio_path: str, output_path: str | None = None) async def convert_video_format( - video_path: str, - output_format: str = "mp4", - output_path: str | None = None, + video_path: str, output_format: str = "mp4", output_path: str | None = None ) -> str: - """使用ffmpeg转换视频格式 + """Convert a video file with ffmpeg. Args: - video_path: 原始视频文件路径 - output_format: 目标格式,默认mp4 - output_path: 输出文件路径,如果为None则自动生成 + video_path: Source video file path. + output_format: Target format, such as ``mp4``. + output_path: Optional output file path. When omitted, a temporary path is + created under AstrBot's temp directory. Returns: - 转换后的视频文件路径 + The converted video file path. Raises: - Exception: 转换失败时抛出异常 - + Exception: Raised when ffmpeg is unavailable or conversion fails. """ - # 如果已经是目标格式,直接返回 + # Return early when the source already appears to be in the target format. if video_path.lower().endswith(f".{output_format}"): return video_path - # 生成输出文件路径 + # Create an output path when the caller does not provide one. if output_path is None: temp_dir = get_astrbot_temp_path() - await anyio.Path(temp_dir).mkdir(parents=True, exist_ok=True) + os.makedirs(temp_dir, exist_ok=True) output_path = os.path.join( temp_dir, f"media_video_{uuid.uuid4().hex}.{output_format}", ) try: - # 使用ffmpeg转换视频格式 + # Convert the video with ffmpeg. process = await asyncio.create_subprocess_exec( "ffmpeg", "-y", @@ -127,35 +988,44 @@ async def convert_video_format( stderr=subprocess.PIPE, ) - _stdout, stderr = await process.communicate() + stdout, stderr = await process.communicate() if process.returncode != 0: - # 清理可能已生成但无效的临时文件 - if output_path and await anyio.Path(output_path).exists(): + # Remove a partial output file created by a failed ffmpeg run. + if output_path and os.path.exists(output_path): try: - await anyio.Path(output_path).unlink() + os.remove(output_path) logger.debug( - f"[Media Utils] 已清理失败的{output_format}输出文件: {output_path}", + "Removed failed %s output file: %s", + output_format, + output_path, ) except OSError as e: logger.warning( - f"[Media Utils] 清理失败的{output_format}输出文件时出错: {e}", + "Failed to remove failed %s output file: %s", + output_format, + e, ) - error_msg = stderr.decode() if stderr else "未知错误" - logger.error(f"[Media Utils] ffmpeg转换视频失败: {error_msg}") + error_msg = stderr.decode() if stderr else "unknown error" + logger.error("ffmpeg video conversion failed: %s", error_msg) raise Exception(f"ffmpeg conversion failed: {error_msg}") - logger.debug(f"[Media Utils] 视频转换成功: {video_path} -> {output_path}") + logger.debug( + "Video converted successfully: %s -> %s", + video_path, + output_path, + ) return output_path - except FileNotFoundError as err: + except FileNotFoundError: logger.error( - "[Media Utils] ffmpeg未安装或不在PATH中,无法转换视频格式。请安装ffmpeg: https://ffmpeg.org/", + "ffmpeg is not installed or not in PATH. " + "Install ffmpeg: https://ffmpeg.org/" ) - raise Exception("ffmpeg not found") from err + raise Exception("ffmpeg not found") except Exception as e: - logger.error(f"[Media Utils] 转换视频格式时出错: {e}") + logger.error("Error while converting video format: %s", e) raise @@ -164,23 +1034,27 @@ async def convert_audio_format( output_format: str = "amr", output_path: str | None = None, ) -> str: - """使用ffmpeg将音频转换为指定格式。 + """Convert an audio file to the requested format with ffmpeg. Args: - audio_path: 原始音频文件路径 - output_format: 目标格式,例如 amr / ogg / opus / wav - output_path: 输出文件路径,如果为None则自动生成 + audio_path: Source audio file path. + output_format: Target format, such as ``amr``, ``ogg``, ``opus``, or + ``wav``. + output_path: Optional output file path. When omitted, a temporary path is + created under AstrBot's temp directory. Returns: - 转换后的音频文件路径 + The converted audio file path. + Raises: + Exception: Raised when ffmpeg is unavailable or conversion fails. """ if audio_path.lower().endswith(f".{output_format}"): return audio_path if output_path is None: - temp_dir = anyio.Path(get_astrbot_temp_path()) - await temp_dir.mkdir(parents=True, exist_ok=True) + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) output_path = str(temp_dir / f"media_audio_{uuid.uuid4().hex}.{output_format}") args = ["ffmpeg", "-y", "-i", audio_path] @@ -201,9 +1075,11 @@ async def convert_audio_format( "loudnorm=I=-18.5:TP=-1.5:LRA=6," "aresample=8000" ), - ], + ] ) - elif output_format == "ogg" or output_format == "opus": + elif output_format == "ogg": + args.extend(["-acodec", "libopus", "-ac", "1", "-ar", "16000"]) + elif output_format == "opus": args.extend(["-acodec", "libopus", "-ac", "1", "-ar", "16000"]) args.append(output_path) @@ -215,21 +1091,37 @@ async def convert_audio_format( ) _, stderr = await process.communicate() if process.returncode != 0: - if output_path and await anyio.Path(output_path).exists(): + if output_path and os.path.exists(output_path): try: - await anyio.Path(output_path).unlink() + os.remove(output_path) except OSError as e: - logger.warning(f"[Media Utils] 清理失败的音频输出文件时出错: {e}") - error_msg = stderr.decode() if stderr else "未知错误" + logger.warning( + "Failed to remove failed audio output file: %s", + e, + ) + error_msg = stderr.decode() if stderr else "unknown error" raise Exception(f"ffmpeg conversion failed: {error_msg}") - logger.debug(f"[Media Utils] 音频转换成功: {audio_path} -> {output_path}") + logger.debug( + "Audio converted successfully: %s -> %s", + audio_path, + output_path, + ) return output_path - except FileNotFoundError as err: - raise Exception("ffmpeg not found") from err + except FileNotFoundError: + raise Exception("ffmpeg not found") async def convert_audio_to_amr(audio_path: str, output_path: str | None = None) -> str: - """将音频转换为amr格式。""" + """Convert an audio file to AMR format. + + Args: + audio_path: Source audio file path. + output_path: Optional output file path. When omitted, a temporary path is + created under AstrBot's temp directory. + + Returns: + The converted AMR file path. + """ return await convert_audio_format( audio_path=audio_path, output_format="amr", @@ -238,7 +1130,16 @@ async def convert_audio_to_amr(audio_path: str, output_path: str | None = None) async def convert_audio_to_wav(audio_path: str, output_path: str | None = None) -> str: - """将音频转换为wav格式。""" + """Convert an audio file to WAV format. + + Args: + audio_path: Source audio file path. + output_path: Optional output file path. When omitted, a temporary path is + created under AstrBot's temp directory. + + Returns: + The converted WAV file path. + """ return await convert_audio_format( audio_path=audio_path, output_format="wav", @@ -249,8 +1150,24 @@ async def convert_audio_to_wav(audio_path: str, output_path: str | None = None) async def ensure_wav(audio_path: str, output_path: str | None = None) -> str: """Ensure the audio path points to wav format by extension/guess and convert when needed. - If the file appears to already be wav, return it directly to avoid extra conversion. + If the file appears to already be WAV, return it directly to avoid extra + conversion. If the file does not exist yet, return the original path so + upstream retry logic can handle platform races. + + Args: + audio_path: Local audio path to inspect and convert when needed. + output_path: Optional destination path. When omitted, conversion helpers + create a temporary file under AstrBot's temp directory. + + Returns: + The original path when it is already WAV or unavailable; otherwise the + converted WAV path. + + Raises: + Exception: Raised by the underlying conversion helper when conversion + fails. """ + if not audio_path: return audio_path @@ -273,16 +1190,108 @@ async def ensure_wav(audio_path: str, output_path: str | None = None) -> str: return await convert_audio_to_wav(audio_path, output_path) +async def ensure_jpeg(image_path: str, output_path: str | None = None) -> str: + """Ensure the image path points to a JPEG file. + + Args: + image_path: Local image path to inspect and convert when needed. + output_path: Optional destination path. When omitted, a temporary file under + AstrBot's temp directory is created for converted JPEG output. + + Returns: + The original path when the source is already a JPEG file with a jpg/jpeg + suffix or cannot be found; otherwise the converted JPEG path. + + Raises: + Exception: Raised by Pillow when the source file cannot be opened or saved as + an image. + """ + + if not image_path: + return image_path + + source_path = Path(image_path) + if not source_path.exists(): + return image_path + + with PILImage.open(source_path) as opened_img: + image_format = str(opened_img.format or "").upper() + + if image_format == "JPEG" and source_path.suffix.lower() in {".jpg", ".jpeg"}: + return image_path + + if output_path is None: + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + output_path = str(temp_dir / f"media_image_{uuid.uuid4().hex}.jpg") + jpeg_output_path = output_path + + def convert_image_to_jpeg() -> str: + converted_img: PILImage.Image | None = None + flattened_img: PILImage.Image | None = None + + with PILImage.open(image_path) as opened_img: + try: + working_img: PILImage.Image = opened_img + if opened_img.mode in {"RGBA", "LA"} or ( + opened_img.mode == "P" and "transparency" in opened_img.info + ): + flattened = PILImage.new("RGB", opened_img.size, (255, 255, 255)) + flattened_img = flattened + alpha_source = opened_img.convert("RGBA") + try: + flattened.paste(alpha_source, mask=alpha_source.getchannel("A")) + finally: + alpha_source.close() + working_img = flattened + elif opened_img.mode != "RGB": + converted_img = opened_img.convert("RGB") + working_img = converted_img + + working_img.save(jpeg_output_path, "JPEG") + return jpeg_output_path + finally: + if converted_img is not None: + converted_img.close() + if flattened_img is not None: + flattened_img.close() + + try: + return await asyncio.to_thread(convert_image_to_jpeg) + except Exception: + if output_path and os.path.exists(output_path): + try: + os.remove(output_path) + except OSError as e: + logger.warning( + "Failed to remove failed image output file: %s", + e, + ) + raise + + def _get_audio_magic_type(audio_path: str) -> str: - """Detect common audio formats from magic bytes.""" + """Detect common audio formats from magic bytes. + + Args: + audio_path: Local audio path to inspect. + + Returns: + A normalized format name such as ``wav``, ``mp3``, ``opus``, ``silk``, or + an empty string when the type cannot be detected. + """ try: with open(audio_path, "rb") as f: header = f.read(64) except FileNotFoundError: - logger.warning(f"[Media Utils] wav check file not found: {audio_path}") + logger.warning("WAV probe file not found: %s", audio_path) return "" except Exception as e: - logger.warning(f"[Media Utils] wav check failed: {audio_path}, error: {e}") + logger.warning( + "WAV probe failed: %s, error: %s", + audio_path, + e, + ) return "" if len(header) < 12: @@ -322,10 +1331,22 @@ async def extract_video_cover( video_path: str, output_path: str | None = None, ) -> str: - """从视频中提取封面图(JPG)""" + """Extract a JPEG cover frame from a video. + + Args: + video_path: Source video file path. + output_path: Optional output image path. When omitted, a temporary JPEG + path is created under AstrBot's temp directory. + + Returns: + The extracted JPEG cover path. + + Raises: + Exception: Raised when ffmpeg is unavailable or cover extraction fails. + """ if output_path is None: - temp_dir = anyio.Path(get_astrbot_temp_path()) - await temp_dir.mkdir(parents=True, exist_ok=True) + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) output_path = str(temp_dir / f"media_cover_{uuid.uuid4().hex}.jpg") try: @@ -344,16 +1365,19 @@ async def extract_video_cover( ) _, stderr = await process.communicate() if process.returncode != 0: - if output_path and await anyio.Path(output_path).exists(): + if output_path and os.path.exists(output_path): try: - await anyio.Path(output_path).unlink() + os.remove(output_path) except OSError as e: - logger.warning(f"[Media Utils] 清理失败的视频封面文件时出错: {e}") - error_msg = stderr.decode() if stderr else "未知错误" + logger.warning( + "Failed to remove failed video cover file: %s", + e, + ) + error_msg = stderr.decode() if stderr else "unknown error" raise Exception(f"ffmpeg extract cover failed: {error_msg}") return output_path - except FileNotFoundError as err: - raise Exception("ffmpeg not found") from err + except FileNotFoundError: + raise Exception("ffmpeg not found") def _compress_image_sync( @@ -365,20 +1389,21 @@ def _compress_image_sync( ) -> str: """Run image compression synchronously via ``asyncio.to_thread``.""" with PILImage.open(io.BytesIO(data)) as opened_img: - img = opened_img converted_img: PILImage.Image | None = None try: - if img.mode != "RGB": - converted_img = img.convert("RGB") - img = converted_img + working_img = opened_img + if opened_img.mode != "RGB": + converted_img = opened_img.convert("RGB") + working_img = converted_img + assert working_img is not None - if max(img.size) > max_size: - img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS) + if max(working_img.size) > max_size: + working_img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS) new_uuid = uuid.uuid4().hex save_path = temp_dir / f"compressed_{new_uuid}.jpg" - img.save(save_path, "JPEG", quality=quality, optimize=optimize) + working_img.save(save_path, "JPEG", quality=quality, optimize=optimize) logger.debug(f"Image compressed successfully: {save_path}") return str(save_path) finally: @@ -401,7 +1426,6 @@ async def compress_image( Returns: The compressed image path. Returns the original path if compression fails or the source does not need compression. - """ max_size = max(int(max_size), 1) quality = min(max(int(quality), 1), 100) @@ -420,37 +1444,30 @@ async def compress_image( # Skip compression for remote images and return the original value. if url_or_path.startswith("http"): return url_or_path - if url_or_path.startswith("data:image"): + elif url_or_path.startswith("data:image"): _header, encoded = url_or_path.split(",", 1) - data = base64.b64decode(encoded) + data = _decode_base64_payload( + encoded, + error_message="invalid image data URI payload", + ) if len(data) < min_file_size_bytes and not _exceeds_max_size(data): return url_or_path else: local_path = Path(url_or_path) - if not await asyncio.to_thread(local_path.exists): + if not local_path.exists(): return url_or_path - if ( - await asyncio.to_thread(local_path.stat) - ).st_size < min_file_size_bytes and not _exceeds_max_size( - local_path, + if local_path.stat().st_size < min_file_size_bytes and not _exceeds_max_size( + local_path ): return url_or_path + with local_path.open("rb") as f: + data = f.read() - def _read_local_path(): - lp = Path(url_or_path) - if not lp.exists(): - return None - if lp.stat().st_size < min_file_size_bytes: - return None - with lp.open("rb") as f: - return f.read() - - data = await asyncio.to_thread(_read_local_path) - if not data: - return url_or_path + if not data: + return url_or_path temp_dir = Path(get_astrbot_temp_path()) - await asyncio.to_thread(temp_dir.mkdir, parents=True, exist_ok=True) + temp_dir.mkdir(parents=True, exist_ok=True) # Offload the blocking image processing task to a thread. return await asyncio.to_thread( diff --git a/astrbot/core/utils/path_util.py b/astrbot/core/utils/path_util.py index f3ec8720d..f30e75b65 100644 --- a/astrbot/core/utils/path_util.py +++ b/astrbot/core/utils/path_util.py @@ -1,6 +1,7 @@ import os from astrbot.core import logger +from astrbot.core.utils.media_utils import file_uri_to_path, is_file_uri def path_Mapping(mappings, srcPath: str) -> str: @@ -43,7 +44,7 @@ def path_Mapping(mappings, srcPath: str) -> str: to_ = to_.removesuffix("\\") # logger.debug(f"\t路径映射-规则(处理): {from_} -> {to_}") - url = srcPath.removeprefix("file://") + url = file_uri_to_path(srcPath) if is_file_uri(srcPath) else srcPath if url.startswith(from_): srcPath = url.replace(from_, to_, 1) if ":" in srcPath: diff --git a/astrbot/core/utils/quoted_message/image_refs.py b/astrbot/core/utils/quoted_message/image_refs.py index a1ea81551..1aad4cd32 100644 --- a/astrbot/core/utils/quoted_message/image_refs.py +++ b/astrbot/core/utils/quoted_message/image_refs.py @@ -3,7 +3,11 @@ from __future__ import annotations import os from urllib.parse import urlsplit -from astrbot.core.utils.image_ref_utils import ALLOWED_IMAGE_EXTENSIONS +from astrbot.core.utils.image_ref_utils import ( + ALLOWED_IMAGE_EXTENSIONS, + resolve_file_url_path, +) +from astrbot.core.utils.media_utils import is_file_uri IMAGE_EXTENSIONS = ALLOWED_IMAGE_EXTENSIONS @@ -51,11 +55,8 @@ def convert_data_image_to_base64_ref(image_ref: str) -> str | None: def get_existing_local_path(value: str) -> str | None: - lower_value = value.lower() - if lower_value.startswith("file://"): - file_path = value[7:] - if file_path.startswith("/") and len(file_path) > 3 and file_path[2] == ":": - file_path = file_path[1:] + if is_file_uri(value): + file_path = resolve_file_url_path(value) if file_path and os.path.exists(file_path): return os.path.abspath(file_path) return None diff --git a/astrbot/core/utils/toml_parser.py b/astrbot/core/utils/toml_parser.py new file mode 100644 index 000000000..011f617b8 --- /dev/null +++ b/astrbot/core/utils/toml_parser.py @@ -0,0 +1,144 @@ +"""Small TOML readers for bootstrapping paths without parser dependencies.""" + +from pathlib import Path + + +def _read_quoted_value(value: str, field_name: str) -> tuple[str, str]: + """Read one quoted TOML string value and return its tail. + + Args: + value: Raw value text that starts with a quoted string. + field_name: Field name used in error messages. + + Returns: + A tuple containing the unquoted string and the remaining text. + + Raises: + ValueError: The value is not a supported quoted string. + """ + value = value.strip() + if len(value) < 2 or value[0] not in ("'", '"'): + raise ValueError(f"Unsupported {field_name} value") + + quote = value[0] + end_index = value.find(quote, 1) + if end_index == -1: + raise ValueError(f"Unterminated {field_name} string") + + result = value[1:end_index] + if not result: + raise ValueError(f"Empty {field_name} value") + return result, value[end_index + 1 :].strip() + + +def _read_dependency_array(raw_value: str) -> list[str]: + """Read a simple inline TOML string array. + + Args: + raw_value: Raw dependency array text, including the surrounding brackets. + + Returns: + Parsed dependency strings. + + Raises: + ValueError: The array is missing brackets or contains unsupported entries. + """ + value = raw_value.strip() + if not value.startswith("["): + raise ValueError("Unsupported project.dependencies value") + + dependencies = [] + value = value[1:].strip() + while value: + if value.startswith("]"): + tail = value[1:].strip() + if tail and not tail.startswith("#"): + raise ValueError("Unsupported content after project.dependencies") + return dependencies + + dependency, tail = _read_quoted_value(value, "project.dependencies entry") + dependencies.append(dependency) + + if tail.startswith(","): + value = tail[1:].strip() + continue + if tail.startswith("]"): + value = tail + continue + if tail: + raise ValueError("Unsupported content after project.dependencies entry") + raise ValueError("Unterminated project.dependencies array") + + raise ValueError("Unterminated project.dependencies array") + + +def read_pyproject_project_dependencies(pyproject_path: Path) -> list[str]: + """Read project dependencies from a pyproject.toml file. + + Args: + pyproject_path: Path to the pyproject.toml file. + + Returns: + The values in the project.dependencies array. + + Raises: + FileNotFoundError: The pyproject.toml file does not exist. + ValueError: The project.dependencies field is missing or unsupported. + """ + dependencies = [] + in_project_section = False + in_dependencies_array = False + + for raw_line in pyproject_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + + if in_dependencies_array: + if line.startswith("]"): + tail = line[1:].strip() + if tail and not tail.startswith("#"): + raise ValueError("Unsupported content after project.dependencies") + return dependencies + + dependency, tail = _read_quoted_value( + line, + "project.dependencies entry", + ) + if tail.startswith(","): + tail = tail[1:].strip() + if tail.startswith("]"): + tail = tail[1:].strip() + dependencies.append(dependency) + if tail and not tail.startswith("#"): + raise ValueError("Unsupported content after project.dependencies") + return dependencies + if tail and not tail.startswith("#"): + raise ValueError("Unsupported content after project.dependencies entry") + + dependencies.append(dependency) + continue + + if line.startswith("[") and line.endswith("]"): + in_project_section = line == "[project]" + continue + + if not in_project_section: + continue + + key, separator, raw_value = line.partition("=") + if key.strip() != "dependencies": + continue + if not separator: + raise ValueError("Unsupported project.dependencies value") + raw_value = raw_value.strip() + if raw_value == "[" or raw_value.startswith("[ #"): + in_dependencies_array = True + continue + if raw_value.startswith("["): + return _read_dependency_array(raw_value) + raise ValueError("Unsupported project.dependencies value") + + if in_dependencies_array: + raise ValueError("Unterminated project.dependencies array") + raise ValueError("Missing project.dependencies") diff --git a/astrbot/dashboard/__init__.py b/astrbot/dashboard/__init__.py new file mode 100644 index 000000000..0fa6e0141 --- /dev/null +++ b/astrbot/dashboard/__init__.py @@ -0,0 +1 @@ +"""Dashboard HTTP API and service layer.""" diff --git a/astrbot/dashboard/api/__init__.py b/astrbot/dashboard/api/__init__.py new file mode 100644 index 000000000..261bb9b65 --- /dev/null +++ b/astrbot/dashboard/api/__init__.py @@ -0,0 +1 @@ +"""Dashboard HTTP API package.""" diff --git a/astrbot/dashboard/api/api_keys.py b/astrbot/dashboard/api/api_keys.py new file mode 100644 index 000000000..06b8e4bc9 --- /dev/null +++ b/astrbot/dashboard/api/api_keys.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request + +from astrbot.dashboard.responses import ApiError, ok +from astrbot.dashboard.schemas import ApiKeyCreateRequest, ApiKeyIdRequest +from astrbot.dashboard.services.api_key_service import ( + ApiKeyService, + ApiKeyServiceError, +) + +from .auth import AuthContext, require_dashboard_user, require_scope + +router = APIRouter(tags=["API Keys"]) +legacy_router = APIRouter( + prefix="/api/apikey", + tags=["Dashboard API Keys"], + include_in_schema=False, +) + + +async def require_system_scope(request: Request) -> AuthContext: + return await require_scope(request, "system") + + +def get_service(request: Request) -> ApiKeyService: + return request.app.state.services.api_keys + + +def _payload_dict(payload: ApiKeyCreateRequest) -> dict: + return payload.model_dump(exclude_none=True) + + +def _raise_api_key_error(exc: ApiKeyServiceError) -> None: + raise ApiError(str(exc)) from exc + + +async def _list_api_keys(service: ApiKeyService): + try: + return ok(await service.list_api_keys()) + except ApiKeyServiceError as exc: + _raise_api_key_error(exc) + + +async def _create_api_key( + payload: ApiKeyCreateRequest, + *, + created_by: str, + service: ApiKeyService, +): + try: + return ok( + await service.create_api_key( + _payload_dict(payload), + created_by=created_by, + ) + ) + except ApiKeyServiceError as exc: + _raise_api_key_error(exc) + + +async def _revoke_api_key(key_id: str, service: ApiKeyService): + try: + if not await service.revoke_api_key(key_id): + raise ApiKeyServiceError("API key not found") + return ok() + except ApiKeyServiceError as exc: + _raise_api_key_error(exc) + + +async def _delete_api_key(key_id: str, service: ApiKeyService): + try: + if not await service.delete_api_key(key_id): + raise ApiKeyServiceError("API key not found") + return ok() + except ApiKeyServiceError as exc: + _raise_api_key_error(exc) + + +@router.get("/api-keys") +async def list_api_keys( + _auth: AuthContext = Depends(require_system_scope), + service: ApiKeyService = Depends(get_service), +): + return await _list_api_keys(service) + + +@router.post("/api-keys") +async def create_api_key( + payload: ApiKeyCreateRequest, + auth: AuthContext = Depends(require_system_scope), + service: ApiKeyService = Depends(get_service), +): + return await _create_api_key(payload, created_by=auth.username, service=service) + + +@router.post("/api-keys/{key_id}/revoke") +async def revoke_api_key( + key_id: str, + _auth: AuthContext = Depends(require_system_scope), + service: ApiKeyService = Depends(get_service), +): + return await _revoke_api_key(key_id, service) + + +@router.delete("/api-keys/{key_id}") +async def delete_api_key( + key_id: str, + _auth: AuthContext = Depends(require_system_scope), + service: ApiKeyService = Depends(get_service), +): + return await _delete_api_key(key_id, service) + + +@legacy_router.get("/list") +async def list_dashboard_api_keys( + _username: str = Depends(require_dashboard_user), + service: ApiKeyService = Depends(get_service), +): + return await _list_api_keys(service) + + +@legacy_router.post("/create") +async def create_dashboard_api_key( + payload: ApiKeyCreateRequest, + username: str = Depends(require_dashboard_user), + service: ApiKeyService = Depends(get_service), +): + return await _create_api_key(payload, created_by=username, service=service) + + +@legacy_router.post("/revoke") +async def revoke_dashboard_api_key( + payload: ApiKeyIdRequest, + _username: str = Depends(require_dashboard_user), + service: ApiKeyService = Depends(get_service), +): + return await _revoke_api_key(payload.key_id, service) + + +@legacy_router.post("/delete") +async def delete_dashboard_api_key( + payload: ApiKeyIdRequest, + _username: str = Depends(require_dashboard_user), + service: ApiKeyService = Depends(get_service), +): + return await _delete_api_key(payload.key_id, service) diff --git a/astrbot/dashboard/api/app.py b/astrbot/dashboard/api/app.py new file mode 100644 index 000000000..00ceb11cf --- /dev/null +++ b/astrbot/dashboard/api/app.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse + +from astrbot.core import LogBroker +from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.core.db import BaseDatabase +from astrbot.dashboard.responses import ApiError, error +from astrbot.dashboard.services.api_key_service import ApiKeyService +from astrbot.dashboard.services.auth_service import AuthService +from astrbot.dashboard.services.backup_service import BackupService +from astrbot.dashboard.services.chat_service import ChatService +from astrbot.dashboard.services.chatui_project_service import ChatUIProjectService +from astrbot.dashboard.services.command_service import CommandService +from astrbot.dashboard.services.config_service import ( + BotConfigService, + ConfigDisplayService, + ConfigFileService, + ConfigProfileService, + ConfigRoutingService, + ProviderConfigService, +) +from astrbot.dashboard.services.conversation_service import ConversationService +from astrbot.dashboard.services.cron_service import CronService +from astrbot.dashboard.services.file_service import FileService +from astrbot.dashboard.services.knowledge_base_service import KnowledgeBaseService +from astrbot.dashboard.services.live_chat_service import LiveChatService +from astrbot.dashboard.services.log_service import LogService +from astrbot.dashboard.services.open_api_service import OpenApiService +from astrbot.dashboard.services.persona_service import PersonaService +from astrbot.dashboard.services.platform_service import PlatformService +from astrbot.dashboard.services.plugin_page_service import PluginPageService +from astrbot.dashboard.services.plugin_service import PluginService +from astrbot.dashboard.services.session_management_service import ( + SessionManagementService, +) +from astrbot.dashboard.services.skills_service import SkillsService +from astrbot.dashboard.services.stat_service import StatService +from astrbot.dashboard.services.subagent_service import SubAgentService +from astrbot.dashboard.services.t2i_service import T2iService +from astrbot.dashboard.services.tools_service import ToolsService +from astrbot.dashboard.services.update_service import ( + DEMO_MODE, + UpdateService, + call_download_dashboard, + call_extract_dashboard, + call_get_dashboard_version, + call_pip_install, +) + +from .api_keys import legacy_router as legacy_api_keys_router +from .auth import legacy_router as legacy_auth_router +from .backups import legacy_router as legacy_backups_router +from .bots import legacy_router as legacy_bots_router +from .chat import legacy_router as legacy_chat_router +from .chat_projects import legacy_router as legacy_chat_projects_router +from .config_profiles import legacy_router as legacy_config_profiles_router +from .conversations import legacy_router as legacy_conversations_router +from .cron import legacy_router as legacy_cron_router +from .extensions import legacy_router as legacy_extensions_router +from .files import legacy_router as legacy_files_router +from .knowledge_bases import legacy_router as legacy_knowledge_bases_router +from .live_chat import legacy_router as legacy_live_chat_router +from .logs import legacy_router as legacy_logs_router +from .personas import legacy_router as legacy_personas_router +from .platform import legacy_router as legacy_platform_router +from .plugins import legacy_router as legacy_plugins_router +from .providers import legacy_router as legacy_providers_router +from .router import API_V1_PREFIX, build_api_router +from .sessions import legacy_router as legacy_sessions_router +from .skills import legacy_router as legacy_skills_router +from .static_files import router as static_files_router +from .stats import legacy_router as legacy_stats_router +from .subagents import legacy_router as legacy_subagents_router +from .t2i import legacy_router as legacy_t2i_router +from .tools import legacy_router as legacy_tools_router +from .updates import legacy_router as legacy_updates_router + +CLEAR_SITE_DATA_HEADERS = {"Clear-Site-Data": '"cache"'} + + +def create_dashboard_asgi_app( + *, + core_lifecycle: AstrBotCoreLifecycle, + db: BaseDatabase, + jwt_secret: str, + static_folder: str | None = None, +) -> FastAPI: + app = FastAPI( + title="AstrBot OpenAPI", + version="1.0.0", + openapi_url=f"{API_V1_PREFIX}/openapi.json", + docs_url=f"{API_V1_PREFIX}/docs", + redoc_url=f"{API_V1_PREFIX}/redoc", + ) + app.state.core_lifecycle = core_lifecycle + app.state.db = db + app.state.jwt_secret = jwt_secret + app.state.dashboard_static_folder = static_folder + log_broker = getattr(core_lifecycle, "log_broker", None) or LogBroker() + app.state.services = SimpleNamespace( + config_profiles=ConfigProfileService(core_lifecycle, db), + config_display=ConfigDisplayService(core_lifecycle), + config_files=ConfigFileService(core_lifecycle), + config_routes=ConfigRoutingService(core_lifecycle), + api_keys=ApiKeyService(db), + auth=AuthService(db, core_lifecycle.astrbot_config), + backups=BackupService(db, core_lifecycle), + chat=ChatService(db, core_lifecycle), + chat_projects=ChatUIProjectService(db), + commands=CommandService(core_lifecycle.astrbot_config, core_lifecycle), + conversations=ConversationService(db, core_lifecycle), + cron=CronService(core_lifecycle), + files=FileService(), + knowledge_bases=KnowledgeBaseService(core_lifecycle), + live_chat=LiveChatService(db, core_lifecycle), + logs=LogService(log_broker, core_lifecycle.astrbot_config), + bots=BotConfigService(core_lifecycle), + platforms=PlatformService(core_lifecycle), + providers=ProviderConfigService(core_lifecycle), + personas=PersonaService(core_lifecycle), + plugins=PluginService(core_lifecycle, core_lifecycle.plugin_manager), + plugin_pages=PluginPageService( + core_lifecycle.plugin_manager, + core_lifecycle=core_lifecycle, + ), + open_api=OpenApiService(db, core_lifecycle), + sessions=SessionManagementService(core_lifecycle, db), + skills=SkillsService(core_lifecycle), + stats=StatService(db, core_lifecycle, core_lifecycle.astrbot_config), + subagents=SubAgentService(core_lifecycle), + t2i=T2iService(core_lifecycle), + tools=ToolsService(core_lifecycle), + updates=UpdateService( + core_lifecycle.astrbot_updator, + core_lifecycle, + download_dashboard_func=call_download_dashboard, + extract_dashboard_func=call_extract_dashboard, + get_dashboard_version_func=call_get_dashboard_version, + pip_install_func=call_pip_install, + demo_mode=DEMO_MODE, + clear_site_data_headers=CLEAR_SITE_DATA_HEADERS, + ), + ) + + @app.exception_handler(ApiError) + async def api_error_handler(_request: Request, exc: ApiError): + return JSONResponse( + error(exc.message, exc.data), + status_code=exc.status_code, + ) + + @app.exception_handler(ValueError) + async def value_error_handler(_request: Request, exc: ValueError): + return JSONResponse(error(str(exc)), status_code=400) + + @app.exception_handler(HTTPException) + async def http_error_handler(_request: Request, exc: HTTPException): + detail = exc.detail if isinstance(exc.detail, str) else "Request failed" + return JSONResponse(error(detail), status_code=exc.status_code) + + # Legacy dashboard routes keep old /api/* callers working without entering OpenAPI. + app.include_router(legacy_api_keys_router) + app.include_router(legacy_auth_router) + app.include_router(legacy_backups_router) + app.include_router(legacy_config_profiles_router) + app.include_router(legacy_bots_router) + app.include_router(legacy_providers_router) + app.include_router(legacy_chat_router) + app.include_router(legacy_chat_projects_router) + app.include_router(legacy_conversations_router) + app.include_router(legacy_cron_router) + app.include_router(legacy_extensions_router) + app.include_router(legacy_files_router) + app.include_router(legacy_knowledge_bases_router) + app.include_router(legacy_live_chat_router) + app.include_router(legacy_logs_router) + app.include_router(legacy_sessions_router) + app.include_router(legacy_skills_router) + app.include_router(legacy_stats_router) + app.include_router(legacy_subagents_router) + app.include_router(legacy_tools_router) + app.include_router(legacy_platform_router) + app.include_router(legacy_plugins_router) + app.include_router(legacy_t2i_router) + app.include_router(legacy_personas_router) + app.include_router(legacy_updates_router) + app.include_router(build_api_router()) + app.include_router(static_files_router) + return app diff --git a/astrbot/dashboard/api/auth.py b/astrbot/dashboard/api/auth.py new file mode 100644 index 000000000..9d343c53a --- /dev/null +++ b/astrbot/dashboard/api/auth.py @@ -0,0 +1,495 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import jwt +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse + +from astrbot.dashboard.responses import ApiError +from astrbot.dashboard.schemas import ( + AccountUpdateRequest, + AuthSetupRequest, + LoginRequest, + TotpSetupRequest, +) +from astrbot.dashboard.services.api_key_service import ApiKeyService +from astrbot.dashboard.services.auth_service import ( + ALL_OPEN_API_SCOPES, + DASHBOARD_JWT_COOKIE_MAX_AGE, + DASHBOARD_JWT_COOKIE_NAME, + OPEN_API_SCOPE_INCLUDES, + TOTP_TRUSTED_DEVICE_COOKIE_NAME, + TOTP_TRUSTED_DEVICE_MAX_AGE, + AuthService, + AuthServiceResult, +) + +router = APIRouter(tags=["Auth"]) +legacy_router = APIRouter( + prefix="/api/auth", + tags=["Dashboard Auth"], + include_in_schema=False, +) + + +@dataclass(frozen=True) +class AuthContext: + username: str + scopes: list[str] + api_key_id: str | None = None + via: str = "jwt" + + +def _extract_raw_api_key(request: Request) -> str | None: + auth_header = request.headers.get("Authorization", "").strip() + if auth_header.startswith("Bearer "): + return None + if auth_header.startswith("ApiKey "): + return auth_header.removeprefix("ApiKey ").strip() + if key := request.query_params.get("api_key"): + return key.strip() + if key := request.query_params.get("key"): + return key.strip() + if key := request.headers.get("X-API-Key"): + return key.strip() + return None + + +def _get_dashboard_state_username(request: Request) -> str | None: + dashboard_g = getattr(request.state, "dashboard_g", None) + if dashboard_g is None: + return None + + username = getattr(dashboard_g, "username", None) + if username is None and hasattr(dashboard_g, "get"): + username = dashboard_g.get("username") + if isinstance(username, str) and username.strip(): + return username + return None + + +def _extract_dashboard_jwt(request: Request) -> str | None: + auth_header = request.headers.get("Authorization", "").strip() + if auth_header.startswith("Bearer "): + token = auth_header.removeprefix("Bearer ").strip() + if token: + return token + + cookie_token = request.cookies.get(DASHBOARD_JWT_COOKIE_NAME, "").strip() + if cookie_token: + return cookie_token + return None + + +async def require_dashboard_user(request: Request) -> str: + if username := _get_dashboard_state_username(request): + return username + + token = _extract_dashboard_jwt(request) + if not token: + raise ApiError("未授权", status_code=401) + + try: + payload = jwt.decode( + token, + request.app.state.jwt_secret, + algorithms=["HS256"], + ) + except jwt.ExpiredSignatureError as exc: + raise ApiError("Token 过期", status_code=401) from exc + except jwt.InvalidTokenError as exc: + raise ApiError("Token 无效", status_code=401) from exc + + username = payload.get("username") + if not isinstance(username, str) or not username.strip(): + raise ApiError("Token 无效", status_code=401) + return username + + +async def _require_api_key_scope( + request: Request, + raw_key: str, + scope: str, +) -> AuthContext: + if scope not in ALL_OPEN_API_SCOPES: + raise ApiError("Insufficient API key scope", status_code=403) + + key_hash = ApiKeyService.hash_key(raw_key) + api_key = await request.app.state.db.get_active_api_key_by_hash(key_hash) + if not api_key: + raise ApiError("Invalid API key", status_code=401) + scopes = ( + [str(scope) for scope in api_key.scopes] + if isinstance(api_key.scopes, list) + else [str(scope) for scope in ALL_OPEN_API_SCOPES] + ) + if ( + "*" not in scopes + and scope not in scopes + and not any( + scope in OPEN_API_SCOPE_INCLUDES.get(api_key_scope, ()) + for api_key_scope in scopes + ) + ): + raise ApiError("Insufficient API key scope", status_code=403) + await request.app.state.db.touch_api_key(api_key.key_id) + return AuthContext( + username=f"api_key:{api_key.key_id}", + scopes=scopes, + api_key_id=api_key.key_id, + via="api_key", + ) + + +async def require_scope(request: Request, scope: str) -> AuthContext: + raw_key = _extract_raw_api_key(request) + if raw_key: + return await _require_api_key_scope(request, raw_key, scope) + + token = _extract_dashboard_jwt(request) + if not token: + raise ApiError("Missing API key", status_code=401) + try: + payload = jwt.decode( + token, + request.app.state.jwt_secret, + algorithms=["HS256"], + ) + except jwt.ExpiredSignatureError as exc: + raise ApiError("Token expired", status_code=401) from exc + except jwt.InvalidTokenError as exc: + auth_header = request.headers.get("Authorization", "").strip() + if auth_header.startswith("Bearer "): + try: + return await _require_api_key_scope(request, token, scope) + except ApiError as api_key_exc: + raise api_key_exc from exc + raise ApiError("Invalid token", status_code=401) from exc + + username = payload.get("username") + if not isinstance(username, str) or not username.strip(): + raise ApiError("Invalid token", status_code=401) + return AuthContext(username=username, scopes=["*"], via="jwt") + + +def get_auth_service(request: Request) -> AuthService: + return request.app.state.services.auth + + +def _payload(payload) -> dict: + if payload is None: + return {} + return payload.model_dump(exclude_none=True) + + +def _auth_result_payload(result: AuthServiceResult) -> dict: + data = result.data if result.data is not None else {} + payload = { + "status": result.status, + "message": result.message, + "data": data, + } + if result.status == "error" and result.data is None: + payload["data"] = None + return payload + + +def _use_secure_dashboard_jwt_cookie(request: Request) -> bool: + adapter = getattr(request.app.state, "dashboard_app_adapter", None) + adapter_config = getattr(adapter, "config", {}) if adapter is not None else {} + default_secure = not bool(getattr(adapter, "debug", False)) and not bool( + getattr(adapter, "testing", False) + ) + return bool( + adapter_config.get( + "DASHBOARD_JWT_COOKIE_SECURE", + default_secure, + ) + ) + + +def _set_dashboard_jwt_cookie( + request: Request, + response: JSONResponse, + token: str, +) -> None: + response.set_cookie( + DASHBOARD_JWT_COOKIE_NAME, + token, + max_age=DASHBOARD_JWT_COOKIE_MAX_AGE, + httponly=True, + samesite="strict", + secure=_use_secure_dashboard_jwt_cookie(request), + path="/", + ) + + +def _clear_dashboard_jwt_cookie(request: Request, response: JSONResponse) -> None: + response.delete_cookie( + DASHBOARD_JWT_COOKIE_NAME, + httponly=True, + samesite="strict", + secure=_use_secure_dashboard_jwt_cookie(request), + path="/", + ) + + +def _set_trusted_device_cookie( + request: Request, + response: JSONResponse, + token: str, +) -> None: + response.set_cookie( + TOTP_TRUSTED_DEVICE_COOKIE_NAME, + token, + max_age=TOTP_TRUSTED_DEVICE_MAX_AGE, + httponly=True, + samesite="strict", + secure=_use_secure_dashboard_jwt_cookie(request), + path="/api/auth", + ) + + +def _auth_service_response( + request: Request, + result: AuthServiceResult, +) -> JSONResponse: + response = JSONResponse( + _auth_result_payload(result), + status_code=result.status_code, + ) + if result.jwt_token: + _set_dashboard_jwt_cookie(request, response, result.jwt_token) + if result.trusted_device_token: + _set_trusted_device_cookie(request, response, result.trusted_device_token) + return response + + +def _has_auth_credentials(request: Request) -> bool: + auth_header = request.headers.get("Authorization", "") + return bool( + auth_header.startswith(("Bearer ", "ApiKey ")) + or request.query_params.get("api_key") + or request.query_params.get("key") + or request.headers.get("X-API-Key") + ) + + +async def require_system_scope(request: Request) -> AuthContext: + return await require_scope(request, "system") + + +async def optional_system_auth(request: Request) -> AuthContext | None: + if not _has_auth_credentials(request): + return None + return await require_system_scope(request) + + +async def _login( + request: Request, + payload: LoginRequest, + service: AuthService, +): + result = await service.login( + _payload(payload), + trusted_device_cookie_token=request.cookies.get( + TOTP_TRUSTED_DEVICE_COOKIE_NAME, + "", + ).strip(), + ) + return _auth_service_response( + request, + result, + ) + + +async def _setup_status(service: AuthService): + return _auth_service_response_from_result(await service.setup_status()) + + +def _auth_service_response_from_result(result: AuthServiceResult) -> JSONResponse: + return JSONResponse( + _auth_result_payload(result), + status_code=result.status_code, + ) + + +async def _setup( + request: Request, + payload: AuthSetupRequest, + service: AuthService, + auth: AuthContext | None, +): + if auth is None: + result = await service.setup(_payload(payload)) + else: + result = await service.setup_authenticated(_payload(payload), auth.username) + return _auth_service_response( + request, + result, + ) + + +async def _totp_setup( + request: Request, + payload: TotpSetupRequest | None, + service: AuthService, +): + return _auth_service_response( + request, + await service.totp_setup(_payload(payload)), + ) + + +async def _totp_recovery( + request: Request, + service: AuthService, +): + return _auth_service_response(request, await service.totp_recovery()) + + +async def _update_account( + request: Request, + payload: AccountUpdateRequest, + service: AuthService, +): + return _auth_service_response( + request, + await service.edit_account(_payload(payload)), + ) + + +@router.post("/auth/login") +async def login( + request: Request, + payload: LoginRequest, + service: AuthService = Depends(get_auth_service), +): + return await _login(request, payload, service) + + +@legacy_router.post("/login") +async def dashboard_login( + request: Request, + payload: LoginRequest, + service: AuthService = Depends(get_auth_service), +): + return await _login(request, payload, service) + + +@router.post("/auth/logout") +async def logout(request: Request): + response = JSONResponse( + {"status": "ok", "message": "已退出登录", "data": {}}, + status_code=200, + ) + _clear_dashboard_jwt_cookie(request, response) + return response + + +@legacy_router.post("/logout") +async def dashboard_logout(request: Request): + return await logout(request) + + +@router.get("/auth/setup-status") +async def setup_status( + service: AuthService = Depends(get_auth_service), +): + return _auth_service_response_from_result(await service.setup_status()) + + +@legacy_router.get("/setup-status") +async def dashboard_setup_status( + service: AuthService = Depends(get_auth_service), +): + return _auth_service_response_from_result(await service.setup_status()) + + +@router.post("/auth/setup") +async def setup( + request: Request, + payload: AuthSetupRequest, + auth: AuthContext | None = Depends(optional_system_auth), + service: AuthService = Depends(get_auth_service), +): + return await _setup(request, payload, service, auth) + + +@legacy_router.post("/setup") +async def dashboard_setup( + request: Request, + payload: AuthSetupRequest, + service: AuthService = Depends(get_auth_service), +): + return await _setup(request, payload, service, None) + + +@legacy_router.post("/setup-authenticated") +async def dashboard_setup_authenticated( + request: Request, + payload: AuthSetupRequest, + username: str = Depends(require_dashboard_user), + service: AuthService = Depends(get_auth_service), +): + auth = AuthContext(username=username, scopes=["*"], via="jwt") + return await _setup(request, payload, service, auth) + + +@router.post("/auth/totp/setup") +async def totp_setup( + request: Request, + payload: TotpSetupRequest | None = None, + _auth: AuthContext = Depends(require_system_scope), + service: AuthService = Depends(get_auth_service), +): + return await _totp_setup(request, payload, service) + + +@legacy_router.post("/totp/setup") +async def dashboard_totp_setup( + request: Request, + payload: TotpSetupRequest | None = None, + _username: str = Depends(require_dashboard_user), + service: AuthService = Depends(get_auth_service), +): + return await _totp_setup(request, payload, service) + + +@router.post("/auth/totp/recovery") +async def totp_recovery( + request: Request, + _auth: AuthContext = Depends(require_system_scope), + service: AuthService = Depends(get_auth_service), +): + return await _totp_recovery(request, service) + + +@legacy_router.post("/totp/recovery") +async def dashboard_totp_recovery( + request: Request, + _username: str = Depends(require_dashboard_user), + service: AuthService = Depends(get_auth_service), +): + return await _totp_recovery(request, service) + + +@router.patch("/auth/account") +async def update_account( + request: Request, + payload: AccountUpdateRequest, + _auth: AuthContext = Depends(require_system_scope), + service: AuthService = Depends(get_auth_service), +): + return await _update_account(request, payload, service) + + +@legacy_router.post("/account/edit") +async def dashboard_update_account( + request: Request, + payload: AccountUpdateRequest, + _username: str = Depends(require_dashboard_user), + service: AuthService = Depends(get_auth_service), +): + return await _update_account(request, payload, service) diff --git a/astrbot/dashboard/api/backups.py b/astrbot/dashboard/api/backups.py new file mode 100644 index 000000000..c741359d3 --- /dev/null +++ b/astrbot/dashboard/api/backups.py @@ -0,0 +1,419 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile +from fastapi.responses import FileResponse + +from astrbot.core import logger +from astrbot.dashboard.async_utils import run_maybe_async +from astrbot.dashboard.responses import error, ok +from astrbot.dashboard.schemas import ( + BackupImportRequest, + BackupRenameRequest, + BackupUploadInitRequest, + BackupUploadSessionRequest, +) +from astrbot.dashboard.services.backup_service import ( + BackupService, + BackupServiceError, +) + +from .auth import AuthContext, require_dashboard_user, require_scope + +router = APIRouter(tags=["Backups"]) +legacy_router = APIRouter( + prefix="/api/backup", + tags=["Dashboard Backups"], + include_in_schema=False, +) + + +def get_service(request: Request) -> BackupService: + return request.app.state.services.backups + + +async def require_system_scope(request: Request) -> AuthContext: + return await require_scope(request, "system") + + +def _model_dict(payload) -> dict: + return payload.model_dump(exclude_none=True) + + +def _ok_result(result): + if isinstance(result, tuple): + data, message = result + return ok(data, message) + return ok(result) + + +def _safe_backup_filename(filename: str | None) -> str: + if not filename: + raise BackupServiceError("缺少参数 filename") + if ".." in filename or "/" in filename or "\\" in filename: + raise BackupServiceError("文件名包含非法路径字符") + return filename + + +async def _json_or_empty(request: Request) -> dict: + try: + data = await request.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +async def _run(operation, *, prefix: str): + try: + result = await run_maybe_async(operation) + return _ok_result(result) + except BackupServiceError as exc: + return error(str(exc)) + except Exception as exc: + logger.error("%s: %s", prefix, exc, exc_info=True) + return error(f"{prefix}: {exc!s}") + + +def _download_response(download) -> FileResponse: + return FileResponse( + download.path, + filename=download.filename, + media_type="application/zip", + ) + + +def _download_backup( + *, + filename: str | None, + token: str | None, + service: BackupService, +): + try: + filename = _safe_backup_filename(filename) + return _download_response( + service.prepare_download( + filename=filename, + token=token, + jwt_secret=service.config.get("dashboard", {}).get("jwt_secret"), + ) + ) + except BackupServiceError as exc: + return error(str(exc)) + except Exception as exc: + logger.error("下载备份失败: %s", exc, exc_info=True) + return error(f"下载备份失败: {exc!s}") + + +@router.get("/backups") +async def list_backups( + page: int = Query(default=1), + page_size: int = Query(default=20), + _auth: AuthContext = Depends(require_system_scope), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.list_backups(page=page, page_size=page_size), + prefix="获取备份列表失败", + ) + + +@legacy_router.get("/list") +async def list_dashboard_backups( + page: int = Query(default=1), + page_size: int = Query(default=20), + _username: str = Depends(require_dashboard_user), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.list_backups(page=page, page_size=page_size), + prefix="获取备份列表失败", + ) + + +@router.post("/backups") +async def create_backup( + _auth: AuthContext = Depends(require_system_scope), + service: BackupService = Depends(get_service), +): + return await _run(service.export_backup, prefix="创建备份失败") + + +@legacy_router.post("/export") +async def export_dashboard_backup( + _username: str = Depends(require_dashboard_user), + service: BackupService = Depends(get_service), +): + return await _run(service.export_backup, prefix="创建备份失败") + + +@router.post("/backups/upload") +async def upload_backup( + file: UploadFile = File(...), + _auth: AuthContext = Depends(require_system_scope), + service: BackupService = Depends(get_service), +): + return await _run(lambda: service.upload_backup(file), prefix="上传备份文件失败") + + +@legacy_router.post("/upload") +async def upload_dashboard_backup( + file: UploadFile = File(...), + _username: str = Depends(require_dashboard_user), + service: BackupService = Depends(get_service), +): + return await _run(lambda: service.upload_backup(file), prefix="上传备份文件失败") + + +@router.post("/backups/upload/init") +async def init_backup_upload( + payload: BackupUploadInitRequest, + _auth: AuthContext = Depends(require_system_scope), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.upload_init(_model_dict(payload)), + prefix="初始化分片上传失败", + ) + + +@legacy_router.post("/upload/init") +async def init_dashboard_backup_upload( + payload: BackupUploadInitRequest, + _username: str = Depends(require_dashboard_user), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.upload_init(_model_dict(payload)), + prefix="初始化分片上传失败", + ) + + +@router.post("/backups/upload/chunk") +async def upload_backup_chunk( + upload_id: str = Form(...), + chunk_index: str = Form(...), + chunk: UploadFile = File(...), + _auth: AuthContext = Depends(require_system_scope), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.upload_chunk( + upload_id=upload_id, + chunk_index_str=chunk_index, + chunk_file=chunk, + ), + prefix="上传分片失败", + ) + + +@legacy_router.post("/upload/chunk") +async def upload_dashboard_backup_chunk( + upload_id: str = Form(...), + chunk_index: str = Form(...), + chunk: UploadFile = File(...), + _username: str = Depends(require_dashboard_user), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.upload_chunk( + upload_id=upload_id, + chunk_index_str=chunk_index, + chunk_file=chunk, + ), + prefix="上传分片失败", + ) + + +@router.post("/backups/upload/complete") +async def complete_backup_upload( + payload: BackupUploadSessionRequest, + _auth: AuthContext = Depends(require_system_scope), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.upload_complete(_model_dict(payload)), + prefix="完成分片上传失败", + ) + + +@legacy_router.post("/upload/complete") +async def complete_dashboard_backup_upload( + payload: BackupUploadSessionRequest, + _username: str = Depends(require_dashboard_user), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.upload_complete(_model_dict(payload)), + prefix="完成分片上传失败", + ) + + +@router.post("/backups/upload/abort") +async def abort_backup_upload( + payload: BackupUploadSessionRequest, + _auth: AuthContext = Depends(require_system_scope), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.upload_abort(_model_dict(payload)), + prefix="取消上传失败", + ) + + +@legacy_router.post("/upload/abort") +async def abort_dashboard_backup_upload( + payload: BackupUploadSessionRequest, + _username: str = Depends(require_dashboard_user), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.upload_abort(_model_dict(payload)), + prefix="取消上传失败", + ) + + +@router.get("/backups/tasks/{task_id}") +async def get_backup_progress( + task_id: str, + _auth: AuthContext = Depends(require_system_scope), + service: BackupService = Depends(get_service), +): + return await _run(lambda: service.get_progress(task_id), prefix="获取任务进度失败") + + +@legacy_router.get("/progress") +async def get_dashboard_backup_progress( + task_id: str | None = Query(default=None), + _username: str = Depends(require_dashboard_user), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.get_progress(task_id), + prefix="获取任务进度失败", + ) + + +@router.get("/backups/{filename:path}") +async def download_backup( + filename: str, + token: str | None = Query(default=None), + service: BackupService = Depends(get_service), +): + return _download_backup(filename=filename, token=token, service=service) + + +@legacy_router.get("/download") +async def download_dashboard_backup( + filename: str | None = Query(default=None), + token: str | None = Query(default=None), + service: BackupService = Depends(get_service), +): + return _download_backup(filename=filename, token=token, service=service) + + +@router.patch("/backups/{filename:path}") +async def rename_backup( + filename: str, + payload: BackupRenameRequest, + _auth: AuthContext = Depends(require_system_scope), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.rename_backup( + {"filename": _safe_backup_filename(filename), **_model_dict(payload)} + ), + prefix="重命名备份失败", + ) + + +@legacy_router.post("/rename") +async def rename_dashboard_backup( + payload: BackupRenameRequest, + filename: str | None = Query(default=None), + _username: str = Depends(require_dashboard_user), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.rename_backup({"filename": filename, **_model_dict(payload)}), + prefix="重命名备份失败", + ) + + +@router.delete("/backups/{filename:path}") +async def delete_backup( + filename: str, + _auth: AuthContext = Depends(require_system_scope), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.delete_backup({"filename": _safe_backup_filename(filename)}), + prefix="删除备份失败", + ) + + +@legacy_router.post("/delete") +async def delete_dashboard_backup( + request: Request, + filename: str | None = Query(default=None), + _username: str = Depends(require_dashboard_user), + service: BackupService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run( + lambda: service.delete_backup({"filename": filename, **body}), + prefix="删除备份失败", + ) + + +@router.post("/backups/{filename:path}/check") +async def check_backup( + filename: str, + _auth: AuthContext = Depends(require_system_scope), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.check_backup({"filename": _safe_backup_filename(filename)}), + prefix="预检查备份文件失败", + ) + + +@legacy_router.post("/check") +async def check_dashboard_backup( + request: Request, + filename: str | None = Query(default=None), + _username: str = Depends(require_dashboard_user), + service: BackupService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run( + lambda: service.check_backup({"filename": filename, **body}), + prefix="预检查备份文件失败", + ) + + +@router.post("/backups/{filename:path}/import") +async def import_backup( + filename: str, + payload: BackupImportRequest, + _auth: AuthContext = Depends(require_system_scope), + service: BackupService = Depends(get_service), +): + return await _run( + lambda: service.import_backup( + {"filename": _safe_backup_filename(filename), **_model_dict(payload)} + ), + prefix="导入备份失败", + ) + + +@legacy_router.post("/import") +async def import_dashboard_backup( + request: Request, + filename: str | None = Query(default=None), + _username: str = Depends(require_dashboard_user), + service: BackupService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run( + lambda: service.import_backup({"filename": filename, **body}), + prefix="导入备份失败", + ) diff --git a/astrbot/dashboard/api/bots.py b/astrbot/dashboard/api/bots.py new file mode 100644 index 000000000..b96c8f378 --- /dev/null +++ b/astrbot/dashboard/api/bots.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request + +from astrbot.dashboard.responses import error, ok +from astrbot.dashboard.schemas import BotConfigRequest, EnabledPatch +from astrbot.dashboard.services.config_service import BotConfigService + +from .auth import AuthContext, require_scope + +router = APIRouter(tags=["Bots"]) +legacy_router = APIRouter( + prefix="/api/config/platform", + tags=["Dashboard Bots"], + include_in_schema=False, +) + + +async def require_bot_scope(request: Request) -> AuthContext: + return await require_scope(request, "bot") + + +def get_service(request: Request) -> BotConfigService: + return request.app.state.services.bots + + +async def _json_or_empty(request: Request) -> dict: + try: + data = await request.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _required_text(value: object, name: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError(f"Missing key: {name}") + return text + + +def _config_from_body(body: dict) -> dict: + config = body.get("config") + if isinstance(config, dict): + return config + return { + key: value + for key, value in body.items() + if key not in {"bot_id", "config", "enabled"} + } + + +def _alias_error(message: str): + return error(message) + + +@router.get("/bot-types") +async def list_bot_types( + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + return ok(service.list_bot_types()) + + +@router.get("/bots") +async def list_bots( + enabled: bool | None = Query(default=None), + type_: str | None = Query(default=None, alias="type"), + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + return ok(service.list_bots(enabled=enabled, type_=type_)) + + +@router.post("/bots") +async def create_bot( + payload: BotConfigRequest, + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + await service.create_bot(payload.to_dashboard_config()) + return ok(message="新增平台配置成功~") + + +@router.get("/bots/stats") +async def list_bot_stats( + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + return ok(service.get_bot_stats()) + + +@router.get("/bots/by-id") +async def get_bot_by_id( + bot_id: str = Query(...), + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + return ok(service.get_bot(bot_id)) + + +@router.put("/bots/by-id") +async def update_bot_by_id( + payload: BotConfigRequest, + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + bot_id = _required_text(payload.bot_id, "bot_id") + await service.update_bot( + bot_id, + payload.to_dashboard_config(fallback_id=bot_id), + ) + return ok(message="更新平台配置成功~") + + +@router.delete("/bots/by-id") +async def delete_bot_by_id( + bot_id: str = Query(...), + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + await service.delete_bot(bot_id) + return ok(message="删除平台配置成功~") + + +@router.patch("/bots/enabled") +async def set_bot_enabled_by_id( + payload: BotConfigRequest, + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + bot_id = _required_text(payload.bot_id, "bot_id") + await service.set_bot_enabled(bot_id, bool(payload.enabled)) + return ok(message="更新平台配置成功~") + + +@router.post("/bots/test") +async def test_bot_by_id( + payload: BotConfigRequest, + _auth: AuthContext = Depends(require_bot_scope), +): + bot_id = _required_text(payload.bot_id, "bot_id") + return ok({"id": bot_id, "status": "unsupported"}) + + +@router.patch("/bots/{bot_id:path}/enabled") +async def set_bot_enabled( + bot_id: str, + payload: EnabledPatch, + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + await service.set_bot_enabled(bot_id, payload.enabled) + return ok(message="更新平台配置成功~") + + +@router.post("/bots/{bot_id:path}/test") +async def test_bot( + bot_id: str, + _auth: AuthContext = Depends(require_bot_scope), +): + return ok({"id": bot_id, "status": "unsupported"}) + + +@router.get("/bots/{bot_id:path}") +async def get_bot( + bot_id: str, + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + return ok(service.get_bot(bot_id)) + + +@router.put("/bots/{bot_id:path}") +async def update_bot( + bot_id: str, + payload: BotConfigRequest, + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + await service.update_bot(bot_id, payload.to_dashboard_config(fallback_id=bot_id)) + return ok(message="更新平台配置成功~") + + +@router.delete("/bots/{bot_id:path}") +async def delete_bot( + bot_id: str, + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + await service.delete_bot(bot_id) + return ok(message="删除平台配置成功~") + + +@legacy_router.get("/list") +async def list_dashboard_alias_platforms( + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + return ok({"platforms": service.list_bots()["bots"]}) + + +@legacy_router.post("/new") +async def create_dashboard_alias_platform( + payload: BotConfigRequest, + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + try: + await service.create_bot(payload.to_dashboard_config()) + return ok(message="新增平台配置成功~") + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.post("/update") +async def update_dashboard_alias_platform( + request: Request, + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + body = await _json_or_empty(request) + bot_id = body.get("id") + config = body.get("config") + if not bot_id or not isinstance(config, dict): + return _alias_error("参数错误") + try: + await service.update_bot( + str(bot_id), + BotConfigRequest(config=config).to_dashboard_config( + fallback_id=str(bot_id) + ), + ) + return ok(message="更新平台配置成功~") + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.post("/delete") +async def delete_dashboard_alias_platform( + request: Request, + _auth: AuthContext = Depends(require_bot_scope), + service: BotConfigService = Depends(get_service), +): + body = await _json_or_empty(request) + bot_id = body.get("id") + if not bot_id: + return _alias_error("缺少参数 id") + try: + await service.delete_bot(str(bot_id)) + return ok(message="删除平台配置成功~") + except ValueError as exc: + return _alias_error(str(exc)) diff --git a/astrbot/dashboard/api/chat.py b/astrbot/dashboard/api/chat.py new file mode 100644 index 000000000..15e17cece --- /dev/null +++ b/astrbot/dashboard/api/chat.py @@ -0,0 +1,526 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse + +from astrbot.dashboard.async_utils import run_maybe_async +from astrbot.dashboard.responses import error, ok +from astrbot.dashboard.schemas import ( + ChatMessagePatchRequest, + ChatMessageRegenerateRequest, + ChatSessionBatchDeleteRequest, + ChatSessionPatchRequest, + ChatThreadCreateRequest, + ChatThreadMessageRequest, +) +from astrbot.dashboard.services.chat_service import ( + ChatService, + ChatServiceError, +) + +from .auth import AuthContext, require_dashboard_user, require_scope +from .multipart import single_upload + +router = APIRouter(tags=["Chat"]) +legacy_router = APIRouter( + prefix="/api/chat", + tags=["Dashboard Chat"], + include_in_schema=False, +) + + +def get_service(request: Request) -> ChatService: + return request.app.state.services.chat + + +async def require_chat_scope(request: Request) -> AuthContext: + return await require_scope(request, "chat") + + +async def _json_or_empty(request: Request) -> dict[str, Any]: + try: + data = await request.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +async def _json_or_none(request: Request) -> dict[str, Any] | None: + try: + data = await request.json() + except Exception: + return None + return data if isinstance(data, dict) else None + + +async def _json_body(request: Request): + try: + return await request.json() + except Exception: + return None + + +def _model_dict(payload) -> dict[str, Any]: + return payload.model_dump(exclude_unset=True, exclude_none=False) + + +async def _run(operation): + try: + result = await run_maybe_async(operation) + return ok(result) + except ChatServiceError as exc: + return error(str(exc)) + + +def _file_response(file_path: str, mimetype: str | None): + if mimetype: + return FileResponse(file_path, media_type=mimetype) + return FileResponse(file_path) + + +async def _send_chat( + *, + request: Request, + username: str, + service: ChatService, + payload: dict[str, Any] | None = None, +): + post_data = payload if payload is not None else await _json_or_none(request) + if post_data is None: + return JSONResponse(error("Missing JSON body")) + + try: + stream = await service.build_chat_stream(username, post_data) + except ChatServiceError as exc: + return JSONResponse(error(str(exc))) + + return StreamingResponse( + stream, + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + }, + ) + + +@router.get("/chat/sessions/new") +async def create_chat_session( + request: Request, + auth: AuthContext = Depends(require_chat_scope), + service: ChatService = Depends(get_service), +): + return await _run( + lambda: service.new_session( + auth.username, + request.query_params.get("platform_id") or "webchat", + ) + ) + + +@router.post("/chat/sessions/batch-delete") +async def batch_delete_chat_sessions( + payload: ChatSessionBatchDeleteRequest, + auth: AuthContext = Depends(require_chat_scope), + service: ChatService = Depends(get_service), +): + return await _run( + lambda: service.batch_delete_sessions_from_dashboard_payload( + auth.username, + _model_dict(payload), + ) + ) + + +@router.get("/chat/sessions/{session_id}") +async def get_chat_session( + session_id: str, + auth: AuthContext = Depends(require_chat_scope), + service: ChatService = Depends(get_service), +): + return await _run(lambda: service.get_session(auth.username, session_id)) + + +@router.patch("/chat/sessions/{session_id}") +async def update_chat_session( + session_id: str, + payload: ChatSessionPatchRequest, + auth: AuthContext = Depends(require_chat_scope), + service: ChatService = Depends(get_service), +): + return await _run( + lambda: service.update_session_display_name( + auth.username, + session_id, + payload.display_name, + ) + ) + + +@router.delete("/chat/sessions/{session_id}") +async def delete_chat_session( + session_id: str, + auth: AuthContext = Depends(require_chat_scope), + service: ChatService = Depends(get_service), +): + return await _run(lambda: service.delete_webchat_session(auth.username, session_id)) + + +@router.post("/chat/sessions/{session_id}/stop") +async def stop_chat_session( + session_id: str, + auth: AuthContext = Depends(require_chat_scope), + service: ChatService = Depends(get_service), +): + return await _run(lambda: service.stop_session(auth.username, session_id)) + + +@router.patch("/chat/sessions/{session_id}/messages/{message_id}") +async def update_chat_message( + session_id: str, + message_id: str, + payload: ChatMessagePatchRequest, + auth: AuthContext = Depends(require_chat_scope), + service: ChatService = Depends(get_service), +): + return await _run( + lambda: service.update_message( + auth.username, + { + "session_id": session_id, + "message_id": message_id, + **_model_dict(payload), + }, + ) + ) + + +@router.post("/chat/sessions/{session_id}/messages/{message_id}/regenerate") +async def regenerate_chat_message( + session_id: str, + message_id: str, + request: Request, + payload: ChatMessageRegenerateRequest | None = None, + auth: AuthContext = Depends(require_chat_scope), + service: ChatService = Depends(get_service), +): + body = _model_dict(payload) if payload is not None else {} + try: + chat_payload = await service.prepare_regenerate_message_payload( + auth.username, + {"session_id": session_id, "message_id": message_id, **body}, + ) + except ChatServiceError as exc: + return JSONResponse(error(str(exc))) + return await _send_chat( + request=request, + username=auth.username, + service=service, + payload=chat_payload, + ) + + +@router.get("/chat/configs") +async def chat_configs( + request: Request, + _auth: AuthContext = Depends(require_chat_scope), +): + return ok(request.app.state.services.config_profiles.list_profiles()) + + +@router.post("/chat/threads") +async def create_chat_thread( + payload: ChatThreadCreateRequest, + auth: AuthContext = Depends(require_chat_scope), + service: ChatService = Depends(get_service), +): + return await _run( + lambda: service.create_thread(auth.username, _model_dict(payload)) + ) + + +@router.get("/chat/threads/{thread_id}") +async def get_chat_thread( + thread_id: str, + auth: AuthContext = Depends(require_chat_scope), + service: ChatService = Depends(get_service), +): + return await _run(lambda: service.get_thread(auth.username, thread_id)) + + +@router.delete("/chat/threads/{thread_id}") +async def delete_chat_thread( + thread_id: str, + auth: AuthContext = Depends(require_chat_scope), + service: ChatService = Depends(get_service), +): + return await _run(lambda: service.delete_thread(auth.username, thread_id)) + + +@router.post("/chat/threads/{thread_id}/messages") +async def send_chat_thread_message( + thread_id: str, + request: Request, + payload: ChatThreadMessageRequest, + auth: AuthContext = Depends(require_chat_scope), + service: ChatService = Depends(get_service), +): + try: + chat_payload = await service.prepare_thread_chat_payload( + auth.username, + {"thread_id": thread_id, **_model_dict(payload)}, + ) + except ChatServiceError as exc: + return JSONResponse(error(str(exc))) + return await _send_chat( + request=request, + username=auth.username, + service=service, + payload=chat_payload, + ) + + +@legacy_router.post("/send") +async def dashboard_send_chat( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + return await _send_chat(request=request, username=username, service=service) + + +@legacy_router.get("/new_session") +async def dashboard_new_session( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + return await _run( + lambda: service.new_session( + username, + request.query_params.get("platform_id") or "webchat", + ) + ) + + +@legacy_router.get("/sessions") +async def dashboard_get_sessions( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + return await _run( + lambda: service.get_sessions(username, request.query_params.get("platform_id")) + ) + + +@legacy_router.get("/get_session") +async def dashboard_get_session( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + return await _run( + lambda: service.get_session_from_dashboard_query( + username, + request.query_params.get("session_id"), + ) + ) + + +@legacy_router.post("/stop") +async def dashboard_stop_session( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run( + lambda: service.stop_session_from_dashboard_payload(username, body) + ) + + +@legacy_router.get("/delete_session") +async def dashboard_delete_session( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + return await _run( + lambda: service.delete_webchat_session_from_dashboard_query( + username, + request.query_params.get("session_id"), + ) + ) + + +@legacy_router.post("/batch_delete_sessions") +async def dashboard_batch_delete_sessions( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + body = await _json_body(request) + return await _run( + lambda: service.batch_delete_sessions_from_dashboard_payload(username, body) + ) + + +@legacy_router.post("/update_session_display_name") +async def dashboard_update_session_display_name( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run( + lambda: service.update_session_display_name_from_dashboard_payload( + username, + body, + ) + ) + + +@legacy_router.post("/message/edit") +async def dashboard_update_message( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.update_message(username, body)) + + +@legacy_router.post("/message/regenerate") +async def dashboard_regenerate_message( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + try: + payload = ( + await service.prepare_regenerate_message_payload_from_dashboard_payload( + username, + await _json_or_empty(request), + ) + ) + except ChatServiceError as exc: + return JSONResponse(error(str(exc))) + return await _send_chat( + request=request, + username=username, + service=service, + payload=payload, + ) + + +@legacy_router.post("/thread/create") +async def dashboard_create_thread( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.create_thread(username, body)) + + +@legacy_router.get("/thread/get") +async def dashboard_get_thread( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + return await _run( + lambda: service.get_thread_from_dashboard_query( + username, + request.query_params.get("thread_id"), + ) + ) + + +@legacy_router.post("/thread/send") +async def dashboard_send_thread_message( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + try: + payload = await service.prepare_thread_chat_payload_from_dashboard_payload( + username, + await _json_or_empty(request), + ) + except ChatServiceError as exc: + return JSONResponse(error(str(exc))) + return await _send_chat( + request=request, + username=username, + service=service, + payload=payload, + ) + + +@legacy_router.post("/thread/delete") +async def dashboard_delete_thread( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run( + lambda: service.delete_thread_from_dashboard_payload(username, body) + ) + + +@legacy_router.get("/get_file") +async def dashboard_get_file( + request: Request, + _username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + try: + file_path, mimetype = await service.resolve_webchat_file_from_dashboard_query( + request.query_params.get("filename") + ) + return _file_response(file_path, mimetype) + except ChatServiceError as exc: + return JSONResponse(error(str(exc))) + except (FileNotFoundError, OSError): + return JSONResponse(error("File access error")) + + +@legacy_router.get("/get_attachment") +async def dashboard_get_attachment( + request: Request, + _username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + try: + ( + file_path, + mimetype, + ) = await service.resolve_attachment_file_from_dashboard_query( + request.query_params.get("attachment_id") + ) + return _file_response(file_path, mimetype) + except ChatServiceError as exc: + return JSONResponse(error(str(exc))) + except (FileNotFoundError, OSError): + return JSONResponse(error("File access error")) + + +@legacy_router.post("/post_file") +async def dashboard_post_file( + request: Request, + _username: str = Depends(require_dashboard_user), + service: ChatService = Depends(get_service), +): + try: + upload = await single_upload(request) + if upload is None: + raise ChatServiceError("Missing key: file") + return ok(await service.save_uploaded_file(upload)) + except ChatServiceError as exc: + return error(str(exc)) diff --git a/astrbot/dashboard/api/chat_projects.py b/astrbot/dashboard/api/chat_projects.py new file mode 100644 index 000000000..a8d4ba448 --- /dev/null +++ b/astrbot/dashboard/api/chat_projects.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request + +from astrbot.dashboard.async_utils import run_maybe_async +from astrbot.dashboard.responses import error, ok +from astrbot.dashboard.schemas import ChatProjectRequest +from astrbot.dashboard.services.chatui_project_service import ( + ChatUIProjectService, + ChatUIProjectServiceError, +) + +from .auth import AuthContext, require_dashboard_user, require_scope + +router = APIRouter(tags=["Chat Projects"]) +legacy_router = APIRouter( + prefix="/api/chatui_project", + tags=["Dashboard Chat Projects"], + include_in_schema=False, +) + + +def get_service(request: Request) -> ChatUIProjectService: + return request.app.state.services.chat_projects + + +async def require_chat_scope(request: Request) -> AuthContext: + return await require_scope(request, "chat") + + +async def _json_or_empty(request: Request) -> dict: + try: + data = await request.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _model_dict(payload) -> dict: + return payload.model_dump(exclude_none=True) + + +async def _run(operation): + try: + result = await run_maybe_async(operation) + return ok(result) + except ChatUIProjectServiceError as exc: + return error(str(exc)) + + +@router.get("/chat/projects") +async def list_chat_projects( + auth: AuthContext = Depends(require_chat_scope), + service: ChatUIProjectService = Depends(get_service), +): + return await _run(lambda: service.list_projects(auth.username)) + + +@legacy_router.get("/list") +async def list_dashboard_chat_projects( + username: str = Depends(require_dashboard_user), + service: ChatUIProjectService = Depends(get_service), +): + return await _run(lambda: service.list_projects(username)) + + +@router.post("/chat/projects") +async def create_chat_project( + payload: ChatProjectRequest, + auth: AuthContext = Depends(require_chat_scope), + service: ChatUIProjectService = Depends(get_service), +): + return await _run( + lambda: service.create_project(auth.username, _model_dict(payload)) + ) + + +@legacy_router.post("/create") +async def create_dashboard_chat_project( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatUIProjectService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.create_project(username, body)) + + +@router.get("/chat/projects/{project_id}") +async def get_chat_project( + project_id: str, + auth: AuthContext = Depends(require_chat_scope), + service: ChatUIProjectService = Depends(get_service), +): + return await _run(lambda: service.get_project(auth.username, project_id)) + + +@legacy_router.get("/get") +async def get_dashboard_chat_project( + project_id: str | None = Query(default=None), + username: str = Depends(require_dashboard_user), + service: ChatUIProjectService = Depends(get_service), +): + return await _run(lambda: service.get_project_from_query(username, project_id)) + + +@router.patch("/chat/projects/{project_id}") +async def update_chat_project( + project_id: str, + payload: ChatProjectRequest, + auth: AuthContext = Depends(require_chat_scope), + service: ChatUIProjectService = Depends(get_service), +): + return await _run( + lambda: service.update_project( + auth.username, + {"project_id": project_id, **_model_dict(payload)}, + ) + ) + + +@legacy_router.post("/update") +async def update_dashboard_chat_project( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatUIProjectService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.update_project(username, body)) + + +@router.delete("/chat/projects/{project_id}") +async def delete_chat_project( + project_id: str, + auth: AuthContext = Depends(require_chat_scope), + service: ChatUIProjectService = Depends(get_service), +): + return await _run(lambda: service.delete_project(auth.username, project_id)) + + +@legacy_router.get("/delete") +async def delete_dashboard_chat_project( + project_id: str | None = Query(default=None), + username: str = Depends(require_dashboard_user), + service: ChatUIProjectService = Depends(get_service), +): + return await _run(lambda: service.delete_project_from_query(username, project_id)) + + +@router.get("/chat/projects/{project_id}/sessions") +async def list_chat_project_sessions( + project_id: str, + auth: AuthContext = Depends(require_chat_scope), + service: ChatUIProjectService = Depends(get_service), +): + return await _run(lambda: service.get_project_sessions(auth.username, project_id)) + + +@legacy_router.get("/get_sessions") +async def list_dashboard_chat_project_sessions( + project_id: str | None = Query(default=None), + username: str = Depends(require_dashboard_user), + service: ChatUIProjectService = Depends(get_service), +): + return await _run( + lambda: service.get_project_sessions_from_query(username, project_id) + ) + + +@router.post("/chat/projects/{project_id}/sessions/{session_id}") +async def add_chat_project_session( + project_id: str, + session_id: str, + auth: AuthContext = Depends(require_chat_scope), + service: ChatUIProjectService = Depends(get_service), +): + return await _run( + lambda: service.add_session_to_project( + auth.username, + {"project_id": project_id, "session_id": session_id}, + ) + ) + + +@legacy_router.post("/add_session") +async def add_dashboard_chat_project_session( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatUIProjectService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.add_session_to_project(username, body)) + + +@router.delete("/chat/projects/sessions/{session_id}") +async def remove_chat_project_session( + session_id: str, + auth: AuthContext = Depends(require_chat_scope), + service: ChatUIProjectService = Depends(get_service), +): + return await _run( + lambda: service.remove_session_from_project( + auth.username, + {"session_id": session_id}, + ) + ) + + +@legacy_router.post("/remove_session") +async def remove_dashboard_chat_project_session( + request: Request, + username: str = Depends(require_dashboard_user), + service: ChatUIProjectService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.remove_session_from_project(username, body)) diff --git a/astrbot/dashboard/api/config_profiles.py b/astrbot/dashboard/api/config_profiles.py new file mode 100644 index 000000000..943cc7d7e --- /dev/null +++ b/astrbot/dashboard/api/config_profiles.py @@ -0,0 +1,468 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Query, Request + +from astrbot.dashboard.responses import error, ok +from astrbot.dashboard.schemas import ( + ConfigContentRequest, + ConfigProfileCreateRequest, + ConfigRoutesReplaceRequest, + ConfigRouteUpsertRequest, + RenameRequest, +) +from astrbot.dashboard.services.config_service import ( + ConfigDisplayService, + ConfigFileService, + ConfigProfileService, + ConfigRoutingService, +) + +from .auth import AuthContext, require_scope +from .multipart import multipart_parts + +router = APIRouter(tags=["Config Profiles"]) +legacy_router = APIRouter( + prefix="/api/config", + tags=["Dashboard Config"], + include_in_schema=False, +) + + +async def require_config_scope(request: Request) -> AuthContext: + return await require_scope(request, "config") + + +def get_service(request: Request) -> ConfigProfileService: + return request.app.state.services.config_profiles + + +def get_routing_service(request: Request) -> ConfigRoutingService: + return request.app.state.services.config_routes + + +def get_display_service(request: Request) -> ConfigDisplayService: + return request.app.state.services.config_display + + +def get_file_service(request: Request) -> ConfigFileService: + return request.app.state.services.config_files + + +async def _json_or_empty(request: Request) -> dict: + try: + data = await request.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _alias_error(message: str): + return error(message) + + +def _model_dict(payload) -> dict[str, Any]: + return payload.model_dump(exclude_none=True) + + +@router.get("/config-profiles/schema") +async def get_config_profile_schema( + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + return ok(service.get_profile_schema()) + + +@router.get("/config-profiles") +async def list_config_profiles( + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + return ok(service.list_profiles()) + + +@router.post("/config-profiles") +async def create_config_profile( + payload: ConfigProfileCreateRequest, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + return ok(await service.create_profile(payload.name, payload.config), "创建成功") + + +@router.get("/config-profiles/{config_id}") +async def get_config_profile( + config_id: str, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + return ok(service.get_profile(config_id)) + + +@router.put("/config-profiles/{config_id}") +async def update_config_profile( + config_id: str, + payload: ConfigContentRequest, + request: Request, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + message = await service.update_profile( + config_id, + _model_dict(payload), + two_factor_code=request.headers.get("X-2FA-Code"), + ) + return ok(message=message or "保存成功") + + +@router.patch("/config-profiles/{config_id}") +async def rename_config_profile( + config_id: str, + payload: RenameRequest, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + service.rename_profile(config_id, payload.name) + return ok(message="更新成功") + + +@router.delete("/config-profiles/{config_id}") +async def delete_config_profile( + config_id: str, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + service.delete_profile(config_id) + return ok(message="删除成功") + + +@router.get("/system-config/schema") +async def get_system_config_schema( + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + return ok(service.get_system_schema()) + + +@router.get("/system-config") +async def get_system_config( + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + return ok(service.get_system_config()) + + +@router.get("/system-config/runtime") +async def get_system_config_runtime( + _auth: AuthContext = Depends(require_config_scope), + service: ConfigDisplayService = Depends(get_display_service), +): + return ok(await service.get_configs()) + + +@router.put("/system-config") +async def update_system_config( + payload: ConfigContentRequest, + request: Request, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + message = await service.update_profile( + "default", + _model_dict(payload), + two_factor_code=request.headers.get("X-2FA-Code"), + ) + return ok(message=message or "保存成功") + + +@router.get("/config-routes") +async def list_config_routes( + _auth: AuthContext = Depends(require_config_scope), + service: ConfigRoutingService = Depends(get_routing_service), +): + return ok(service.list_routes()) + + +@router.put("/config-routes") +async def replace_config_routes( + payload: ConfigRoutesReplaceRequest, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigRoutingService = Depends(get_routing_service), +): + await service.replace_route_mapping(payload.routing) + return ok(message="更新成功") + + +@router.put("/config-routes/{umo}") +async def upsert_config_route( + umo: str, + payload: ConfigRouteUpsertRequest, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigRoutingService = Depends(get_routing_service), +): + await service.set_route(umo, payload.config_id) + return ok(message="更新成功") + + +@router.delete("/config-routes/{umo}") +async def delete_config_route( + umo: str, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigRoutingService = Depends(get_routing_service), +): + await service.delete_route_by_umo(umo) + return ok(message="删除成功") + + +@legacy_router.get("/default") +async def get_dashboard_alias_default_config( + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + return ok(service.get_profile_schema()) + + +@legacy_router.get("/abconfs") +async def list_dashboard_alias_config_profiles( + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + return ok(service.list_profiles()) + + +@legacy_router.post("/abconf/new") +async def create_dashboard_alias_config_profile( + request: Request, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + body = await _json_or_empty(request) + try: + return ok( + await service.create_profile( + body.get("name"), + body.get("config"), + ), + "创建成功", + ) + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.get("/abconf") +async def get_dashboard_alias_config_profile( + id: str | None = Query(default=None), + system_config: str = Query(default="0"), + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + if system_config.lower() == "1": + return ok(service.get_system_schema()) + if not id: + return _alias_error("缺少配置文件 ID") + try: + return ok(service.get_profile(id)) + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.post("/abconf/delete") +async def delete_dashboard_alias_config_profile( + request: Request, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + body = await _json_or_empty(request) + config_id = body.get("id") + if not config_id: + return _alias_error("缺少配置文件 ID") + try: + service.delete_profile(str(config_id)) + return ok(message="删除成功") + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.post("/abconf/update") +async def rename_dashboard_alias_config_profile( + request: Request, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + body = await _json_or_empty(request) + config_id = body.get("id") + if not config_id: + return _alias_error("缺少配置文件 ID") + try: + service.rename_profile(str(config_id), body.get("name")) + return ok(message="更新成功") + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.post("/astrbot/update") +async def update_dashboard_alias_astrbot_config( + request: Request, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigProfileService = Depends(get_service), +): + body = await _json_or_empty(request) + config = body.get("config") + config_id = body.get("conf_id") + if not isinstance(config, dict): + return _alias_error("Invalid config payload") + if not config_id: + return _alias_error("Config file None does not exist") + try: + message = await service.update_profile( + str(config_id), + config, + two_factor_code=request.headers.get("X-2FA-Code"), + ) + return ok(message=message or "保存成功~") + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.get("/get") +async def get_dashboard_alias_configs( + request: Request, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigDisplayService = Depends(get_display_service), +): + try: + return ok(await service.get_configs_from_dashboard_args(request.query_params)) + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.post("/plugin/update") +async def update_dashboard_alias_plugin_configs( + request: Request, + plugin_name: str = Query(default="unknown"), + _auth: AuthContext = Depends(require_config_scope), + service: ConfigFileService = Depends(get_file_service), +): + body = await _json_or_empty(request) + try: + message = await service.save_plugin_configs_from_dashboard_payload( + body, + plugin_name=plugin_name, + ) + return ok(message=message) + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.post("/file/upload") +async def upload_dashboard_alias_config_file( + request: Request, + scope: str | None = Query(default=None), + name: str | None = Query(default=None), + key: str | None = Query(default=None), + _auth: AuthContext = Depends(require_config_scope), + service: ConfigFileService = Depends(get_file_service), +): + _, files = await multipart_parts(request) + try: + return ok( + await service.upload_config_file( + scope=scope, + name=name, + key_path=key, + files=files, + ) + ) + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.post("/file/delete") +async def delete_dashboard_alias_config_file( + request: Request, + scope: str | None = Query(default=None), + name: str | None = Query(default=None), + _auth: AuthContext = Depends(require_config_scope), + service: ConfigFileService = Depends(get_file_service), +): + body = await _json_or_empty(request) + try: + message = service.delete_config_file_from_dashboard_payload( + scope=scope or "plugin", + name=name, + payload=body, + ) + return ok(message=message) + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.get("/file/get") +async def list_dashboard_alias_config_files( + scope: str | None = Query(default=None), + name: str | None = Query(default=None), + key: str | None = Query(default=None), + _auth: AuthContext = Depends(require_config_scope), + service: ConfigFileService = Depends(get_file_service), +): + try: + return ok( + service.list_config_files( + scope=scope, + name=name, + key_path=key, + ) + ) + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.get("/umo_abconf_routes") +async def get_dashboard_alias_config_routes( + _auth: AuthContext = Depends(require_config_scope), + service: ConfigRoutingService = Depends(get_routing_service), +): + return ok(service.list_routes()) + + +@legacy_router.post("/umo_abconf_route/update_all") +async def update_dashboard_alias_config_routes( + request: Request, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigRoutingService = Depends(get_routing_service), +): + body = await _json_or_empty(request) + try: + await service.replace_routes(body) + except ValueError: + return _alias_error("缺少或错误的路由表数据") + return ok(message="更新成功") + + +@legacy_router.post("/umo_abconf_route/update") +async def upsert_dashboard_alias_config_route( + request: Request, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigRoutingService = Depends(get_routing_service), +): + body = await _json_or_empty(request) + try: + await service.upsert_route(body) + except ValueError: + return _alias_error("缺少 UMO 或配置文件 ID") + return ok(message="更新成功") + + +@legacy_router.post("/umo_abconf_route/delete") +async def delete_dashboard_alias_config_route( + request: Request, + _auth: AuthContext = Depends(require_config_scope), + service: ConfigRoutingService = Depends(get_routing_service), +): + body = await _json_or_empty(request) + try: + await service.delete_route(body) + except ValueError: + return _alias_error("缺少 UMO") + return ok(message="删除成功") diff --git a/astrbot/dashboard/api/conversations.py b/astrbot/dashboard/api/conversations.py new file mode 100644 index 000000000..50edc5fc6 --- /dev/null +++ b/astrbot/dashboard/api/conversations.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import StreamingResponse + +from astrbot.dashboard.async_utils import run_maybe_async +from astrbot.dashboard.responses import ApiError, ok +from astrbot.dashboard.schemas import ( + ConversationBatchDeleteRequest, + ConversationExportRequest, + ConversationMessagesReplaceRequest, + ConversationPatchRequest, +) +from astrbot.dashboard.services.conversation_service import ( + ConversationExport, + ConversationService, + ConversationServiceError, +) + +from .auth import AuthContext, require_dashboard_user, require_scope + +router = APIRouter(tags=["Conversations"]) +legacy_router = APIRouter( + prefix="/api/conversation", + tags=["Dashboard Conversations"], + include_in_schema=False, +) + + +def get_service(request: Request) -> ConversationService: + return request.app.state.services.conversations + + +async def require_data_scope(request: Request) -> AuthContext: + return await require_scope(request, "data") + + +async def _json_or_empty(request: Request) -> dict[str, Any]: + try: + data = await request.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _model_dict(payload) -> dict[str, Any]: + return payload.model_dump(exclude_none=True) + + +def _raise_conversation_error(exc: ConversationServiceError) -> None: + raise ApiError(str(exc)) from exc + + +async def _run(operation): + try: + result = await run_maybe_async(operation) + return ok(result) + except ConversationServiceError as exc: + _raise_conversation_error(exc) + + +def _export_response(export: ConversationExport) -> StreamingResponse: + export.file_obj.seek(0) + + def iter_file(): + while chunk := export.file_obj.read(8192): + yield chunk + + return StreamingResponse( + iter_file(), + media_type=export.mimetype, + headers={"Content-Disposition": f'attachment; filename="{export.filename}"'}, + ) + + +async def _export_conversations( + payload: dict[str, Any], + service: ConversationService, +): + try: + return _export_response(await service.export_conversations(payload)) + except ConversationServiceError as exc: + _raise_conversation_error(exc) + + +async def _list_conversations( + service: ConversationService, + *, + page: int, + page_size: int, + platforms: str, + message_types: str, + search: str, + exclude_ids: str, + exclude_platforms: str, +): + return await _run( + lambda: service.list_conversations( + page=page, + page_size=page_size, + platforms=platforms, + message_types=message_types, + search_query=search, + exclude_ids=exclude_ids, + exclude_platforms=exclude_platforms, + ) + ) + + +@router.get("/conversations") +async def list_conversations( + page: int = Query(default=1), + page_size: int = Query(default=20), + platforms: str = Query(default=""), + message_types: str = Query(default=""), + search: str = Query(default=""), + exclude_ids: str = Query(default=""), + exclude_platforms: str = Query(default=""), + _auth: AuthContext = Depends(require_data_scope), + service: ConversationService = Depends(get_service), +): + return await _list_conversations( + service, + page=page, + page_size=page_size, + platforms=platforms, + message_types=message_types, + search=search, + exclude_ids=exclude_ids, + exclude_platforms=exclude_platforms, + ) + + +@router.post("/conversations/export") +async def export_conversations( + payload: ConversationExportRequest, + _auth: AuthContext = Depends(require_data_scope), + service: ConversationService = Depends(get_service), +): + return await _export_conversations(_model_dict(payload), service) + + +@router.post("/conversations/batch-delete") +async def batch_delete_conversations( + payload: ConversationBatchDeleteRequest, + _auth: AuthContext = Depends(require_data_scope), + service: ConversationService = Depends(get_service), +): + return await _run(lambda: service.delete_conversation(_model_dict(payload))) + + +@router.put("/conversations/{conversation_id:path}/messages") +async def replace_conversation_messages( + conversation_id: str, + payload: ConversationMessagesReplaceRequest, + user_id: str = Query(...), + _auth: AuthContext = Depends(require_data_scope), + service: ConversationService = Depends(get_service), +): + body = _model_dict(payload) + body_user_id = body.pop("user_id", None) or user_id + if "messages" in body and "history" not in body: + body["history"] = body.pop("messages") + return await _run( + lambda: service.update_history( + {"user_id": body_user_id, "cid": conversation_id, **body} + ) + ) + + +@router.get("/conversations/{conversation_id:path}") +async def get_conversation( + conversation_id: str, + user_id: str = Query(...), + _auth: AuthContext = Depends(require_data_scope), + service: ConversationService = Depends(get_service), +): + return await _run( + lambda: service.get_conversation_detail( + {"user_id": user_id, "cid": conversation_id} + ) + ) + + +@router.patch("/conversations/{conversation_id:path}") +async def update_conversation( + conversation_id: str, + payload: ConversationPatchRequest, + user_id: str = Query(...), + _auth: AuthContext = Depends(require_data_scope), + service: ConversationService = Depends(get_service), +): + body = _model_dict(payload) + body_user_id = body.pop("user_id", None) or user_id + return await _run( + lambda: service.update_conversation( + {"user_id": body_user_id, "cid": conversation_id, **body} + ) + ) + + +@router.delete("/conversations/{conversation_id:path}") +async def delete_conversation( + conversation_id: str, + user_id: str = Query(...), + _auth: AuthContext = Depends(require_data_scope), + service: ConversationService = Depends(get_service), +): + return await _run( + lambda: service.delete_conversation( + {"user_id": user_id, "cid": conversation_id} + ) + ) + + +@legacy_router.get("/list") +async def list_dashboard_conversations( + page: int = Query(default=1), + page_size: int = Query(default=20), + platforms: str = Query(default=""), + message_types: str = Query(default=""), + search: str = Query(default=""), + exclude_ids: str = Query(default=""), + exclude_platforms: str = Query(default=""), + _username: str = Depends(require_dashboard_user), + service: ConversationService = Depends(get_service), +): + return await _list_conversations( + service, + page=page, + page_size=page_size, + platforms=platforms, + message_types=message_types, + search=search, + exclude_ids=exclude_ids, + exclude_platforms=exclude_platforms, + ) + + +@legacy_router.post("/detail") +async def get_dashboard_conversation_detail( + request: Request, + _username: str = Depends(require_dashboard_user), + service: ConversationService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.get_conversation_detail(body)) + + +@legacy_router.post("/update") +async def update_dashboard_conversation( + request: Request, + _username: str = Depends(require_dashboard_user), + service: ConversationService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.update_conversation(body)) + + +@legacy_router.post("/delete") +async def delete_dashboard_conversation( + request: Request, + _username: str = Depends(require_dashboard_user), + service: ConversationService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.delete_conversation(body)) + + +@legacy_router.post("/update_history") +async def update_dashboard_conversation_history( + request: Request, + _username: str = Depends(require_dashboard_user), + service: ConversationService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.update_history(body)) + + +@legacy_router.post("/export") +async def export_dashboard_conversations( + request: Request, + _username: str = Depends(require_dashboard_user), + service: ConversationService = Depends(get_service), +): + return await _export_conversations(await _json_or_empty(request), service) diff --git a/astrbot/dashboard/api/cron.py b/astrbot/dashboard/api/cron.py new file mode 100644 index 000000000..1b1f9cc43 --- /dev/null +++ b/astrbot/dashboard/api/cron.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request + +from astrbot.dashboard.responses import ApiError, ok +from astrbot.dashboard.schemas import CronJobRequest +from astrbot.dashboard.services.cron_service import CronService, CronServiceError + +from .auth import AuthContext, require_dashboard_user, require_scope + +router = APIRouter(tags=["Cron"]) +legacy_router = APIRouter( + prefix="/api/cron", + tags=["Dashboard Cron"], + include_in_schema=False, +) + + +async def require_system_scope(request: Request) -> AuthContext: + return await require_scope(request, "system") + + +def get_service(request: Request) -> CronService: + return request.app.state.services.cron + + +def _payload_dict(payload: CronJobRequest) -> dict: + return payload.model_dump(exclude_none=True) + + +def _raise_cron_error(exc: CronServiceError) -> None: + raise ApiError(str(exc)) from exc + + +async def _list_jobs(job_type: str | None, service: CronService): + try: + return ok(await service.list_jobs(job_type)) + except CronServiceError as exc: + _raise_cron_error(exc) + + +async def _create_job(payload: CronJobRequest, service: CronService): + try: + return ok(await service.create_job(_payload_dict(payload))) + except CronServiceError as exc: + _raise_cron_error(exc) + + +async def _update_job(job_id: str, payload: CronJobRequest, service: CronService): + try: + return ok(await service.update_job(job_id, _payload_dict(payload))) + except CronServiceError as exc: + _raise_cron_error(exc) + + +async def _delete_job(job_id: str, service: CronService): + try: + await service.delete_job(job_id) + return ok(message="deleted") + except CronServiceError as exc: + _raise_cron_error(exc) + + +async def _run_job(job_id: str, service: CronService): + try: + await service.run_job_now(job_id) + return ok(message="started") + except CronServiceError as exc: + _raise_cron_error(exc) + + +@router.get("/cron/jobs") +async def list_cron_jobs( + job_type: str | None = Query(default=None, alias="type"), + _auth: AuthContext = Depends(require_system_scope), + service: CronService = Depends(get_service), +): + return await _list_jobs(job_type, service) + + +@router.post("/cron/jobs") +async def create_cron_job( + payload: CronJobRequest, + _auth: AuthContext = Depends(require_system_scope), + service: CronService = Depends(get_service), +): + return await _create_job(payload, service) + + +@router.patch("/cron/jobs/{job_id}") +async def update_cron_job( + job_id: str, + payload: CronJobRequest, + _auth: AuthContext = Depends(require_system_scope), + service: CronService = Depends(get_service), +): + return await _update_job(job_id, payload, service) + + +@router.delete("/cron/jobs/{job_id}") +async def delete_cron_job( + job_id: str, + _auth: AuthContext = Depends(require_system_scope), + service: CronService = Depends(get_service), +): + return await _delete_job(job_id, service) + + +@router.post("/cron/jobs/{job_id}/run") +async def run_cron_job( + job_id: str, + _auth: AuthContext = Depends(require_system_scope), + service: CronService = Depends(get_service), +): + return await _run_job(job_id, service) + + +@legacy_router.get("/jobs") +async def list_dashboard_cron_jobs( + job_type: str | None = Query(default=None, alias="type"), + _username: str = Depends(require_dashboard_user), + service: CronService = Depends(get_service), +): + return await _list_jobs(job_type, service) + + +@legacy_router.post("/jobs") +async def create_dashboard_cron_job( + payload: CronJobRequest, + _username: str = Depends(require_dashboard_user), + service: CronService = Depends(get_service), +): + return await _create_job(payload, service) + + +@legacy_router.patch("/jobs/{job_id}") +async def update_dashboard_cron_job( + job_id: str, + payload: CronJobRequest, + _username: str = Depends(require_dashboard_user), + service: CronService = Depends(get_service), +): + return await _update_job(job_id, payload, service) + + +@legacy_router.delete("/jobs/{job_id}") +async def delete_dashboard_cron_job( + job_id: str, + _username: str = Depends(require_dashboard_user), + service: CronService = Depends(get_service), +): + return await _delete_job(job_id, service) + + +@legacy_router.post("/jobs/{job_id}/run") +async def run_dashboard_cron_job( + job_id: str, + _username: str = Depends(require_dashboard_user), + service: CronService = Depends(get_service), +): + return await _run_job(job_id, service) diff --git a/astrbot/dashboard/api/extensions.py b/astrbot/dashboard/api/extensions.py new file mode 100644 index 000000000..2c88a66cc --- /dev/null +++ b/astrbot/dashboard/api/extensions.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request + +from astrbot.dashboard.responses import ApiError, ok +from astrbot.dashboard.schemas import ( + CommandPermissionRequest, + CommandRenameRequest, + CommandToggleRequest, + CommandUpdateRequest, +) +from astrbot.dashboard.services.command_service import ( + CommandService, + CommandServiceError, +) + +from .auth import AuthContext, require_dashboard_user, require_scope + +router = APIRouter(tags=["Extension Components"]) +legacy_router = APIRouter( + prefix="/api", + tags=["Dashboard Extension Components"], + include_in_schema=False, +) + + +def get_command_service(request: Request) -> CommandService: + return request.app.state.services.commands + + +async def require_tool_scope(request: Request) -> AuthContext: + return await require_scope(request, "tool") + + +def _raise_command_error(exc: CommandServiceError) -> None: + raise ApiError(str(exc)) from exc + + +async def _list_commands(config_id: str | None, service: CommandService): + try: + return ok(await service.list_commands(config_id or "")) + except CommandServiceError as exc: + _raise_command_error(exc) + + +async def _list_command_conflicts(service: CommandService): + try: + return ok(await service.list_conflicts()) + except CommandServiceError as exc: + _raise_command_error(exc) + + +async def _toggle_command(payload: CommandToggleRequest, service: CommandService): + try: + return ok( + await service.toggle_command(payload.handler_full_name, payload.enabled) + ) + except CommandServiceError as exc: + _raise_command_error(exc) + + +async def _rename_command(payload: CommandRenameRequest, service: CommandService): + try: + return ok( + await service.rename_command( + payload.handler_full_name, + payload.new_name, + aliases=payload.aliases, + ) + ) + except CommandServiceError as exc: + _raise_command_error(exc) + + +async def _update_command_permission( + payload: CommandPermissionRequest, + service: CommandService, +): + try: + return ok( + await service.update_permission( + payload.handler_full_name, payload.permission + ) + ) + except CommandServiceError as exc: + _raise_command_error(exc) + + +@router.get("/commands") +async def list_commands( + config_id: str | None = None, + _auth: AuthContext = Depends(require_tool_scope), + service: CommandService = Depends(get_command_service), +): + return await _list_commands(config_id, service) + + +@router.get("/commands/conflicts") +async def list_command_conflicts( + _auth: AuthContext = Depends(require_tool_scope), + service: CommandService = Depends(get_command_service), +): + return await _list_command_conflicts(service) + + +@router.patch("/commands/{command_id:path}") +async def update_command( + command_id: str, + payload: CommandUpdateRequest, + _auth: AuthContext = Depends(require_tool_scope), + service: CommandService = Depends(get_command_service), +): + if payload.enabled is not None: + return await _toggle_command( + CommandToggleRequest( + handler_full_name=command_id, + enabled=payload.enabled, + ), + service, + ) + if payload.alias is not None: + return await _rename_command( + CommandRenameRequest( + handler_full_name=command_id, + new_name=payload.alias, + aliases=payload.aliases, + ), + service, + ) + return await _update_command_permission( + CommandPermissionRequest( + handler_full_name=command_id, + permission=payload.permission_group or "", + ), + service, + ) + + +@legacy_router.get("/commands") +async def list_dashboard_commands( + config_id: str | None = None, + _username: str = Depends(require_dashboard_user), + service: CommandService = Depends(get_command_service), +): + return await _list_commands(config_id, service) + + +@legacy_router.get("/commands/conflicts") +async def list_dashboard_command_conflicts( + _username: str = Depends(require_dashboard_user), + service: CommandService = Depends(get_command_service), +): + return await _list_command_conflicts(service) + + +@legacy_router.post("/commands/toggle") +async def toggle_dashboard_command( + payload: CommandToggleRequest, + _username: str = Depends(require_dashboard_user), + service: CommandService = Depends(get_command_service), +): + return await _toggle_command(payload, service) + + +@legacy_router.post("/commands/rename") +async def rename_dashboard_command( + payload: CommandRenameRequest, + _username: str = Depends(require_dashboard_user), + service: CommandService = Depends(get_command_service), +): + return await _rename_command(payload, service) + + +@legacy_router.post("/commands/permission") +async def update_dashboard_command_permission( + payload: CommandPermissionRequest, + _username: str = Depends(require_dashboard_user), + service: CommandService = Depends(get_command_service), +): + return await _update_command_permission(payload, service) diff --git a/astrbot/dashboard/api/files.py b/astrbot/dashboard/api/files.py new file mode 100644 index 000000000..46ae76a69 --- /dev/null +++ b/astrbot/dashboard/api/files.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile +from fastapi.responses import FileResponse + +from astrbot.dashboard.async_utils import run_maybe_async +from astrbot.dashboard.responses import error, ok +from astrbot.dashboard.services.chat_service import ChatService, ChatServiceError +from astrbot.dashboard.services.file_service import FileService, FileServiceError + +from .auth import AuthContext, require_scope +from .multipart import UploadFileAdapter + +router = APIRouter(tags=["Files"]) +legacy_router = APIRouter(prefix="/api", include_in_schema=False) + + +def get_service(request: Request) -> FileService: + return request.app.state.services.files + + +def get_chat_service(request: Request) -> ChatService: + return request.app.state.services.chat + + +async def require_file_scope(request: Request) -> AuthContext: + return await require_scope(request, "file") + + +async def _serve_token_file(file_token: str, service: FileService): + try: + return FileResponse(await service.resolve_token_file(file_token)) + except FileServiceError as exc: + raise HTTPException(status_code=404) from exc + + +def _file_response(file_path: str, mimetype: str | None = None) -> FileResponse: + if mimetype: + return FileResponse(file_path, media_type=mimetype) + return FileResponse(file_path) + + +async def _run_file(operation, *, error_message: str = "File access error"): + try: + result = await run_maybe_async(operation) + return result + except ChatServiceError as exc: + return error(str(exc)) + except (FileNotFoundError, OSError): + return error(error_message) + + +async def _upload_file(file: UploadFile, service: ChatService): + result = await _run_file( + lambda: service.save_uploaded_file(UploadFileAdapter(file)) + ) + if isinstance(result, dict) and result.get("status") == "error": + return result + return ok(result) + + +@router.get("/files/tokens/{file_token}") +async def get_token_file( + file_token: str, + service: FileService = Depends(get_service), +): + return await _serve_token_file(file_token, service) + + +@router.post("/files") +async def upload_file( + file: UploadFile = File(...), + _auth: AuthContext = Depends(require_file_scope), + service: ChatService = Depends(get_chat_service), +): + return await _upload_file(file, service) + + +@router.get("/files/content") +async def get_file_by_name( + filename: str | None = Query(default=None), + _auth: AuthContext = Depends(require_file_scope), + service: ChatService = Depends(get_chat_service), +): + result = await _run_file(lambda: service.resolve_webchat_file(filename)) + if isinstance(result, dict) and result.get("status") == "error": + return result + file_path, mimetype = result + return _file_response(file_path, mimetype) + + +@router.get("/files/{attachment_id}") +@router.get("/files/{attachment_id}/content") +async def get_file( + attachment_id: str, + _auth: AuthContext = Depends(require_file_scope), + service: ChatService = Depends(get_chat_service), +): + result = await _run_file(lambda: service.resolve_attachment_file(attachment_id)) + if isinstance(result, dict) and result.get("status") == "error": + return result + file_path, mimetype = result + return _file_response(file_path, mimetype) + + +@router.delete("/files/{attachment_id}") +async def delete_file( + attachment_id: str, + _auth: AuthContext = Depends(require_file_scope), +): + return ok({"attachment_id": attachment_id}) + + +@legacy_router.get("/file/{file_token}") +async def get_dashboard_token_file( + file_token: str, + service: FileService = Depends(get_service), +): + return await _serve_token_file(file_token, service) diff --git a/astrbot/dashboard/api/knowledge_bases.py b/astrbot/dashboard/api/knowledge_bases.py new file mode 100644 index 000000000..c6f62235d --- /dev/null +++ b/astrbot/dashboard/api/knowledge_bases.py @@ -0,0 +1,505 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from fastapi import APIRouter, Depends, Request + +from astrbot.core import logger +from astrbot.dashboard.async_utils import run_maybe_async +from astrbot.dashboard.responses import error, ok +from astrbot.dashboard.schemas import ( + KnowledgeBaseImportRequest, + KnowledgeBaseRequest, + KnowledgeBaseRetrieveRequest, + KnowledgeBaseUrlImportRequest, +) +from astrbot.dashboard.services.knowledge_base_service import ( + KnowledgeBaseService, + KnowledgeBaseServiceError, +) + +from .auth import AuthContext, require_dashboard_user, require_scope +from .multipart import multipart_parts + +router = APIRouter(tags=["Knowledge Bases"]) +legacy_router = APIRouter( + prefix="/api/kb", + tags=["Dashboard Knowledge Bases"], + include_in_schema=False, +) + + +def get_service(request: Request) -> KnowledgeBaseService: + return request.app.state.services.knowledge_bases + + +async def require_kb_scope(request: Request) -> AuthContext: + return await require_scope(request, "kb") + + +async def _json_or_empty(request: Request) -> dict[str, Any]: + try: + data = await request.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _to_int(value: Any, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _model_dict(payload) -> dict[str, Any]: + if payload is None: + return {} + if hasattr(payload, "model_dump"): + return payload.model_dump(exclude_none=True) + return payload if isinstance(payload, dict) else {} + + +async def _run(operation, *, prefix: str): + try: + result = await run_maybe_async(operation) + if isinstance(result, tuple): + data, message = result + return ok(data, message) + return ok(result) + except (KnowledgeBaseServiceError, ValueError) as exc: + return error(str(exc)) + except Exception as exc: + logger.error("%s: %s", prefix, exc, exc_info=True) + return error(f"{prefix}: {exc!s}") + + +async def _run_json( + request: Request, + operation: Callable[[dict[str, Any]], Any], + *, + prefix: str, +): + body = await _json_or_empty(request) + return await _run(lambda: operation(body), prefix=prefix) + + +@router.get("/knowledge-bases") +async def list_knowledge_bases( + request: Request, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run( + lambda: service.list_kbs( + page=_to_int(request.query_params.get("page"), 1), + page_size=_to_int(request.query_params.get("page_size"), 20), + ), + prefix="获取知识库列表失败", + ) + + +@router.post("/knowledge-bases") +async def create_knowledge_base( + payload: KnowledgeBaseRequest, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run( + lambda: service.create_kb(_model_dict(payload)), + prefix="创建知识库失败", + ) + + +@router.get("/knowledge-bases/tasks/{task_id}") +async def get_knowledge_base_task( + task_id: str, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run( + lambda: service.get_upload_progress(task_id), + prefix="获取上传进度失败", + ) + + +@router.get("/knowledge-bases/{kb_id}") +async def get_knowledge_base( + kb_id: str, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run(lambda: service.get_kb(kb_id), prefix="获取知识库详情失败") + + +@router.put("/knowledge-bases/{kb_id}") +async def update_knowledge_base( + kb_id: str, + payload: KnowledgeBaseRequest, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + body = _model_dict(payload) + return await _run( + lambda: service.update_kb({"kb_id": kb_id, **body}), + prefix="更新知识库失败", + ) + + +@router.delete("/knowledge-bases/{kb_id}") +async def delete_knowledge_base( + kb_id: str, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run( + lambda: service.delete_kb({"kb_id": kb_id}), prefix="删除知识库失败" + ) + + +@router.get("/knowledge-bases/{kb_id}/stats") +async def get_knowledge_base_stats( + kb_id: str, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run( + lambda: service.get_kb_stats(kb_id), + prefix="获取知识库统计失败", + ) + + +@router.get("/knowledge-bases/{kb_id}/documents") +async def list_knowledge_base_documents( + kb_id: str, + request: Request, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run( + lambda: service.list_documents( + kb_id=kb_id, + page=_to_int(request.query_params.get("page"), 1), + page_size=_to_int(request.query_params.get("page_size"), 100), + ), + prefix="获取文档列表失败", + ) + + +@router.post("/knowledge-bases/{kb_id}/documents") +async def upload_knowledge_base_document( + kb_id: str, + request: Request, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + async def _operation(): + form_data, files = await multipart_parts(request, extra_form={"kb_id": kb_id}) + return await service.upload_document( + content_type=request.headers.get("content-type"), + form_data=form_data, + files=files, + ) + + return await _run(_operation, prefix="上传文档失败") + + +@router.post("/knowledge-bases/{kb_id}/documents/import") +async def import_knowledge_base_documents( + kb_id: str, + payload: KnowledgeBaseImportRequest, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + body = _model_dict(payload) + return await _run( + lambda: service.import_documents({"kb_id": kb_id, **body}), + prefix="导入文档失败", + ) + + +@router.post("/knowledge-bases/{kb_id}/documents/import-url") +async def import_knowledge_base_document_url( + kb_id: str, + payload: KnowledgeBaseUrlImportRequest, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + body = _model_dict(payload) + return await _run( + lambda: service.upload_document_from_url({"kb_id": kb_id, **body}), + prefix="从URL上传文档失败", + ) + + +@router.get("/knowledge-bases/{kb_id}/documents/{document_id}") +async def get_knowledge_base_document( + kb_id: str, + document_id: str, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run( + lambda: service.get_document(kb_id=kb_id, doc_id=document_id), + prefix="获取文档详情失败", + ) + + +@router.delete("/knowledge-bases/{kb_id}/documents/{document_id}") +async def delete_knowledge_base_document( + kb_id: str, + document_id: str, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run( + lambda: service.delete_document({"kb_id": kb_id, "doc_id": document_id}), + prefix="删除文档失败", + ) + + +@router.get("/knowledge-bases/{kb_id}/chunks") +async def list_knowledge_base_chunks( + kb_id: str, + request: Request, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + document_id = request.query_params.get("document_id") or request.query_params.get( + "doc_id" + ) + return await _run( + lambda: service.list_chunks( + kb_id=kb_id, + doc_id=document_id, + page=_to_int(request.query_params.get("page"), 1), + page_size=_to_int(request.query_params.get("page_size"), 100), + ), + prefix="获取块列表失败", + ) + + +@router.delete("/knowledge-bases/{kb_id}/chunks/{chunk_id}") +async def delete_knowledge_base_chunk( + kb_id: str, + chunk_id: str, + request: Request, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + document_id = request.query_params.get("document_id") or request.query_params.get( + "doc_id" + ) + return await _run( + lambda: service.delete_chunk( + {"kb_id": kb_id, "chunk_id": chunk_id, "doc_id": document_id} + ), + prefix="删除文本块失败", + ) + + +@router.post("/knowledge-bases/{kb_id}/retrieve") +async def retrieve_knowledge_base( + kb_id: str, + payload: KnowledgeBaseRetrieveRequest, + _auth: AuthContext = Depends(require_kb_scope), + service: KnowledgeBaseService = Depends(get_service), +): + body = _model_dict(payload) + return await _run( + lambda: service.retrieve({"kb_id": kb_id, **body}), + prefix="检索失败", + ) + + +@legacy_router.get("/list") +async def dashboard_list_kbs( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run( + lambda: service.list_kbs( + page=_to_int(request.query_params.get("page"), 1), + page_size=_to_int(request.query_params.get("page_size"), 20), + ), + prefix="获取知识库列表失败", + ) + + +@legacy_router.post("/create") +async def dashboard_create_kb( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run_json(request, service.create_kb, prefix="创建知识库失败") + + +@legacy_router.get("/get") +async def dashboard_get_kb( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run( + lambda: service.get_kb(request.query_params.get("kb_id")), + prefix="获取知识库详情失败", + ) + + +@legacy_router.post("/update") +async def dashboard_update_kb( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run_json(request, service.update_kb, prefix="更新知识库失败") + + +@legacy_router.post("/delete") +async def dashboard_delete_kb( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run_json(request, service.delete_kb, prefix="删除知识库失败") + + +@legacy_router.get("/stats") +async def dashboard_get_kb_stats( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run( + lambda: service.get_kb_stats(request.query_params.get("kb_id")), + prefix="获取知识库统计失败", + ) + + +@legacy_router.get("/document/list") +async def dashboard_list_documents( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run( + lambda: service.list_documents( + kb_id=request.query_params.get("kb_id"), + page=_to_int(request.query_params.get("page"), 1), + page_size=_to_int(request.query_params.get("page_size"), 100), + ), + prefix="获取文档列表失败", + ) + + +@legacy_router.post("/document/upload") +async def dashboard_upload_document( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + async def _operation(): + form_data, files = await multipart_parts(request) + return await service.upload_document( + content_type=request.headers.get("content-type"), + form_data=form_data, + files=files, + ) + + return await _run(_operation, prefix="上传文档失败") + + +@legacy_router.post("/document/import") +async def dashboard_import_documents( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run_json(request, service.import_documents, prefix="导入文档失败") + + +@legacy_router.post("/document/upload/url") +async def dashboard_upload_document_from_url( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run_json( + request, + service.upload_document_from_url, + prefix="从URL上传文档失败", + ) + + +@legacy_router.get("/document/upload/progress") +async def dashboard_get_upload_progress( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run( + lambda: service.get_upload_progress(request.query_params.get("task_id")), + prefix="获取上传进度失败", + ) + + +@legacy_router.get("/document/get") +async def dashboard_get_document( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run( + lambda: service.get_document( + kb_id=request.query_params.get("kb_id"), + doc_id=request.query_params.get("doc_id"), + ), + prefix="获取文档详情失败", + ) + + +@legacy_router.post("/document/delete") +async def dashboard_delete_document( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run_json(request, service.delete_document, prefix="删除文档失败") + + +@legacy_router.get("/chunk/list") +async def dashboard_list_chunks( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run( + lambda: service.list_chunks( + kb_id=request.query_params.get("kb_id"), + doc_id=request.query_params.get("doc_id"), + page=_to_int(request.query_params.get("page"), 1), + page_size=_to_int(request.query_params.get("page_size"), 100), + ), + prefix="获取块列表失败", + ) + + +@legacy_router.post("/chunk/delete") +async def dashboard_delete_chunk( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run_json(request, service.delete_chunk, prefix="删除文本块失败") + + +@legacy_router.post("/retrieve") +async def dashboard_retrieve( + request: Request, + _username: str = Depends(require_dashboard_user), + service: KnowledgeBaseService = Depends(get_service), +): + return await _run_json(request, service.retrieve, prefix="检索失败") diff --git a/astrbot/dashboard/api/live_chat.py b/astrbot/dashboard/api/live_chat.py new file mode 100644 index 000000000..470e6abf5 --- /dev/null +++ b/astrbot/dashboard/api/live_chat.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from fastapi import APIRouter, WebSocket + +from astrbot.dashboard.services.live_chat_service import LiveChatService + +router = APIRouter(tags=["Live Chat"]) +legacy_router = APIRouter( + prefix="/api", + tags=["Dashboard Live Chat"], + include_in_schema=False, +) + + +def get_service(websocket: WebSocket) -> LiveChatService: + return websocket.app.state.services.live_chat + + +async def _run_live_chat_ws( + websocket: WebSocket, + *, + force_ct: str | None, +) -> None: + await websocket.accept() + service = get_service(websocket) + await service.run_websocket_session( + token=websocket.query_params.get("token"), + force_ct=force_ct, + receive_json=websocket.receive_json, + send_json=websocket.send_json, + close=websocket.close, + ) + + +@router.websocket("/live-chat/ws") +async def live_chat_ws(websocket: WebSocket) -> None: + await _run_live_chat_ws(websocket, force_ct="live") + + +@router.websocket("/unified-chat/ws") +async def unified_chat_ws(websocket: WebSocket) -> None: + await _run_live_chat_ws(websocket, force_ct=None) + + +@legacy_router.websocket("/live_chat/ws") +async def dashboard_live_chat_ws(websocket: WebSocket) -> None: + await _run_live_chat_ws(websocket, force_ct="live") + + +@legacy_router.websocket("/unified_chat/ws") +async def dashboard_unified_chat_ws(websocket: WebSocket) -> None: + await _run_live_chat_ws(websocket, force_ct=None) diff --git a/astrbot/dashboard/api/logs.py b/astrbot/dashboard/api/logs.py new file mode 100644 index 000000000..de5e6b572 --- /dev/null +++ b/astrbot/dashboard/api/logs.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Header, Request +from fastapi.responses import StreamingResponse + +from astrbot.dashboard.responses import ApiError, ok +from astrbot.dashboard.schemas import TraceSettingsRequest +from astrbot.dashboard.services.log_service import LogService, LogServiceError + +from .auth import AuthContext, require_dashboard_user, require_scope + +router = APIRouter(tags=["Logs"]) +legacy_router = APIRouter( + prefix="/api", + tags=["Dashboard Logs"], + include_in_schema=False, +) + + +async def require_system_scope(request: Request) -> AuthContext: + return await require_scope(request, "system") + + +def get_service(request: Request) -> LogService: + return request.app.state.services.logs + + +def _raise_log_error(exc: LogServiceError) -> None: + raise ApiError(str(exc)) from exc + + +def _log_stream_response(last_event_id: str | None, service: LogService): + return StreamingResponse( + service.stream_log_events(last_event_id), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Transfer-Encoding": "chunked", + }, + ) + + +def _get_log_history(service: LogService): + try: + return ok(service.get_log_history()) + except LogServiceError as exc: + _raise_log_error(exc) + + +def _get_trace_settings(service: LogService): + try: + return ok(service.get_trace_settings()) + except LogServiceError as exc: + _raise_log_error(exc) + + +def _update_trace_settings(payload: TraceSettingsRequest, service: LogService): + try: + message = service.update_trace_settings(payload.model_dump(exclude_none=True)) + return ok(message=message) + except LogServiceError as exc: + _raise_log_error(exc) + + +@router.get("/logs/history") +async def get_log_history( + _auth: AuthContext = Depends(require_system_scope), + service: LogService = Depends(get_service), +): + return _get_log_history(service) + + +@router.get("/logs/live") +async def live_logs( + last_event_id: str | None = Header(default=None, alias="Last-Event-ID"), + _auth: AuthContext = Depends(require_system_scope), + service: LogService = Depends(get_service), +): + return _log_stream_response(last_event_id, service) + + +@router.get("/trace/settings") +async def get_trace_settings( + _auth: AuthContext = Depends(require_system_scope), + service: LogService = Depends(get_service), +): + return _get_trace_settings(service) + + +@router.put("/trace/settings") +async def update_trace_settings( + payload: TraceSettingsRequest, + _auth: AuthContext = Depends(require_system_scope), + service: LogService = Depends(get_service), +): + return _update_trace_settings(payload, service) + + +@legacy_router.get("/log-history") +async def get_dashboard_log_history( + _username: str = Depends(require_dashboard_user), + service: LogService = Depends(get_service), +): + return _get_log_history(service) + + +@legacy_router.get("/live-log") +async def get_dashboard_live_logs( + last_event_id: str | None = Header(default=None, alias="Last-Event-ID"), + _username: str = Depends(require_dashboard_user), + service: LogService = Depends(get_service), +): + return _log_stream_response(last_event_id, service) + + +@legacy_router.get("/trace/settings") +async def get_dashboard_trace_settings( + _username: str = Depends(require_dashboard_user), + service: LogService = Depends(get_service), +): + return _get_trace_settings(service) + + +@legacy_router.post("/trace/settings") +async def update_dashboard_trace_settings( + payload: TraceSettingsRequest, + _username: str = Depends(require_dashboard_user), + service: LogService = Depends(get_service), +): + return _update_trace_settings(payload, service) diff --git a/astrbot/dashboard/api/multipart.py b/astrbot/dashboard/api/multipart.py new file mode 100644 index 000000000..4979ddfb9 --- /dev/null +++ b/astrbot/dashboard/api/multipart.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from fastapi import Request +from starlette.datastructures import UploadFile as StarletteUploadFile + + +class UploadFileAdapter: + def __init__(self, upload_file: StarletteUploadFile) -> None: + self._upload_file = upload_file + self.filename = upload_file.filename + self.content_type = upload_file.content_type + self.headers = upload_file.headers + self.content_length = self._resolve_content_length() + + def _resolve_content_length(self) -> int | None: + try: + raw = self.headers.get("content-length") + return int(raw) if raw else None + except (TypeError, ValueError): + return None + + async def save(self, destination: str | Path) -> None: + path = Path(destination) + try: + await self._upload_file.seek(0) + except Exception: + pass + with path.open("wb") as output: + while True: + chunk = await self._upload_file.read(1024 * 1024) + if not chunk: + break + output.write(chunk) + + +class MultiDict: + def __init__(self, pairs: list[tuple[str, Any]]) -> None: + self._pairs = pairs + + def get(self, key: str, default: Any = None, type: Callable | None = None): + for item_key, item_value in reversed(self._pairs): + if item_key != key: + continue + if type is None: + return item_value + try: + return type(item_value) + except (TypeError, ValueError): + return default + return default + + def getlist(self, key: str) -> list[Any]: + return [item_value for item_key, item_value in self._pairs if item_key == key] + + def keys(self): + return dict.fromkeys(item_key for item_key, _ in self._pairs).keys() + + def values(self): + return [self[key] for key in self.keys()] + + def __contains__(self, key: str) -> bool: + return any(item_key == key for item_key, _ in self._pairs) + + def __getitem__(self, key: str): + value = self.get(key) + if value is None and key not in self: + raise KeyError(key) + return value + + def __bool__(self) -> bool: + return bool(self._pairs) + + +async def multipart_parts( + request: Request, + *, + extra_form: dict[str, Any] | None = None, +) -> tuple[MultiDict, MultiDict]: + form = await request.form() + form_pairs: list[tuple[str, Any]] = [] + file_pairs: list[tuple[str, Any]] = [] + for key, value in form.multi_items(): + if isinstance(value, StarletteUploadFile): + file_pairs.append((key, UploadFileAdapter(value))) + else: + form_pairs.append((key, value)) + form_data = MultiDict(form_pairs) + for key, value in (extra_form or {}).items(): + if value is not None and key not in form_data: + form_pairs.append((key, value)) + return MultiDict(form_pairs), MultiDict(file_pairs) + + +async def single_upload( + request: Request, + *, + field_name: str = "file", +) -> UploadFileAdapter | None: + _, files = await multipart_parts(request) + upload = files.get(field_name) + if isinstance(upload, UploadFileAdapter): + return upload + return None diff --git a/astrbot/dashboard/api/open_api.py b/astrbot/dashboard/api/open_api.py new file mode 100644 index 000000000..6a4b03530 --- /dev/null +++ b/astrbot/dashboard/api/open_api.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +from typing import Any + +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 +from astrbot.dashboard.schemas import ImMessageRequest, OpenApiChatRequest +from astrbot.dashboard.services.chat_service import ( + ChatService, + ChatServiceError, + extract_web_search_refs, +) +from astrbot.dashboard.services.open_api_service import ( + OpenApiService, + OpenApiServiceError, + OpenApiWebSocketChatBridge, +) + +from .auth import AuthContext, require_scope +from .multipart import UploadFileAdapter + +router = APIRouter(tags=["Open API"]) + + +async def require_im_scope(request: Request) -> AuthContext: + return await require_scope(request, "im") + + +async def require_chat_scope(request: Request) -> AuthContext: + return await require_scope(request, "chat") + + +async def require_config_scope(request: Request) -> AuthContext: + return await require_scope(request, "config") + + +async def require_file_scope(request: Request) -> AuthContext: + return await require_scope(request, "file") + + +def get_service(request: Request) -> OpenApiService: + return request.app.state.services.open_api + + +def get_chat_service(request: Request) -> ChatService: + return request.app.state.services.chat + + +def _model_dict(payload) -> dict[str, Any]: + if payload is None: + return {} + if hasattr(payload, "model_dump"): + return payload.model_dump(exclude_unset=True, exclude_none=False) + return payload if isinstance(payload, dict) else {} + + +def _open_api_error(message: str) -> JSONResponse: + return JSONResponse(error(message)) + + +def _get_chat_config_list(service: OpenApiService) -> list[dict]: + return service.get_chat_config_list() + + +async def _build_streaming_chat_response( + chat_service: ChatService, + username: str, + post_data: dict[str, Any], +) -> StreamingResponse | JSONResponse: + try: + stream = await chat_service.build_chat_stream(username, post_data) + except ChatServiceError as exc: + return _open_api_error(str(exc)) + + return StreamingResponse( + stream, + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + }, + ) + + +async def _open_api_chat_response( + post_data: dict[str, Any], + auth: AuthContext, + open_api_service: OpenApiService, + chat_service: ChatService, +) -> StreamingResponse | JSONResponse: + if auth.via != "api_key": + return await _build_streaming_chat_response( + chat_service, + auth.username, + post_data, + ) + + try: + ( + effective_username, + session_id, + config_id, + ) = await open_api_service.prepare_chat_send( + post_data, + _get_chat_config_list(open_api_service), + ) + except OpenApiServiceError as exc: + return _open_api_error(str(exc)) + + config_err = await open_api_service.update_session_config_route( + username=effective_username, + session_id=session_id, + config_id=config_id, + ) + if config_err: + return _open_api_error(config_err) + + return await _build_streaming_chat_response( + chat_service, + effective_username, + post_data, + ) + + +async def _insert_webchat_user_message( + service: OpenApiService, + session_id: str, + effective_username: str, + message_parts: list, +) -> None: + await service.insert_webchat_user_message( + session_id=session_id, + effective_username=effective_username, + message_parts=message_parts, + ) + + +def _build_chat_ws_bridge( + open_api_service: OpenApiService, + chat_service: ChatService, +) -> OpenApiWebSocketChatBridge: + return OpenApiWebSocketChatBridge( + build_user_message_parts=lambda message: chat_service.build_user_message_parts( + message if isinstance(message, str | list) else str(message), + ), + create_attachment_from_file=chat_service.create_attachment_from_file, + extract_web_search_refs=extract_web_search_refs, + insert_user_message=lambda session_id, effective_username, message_parts: ( + _insert_webchat_user_message( + open_api_service, + session_id, + effective_username, + message_parts, + ) + ), + save_bot_message=chat_service.save_bot_message, + ) + + +def _extract_ws_api_key(websocket: WebSocket) -> str | None: + if key := websocket.query_params.get("api_key"): + return key.strip() + if key := websocket.query_params.get("key"): + return key.strip() + if key := websocket.headers.get("X-API-Key"): + return key.strip() + + auth_header = websocket.headers.get("Authorization", "").strip() + if auth_header.startswith("Bearer "): + return auth_header.removeprefix("Bearer ").strip() + if auth_header.startswith("ApiKey "): + return auth_header.removeprefix("ApiKey ").strip() + return None + + +@router.post("/chat") +async def chat( + payload: OpenApiChatRequest, + auth: AuthContext = Depends(require_chat_scope), + service: OpenApiService = Depends(get_service), + chat_service: ChatService = Depends(get_chat_service), +): + return await _open_api_chat_response( + _model_dict(payload), + auth, + service, + chat_service, + ) + + +@router.get("/chat/sessions") +async def chat_sessions( + request: Request, + auth: AuthContext = Depends(require_chat_scope), + service: OpenApiService = Depends(get_service), + chat_service: ChatService = Depends(get_chat_service), +): + if auth.via != "api_key": + try: + return ok( + await chat_service.get_sessions( + auth.username, + request.query_params.get("platform_id"), + ) + ) + except ChatServiceError as exc: + return error(str(exc)) + + try: + return ok( + await service.get_chat_sessions_from_dashboard_query( + username=request.query_params.get("username"), + page=request.query_params.get("page", 1), + page_size=request.query_params.get("page_size", 20), + platform_id=request.query_params.get("platform_id"), + ) + ) + except OpenApiServiceError as exc: + return error(str(exc)) + + +@router.get("/configs", include_in_schema=False) +async def get_chat_configs( + _auth: AuthContext = Depends(require_config_scope), + service: OpenApiService = Depends(get_service), +): + return ok(service.get_chat_configs()) + + +@router.post( + "/file", + summary="Upload a file", + operation_id="uploadOpenApiFile", + openapi_extra={"x-astrbot-scope": "file"}, +) +async def upload_open_api_file( + file: UploadFile = File(...), + _auth: AuthContext = Depends(require_file_scope), + chat_service: ChatService = Depends(get_chat_service), +): + try: + return ok(await chat_service.save_uploaded_file(UploadFileAdapter(file))) + except ChatServiceError as exc: + return error(str(exc)) + + +@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( + 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(attachment_id) + return FileResponse(file_path, media_type=mimetype) + except ChatServiceError as exc: + return _open_api_error(str(exc)) + except (FileNotFoundError, OSError): + return _open_api_error("File access error") + + +@router.websocket("/chat/ws") +async def chat_ws(websocket: WebSocket) -> None: + await websocket.accept() + service: OpenApiService = websocket.app.state.services.open_api + chat_service: ChatService = websocket.app.state.services.chat + + async def close_ws(code: int, reason: str) -> None: + await websocket.close(code=code, reason=reason) + + await service.run_chat_websocket( + raw_api_key=_extract_ws_api_key(websocket), + receive_json=websocket.receive_json, + send_json=websocket.send_json, + close=close_ws, + conf_list=_get_chat_config_list(service), + chat_bridge=_build_chat_ws_bridge(service, chat_service), + ) + + +@router.post("/im/messages") +async def send_im_message( + payload: ImMessageRequest, + _auth: AuthContext = Depends(require_im_scope), + service: OpenApiService = Depends(get_service), +): + body = _model_dict(payload) + try: + await service.send_message(body) + except OpenApiServiceError as exc: + raise ApiError(str(exc)) from exc + + return ok() + + +@router.post("/im/message", include_in_schema=False) +async def send_im_message_alias( + payload: ImMessageRequest, + auth: AuthContext = Depends(require_im_scope), + service: OpenApiService = Depends(get_service), +): + return await send_im_message(payload, auth, service) + + +@router.get("/im/bots") +async def list_im_bots( + _request: Request, + _auth: AuthContext = Depends(require_im_scope), + service: OpenApiService = Depends(get_service), +): + return ok(service.get_bots()) diff --git a/astrbot/dashboard/api/personas.py b/astrbot/dashboard/api/personas.py new file mode 100644 index 000000000..00bdeba6a --- /dev/null +++ b/astrbot/dashboard/api/personas.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Query, Request + +from astrbot.dashboard.async_utils import run_maybe_async +from astrbot.dashboard.responses import ApiError, ok +from astrbot.dashboard.schemas import ( + PersonaByIdRequest, + PersonaFolderRequest, + PersonaMoveRequest, + PersonaReorderRequest, + PersonaRequest, +) +from astrbot.dashboard.services.persona_service import ( + PersonaService, + PersonaServiceError, +) + +from .auth import AuthContext, require_dashboard_user, require_scope + +router = APIRouter(tags=["Personas"]) +legacy_router = APIRouter( + prefix="/api/persona", + tags=["Dashboard Personas"], + include_in_schema=False, +) + + +def get_service(request: Request) -> PersonaService: + return request.app.state.services.personas + + +async def require_persona_scope(request: Request) -> AuthContext: + return await require_scope(request, "persona") + + +async def _json_or_empty(request: Request) -> dict[str, Any]: + try: + data = await request.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _model_dict(payload) -> dict[str, Any]: + """Serialize a request model while preserving explicit null updates. + + Args: + payload: Pydantic request model. + + Returns: + Request data without fields omitted by the caller. + """ + return payload.model_dump(exclude_unset=True) + + +def _raise_persona_error(exc: PersonaServiceError | ValueError) -> None: + raise ApiError(str(exc)) from exc + + +async def _run(operation): + try: + result = await run_maybe_async(operation) + return ok(result) + except (PersonaServiceError, ValueError) as exc: + _raise_persona_error(exc) + + +@router.get("/personas/tree") +async def persona_tree( + _auth: AuthContext = Depends(require_persona_scope), + service: PersonaService = Depends(get_service), +): + return await _run(service.get_folder_tree) + + +@router.get("/personas") +async def list_personas( + request: Request, + folder_id: str | None = Query(default=None), + _auth: AuthContext = Depends(require_persona_scope), + service: PersonaService = Depends(get_service), +): + return await _run( + lambda: service.list_personas(folder_id, "folder_id" in request.query_params) + ) + + +@router.post("/personas") +async def create_persona( + payload: PersonaRequest, + _auth: AuthContext = Depends(require_persona_scope), + service: PersonaService = Depends(get_service), +): + return await _run(lambda: service.create_persona(_model_dict(payload))) + + +@router.get("/personas/by-id") +async def get_persona_by_id( + persona_id: str = Query(...), + _auth: AuthContext = Depends(require_persona_scope), + service: PersonaService = Depends(get_service), +): + return await _run(lambda: service.get_persona_detail({"persona_id": persona_id})) + + +@router.put("/personas/by-id") +async def update_persona_by_id( + payload: PersonaByIdRequest, + _auth: AuthContext = Depends(require_persona_scope), + service: PersonaService = Depends(get_service), +): + return await _run(lambda: service.update_persona(_model_dict(payload))) + + +@router.delete("/personas/by-id") +async def delete_persona_by_id( + persona_id: str = Query(...), + _auth: AuthContext = Depends(require_persona_scope), + service: PersonaService = Depends(get_service), +): + return await _run(lambda: service.delete_persona({"persona_id": persona_id})) + + +@router.post("/personas/move") +async def move_persona( + payload: PersonaMoveRequest, + _auth: AuthContext = Depends(require_persona_scope), + service: PersonaService = Depends(get_service), +): + return await _run(lambda: service.move_persona(_model_dict(payload))) + + +@router.post("/personas/reorder") +async def reorder_personas( + payload: PersonaReorderRequest, + _auth: AuthContext = Depends(require_persona_scope), + service: PersonaService = Depends(get_service), +): + return await _run(lambda: service.reorder_items(_model_dict(payload))) + + +@router.get("/persona-folders") +async def list_persona_folders( + parent_id: str | None = Query(default=None), + _auth: AuthContext = Depends(require_persona_scope), + service: PersonaService = Depends(get_service), +): + return await _run(lambda: service.list_folders(parent_id)) + + +@router.post("/persona-folders") +async def create_persona_folder( + payload: PersonaFolderRequest, + _auth: AuthContext = Depends(require_persona_scope), + service: PersonaService = Depends(get_service), +): + return await _run(lambda: service.create_folder(_model_dict(payload))) + + +@router.put("/persona-folders/{folder_id:path}") +async def update_persona_folder( + folder_id: str, + payload: PersonaFolderRequest, + _auth: AuthContext = Depends(require_persona_scope), + service: PersonaService = Depends(get_service), +): + return await _run( + lambda: service.update_folder({"folder_id": folder_id, **_model_dict(payload)}) + ) + + +@router.delete("/persona-folders/{folder_id:path}") +async def delete_persona_folder( + folder_id: str, + _auth: AuthContext = Depends(require_persona_scope), + service: PersonaService = Depends(get_service), +): + return await _run(lambda: service.delete_folder({"folder_id": folder_id})) + + +@router.get("/personas/{persona_id:path}") +async def get_persona( + persona_id: str, + _auth: AuthContext = Depends(require_persona_scope), + service: PersonaService = Depends(get_service), +): + return await _run(lambda: service.get_persona_detail({"persona_id": persona_id})) + + +@router.put("/personas/{persona_id:path}") +async def update_persona( + persona_id: str, + payload: PersonaRequest, + _auth: AuthContext = Depends(require_persona_scope), + service: PersonaService = Depends(get_service), +): + return await _run( + lambda: service.update_persona( + {"persona_id": persona_id, **_model_dict(payload)} + ) + ) + + +@router.delete("/personas/{persona_id:path}") +async def delete_persona( + persona_id: str, + _auth: AuthContext = Depends(require_persona_scope), + service: PersonaService = Depends(get_service), +): + return await _run(lambda: service.delete_persona({"persona_id": persona_id})) + + +@legacy_router.get("/list") +async def list_dashboard_personas( + request: Request, + folder_id: str | None = Query(default=None), + _username: str = Depends(require_dashboard_user), + service: PersonaService = Depends(get_service), +): + return await _run( + lambda: service.list_personas(folder_id, "folder_id" in request.query_params) + ) + + +@legacy_router.post("/detail") +async def get_dashboard_persona_detail( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PersonaService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.get_persona_detail(body)) + + +@legacy_router.post("/create") +async def create_dashboard_persona( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PersonaService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.create_persona(body)) + + +@legacy_router.post("/update") +async def update_dashboard_persona( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PersonaService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.update_persona(body)) + + +@legacy_router.post("/delete") +async def delete_dashboard_persona( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PersonaService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.delete_persona(body)) + + +@legacy_router.post("/move") +async def move_dashboard_persona( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PersonaService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.move_persona(body)) + + +@legacy_router.post("/reorder") +async def reorder_dashboard_personas( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PersonaService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.reorder_items(body)) + + +@legacy_router.get("/folder/list") +async def list_dashboard_persona_folders( + parent_id: str | None = Query(default=None), + _username: str = Depends(require_dashboard_user), + service: PersonaService = Depends(get_service), +): + return await _run(lambda: service.list_folders(parent_id)) + + +@legacy_router.get("/folder/tree") +async def get_dashboard_persona_folder_tree( + _username: str = Depends(require_dashboard_user), + service: PersonaService = Depends(get_service), +): + return await _run(service.get_folder_tree) + + +@legacy_router.post("/folder/detail") +async def get_dashboard_persona_folder_detail( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PersonaService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.get_folder_detail(body)) + + +@legacy_router.post("/folder/create") +async def create_dashboard_persona_folder( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PersonaService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.create_folder(body)) + + +@legacy_router.post("/folder/update") +async def update_dashboard_persona_folder( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PersonaService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.update_folder(body)) + + +@legacy_router.post("/folder/delete") +async def delete_dashboard_persona_folder( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PersonaService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.delete_folder(body)) diff --git a/astrbot/dashboard/api/platform.py b/astrbot/dashboard/api/platform.py new file mode 100644 index 000000000..c6a6f7f55 --- /dev/null +++ b/astrbot/dashboard/api/platform.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Request + +from astrbot.dashboard.asgi_runtime import DashboardRequest +from astrbot.dashboard.async_utils import run_maybe_async +from astrbot.dashboard.responses import ApiError, ok +from astrbot.dashboard.schemas import BotRegistrationRequest +from astrbot.dashboard.services.platform_service import ( + PlatformService, + PlatformServiceError, +) + +from .auth import AuthContext, require_dashboard_user, require_scope + +router = APIRouter(tags=["Platforms"]) +legacy_router = APIRouter( + prefix="/api/platform", + tags=["Dashboard Platforms"], + include_in_schema=False, +) + + +def get_service(request: Request) -> PlatformService: + return request.app.state.services.platforms + + +async def require_config_scope(request: Request) -> AuthContext: + return await require_scope(request, "config") + + +async def _json_or_empty(request: Request) -> dict[str, Any]: + try: + data = await request.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _raise_platform_error(exc: PlatformServiceError) -> None: + raise ApiError(str(exc), status_code=exc.status_code) from exc + + +def _model_dict(payload) -> dict[str, Any]: + return payload.model_dump(exclude_none=True) + + +async def _run(operation): + try: + result = await run_maybe_async(operation) + return ok(result) + except PlatformServiceError as exc: + _raise_platform_error(exc) + + +@router.post("/bot-types/{bot_type}/registration") +async def register_bot_type( + bot_type: str, + payload: BotRegistrationRequest, + _auth: AuthContext = Depends(require_config_scope), + service: PlatformService = Depends(get_service), +): + return await _run( + lambda: service.handle_platform_registration(bot_type, _model_dict(payload)) + ) + + +@router.get("/webhooks/platforms/{webhook_uuid}") +async def verify_platform_webhook( + webhook_uuid: str, + request: Request, + service: PlatformService = Depends(get_service), +): + return await _run( + lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request)) + ) + + +@router.post("/webhooks/platforms/{webhook_uuid}") +async def receive_platform_webhook( + webhook_uuid: str, + request: Request, + service: PlatformService = Depends(get_service), +): + return await _run( + lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request)) + ) + + +@legacy_router.api_route("/webhook/{webhook_uuid}", methods=["GET", "POST"]) +async def dashboard_platform_webhook( + webhook_uuid: str, + request: Request, + service: PlatformService = Depends(get_service), +): + return await _run( + lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request)) + ) + + +@legacy_router.get("/stats") +async def get_dashboard_platform_stats( + _username: str = Depends(require_dashboard_user), + service: PlatformService = Depends(get_service), +): + return await _run(service.get_platform_stats) + + +@legacy_router.post("/registration/{platform_type}") +async def handle_dashboard_platform_registration( + platform_type: str, + request: Request, + _username: str = Depends(require_dashboard_user), + service: PlatformService = Depends(get_service), +): + payload = await _json_or_empty(request) + return await _run( + lambda: service.handle_platform_registration(platform_type, payload) + ) diff --git a/astrbot/dashboard/api/plugins.py b/astrbot/dashboard/api/plugins.py new file mode 100644 index 000000000..7c4dadaeb --- /dev/null +++ b/astrbot/dashboard/api/plugins.py @@ -0,0 +1,1426 @@ +from __future__ import annotations + +import re +from collections.abc import Callable +from typing import Any +from urllib.parse import quote + +from fastapi import APIRouter, Body, Depends, Query, Request +from fastapi.responses import PlainTextResponse, Response + +from astrbot.api.web import PluginRequest, bind_request_context +from astrbot.core import logger +from astrbot.dashboard.asgi_runtime import ( + DashboardRequestState, + call_request_view, +) +from astrbot.dashboard.async_utils import run_maybe_async +from astrbot.dashboard.responses import ok +from astrbot.dashboard.schemas import ( + EnabledPatch, + PluginByIdRequest, + PluginConfigFileDeleteRequest, + PluginConfigPayload, + PluginConfigUpdateRequest, + PluginEnabledRequest, + PluginInstallRequest, + PluginSourceRequest, + PluginUninstallRequest, + PluginUpdateRequest, + PluginVersionSupportRequest, +) +from astrbot.dashboard.services.config_service import ( + ConfigDisplayService, + ConfigFileService, +) +from astrbot.dashboard.services.plugin_page_service import ( + PluginPageContentPayload, + PluginPageService, + PluginPageServiceError, +) +from astrbot.dashboard.services.plugin_service import ( + PLUGIN_OPERATION_FAILED_MESSAGE, + PluginService, + PluginServiceError, + PluginServiceWarning, +) + +from .auth import AuthContext, require_dashboard_user, require_scope +from .multipart import multipart_parts + +router = APIRouter(tags=["Plugins"]) +legacy_router = APIRouter(tags=["Dashboard Plugins"], include_in_schema=False) + + +async def require_plugin_scope(request: Request) -> AuthContext: + return await require_scope(request, "plugin") + + +def get_service(request: Request) -> PluginService: + return request.app.state.services.plugins + + +def get_page_service(request: Request) -> PluginPageService: + return request.app.state.services.plugin_pages + + +def get_config_display_service(request: Request) -> ConfigDisplayService: + return request.app.state.services.config_display + + +def get_config_file_service(request: Request) -> ConfigFileService: + return request.app.state.services.config_files + + +async def _json_or_empty(request: Request) -> dict[str, Any]: + try: + data = await request.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _required_text(value: object, name: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError(f"Missing key: {name}") + return text + + +def _plugin_id_from_body(body: dict[str, Any]) -> str: + return _required_text(body.get("plugin_id"), "plugin_id") + + +def _model_dict(payload) -> dict[str, Any]: + if payload is None: + return {} + if hasattr(payload, "model_dump"): + return payload.model_dump(exclude_none=True) + return payload if isinstance(payload, dict) else {} + + +def _service_ok(result): + if isinstance(result, tuple): + data, message = result + return ok(data, message) + return ok(result) + + +async def _run_service(operation, *, log_label: str | None = None): + try: + result = await run_maybe_async(operation) + return _service_ok(result) + except PluginServiceWarning as exc: + return { + "status": "warning", + "message": exc.public_message, + "data": exc.data, + } + except PluginServiceError as exc: + return {"status": "error", "message": exc.public_message, "data": {}} + except Exception: + if log_label: + logger.error("%s failed", log_label, exc_info=True) + else: + logger.error("Plugin service operation failed", exc_info=True) + return { + "status": "error", + "message": PLUGIN_OPERATION_FAILED_MESSAGE, + "data": {}, + } + + +async def _run_json( + request: Request, + operation: Callable[[dict[str, Any]], Any], + *, + log_label: str | None = None, +): + body = await _json_or_empty(request) + return await _run_service(lambda: operation(body), log_label=log_label) + + +def _normalize_plugin_api_route(route: str) -> str: + route = route.strip() + if not route.startswith("/"): + route = f"/{route}" + return route + + +def _plugin_api_route_pattern(route: str) -> str: + normalized = _normalize_plugin_api_route(route) + chunks = [] + pos = 0 + for match in re.finditer(r"<(?:(path):)?([A-Za-z_][A-Za-z0-9_]*)>", normalized): + chunks.append(re.escape(normalized[pos : match.start()])) + name = match.group(2) + chunks.append(f"(?P<{name}>.*)" if match.group(1) else f"(?P<{name}>[^/]+)") + pos = match.end() + chunks.append(re.escape(normalized[pos:])) + return "".join(chunks) + + +def _match_registered_web_api(registered_web_apis, subpath: str, method: str): + request_path = f"/{subpath.lstrip('/')}" + request_method = method.upper() + + for route, view_handler, methods, _ in registered_web_apis: + allowed_methods = [item.upper() for item in methods] + if request_method not in allowed_methods: + continue + + pattern = _plugin_api_route_pattern(route) + matched = re.fullmatch(pattern, request_path) + if matched: + return view_handler, matched.groupdict() + return None + + +def _plugin_extension_legacy_path(plugin_path: str, request: Request) -> str: + encoded_path = quote(plugin_path.lstrip("/"), safe="/:@!$&'()*+,;=-._~") + path = f"/api/plug/{encoded_path}" + if request.url.query: + return f"{path}?{request.url.query}" + return path + + +async def _call_plugin_extension( + plugin_path: str, + request: Request, + username: str, +): + registered_web_apis = ( + request.app.state.core_lifecycle.star_context.registered_web_apis + ) + matched_api = _match_registered_web_api( + registered_web_apis, + plugin_path, + request.method, + ) + if not matched_api: + return {"status": "error", "message": "未找到该路由", "data": {}} + + view_handler, path_values = matched_api + plugin_name = plugin_path.strip("/").split("/", 1)[0].strip() or None + plugin_request = PluginRequest( + request, + path_params=path_values, + plugin_name=plugin_name, + username=username, + ) + app_adapter = getattr(request.app.state, "dashboard_app_adapter", None) + if app_adapter is None: + with bind_request_context(plugin_request): + return await run_maybe_async(lambda: view_handler(**path_values)) + + g_obj = DashboardRequestState() + g_obj.username = username + with bind_request_context(plugin_request): + return await call_request_view( + request, + app_adapter, + view_handler, + path_values, + g_obj=g_obj, + quart_compat_path=_plugin_extension_legacy_path(plugin_path, request), + ) + + +def _get_request_locale(request: Request, 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_request_theme(request: Request) -> str | None: + theme = request.query_params.get("theme", "").strip() + return theme if theme in ("dark", "light") else None + + +def _plugin_page_error_response(status_code: int, message: str): + return PlainTextResponse( + message, + status_code=status_code, + headers={ + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + }, + ) + + +def _plugin_page_payload_response(payload: PluginPageContentPayload): + return Response( + content=payload.content, + media_type=payload.content_type, + headers=PluginPageService.build_security_headers(), + ) + + +async def _serve_plugin_page_content( + *, + request: Request, + page_service: PluginPageService, + username: str | None, + plugin_id: str, + page_name: str, + asset_path: str, +): + try: + payload = await page_service.serve_page_content( + plugin_name=plugin_id, + page_name=page_name, + asset_path=asset_path, + asset_token=request.query_params.get("asset_token", "").strip(), + username=username, + locale=_get_request_locale(request), + theme=_get_request_theme(request), + ) + except PluginPageServiceError as exc: + return _plugin_page_error_response(exc.status_code, exc.public_message) + return _plugin_page_payload_response(payload) + + +async def _serve_plugin_page_bridge_sdk( + *, + request: Request, + page_service: PluginPageService, +): + try: + payload = await page_service.serve_bridge_sdk( + asset_token=request.query_params.get("asset_token", "").strip(), + locale=_get_request_locale(request), + theme=_get_request_theme(request), + ) + except PluginPageServiceError as exc: + return _plugin_page_error_response(exc.status_code, exc.public_message) + return _plugin_page_payload_response(payload) + + +async def _get_plugin_page_entry_config( + *, + request: Request, + page_service: PluginPageService, + username: str | None, + plugin_id: str | None, + page_name: str | None, +): + try: + return ok( + await page_service.get_plugin_page_entry_config( + plugin_name=plugin_id, + page_name=page_name, + username=username, + locale=_get_request_locale(request), + ) + ) + except PluginPageServiceError as exc: + return {"status": "error", "message": exc.public_message, "data": {}} + + +async def _list_plugins( + *, + request: Request, + service: PluginService, + page_service: PluginPageService, +): + return await _run_service( + service.list_plugins_from_dashboard_query( + plugin_name=request.query_params.get("name") + or request.query_params.get("plugin_id"), + logo_token_resolver=service.get_plugin_logo_token, + installed_at_resolver=service.get_plugin_installed_at, + discover_pages=page_service.discover_plugin_pages, + ), + log_label="/api/plugin/get", + ) + + +async def _get_plugin_detail( + *, + plugin_id: str | None, + service: PluginService, + page_service: PluginPageService, +): + return await _run_service( + service.get_plugin_detail( + plugin_name=plugin_id, + logo_token_resolver=service.get_plugin_logo_token, + installed_at_resolver=service.get_plugin_installed_at, + serialize_pages=page_service.serialize_plugin_pages, + ), + log_label="/api/plugin/detail", + ) + + +async def _install_plugin_upload( + request: Request, + service: PluginService, + *, + log_label: str, +): + async def operation(): + form, files = await multipart_parts(request) + upload_file = files.get("file") + if upload_file is None: + raise PluginServiceError("缺少插件文件") + return await service.install_plugin_upload_from_dashboard_form( + upload_file=upload_file, + ignore_version_check=form.get("ignore_version_check", "false"), + ) + + return await _run_service(operation, log_label=log_label) + + +@router.get("/plugins/extensions/{plugin_path:path}") +async def get_plugin_extension_route( + plugin_path: str, + request: Request, + auth: AuthContext = Depends(require_plugin_scope), +): + return await _call_plugin_extension(plugin_path, request, auth.username) + + +@router.post("/plugins/extensions/{plugin_path:path}") +async def post_plugin_extension_route( + plugin_path: str, + request: Request, + auth: AuthContext = Depends(require_plugin_scope), +): + return await _call_plugin_extension(plugin_path, request, auth.username) + + +@router.put("/plugins/extensions/{plugin_path:path}") +async def put_plugin_extension_route( + plugin_path: str, + request: Request, + auth: AuthContext = Depends(require_plugin_scope), +): + return await _call_plugin_extension(plugin_path, request, auth.username) + + +@router.patch("/plugins/extensions/{plugin_path:path}") +async def patch_plugin_extension_route( + plugin_path: str, + request: Request, + auth: AuthContext = Depends(require_plugin_scope), +): + return await _call_plugin_extension(plugin_path, request, auth.username) + + +@router.delete("/plugins/extensions/{plugin_path:path}") +async def delete_plugin_extension_route( + plugin_path: str, + request: Request, + auth: AuthContext = Depends(require_plugin_scope), +): + return await _call_plugin_extension(plugin_path, request, auth.username) + + +@router.get("/plugins/failed") +async def list_failed_plugins( + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return await _run_service(service.get_failed_plugins) + + +@router.post("/plugins/update") +async def update_plugins( + payload: PluginUpdateRequest, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + body = _model_dict(payload) + if body.get("plugin_id"): + plugin_id = _plugin_id_from_body(body) + return await _run_service( + service.update_plugin( + { + "name": plugin_id, + **{key: value for key, value in body.items() if key != "plugin_id"}, + } + ), + log_label="/api/plugin/update", + ) + return await _run_service( + service.update_all_plugins( + { + **body, + "names": body.get("names") or body.get("plugin_ids") or [], + } + ), + log_label="/api/plugin/update-all", + ) + + +async def _check_plugin_version_support_payload( + payload: dict[str, Any], + service: PluginService, +): + return await _run_service( + lambda: service.check_plugin_version_support(payload), + log_label="/api/plugin/version-support/check", + ) + + +async def _check_plugin_version_support_request( + request: Request, + service: PluginService, +): + return await _check_plugin_version_support_payload( + await _json_or_empty(request), + service, + ) + + +@router.post("/plugins/version-support/check") +async def check_plugin_version_support( + payload: PluginVersionSupportRequest, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return await _check_plugin_version_support_payload(_model_dict(payload), service) + + +@router.post("/plugins/install/github") +async def install_plugin_from_github( + payload: PluginInstallRequest, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + body = _model_dict(payload) + repository = str(body.get("repository") or body.get("url") or "").strip() + if repository and not repository.startswith(("http://", "https://")): + repository = f"https://github.com/{repository}" + install_payload = { + "url": repository, + "proxy": body.get("proxy"), + "ignore_version_check": body.get("ignore_version_check", False), + } + if body.get("download_url"): + install_payload["download_url"] = body["download_url"] + return await _run_service( + service.install_plugin(install_payload), + log_label="/api/plugin/install", + ) + + +@router.post("/plugins/install/url") +async def install_plugin_from_url( + payload: PluginInstallRequest | None = Body(default=None), + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + body = _model_dict(payload) + url = str(body.get("url") or body.get("repository") or "").strip() + download_url = str(body.get("download_url") or url).strip() + return await _run_service( + service.install_plugin( + { + "url": url or download_url, + "download_url": download_url, + "proxy": body.get("proxy"), + "ignore_version_check": body.get("ignore_version_check", False), + } + ), + log_label="/api/plugin/install", + ) + + +@router.post("/plugins/install/upload") +async def install_plugin_from_upload( + request: Request, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return await _install_plugin_upload( + request, + service, + log_label="/api/plugin/install-upload", + ) + + +@router.get("/plugins/market") +async def list_plugin_market( + request: Request, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return await _run_service( + service.get_online_plugins_from_dashboard_query( + custom_registry=request.query_params.get("custom_registry"), + force_refresh=request.query_params.get("force_refresh", "false"), + ), + log_label="/api/plugin/market_list", + ) + + +@router.get("/plugins/market/categories") +async def list_plugin_market_categories( + _auth: AuthContext = Depends(require_plugin_scope), +): + return ok({"categories": []}) + + +@router.get("/plugin-sources") +async def list_plugin_sources( + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return ok({"sources": await service.get_custom_sources()}) + + +@router.post("/plugin-sources") +async def create_plugin_source( + payload: PluginSourceRequest, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return ok( + {"sources": await service.create_custom_source(_model_dict(payload))}, + message="保存成功", + ) + + +@router.put("/plugin-sources") +async def replace_plugin_sources( + payload: PluginSourceRequest, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return ok( + {"sources": await service.replace_custom_sources(_model_dict(payload))}, + message="保存成功", + ) + + +@router.delete("/plugin-sources/by-id") +async def delete_plugin_source_by_id( + source_id: str = Query(...), + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return ok( + {"sources": await service.delete_custom_source(source_id)}, + message="保存成功", + ) + + +@router.delete("/plugin-sources/{source_id}") +async def delete_plugin_source( + source_id: str, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return ok( + {"sources": await service.delete_custom_source(source_id)}, + message="保存成功", + ) + + +@router.get("/plugins/page-bridge-sdk.js") +async def get_plugin_page_bridge_sdk( + request: Request, + _auth: AuthContext = Depends(require_plugin_scope), + page_service: PluginPageService = Depends(get_page_service), +): + return await _serve_plugin_page_bridge_sdk( + request=request, + page_service=page_service, + ) + + +@router.get("/plugins") +async def list_plugins( + request: Request, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), + page_service: PluginPageService = Depends(get_page_service), +): + return await _list_plugins( + request=request, + service=service, + page_service=page_service, + ) + + +@router.get("/plugins/by-id") +async def get_plugin_by_id( + plugin_id: str = Query(...), + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), + page_service: PluginPageService = Depends(get_page_service), +): + return await _get_plugin_detail( + plugin_id=plugin_id, + service=service, + page_service=page_service, + ) + + +@router.delete("/plugins/by-id") +async def uninstall_plugin_by_id( + payload: PluginUninstallRequest | None = None, + plugin_id: str = Query(...), + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + body = _model_dict(payload) + return await _run_service( + service.uninstall_plugin({"name": plugin_id, **body}), + log_label="/api/plugin/uninstall", + ) + + +@router.get("/plugins/config") +async def get_plugin_config_by_id( + plugin_id: str = Query(...), + _auth: AuthContext = Depends(require_plugin_scope), + service: ConfigDisplayService = Depends(get_config_display_service), +): + return ok({"plugin_name": plugin_id, **await service.get_configs(plugin_id)}) + + +@router.put("/plugins/config") +async def update_plugin_config_by_id( + payload: PluginConfigUpdateRequest, + _auth: AuthContext = Depends(require_plugin_scope), + service: ConfigFileService = Depends(get_config_file_service), +): + body = _model_dict(payload) + plugin_id = _plugin_id_from_body(body) + config = body.get("config") + config = config if isinstance(config, dict) else {} + return ok( + message=await service.save_plugin_configs_from_dashboard_payload( + config, + plugin_name=plugin_id, + ) + ) + + +@router.get("/plugins/config/schema") +async def get_plugin_config_schema_by_id( + plugin_id: str = Query(...), + _auth: AuthContext = Depends(require_plugin_scope), + service: ConfigDisplayService = Depends(get_config_display_service), +): + return ok({"plugin_name": plugin_id, **await service.get_configs(plugin_id)}) + + +@router.get("/plugins/config-files") +async def list_plugin_config_files_by_id( + plugin_id: str = Query(...), + config_key: str = Query(...), + _auth: AuthContext = Depends(require_plugin_scope), + service: ConfigFileService = Depends(get_config_file_service), +): + return ok( + service.list_config_files( + scope="plugin", + name=plugin_id, + key_path=config_key, + ) + ) + + +@router.post("/plugins/config-files") +async def upload_plugin_config_files_by_id( + request: Request, + plugin_id: str = Query(...), + config_key: str = Query(...), + _auth: AuthContext = Depends(require_plugin_scope), + service: ConfigFileService = Depends(get_config_file_service), +): + _, files = await multipart_parts(request) + return ok( + await service.upload_config_file( + scope="plugin", + name=plugin_id, + key_path=config_key, + files=files, + ) + ) + + +@router.delete("/plugins/config-files") +async def delete_plugin_config_file_by_id( + payload: PluginConfigFileDeleteRequest | None = None, + plugin_id: str = Query(...), + _auth: AuthContext = Depends(require_plugin_scope), + service: ConfigFileService = Depends(get_config_file_service), +): + return ok( + message=service.delete_config_file_from_dashboard_payload( + scope="plugin", + name=plugin_id, + payload=_model_dict(payload), + ) + ) + + +@router.get("/plugins/readme") +async def get_plugin_readme_by_id( + plugin_id: str = Query(...), + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return await _run_service( + lambda: service.get_plugin_readme(plugin_id), + log_label="/api/plugin/readme", + ) + + +@router.get("/plugins/changelog") +async def get_plugin_changelog_by_id( + plugin_id: str = Query(...), + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return await _run_service( + lambda: service.get_plugin_changelog(plugin_id), + log_label="/api/plugin/changelog", + ) + + +@router.post("/plugins/reload") +async def reload_plugin_by_id( + payload: PluginByIdRequest, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + plugin_id = _plugin_id_from_body(_model_dict(payload)) + return await _run_service( + service.reload_plugin({"name": plugin_id}), + log_label="/api/plugin/reload", + ) + + +@router.patch("/plugins/enabled") +async def set_plugin_enabled_by_id( + payload: PluginEnabledRequest, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + body = _model_dict(payload) + plugin_id = _plugin_id_from_body(body) + return await _run_service( + service.set_plugin_enabled( + {"name": plugin_id}, enabled=bool(body.get("enabled")) + ), + log_label="/api/plugin/on" if body.get("enabled") else "/api/plugin/off", + ) + + +@router.get("/plugins/pages") +async def list_plugin_pages_by_id( + plugin_id: str = Query(...), + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), + page_service: PluginPageService = Depends(get_page_service), +): + return await _get_plugin_detail( + plugin_id=plugin_id, + service=service, + page_service=page_service, + ) + + +@router.get("/plugins/page") +async def get_plugin_page_by_id( + request: Request, + plugin_id: str = Query(...), + page_name: str = Query(...), + auth: AuthContext = Depends(require_plugin_scope), + page_service: PluginPageService = Depends(get_page_service), +): + return await _get_plugin_page_entry_config( + request=request, + page_service=page_service, + username=auth.username, + plugin_id=plugin_id, + page_name=page_name, + ) + + +@router.get("/plugins/page/assets") +async def get_plugin_page_asset_by_id( + request: Request, + plugin_id: str = Query(...), + page_name: str = Query(...), + asset_path: str = Query(...), + auth: AuthContext = Depends(require_plugin_scope), + page_service: PluginPageService = Depends(get_page_service), +): + return await _serve_plugin_page_content( + request=request, + page_service=page_service, + username=auth.username, + plugin_id=plugin_id, + page_name=page_name, + asset_path=asset_path, + ) + + +@router.get("/plugins/{plugin_id}") +async def get_plugin( + plugin_id: str, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), + page_service: PluginPageService = Depends(get_page_service), +): + return await _get_plugin_detail( + plugin_id=plugin_id, + service=service, + page_service=page_service, + ) + + +@router.delete("/plugins/{plugin_id}") +async def uninstall_plugin( + plugin_id: str, + payload: PluginUninstallRequest | None = None, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + body = _model_dict(payload) + return await _run_service( + service.uninstall_plugin({"name": plugin_id, **body}), + log_label="/api/plugin/uninstall", + ) + + +@router.delete("/plugins/failed/{plugin_id}") +async def uninstall_failed_plugin( + plugin_id: str, + payload: PluginUninstallRequest | None = None, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + body = _model_dict(payload) + return await _run_service( + service.uninstall_failed_plugin({"dir_name": plugin_id, **body}), + log_label="/api/plugin/uninstall-failed", + ) + + +@router.post("/plugins/failed/{plugin_id}/reload") +async def reload_failed_plugin( + plugin_id: str, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return await _run_service( + service.reload_failed_plugin({"dir_name": plugin_id}), + log_label="/api/plugin/reload-failed", + ) + + +@router.get("/plugins/{plugin_id}/config") +async def get_plugin_config( + plugin_id: str, + _auth: AuthContext = Depends(require_plugin_scope), + service: ConfigDisplayService = Depends(get_config_display_service), +): + return ok({"plugin_name": plugin_id, **await service.get_configs(plugin_id)}) + + +@router.put("/plugins/{plugin_id}/config") +async def update_plugin_config( + plugin_id: str, + payload: PluginConfigPayload, + _auth: AuthContext = Depends(require_plugin_scope), + service: ConfigFileService = Depends(get_config_file_service), +): + body = _model_dict(payload) + config = body.get("config") + config = config if isinstance(config, dict) else body + return ok( + message=await service.save_plugin_configs_from_dashboard_payload( + config, + plugin_name=plugin_id, + ) + ) + + +@router.get("/plugins/{plugin_id}/config/schema") +async def get_plugin_config_schema( + plugin_id: str, + _auth: AuthContext = Depends(require_plugin_scope), + service: ConfigDisplayService = Depends(get_config_display_service), +): + return ok({"plugin_name": plugin_id, **await service.get_configs(plugin_id)}) + + +@router.get("/plugins/{plugin_id}/config-files/{config_key:path}") +async def list_plugin_config_files( + plugin_id: str, + config_key: str, + _auth: AuthContext = Depends(require_plugin_scope), + service: ConfigFileService = Depends(get_config_file_service), +): + return ok( + service.list_config_files( + scope="plugin", + name=plugin_id, + key_path=config_key, + ) + ) + + +@router.post("/plugins/{plugin_id}/config-files/{config_key:path}") +async def upload_plugin_config_files( + plugin_id: str, + config_key: str, + request: Request, + _auth: AuthContext = Depends(require_plugin_scope), + service: ConfigFileService = Depends(get_config_file_service), +): + _, files = await multipart_parts(request) + return ok( + await service.upload_config_file( + scope="plugin", + name=plugin_id, + key_path=config_key, + files=files, + ) + ) + + +@router.delete("/plugins/{plugin_id}/config-files") +async def delete_plugin_config_file( + plugin_id: str, + payload: PluginConfigFileDeleteRequest | None = None, + _auth: AuthContext = Depends(require_plugin_scope), + service: ConfigFileService = Depends(get_config_file_service), +): + return ok( + message=service.delete_config_file_from_dashboard_payload( + scope="plugin", + name=plugin_id, + payload=_model_dict(payload), + ) + ) + + +@router.get("/plugins/{plugin_id}/readme") +async def get_plugin_readme( + plugin_id: str, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return await _run_service( + lambda: service.get_plugin_readme(plugin_id), + log_label="/api/plugin/readme", + ) + + +@router.get("/plugins/{plugin_id}/changelog") +async def get_plugin_changelog( + plugin_id: str, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return await _run_service( + lambda: service.get_plugin_changelog(plugin_id), + log_label="/api/plugin/changelog", + ) + + +@router.post("/plugins/{plugin_id}/reload") +async def reload_plugin( + plugin_id: str, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return await _run_service( + service.reload_plugin({"name": plugin_id}), + log_label="/api/plugin/reload", + ) + + +@router.patch("/plugins/{plugin_id}/enabled") +async def set_plugin_enabled( + plugin_id: str, + payload: EnabledPatch, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + return await _run_service( + service.set_plugin_enabled({"name": plugin_id}, enabled=payload.enabled), + log_label="/api/plugin/on" if payload.enabled else "/api/plugin/off", + ) + + +@router.post("/plugins/{plugin_id}/update") +async def update_plugin( + plugin_id: str, + payload: PluginUpdateRequest | None = None, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), +): + body = _model_dict(payload) + return await _run_service( + service.update_plugin({"name": plugin_id, **body}), + log_label="/api/plugin/update", + ) + + +@router.get("/plugins/{plugin_id}/pages") +async def list_plugin_pages( + plugin_id: str, + _auth: AuthContext = Depends(require_plugin_scope), + service: PluginService = Depends(get_service), + page_service: PluginPageService = Depends(get_page_service), +): + return await _get_plugin_detail( + plugin_id=plugin_id, + service=service, + page_service=page_service, + ) + + +@router.get("/plugins/{plugin_id}/pages/{page_name}") +async def get_plugin_page( + plugin_id: str, + page_name: str, + request: Request, + auth: AuthContext = Depends(require_plugin_scope), + page_service: PluginPageService = Depends(get_page_service), +): + return await _get_plugin_page_entry_config( + request=request, + page_service=page_service, + username=auth.username, + plugin_id=plugin_id, + page_name=page_name, + ) + + +@router.get("/plugins/{plugin_id}/pages/{page_name}/assets/{asset_path:path}") +async def get_plugin_page_asset( + plugin_id: str, + page_name: str, + asset_path: str, + request: Request, + auth: AuthContext = Depends(require_plugin_scope), + page_service: PluginPageService = Depends(get_page_service), +): + return await _serve_plugin_page_content( + request=request, + page_service=page_service, + username=auth.username, + plugin_id=plugin_id, + page_name=page_name, + asset_path=asset_path, + ) + + +@legacy_router.get("/api/plugin/get") +async def dashboard_list_plugins( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), + page_service: PluginPageService = Depends(get_page_service), +): + return await _list_plugins( + request=request, + service=service, + page_service=page_service, + ) + + +@legacy_router.get("/api/plugin/detail") +async def dashboard_get_plugin_detail( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), + page_service: PluginPageService = Depends(get_page_service), +): + return await _get_plugin_detail( + plugin_id=request.query_params.get("name"), + service=service, + page_service=page_service, + ) + + +@legacy_router.post("/api/plugin/check-compat") +async def dashboard_check_plugin_version_support( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _check_plugin_version_support_request(request, service) + + +@legacy_router.get("/api/plugin/page/entry") +async def dashboard_get_plugin_page_entry_config( + request: Request, + username: str = Depends(require_dashboard_user), + page_service: PluginPageService = Depends(get_page_service), +): + return await _get_plugin_page_entry_config( + request=request, + page_service=page_service, + username=username, + plugin_id=request.query_params.get("name"), + page_name=request.query_params.get("page"), + ) + + +@legacy_router.post("/api/plugin/install") +async def dashboard_install_plugin( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _run_json( + request, + service.install_plugin, + log_label="/api/plugin/install", + ) + + +@legacy_router.post("/api/plugin/install-upload") +async def dashboard_install_plugin_upload( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _install_plugin_upload( + request, + service, + log_label="/api/plugin/install-upload", + ) + + +@legacy_router.post("/api/plugin/update") +async def dashboard_update_plugin( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _run_json( + request, + service.update_plugin, + log_label="/api/plugin/update", + ) + + +@legacy_router.post("/api/plugin/update-all") +async def dashboard_update_all_plugins( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _run_json( + request, + service.update_all_plugins, + log_label="/api/plugin/update-all", + ) + + +@legacy_router.post("/api/plugin/uninstall") +async def dashboard_uninstall_plugin( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _run_json( + request, + service.uninstall_plugin, + log_label="/api/plugin/uninstall", + ) + + +@legacy_router.post("/api/plugin/uninstall-failed") +async def dashboard_uninstall_failed_plugin( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _run_json( + request, + service.uninstall_failed_plugin, + log_label="/api/plugin/uninstall-failed", + ) + + +@legacy_router.get("/api/plugin/market_list") +async def dashboard_list_plugin_market( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _run_service( + service.get_online_plugins_from_dashboard_query( + custom_registry=request.query_params.get("custom_registry"), + force_refresh=request.query_params.get("force_refresh", "false"), + ), + log_label="/api/plugin/market_list", + ) + + +@legacy_router.post("/api/plugin/off") +async def dashboard_disable_plugin( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _run_json( + request, + lambda data: service.set_plugin_enabled(data, enabled=False), + log_label="/api/plugin/off", + ) + + +@legacy_router.post("/api/plugin/on") +async def dashboard_enable_plugin( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _run_json( + request, + lambda data: service.set_plugin_enabled(data, enabled=True), + log_label="/api/plugin/on", + ) + + +@legacy_router.post("/api/plugin/reload-failed") +async def dashboard_reload_failed_plugin( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _run_json( + request, + service.reload_failed_plugin, + log_label="/api/plugin/reload-failed", + ) + + +@legacy_router.post("/api/plugin/reload") +async def dashboard_reload_plugin( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _run_json( + request, + service.reload_plugin, + log_label="/api/plugin/reload", + ) + + +@legacy_router.get("/api/plugin/readme") +async def dashboard_get_plugin_readme( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _run_service( + lambda: service.get_plugin_readme(request.query_params.get("name")), + log_label="/api/plugin/readme", + ) + + +@legacy_router.get("/api/plugin/changelog") +async def dashboard_get_plugin_changelog( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _run_service( + lambda: service.get_plugin_changelog(request.query_params.get("name")), + log_label="/api/plugin/changelog", + ) + + +@legacy_router.get("/api/plugin/source/get") +async def dashboard_get_custom_source( + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _run_service(service.get_custom_sources) + + +@legacy_router.post("/api/plugin/source/save") +async def dashboard_save_custom_source( + request: Request, + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _run_json( + request, + service.save_custom_sources, + log_label="/api/plugin/source/save", + ) + + +@legacy_router.get("/api/plugin/source/get-failed-plugins") +async def dashboard_get_failed_plugins( + _username: str = Depends(require_dashboard_user), + service: PluginService = Depends(get_service), +): + return await _run_service(service.get_failed_plugins) + + +@legacy_router.get("/api/plugin/page/bridge-sdk.js") +async def dashboard_get_plugin_page_bridge_sdk( + request: Request, + _username: str = Depends(require_dashboard_user), + page_service: PluginPageService = Depends(get_page_service), +): + return await _serve_plugin_page_bridge_sdk( + request=request, + page_service=page_service, + ) + + +@legacy_router.get("/api/plugin/page/content/{plugin_id}/{page_name}/") +async def dashboard_get_plugin_page_entry( + plugin_id: str, + page_name: str, + request: Request, + username: str = Depends(require_dashboard_user), + page_service: PluginPageService = Depends(get_page_service), +): + return await _serve_plugin_page_content( + request=request, + page_service=page_service, + username=username, + plugin_id=plugin_id, + page_name=page_name, + asset_path="", + ) + + +@legacy_router.get("/api/plugin/page/content/{plugin_id}/{page_name}/{asset_path:path}") +async def dashboard_get_plugin_page_asset( + plugin_id: str, + page_name: str, + asset_path: str, + request: Request, + username: str = Depends(require_dashboard_user), + page_service: PluginPageService = Depends(get_page_service), +): + return await _serve_plugin_page_content( + request=request, + page_service=page_service, + username=username, + plugin_id=plugin_id, + page_name=page_name, + asset_path=asset_path, + ) + + +@legacy_router.api_route("/api/plug/{plugin_path:path}", methods=["GET", "POST"]) +async def dashboard_plugin_extension_route( + plugin_path: str, + request: Request, + username: str = Depends(require_dashboard_user), +): + return await _call_plugin_extension(plugin_path, request, username) diff --git a/astrbot/dashboard/api/providers.py b/astrbot/dashboard/api/providers.py new file mode 100644 index 000000000..c47ab489c --- /dev/null +++ b/astrbot/dashboard/api/providers.py @@ -0,0 +1,593 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request + +from astrbot.dashboard.responses import error, ok +from astrbot.dashboard.schemas import ( + EnabledPatch, + ProviderConfigRequest, + ProviderSourceRequest, +) +from astrbot.dashboard.services.config_service import ProviderConfigService + +from .auth import AuthContext, require_scope + +router = APIRouter(tags=["Providers"]) +legacy_router = APIRouter( + prefix="/api/config", + tags=["Dashboard Providers"], + include_in_schema=False, +) + + +async def require_provider_scope(request: Request) -> AuthContext: + return await require_scope(request, "provider") + + +def get_service(request: Request) -> ProviderConfigService: + return request.app.state.services.providers + + +async def _json_or_empty(request: Request) -> dict: + try: + data = await request.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _required_text(value: object, name: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError(f"Missing key: {name}") + return text + + +def _model_dict(payload) -> dict: + if payload is None: + return {} + if hasattr(payload, "model_dump"): + return payload.model_dump(exclude_none=True) + return payload if isinstance(payload, dict) else {} + + +def _config_from_body(body: dict) -> dict: + config = body.get("config") + if isinstance(config, dict): + return config + return { + key: value + for key, value in body.items() + if key + not in { + "provider_id", + "source_id", + "config", + "enabled", + "provider_config", + } + } + + +def _provider_config_for_dimension( + service: ProviderConfigService, + provider_id: str, + body: dict, +) -> dict: + provider = service.get_provider(provider_id, merged=True) + base_config = provider.get("provider") if isinstance(provider, dict) else {} + if not isinstance(base_config, dict): + base_config = {} + provider_config = body.get("provider_config") + if isinstance(provider_config, dict): + return {**base_config, **provider_config} + return base_config + + +def _alias_error(message: str): + return error(message) + + +@router.get("/providers/schema") +async def get_provider_schema( + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + return ok(service.get_provider_schema()) + + +@router.get("/provider-sources") +async def list_provider_sources( + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + return ok(service.list_provider_sources()) + + +@router.post("/provider-sources") +async def create_provider_source( + payload: ProviderSourceRequest, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + config = payload.to_dashboard_config() + source_id = config.get("id") + if not source_id: + raise ValueError("Provider source config must have an 'id' field") + await service.upsert_provider_source(source_id, config) + return ok(message="更新 provider source 成功") + + +@router.get("/provider-sources/by-id") +async def get_provider_source_by_id( + source_id: str = Query(...), + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + return ok(service.get_provider_source(source_id)) + + +@router.put("/provider-sources/by-id") +async def upsert_provider_source_by_id( + payload: ProviderSourceRequest, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + source_id = _required_text(payload.source_id, "source_id") + await service.upsert_provider_source( + source_id, + payload.to_dashboard_config(fallback_id=source_id), + ) + return ok(message="更新 provider source 成功") + + +@router.delete("/provider-sources/by-id") +async def delete_provider_source_by_id( + source_id: str = Query(...), + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + await service.delete_provider_source(source_id) + return ok(message="删除 provider source 成功") + + +@router.get("/provider-sources/models") +async def list_provider_source_models_by_id( + source_id: str = Query(...), + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + return ok(await service.list_provider_source_models(source_id)) + + +@router.get("/provider-sources/providers") +async def list_providers_by_source_id( + source_id: str = Query(...), + capability: str | None = Query(default=None), + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + return ok(service.list_providers(capability=capability, source_id=source_id)) + + +@router.post("/provider-sources/providers") +async def create_provider_in_source_by_id( + payload: ProviderConfigRequest, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + source_id = _required_text(payload.source_id, "source_id") + await service.create_provider( + payload.to_dashboard_config(source_id=source_id), + source_id, + ) + return ok(message="新增服务提供商配置成功") + + +@router.get("/provider-sources/{source_id:path}/models") +async def list_provider_source_models( + source_id: str, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + return ok(await service.list_provider_source_models(source_id)) + + +@router.get("/provider-sources/{source_id:path}/providers") +async def list_providers_by_source( + source_id: str, + capability: str | None = Query(default=None), + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + return ok(service.list_providers(capability=capability, source_id=source_id)) + + +@router.post("/provider-sources/{source_id:path}/providers") +async def create_provider_in_source( + source_id: str, + payload: ProviderConfigRequest, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + await service.create_provider( + payload.to_dashboard_config(source_id=source_id), source_id + ) + return ok(message="新增服务提供商配置成功") + + +@router.get("/provider-sources/{source_id:path}") +async def get_provider_source( + source_id: str, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + return ok(service.get_provider_source(source_id)) + + +@router.put("/provider-sources/{source_id:path}") +async def upsert_provider_source( + source_id: str, + payload: ProviderSourceRequest, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + await service.upsert_provider_source( + source_id, + payload.to_dashboard_config(), + ) + return ok(message="更新 provider source 成功") + + +@router.delete("/provider-sources/{source_id:path}") +async def delete_provider_source( + source_id: str, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + await service.delete_provider_source(source_id) + return ok(message="删除 provider source 成功") + + +@router.get("/providers") +async def list_providers( + capability: str | None = Query(default=None), + source_id: str | None = Query(default=None), + enabled: bool | None = Query(default=None), + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + return ok( + service.list_providers( + capability=capability, + source_id=source_id, + enabled=enabled, + ) + ) + + +@router.post("/providers") +async def create_provider( + payload: ProviderConfigRequest, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + await service.create_provider(payload.to_dashboard_config()) + return ok(message="新增服务提供商配置成功") + + +@router.get("/providers/by-id") +async def get_provider_by_id( + provider_id: str = Query(...), + merged: bool = Query(default=False), + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + return ok(service.get_provider(provider_id, merged=merged)) + + +@router.put("/providers/by-id") +async def update_provider_by_id( + payload: ProviderConfigRequest, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + provider_id = _required_text(payload.provider_id, "provider_id") + await service.update_provider( + provider_id, + payload.to_dashboard_config(fallback_id=provider_id), + ) + return ok(message="更新成功,已经实时生效~") + + +@router.delete("/providers/by-id") +async def delete_provider_by_id( + provider_id: str = Query(...), + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + await service.delete_provider(provider_id) + return ok(message="删除成功,已经实时生效。") + + +@router.patch("/providers/enabled") +async def set_provider_enabled_by_id( + payload: ProviderConfigRequest, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + provider_id = _required_text(payload.provider_id, "provider_id") + await service.set_provider_enabled(provider_id, bool(payload.enabled)) + return ok(message="更新成功,已经实时生效~") + + +@router.post("/providers/test") +async def test_provider_by_id( + payload: ProviderConfigRequest, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + provider_id = _required_text(payload.provider_id, "provider_id") + return ok(await service.test_provider(provider_id)) + + +@router.post("/providers/embedding-dimension") +async def get_embedding_dimension_by_id( + payload: ProviderConfigRequest, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + body = _model_dict(payload) + provider_id = _required_text(payload.provider_id, "provider_id") + return ok( + await service.get_embedding_dimension( + _provider_config_for_dimension(service, provider_id, body) + ) + ) + + +@router.patch("/providers/{provider_id:path}/enabled") +async def set_provider_enabled( + provider_id: str, + payload: EnabledPatch, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + await service.set_provider_enabled(provider_id, payload.enabled) + return ok(message="更新成功,已经实时生效~") + + +@router.post("/providers/{provider_id:path}/test") +async def test_provider( + provider_id: str, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + return ok(await service.test_provider(provider_id)) + + +@router.post("/providers/{provider_id:path}/embedding-dimension") +async def get_embedding_dimension( + provider_id: str, + payload: ProviderConfigRequest | None = None, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + body = _model_dict(payload) + return ok( + await service.get_embedding_dimension( + _provider_config_for_dimension(service, provider_id, body) + ) + ) + + +@router.get("/providers/{provider_id:path}") +async def get_provider( + provider_id: str, + merged: bool = Query(default=False), + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + return ok(service.get_provider(provider_id, merged=merged)) + + +@router.put("/providers/{provider_id:path}") +async def update_provider( + provider_id: str, + payload: ProviderConfigRequest, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + await service.update_provider( + provider_id, + payload.to_dashboard_config(fallback_id=provider_id), + ) + return ok(message="更新成功,已经实时生效~") + + +@router.delete("/providers/{provider_id:path}") +async def delete_provider( + provider_id: str, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + await service.delete_provider(provider_id) + return ok(message="删除成功,已经实时生效。") + + +@legacy_router.get("/provider/template") +async def get_dashboard_alias_provider_template( + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + return ok(service.get_provider_schema()) + + +@legacy_router.get("/provider/list") +async def list_dashboard_alias_providers( + provider_type: str | None = Query(default=None), + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + if not provider_type: + return _alias_error("缺少参数 provider_type") + providers = [] + seen_ids = set() + for item in provider_type.split(","): + for provider in service.list_providers(capability=item)["providers"]: + provider_id = provider.get("id") + if provider_id in seen_ids: + continue + seen_ids.add(provider_id) + providers.append(provider) + return ok(providers) + + +@legacy_router.post("/provider/new") +async def create_dashboard_alias_provider( + payload: ProviderConfigRequest, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + try: + await service.create_provider(payload.to_dashboard_config()) + return ok(message="新增服务提供商配置成功") + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.post("/provider/update") +async def update_dashboard_alias_provider( + request: Request, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + body = await _json_or_empty(request) + provider_id = body.get("id") + config = body.get("config") + if not provider_id or not isinstance(config, dict): + return _alias_error("参数错误") + try: + await service.update_provider( + str(provider_id), + ProviderConfigRequest(config=config).to_dashboard_config( + fallback_id=str(provider_id), + ), + ) + return ok(message="更新成功,已经实时生效~") + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.post("/provider/delete") +async def delete_dashboard_alias_provider( + request: Request, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + body = await _json_or_empty(request) + provider_id = body.get("id") + if not provider_id: + return _alias_error("缺少参数 id") + try: + await service.delete_provider(str(provider_id)) + return ok(message="删除成功,已经实时生效。") + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.get("/provider/check_one") +async def check_dashboard_alias_provider( + id: str | None = Query(default=None), + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + if not id: + return _alias_error("Missing provider_id parameter") + try: + return ok(await service.test_provider(id)) + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.get("/provider/model_list") +async def list_dashboard_alias_provider_models( + provider_id: str | None = Query(default=None), + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + try: + return ok(await service.list_provider_models_for_dashboard(provider_id)) + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.post("/provider/get_embedding_dim") +async def get_dashboard_alias_provider_embedding_dimension( + request: Request, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + body = await _json_or_empty(request) + try: + return ok(await service.get_embedding_dimension_from_dashboard_payload(body)) + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.get("/provider_sources/models") +async def list_dashboard_alias_provider_source_models( + source_id: str | None = Query(default=None), + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + if not source_id: + return _alias_error("缺少参数 source_id") + try: + data = await service.list_provider_source_models(source_id) + data.pop("provider_source_id", None) + return ok(data) + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.post("/provider_sources/update") +async def update_dashboard_alias_provider_source( + request: Request, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + body = await _json_or_empty(request) + source_id = body.get("original_id") + config = body.get("config") or body + if not source_id: + return _alias_error("缺少 original_id") + if not isinstance(config, dict): + return _alias_error("缺少或错误的配置数据") + try: + await service.upsert_provider_source( + str(source_id), + ProviderSourceRequest(config=config).to_dashboard_config( + fallback_id=str(source_id), + ), + ) + return ok(message="更新 provider source 成功") + except ValueError as exc: + return _alias_error(str(exc)) + + +@legacy_router.post("/provider_sources/delete") +async def delete_dashboard_alias_provider_source( + request: Request, + _auth: AuthContext = Depends(require_provider_scope), + service: ProviderConfigService = Depends(get_service), +): + body = await _json_or_empty(request) + source_id = body.get("id") + if not source_id: + return _alias_error("缺少 provider_source_id") + try: + await service.delete_provider_source(str(source_id)) + return ok(message="删除 provider source 成功") + except ValueError as exc: + return _alias_error(str(exc)) diff --git a/astrbot/dashboard/api/router.py b/astrbot/dashboard/api/router.py new file mode 100644 index 000000000..b59ea6285 --- /dev/null +++ b/astrbot/dashboard/api/router.py @@ -0,0 +1,63 @@ +"""FastAPI HTTP API surface for the AstrBot dashboard.""" + +from fastapi import APIRouter + +from .api_keys import router as api_keys_router +from .auth import router as auth_router +from .backups import router as backups_router +from .bots import router as bots_router +from .chat import router as chat_router +from .chat_projects import router as chat_projects_router +from .config_profiles import router as config_profiles_router +from .conversations import router as conversations_router +from .cron import router as cron_router +from .extensions import router as extensions_router +from .files import router as files_router +from .knowledge_bases import router as knowledge_bases_router +from .live_chat import router as live_chat_router +from .logs import router as logs_router +from .open_api import router as open_api_router +from .personas import router as personas_router +from .platform import router as platform_router +from .plugins import router as plugins_router +from .providers import router as providers_router +from .sessions import router as sessions_router +from .skills import router as skills_router +from .stats import router as stats_router +from .subagents import router as subagents_router +from .t2i import router as t2i_router +from .tools import router as tools_router +from .updates import router as updates_router + +API_V1_PREFIX = "/api/v1" + + +def build_api_router() -> APIRouter: + router = APIRouter(prefix=API_V1_PREFIX) + router.include_router(auth_router) + router.include_router(backups_router) + router.include_router(config_profiles_router) + router.include_router(api_keys_router) + router.include_router(bots_router) + router.include_router(providers_router) + router.include_router(plugins_router) + router.include_router(chat_router) + router.include_router(chat_projects_router) + router.include_router(conversations_router) + router.include_router(cron_router) + router.include_router(files_router) + router.include_router(knowledge_bases_router) + router.include_router(extensions_router) + router.include_router(skills_router) + router.include_router(sessions_router) + router.include_router(subagents_router) + router.include_router(logs_router) + router.include_router(stats_router) + router.include_router(tools_router) + router.include_router(platform_router) + router.include_router(t2i_router) + router.include_router(personas_router) + router.include_router(updates_router) + router.include_router(open_api_router) + router.include_router(live_chat_router) + return router diff --git a/astrbot/dashboard/api/sessions.py b/astrbot/dashboard/api/sessions.py new file mode 100644 index 000000000..42ede8ea5 --- /dev/null +++ b/astrbot/dashboard/api/sessions.py @@ -0,0 +1,414 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request + +from astrbot.core import logger +from astrbot.dashboard.async_utils import run_maybe_async +from astrbot.dashboard.responses import error, ok +from astrbot.dashboard.schemas import ( + BatchSessionProviderRequest, + BatchSessionServiceRequest, + SessionGroupRequest, + SessionRuleRequest, + UmoListRequest, +) +from astrbot.dashboard.services.session_management_service import ( + SessionManagementService, + SessionManagementServiceError, +) + +from .auth import AuthContext, require_dashboard_user, require_scope + +router = APIRouter(tags=["Sessions"]) +legacy_router = APIRouter( + prefix="/api/session", + tags=["Dashboard Sessions"], + include_in_schema=False, +) + + +def get_service(request: Request) -> SessionManagementService: + return request.app.state.services.sessions + + +async def require_data_scope(request: Request) -> AuthContext: + return await require_scope(request, "data") + + +async def _json_or_empty(request: Request) -> dict: + try: + data = await request.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _service_error(exc: SessionManagementServiceError) -> dict: + return error(str(exc)) + + +def _unexpected_error(prefix: str, exc: Exception) -> dict: + logger.error(f"{prefix}: {exc!s}") + return error(f"{prefix}: {exc!s}") + + +async def _run(operation, *, label: str) -> dict: + try: + result = await run_maybe_async(operation) + return ok(result) + except SessionManagementServiceError as exc: + return _service_error(exc) + except Exception as exc: + return _unexpected_error(label, exc) + + +async def _run_dashboard_json( + request: Request, + operation, + *, + label: str, +) -> dict: + body = await _json_or_empty(request) + return await _run(lambda: operation(body), label=label) + + +@router.get("/sessions") +async def list_sessions( + page: int = Query(1), + page_size: int = Query(20), + search: str = Query(""), + message_type: str = Query("all"), + platform: str = Query(""), + _auth: AuthContext = Depends(require_data_scope), + service: SessionManagementService = Depends(get_service), +): + try: + return ok( + await service.list_all_umos_with_status( + page=page, + page_size=page_size, + search=search.strip(), + message_type=message_type, + platform=platform, + ) + ) + except SessionManagementServiceError as exc: + return _service_error(exc) + except Exception as exc: + return _unexpected_error("获取会话状态列表失败", exc) + + +@router.get("/sessions/active-umos") +async def list_active_umos( + _auth: AuthContext = Depends(require_data_scope), + service: SessionManagementService = Depends(get_service), +): + try: + return ok(await service.list_active_umos()) + except SessionManagementServiceError as exc: + return _service_error(exc) + except Exception as exc: + return _unexpected_error("获取 UMO 列表失败", exc) + + +@router.get("/sessions/rules") +async def list_session_rules( + page: int = Query(1), + page_size: int = Query(10), + search: str = Query(""), + _auth: AuthContext = Depends(require_data_scope), + service: SessionManagementService = Depends(get_service), +): + try: + return ok( + await service.list_session_rules( + page=page, + page_size=page_size, + search=search.strip(), + ) + ) + except SessionManagementServiceError as exc: + return _service_error(exc) + except Exception as exc: + return _unexpected_error("获取规则列表失败", exc) + + +@router.post("/sessions/rules") +async def update_session_rule( + payload: SessionRuleRequest, + _auth: AuthContext = Depends(require_data_scope), + service: SessionManagementService = Depends(get_service), +): + try: + return ok( + await service.update_session_rule(payload.model_dump(exclude_none=True)) + ) + except SessionManagementServiceError as exc: + return _service_error(exc) + except Exception as exc: + return _unexpected_error("更新会话规则失败", exc) + + +@router.post("/sessions/rules/delete") +async def delete_session_rule( + payload: UmoListRequest, + _auth: AuthContext = Depends(require_data_scope), + service: SessionManagementService = Depends(get_service), +): + try: + return ok( + await service.delete_session_rules(payload.model_dump(exclude_none=True)) + ) + except SessionManagementServiceError as exc: + return _service_error(exc) + except Exception as exc: + return _unexpected_error("删除会话规则失败", exc) + + +@router.patch("/sessions/provider") +async def update_session_provider( + payload: BatchSessionProviderRequest, + _auth: AuthContext = Depends(require_data_scope), + service: SessionManagementService = Depends(get_service), +): + try: + return ok( + await service.batch_update_provider(payload.model_dump(exclude_none=True)) + ) + except SessionManagementServiceError as exc: + return _service_error(exc) + except Exception as exc: + return _unexpected_error("批量更新 Provider 失败", exc) + + +@router.patch("/sessions/service") +async def update_session_service( + payload: BatchSessionServiceRequest, + _auth: AuthContext = Depends(require_data_scope), + service: SessionManagementService = Depends(get_service), +): + try: + return ok( + await service.batch_update_service(payload.model_dump(exclude_none=True)) + ) + except SessionManagementServiceError as exc: + return _service_error(exc) + except Exception as exc: + return _unexpected_error("批量更新服务状态失败", exc) + + +@router.get("/session-groups") +async def list_session_groups( + _auth: AuthContext = Depends(require_data_scope), + service: SessionManagementService = Depends(get_service), +): + try: + return ok(service.list_groups()) + except SessionManagementServiceError as exc: + return _service_error(exc) + except Exception as exc: + return _unexpected_error("获取分组列表失败", exc) + + +@router.post("/session-groups") +async def create_session_group( + payload: SessionGroupRequest, + _auth: AuthContext = Depends(require_data_scope), + service: SessionManagementService = Depends(get_service), +): + try: + return ok(service.create_group(payload.model_dump(exclude_none=True))) + except SessionManagementServiceError as exc: + return _service_error(exc) + except Exception as exc: + return _unexpected_error("创建分组失败", exc) + + +@router.put("/session-groups/{group_id}") +async def update_session_group( + group_id: str, + payload: SessionGroupRequest, + _auth: AuthContext = Depends(require_data_scope), + service: SessionManagementService = Depends(get_service), +): + try: + body = payload.model_dump(exclude_none=True) + return ok(service.update_group({"group_id": group_id, **body})) + except SessionManagementServiceError as exc: + return _service_error(exc) + except Exception as exc: + return _unexpected_error("更新分组失败", exc) + + +@router.delete("/session-groups/{group_id}") +async def delete_session_group( + group_id: str, + _auth: AuthContext = Depends(require_data_scope), + service: SessionManagementService = Depends(get_service), +): + try: + return ok(service.delete_group({"group_id": group_id})) + except SessionManagementServiceError as exc: + return _service_error(exc) + except Exception as exc: + return _unexpected_error("删除分组失败", exc) + + +@legacy_router.get("/list-rule") +async def list_dashboard_session_rules( + page: int = Query(1), + page_size: int = Query(10), + search: str = Query(""), + _username: str = Depends(require_dashboard_user), + service: SessionManagementService = Depends(get_service), +): + return await _run( + lambda: service.list_session_rules( + page=page, + page_size=page_size, + search=search.strip(), + ), + label="获取规则列表失败", + ) + + +@legacy_router.post("/update-rule") +async def update_dashboard_session_rule( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SessionManagementService = Depends(get_service), +): + return await _run_dashboard_json( + request, + service.update_session_rule, + label="更新会话规则失败", + ) + + +@legacy_router.post("/delete-rule") +async def delete_dashboard_session_rule( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SessionManagementService = Depends(get_service), +): + return await _run_dashboard_json( + request, + service.delete_session_rule, + label="删除会话规则失败", + ) + + +@legacy_router.post("/batch-delete-rule") +async def batch_delete_dashboard_session_rule( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SessionManagementService = Depends(get_service), +): + return await _run_dashboard_json( + request, + service.batch_delete_session_rule, + label="批量删除会话规则失败", + ) + + +@legacy_router.get("/active-umos") +async def list_dashboard_active_umos( + _username: str = Depends(require_dashboard_user), + service: SessionManagementService = Depends(get_service), +): + return await _run(service.list_active_umos, label="获取 UMO 列表失败") + + +@legacy_router.get("/list-all-with-status") +async def list_dashboard_umos_with_status( + page: int = Query(1), + page_size: int = Query(20), + search: str = Query(""), + message_type: str = Query("all"), + platform: str = Query(""), + _username: str = Depends(require_dashboard_user), + service: SessionManagementService = Depends(get_service), +): + return await _run( + lambda: service.list_all_umos_with_status( + page=page, + page_size=page_size, + search=search.strip(), + message_type=message_type, + platform=platform, + ), + label="获取会话状态列表失败", + ) + + +@legacy_router.post("/batch-update-service") +async def batch_update_dashboard_session_service( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SessionManagementService = Depends(get_service), +): + return await _run_dashboard_json( + request, + service.batch_update_service, + label="批量更新服务状态失败", + ) + + +@legacy_router.post("/batch-update-provider") +async def batch_update_dashboard_session_provider( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SessionManagementService = Depends(get_service), +): + return await _run_dashboard_json( + request, + service.batch_update_provider, + label="批量更新 Provider 失败", + ) + + +@legacy_router.get("/groups") +async def list_dashboard_session_groups( + _username: str = Depends(require_dashboard_user), + service: SessionManagementService = Depends(get_service), +): + return await _run(service.list_groups, label="获取分组列表失败") + + +@legacy_router.post("/group/create") +async def create_dashboard_session_group( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SessionManagementService = Depends(get_service), +): + return await _run_dashboard_json( + request, + service.create_group, + label="创建分组失败", + ) + + +@legacy_router.post("/group/update") +async def update_dashboard_session_group( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SessionManagementService = Depends(get_service), +): + return await _run_dashboard_json( + request, + service.update_group, + label="更新分组失败", + ) + + +@legacy_router.post("/group/delete") +async def delete_dashboard_session_group( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SessionManagementService = Depends(get_service), +): + return await _run_dashboard_json( + request, + service.delete_group, + label="删除分组失败", + ) diff --git a/astrbot/dashboard/api/skills.py b/astrbot/dashboard/api/skills.py new file mode 100644 index 000000000..c8925c8e4 --- /dev/null +++ b/astrbot/dashboard/api/skills.py @@ -0,0 +1,558 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import FileResponse + +from astrbot.core import logger +from astrbot.dashboard.async_utils import run_maybe_async +from astrbot.dashboard.responses import error, ok +from astrbot.dashboard.schemas import ( + SkillByNameUpdateRequest, + SkillFileUpdateRequest, + SkillNeoRequest, + SkillUpdateRequest, +) +from astrbot.dashboard.services.skills_service import ( + SkillArchive, + SkillsOperationResult, + SkillsService, + SkillsServiceError, +) + +from .auth import AuthContext, require_dashboard_user, require_scope +from .multipart import multipart_parts, single_upload + +router = APIRouter(tags=["Skills"]) +legacy_router = APIRouter( + prefix="/api/skills", + tags=["Dashboard Skills"], + include_in_schema=False, +) + + +def get_service(request: Request) -> SkillsService: + return request.app.state.services.skills + + +async def require_skill_scope(request: Request) -> AuthContext: + return await require_scope(request, "skill") + + +async def _json_or_empty(request: Request) -> dict[str, Any]: + try: + data = await request.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _required_text(value: object, name: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError(f"Missing key: {name}") + return text + + +def _model_dict(payload) -> dict[str, Any]: + if payload is None: + return {} + if hasattr(payload, "model_dump"): + return payload.model_dump(exclude_none=True) + return payload if isinstance(payload, dict) else {} + + +def _serialize_result(result: SkillsOperationResult): + if result.ok: + return ok(result.data, result.message) + return error(result.message or "", result.data) + + +async def _run(operation, *, trace: bool = True): + try: + result = await run_maybe_async(operation) + if isinstance(result, SkillsOperationResult): + return _serialize_result(result) + return ok(result) + except SkillsServiceError as exc: + return error(str(exc)) + except Exception as exc: + logger.error(str(exc), exc_info=trace) + return error(str(exc)) + + +def _archive_response(archive: SkillArchive): + return FileResponse( + archive.path, + filename=archive.filename, + media_type="application/zip", + ) + + +async def _download_skill(service: SkillsService, name: str): + try: + return _archive_response(service.prepare_skill_archive(name)) + except SkillsServiceError as exc: + return error(str(exc)) + except Exception as exc: + logger.error(str(exc), exc_info=True) + return error(str(exc)) + + +@router.get("/skills") +async def list_skills( + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run(service.get_skills) + + +@router.post("/skills") +async def upload_skill( + request: Request, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + async def _operation(): + return await service.upload_skill(await single_upload(request)) + + return await _run(_operation) + + +@router.post("/skills/batch") +async def upload_skills_batch( + request: Request, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + async def _operation(): + _, files = await multipart_parts(request) + return await service.batch_upload_skills(files.getlist("files")) + + return await _run(_operation) + + +@router.patch("/skills/by-name") +async def update_skill_by_name( + payload: SkillByNameUpdateRequest, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + skill_name = _required_text(payload.skill_name, "skill_name") + return await _run( + lambda: service.update_skill( + { + "name": skill_name, + "active": payload.active_value(), + } + ) + ) + + +@router.delete("/skills/by-name") +async def delete_skill_by_name( + skill_name: str, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run(lambda: service.delete_skill({"name": skill_name})) + + +@router.get("/skills/archive") +async def download_skill_by_name( + skill_name: str, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _download_skill(service, skill_name) + + +@router.get("/skills/files") +async def list_skill_files_by_name( + request: Request, + skill_name: str, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run( + lambda: service.list_skill_files( + skill_name, + request.query_params.get("path", ""), + ) + ) + + +@router.get("/skills/file") +async def get_skill_file_by_name( + skill_name: str, + path: str, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run(lambda: service.get_skill_file(skill_name, path)) + + +@router.put("/skills/file") +async def update_skill_file_by_name( + payload: SkillFileUpdateRequest, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + skill_name = _required_text(payload.skill_name, "skill_name") + path = _required_text(payload.path, "path") + return await _run( + lambda: service.update_skill_file( + { + "name": skill_name, + "path": path, + "content": payload.content, + } + ) + ) + + +@router.get("/skills/{skill_name:path}/archive") +async def download_skill( + skill_name: str, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _download_skill(service, skill_name) + + +@router.get("/skills/{skill_name:path}/files") +async def list_skill_files( + skill_name: str, + request: Request, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run( + lambda: service.list_skill_files( + skill_name, + request.query_params.get("path", ""), + ) + ) + + +@router.get("/skills/{skill_name:path}/files/{file_path:path}") +async def get_skill_file( + skill_name: str, + file_path: str, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run(lambda: service.get_skill_file(skill_name, file_path)) + + +@router.put("/skills/{skill_name:path}/files/{file_path:path}") +async def update_skill_file( + skill_name: str, + file_path: str, + request: Request, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + content = (await request.body()).decode("utf-8") + return await _run( + lambda: service.update_skill_file( + {"name": skill_name, "path": file_path, "content": content} + ) + ) + + +@router.patch("/skills/{skill_name:path}") +async def update_skill( + skill_name: str, + payload: SkillUpdateRequest, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run( + lambda: service.update_skill( + { + "name": skill_name, + "active": payload.active_value(), + } + ) + ) + + +@router.delete("/skills/{skill_name:path}") +async def delete_skill( + skill_name: str, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run(lambda: service.delete_skill({"name": skill_name})) + + +@router.get("/skills/neo/candidates") +async def list_neo_skill_candidates( + request: Request, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run( + service.get_neo_candidates( + dict(request.query_params), + ) + ) + + +@router.get("/skills/neo/releases") +async def list_neo_skill_releases( + request: Request, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run( + service.get_neo_releases( + dict(request.query_params), + ) + ) + + +@router.get("/skills/neo/payload") +async def get_neo_skill_payload( + request: Request, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run(service.get_neo_payload(dict(request.query_params))) + + +@router.post("/skills/neo/evaluate") +async def evaluate_neo_skill_candidate( + payload: SkillNeoRequest, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run(lambda: service.evaluate_neo_candidate(_model_dict(payload))) + + +@router.post("/skills/neo/promote") +async def promote_neo_skill_candidate( + payload: SkillNeoRequest, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run(lambda: service.promote_neo_candidate(_model_dict(payload))) + + +@router.post("/skills/neo/rollback") +async def rollback_neo_skill_release( + payload: SkillNeoRequest, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run(lambda: service.rollback_neo_release(_model_dict(payload))) + + +@router.post("/skills/neo/sync") +async def sync_neo_skill_release( + payload: SkillNeoRequest, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run(lambda: service.sync_neo_release(_model_dict(payload))) + + +@router.post("/skills/neo/candidates/delete") +async def delete_neo_skill_candidate( + payload: SkillNeoRequest, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run(lambda: service.delete_neo_candidate(_model_dict(payload))) + + +@router.post("/skills/neo/releases/delete") +async def delete_neo_skill_release( + payload: SkillNeoRequest, + _auth: AuthContext = Depends(require_skill_scope), + service: SkillsService = Depends(get_service), +): + return await _run(lambda: service.delete_neo_release(_model_dict(payload))) + + +@legacy_router.get("") +async def list_dashboard_skills( + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + return await _run(service.get_skills) + + +@legacy_router.post("/upload") +async def upload_dashboard_skill( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + async def _operation(): + return await service.upload_skill(await single_upload(request)) + + return await _run(_operation) + + +@legacy_router.post("/batch-upload") +async def batch_upload_dashboard_skills( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + async def _operation(): + _, files = await multipart_parts(request) + return await service.batch_upload_skills(files.getlist("files")) + + return await _run(_operation) + + +@legacy_router.get("/download") +async def download_dashboard_skill( + name: str, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + return await _download_skill(service, name) + + +@legacy_router.get("/files") +async def list_dashboard_skill_files( + request: Request, + name: str, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + return await _run( + lambda: service.list_skill_files(name, request.query_params.get("path", "")) + ) + + +@legacy_router.get("/file") +async def get_dashboard_skill_file( + name: str, + path: str = "SKILL.md", + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + return await _run(lambda: service.get_skill_file(name, path)) + + +@legacy_router.post("/file") +async def update_dashboard_skill_file( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.update_skill_file(body)) + + +@legacy_router.post("/update") +async def update_dashboard_skill( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.update_skill(body)) + + +@legacy_router.post("/delete") +async def delete_dashboard_skill( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.delete_skill(body)) + + +@legacy_router.get("/neo/candidates") +async def list_dashboard_neo_skill_candidates( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + return await _run(service.get_neo_candidates(dict(request.query_params))) + + +@legacy_router.get("/neo/releases") +async def list_dashboard_neo_skill_releases( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + return await _run(service.get_neo_releases(dict(request.query_params))) + + +@legacy_router.get("/neo/payload") +async def get_dashboard_neo_skill_payload( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + return await _run(service.get_neo_payload(dict(request.query_params))) + + +@legacy_router.post("/neo/evaluate") +async def evaluate_dashboard_neo_skill_candidate( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.evaluate_neo_candidate(body)) + + +@legacy_router.post("/neo/promote") +async def promote_dashboard_neo_skill_candidate( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.promote_neo_candidate(body)) + + +@legacy_router.post("/neo/rollback") +async def rollback_dashboard_neo_skill_release( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.rollback_neo_release(body)) + + +@legacy_router.post("/neo/sync") +async def sync_dashboard_neo_skill_release( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.sync_neo_release(body)) + + +@legacy_router.post("/neo/delete-candidate") +async def delete_dashboard_neo_skill_candidate( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.delete_neo_candidate(body)) + + +@legacy_router.post("/neo/delete-release") +async def delete_dashboard_neo_skill_release( + request: Request, + _username: str = Depends(require_dashboard_user), + service: SkillsService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.delete_neo_release(body)) diff --git a/astrbot/dashboard/api/static_files.py b/astrbot/dashboard/api/static_files.py new file mode 100644 index 000000000..02aaa3ca6 --- /dev/null +++ b/astrbot/dashboard/api/static_files.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import FileResponse, PlainTextResponse + +from astrbot.dashboard.services.static_file_service import StaticFileService + +router = APIRouter(include_in_schema=False) +service = StaticFileService() + + +def _static_folder(request: Request) -> str | None: + return getattr(request.app.state, "dashboard_static_folder", None) + + +def _not_found_response() -> PlainTextResponse: + return PlainTextResponse(service.get_not_found_message(), status_code=404) + + +async def serve_index(request: Request): + index_file = service.resolve_index_file(_static_folder(request)) + if index_file is None: + return _not_found_response() + return FileResponse(index_file) + + +async def serve_static_file(request: Request, static_path: str): + if request.url.path.startswith("/api"): + raise HTTPException(status_code=404) + + file_path = service.resolve_static_file(_static_folder(request), static_path) + if file_path is None: + return _not_found_response() + return FileResponse(file_path) + + +for index_route in service.list_index_routes(): + router.add_api_route(index_route, serve_index, methods=["GET"]) + +router.add_api_route("/{static_path:path}", serve_static_file, methods=["GET"]) diff --git a/astrbot/dashboard/api/stats.py b/astrbot/dashboard/api/stats.py new file mode 100644 index 000000000..cdcb505a4 --- /dev/null +++ b/astrbot/dashboard/api/stats.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request + +from astrbot.dashboard.async_utils import run_maybe_async +from astrbot.dashboard.responses import ApiError, ok +from astrbot.dashboard.schemas import GhProxyTestRequest, StorageCleanupRequest +from astrbot.dashboard.services.stat_service import StatService, StatServiceError + +from .auth import AuthContext, require_dashboard_user, require_scope + +router = APIRouter(tags=["System Stats"]) +legacy_router = APIRouter( + prefix="/api/stat", + tags=["Dashboard System Stats"], + include_in_schema=False, +) + + +async def require_system_scope(request: Request) -> AuthContext: + return await require_scope(request, "system") + + +def get_service(request: Request) -> StatService: + return request.app.state.services.stats + + +def _raise_stat_error(exc: StatServiceError) -> None: + raise ApiError(str(exc)) from exc + + +async def _run(operation): + try: + result = await run_maybe_async(operation) + return ok(result) + except StatServiceError as exc: + _raise_stat_error(exc) + + +def _parse_int(value: object, default: int, name: str) -> int: + if value is None: + return default + if not isinstance(value, int | float | str | bytes | bytearray): + raise ApiError(f"{name} must be an integer") + try: + return int(value) + except (TypeError, ValueError) as exc: + raise ApiError(f"{name} must be an integer") from exc + + +@router.get("/stats") +async def get_stats( + offset_sec: int = Query(default=86400), + _auth: AuthContext = Depends(require_system_scope), + service: StatService = Depends(get_service), +): + return await _run(service.get_stat(offset_sec)) + + +@router.get("/stats/provider-tokens") +async def get_provider_token_stats( + days: int = Query(default=1), + _auth: AuthContext = Depends(require_system_scope), + service: StatService = Depends(get_service), +): + return await _run(service.get_provider_token_stats(days)) + + +@router.get("/stats/version") +async def get_version( + _auth: AuthContext = Depends(require_system_scope), + service: StatService = Depends(get_service), +): + return await _run(service.get_version()) + + +@router.get("/stats/versions") +async def get_public_versions( + request: Request, + service: StatService = Depends(get_service), +): + return ok( + await service.get_public_versions( + getattr(request.app.state, "dashboard_static_folder", None) + ) + ) + + +@router.get("/stats/first-notice") +async def get_first_notice( + locale: str | None = None, + _auth: AuthContext = Depends(require_system_scope), + service: StatService = Depends(get_service), +): + return await _run(lambda: service.get_first_notice(locale)) + + +@router.post("/stats/ghproxy/test") +async def test_ghproxy_connection( + payload: GhProxyTestRequest, + _auth: AuthContext = Depends(require_system_scope), + service: StatService = Depends(get_service), +): + return await _run(service.test_ghproxy_connection(payload.proxy_url)) + + +@router.get("/changelogs") +async def list_changelog_versions( + _auth: AuthContext = Depends(require_system_scope), + service: StatService = Depends(get_service), +): + return await _run(service.list_changelog_versions) + + +@router.get("/changelogs/{version}") +async def get_changelog( + version: str, + _auth: AuthContext = Depends(require_system_scope), + service: StatService = Depends(get_service), +): + return await _run(lambda: service.get_changelog(version)) + + +@router.get("/stats/start-time") +async def get_start_time( + service: StatService = Depends(get_service), +): + return await _run(service.get_start_time) + + +@router.get("/stats/storage") +async def get_storage_status( + _auth: AuthContext = Depends(require_system_scope), + service: StatService = Depends(get_service), +): + return await _run(service.get_storage_status()) + + +@router.post("/stats/storage/cleanup") +async def cleanup_storage( + payload: StorageCleanupRequest, + _auth: AuthContext = Depends(require_system_scope), + service: StatService = Depends(get_service), +): + return await _run(service.cleanup_storage(payload.target)) + + +@router.post("/system/restart") +async def restart_system( + _auth: AuthContext = Depends(require_system_scope), + service: StatService = Depends(get_service), +): + return await _run(service.restart_core()) + + +@legacy_router.get("/get") +async def get_dashboard_stats( + offset_sec: int | None = Query(default=86400), + _username: str = Depends(require_dashboard_user), + service: StatService = Depends(get_service), +): + return await _run(service.get_stat(_parse_int(offset_sec, 86400, "offset_sec"))) + + +@legacy_router.get("/provider-tokens") +async def get_dashboard_provider_token_stats( + days: int | None = Query(default=1), + _username: str = Depends(require_dashboard_user), + service: StatService = Depends(get_service), +): + return await _run(service.get_provider_token_stats(_parse_int(days, 1, "days"))) + + +@legacy_router.get("/version") +async def get_dashboard_version( + _username: str = Depends(require_dashboard_user), + service: StatService = Depends(get_service), +): + return await _run(service.get_version()) + + +@legacy_router.get("/versions") +async def get_dashboard_public_versions( + request: Request, + service: StatService = Depends(get_service), +): + return ok( + await service.get_public_versions( + getattr(request.app.state, "dashboard_static_folder", None) + ) + ) + + +@legacy_router.get("/start-time") +async def get_dashboard_start_time( + service: StatService = Depends(get_service), +): + return await _run(service.get_start_time) + + +@legacy_router.post("/restart-core") +async def restart_dashboard_core( + _username: str = Depends(require_dashboard_user), + service: StatService = Depends(get_service), +): + return await _run(service.restart_core()) + + +@legacy_router.post("/test-ghproxy-connection") +async def test_dashboard_ghproxy_connection( + payload: GhProxyTestRequest, + _username: str = Depends(require_dashboard_user), + service: StatService = Depends(get_service), +): + return await _run(service.test_ghproxy_connection(payload.proxy_url)) + + +@legacy_router.get("/changelog") +async def get_dashboard_changelog( + version: str | None = None, + _username: str = Depends(require_dashboard_user), + service: StatService = Depends(get_service), +): + return await _run(lambda: service.get_changelog(version)) + + +@legacy_router.get("/changelog/list") +async def list_dashboard_changelog_versions( + _username: str = Depends(require_dashboard_user), + service: StatService = Depends(get_service), +): + return await _run(service.list_changelog_versions) + + +@legacy_router.get("/first-notice") +async def get_dashboard_first_notice( + locale: str | None = None, + _username: str = Depends(require_dashboard_user), + service: StatService = Depends(get_service), +): + return await _run(lambda: service.get_first_notice(locale)) + + +@legacy_router.get("/storage") +async def get_dashboard_storage_status( + _username: str = Depends(require_dashboard_user), + service: StatService = Depends(get_service), +): + return await _run(service.get_storage_status()) + + +@legacy_router.post("/storage/cleanup") +async def cleanup_dashboard_storage( + payload: StorageCleanupRequest | None = None, + _username: str = Depends(require_dashboard_user), + service: StatService = Depends(get_service), +): + target = payload.target if payload is not None else "all" + return await _run(service.cleanup_storage(target)) diff --git a/astrbot/dashboard/api/subagents.py b/astrbot/dashboard/api/subagents.py new file mode 100644 index 000000000..c26bf4ad5 --- /dev/null +++ b/astrbot/dashboard/api/subagents.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request + +from astrbot.dashboard.responses import ApiError, ok +from astrbot.dashboard.schemas import SubAgentConfigRequest +from astrbot.dashboard.services.subagent_service import ( + SubAgentService, + SubAgentServiceError, +) + +from .auth import AuthContext, require_dashboard_user, require_scope + +router = APIRouter(tags=["Subagents"]) +legacy_router = APIRouter( + prefix="/api/subagent", + tags=["Dashboard Subagents"], + include_in_schema=False, +) + + +async def require_config_scope(request: Request) -> AuthContext: + return await require_scope(request, "config") + + +def get_service(request: Request) -> SubAgentService: + return request.app.state.services.subagents + + +def _payload_dict(payload: SubAgentConfigRequest) -> dict: + return payload.model_dump(exclude_none=True) + + +def _raise_subagent_error(exc: SubAgentServiceError) -> None: + raise ApiError(str(exc)) from exc + + +async def _get_config(service: SubAgentService): + try: + return ok(service.get_config()) + except SubAgentServiceError as exc: + _raise_subagent_error(exc) + + +async def _update_config(payload: SubAgentConfigRequest, service: SubAgentService): + try: + await service.update_config(_payload_dict(payload)) + return ok(message="保存成功") + except SubAgentServiceError as exc: + _raise_subagent_error(exc) + + +async def _get_available_tools(service: SubAgentService): + try: + return ok(service.get_available_tools()) + except SubAgentServiceError as exc: + _raise_subagent_error(exc) + + +@router.get("/subagents/config") +async def get_subagent_config( + _auth: AuthContext = Depends(require_config_scope), + service: SubAgentService = Depends(get_service), +): + return await _get_config(service) + + +@router.put("/subagents/config") +async def update_subagent_config( + payload: SubAgentConfigRequest, + _auth: AuthContext = Depends(require_config_scope), + service: SubAgentService = Depends(get_service), +): + return await _update_config(payload, service) + + +@router.get("/subagents/available-tools") +async def get_subagent_tools( + _auth: AuthContext = Depends(require_config_scope), + service: SubAgentService = Depends(get_service), +): + return await _get_available_tools(service) + + +@legacy_router.get("/config") +async def get_dashboard_subagent_config( + _username: str = Depends(require_dashboard_user), + service: SubAgentService = Depends(get_service), +): + return await _get_config(service) + + +@legacy_router.post("/config") +async def update_dashboard_subagent_config( + payload: SubAgentConfigRequest, + _username: str = Depends(require_dashboard_user), + service: SubAgentService = Depends(get_service), +): + return await _update_config(payload, service) + + +@legacy_router.get("/available-tools") +async def get_dashboard_subagent_tools( + _username: str = Depends(require_dashboard_user), + service: SubAgentService = Depends(get_service), +): + return await _get_available_tools(service) diff --git a/astrbot/dashboard/api/t2i.py b/astrbot/dashboard/api/t2i.py new file mode 100644 index 000000000..3edc66fd7 --- /dev/null +++ b/astrbot/dashboard/api/t2i.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse + +from astrbot.dashboard.async_utils import run_maybe_async +from astrbot.dashboard.responses import ApiError, ok +from astrbot.dashboard.schemas import T2iActiveTemplateRequest, T2iTemplateRequest +from astrbot.dashboard.services.t2i_service import T2iService, T2iServiceError + +from .auth import AuthContext, require_dashboard_user, require_scope + +router = APIRouter(tags=["Text To Image"]) +legacy_router = APIRouter( + prefix="/api/t2i", + tags=["Dashboard Text To Image"], + include_in_schema=False, +) + + +def get_service(request: Request) -> T2iService: + return request.app.state.services.t2i + + +async def require_config_scope(request: Request) -> AuthContext: + return await require_scope(request, "config") + + +async def _json_or_empty(request: Request) -> dict: + try: + data = await request.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _raise_t2i_error(exc: T2iServiceError) -> None: + raise ApiError(str(exc), status_code=exc.status_code) from exc + + +def _response( + data=None, + *, + message: str | None = None, + status_code: int = 200, +): + payload = ok(data, message) + if status_code == 200: + return payload + return JSONResponse(payload, status_code=status_code) + + +async def _run( + operation, + *, + message: str | None = None, + status_code: int = 200, + result_as_message: bool = False, +): + try: + result = await run_maybe_async(operation) + if isinstance(result, tuple): + payload, result_message = result + return _response(payload, message=result_message) + if result_as_message: + return _response(message=str(result), status_code=status_code) + return _response(result, message=message, status_code=status_code) + except T2iServiceError as exc: + _raise_t2i_error(exc) + + +@router.get("/t2i/templates") +async def list_t2i_templates( + _auth: AuthContext = Depends(require_config_scope), + service: T2iService = Depends(get_service), +): + return await _run(service.list_templates) + + +@router.post("/t2i/templates") +async def create_t2i_template( + payload: T2iTemplateRequest, + _auth: AuthContext = Depends(require_config_scope), + service: T2iService = Depends(get_service), +): + return await _run( + lambda: service.create_template(payload.name, payload.content), + message="Template created successfully.", + status_code=201, + ) + + +@router.get("/t2i/templates/active") +async def get_active_t2i_template( + _auth: AuthContext = Depends(require_config_scope), + service: T2iService = Depends(get_service), +): + return await _run(service.get_active_template) + + +@router.put("/t2i/templates/active") +async def set_active_t2i_template( + payload: T2iActiveTemplateRequest, + _auth: AuthContext = Depends(require_config_scope), + service: T2iService = Depends(get_service), +): + return await _run( + lambda: service.set_active_template(payload.name), + result_as_message=True, + ) + + +@router.post("/t2i/templates/default/reset") +async def reset_default_t2i_template( + _auth: AuthContext = Depends(require_config_scope), + service: T2iService = Depends(get_service), +): + return await _run( + service.reset_default_template, + result_as_message=True, + ) + + +@router.get("/t2i/templates/{name:path}") +async def get_t2i_template( + name: str, + _auth: AuthContext = Depends(require_config_scope), + service: T2iService = Depends(get_service), +): + return await _run(lambda: service.get_template(name)) + + +@router.put("/t2i/templates/{name:path}") +async def update_t2i_template( + name: str, + payload: T2iTemplateRequest, + _auth: AuthContext = Depends(require_config_scope), + service: T2iService = Depends(get_service), +): + return await _run(lambda: service.update_template(name, payload.content)) + + +@router.delete("/t2i/templates/{name:path}") +async def delete_t2i_template( + name: str, + _auth: AuthContext = Depends(require_config_scope), + service: T2iService = Depends(get_service), +): + return await _run( + lambda: service.delete_template(name), + message="Template deleted successfully.", + ) + + +@legacy_router.get("/templates") +async def list_dashboard_t2i_templates( + _username: str = Depends(require_dashboard_user), + service: T2iService = Depends(get_service), +): + return await _run(service.list_templates) + + +@legacy_router.get("/templates/active") +async def get_dashboard_active_t2i_template( + _username: str = Depends(require_dashboard_user), + service: T2iService = Depends(get_service), +): + return await _run(service.get_active_template) + + +@legacy_router.post("/templates/create") +async def create_dashboard_t2i_template( + request: Request, + _username: str = Depends(require_dashboard_user), + service: T2iService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run( + lambda: service.create_template(body.get("name"), body.get("content")), + message="Template created successfully.", + status_code=201, + ) + + +@legacy_router.post("/templates/reset_default") +async def reset_dashboard_default_t2i_template( + _username: str = Depends(require_dashboard_user), + service: T2iService = Depends(get_service), +): + return await _run(service.reset_default_template, result_as_message=True) + + +@legacy_router.post("/templates/set_active") +async def set_dashboard_active_t2i_template( + request: Request, + _username: str = Depends(require_dashboard_user), + service: T2iService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run( + lambda: service.set_active_template(body.get("name")), + result_as_message=True, + ) + + +@legacy_router.get("/templates/{name:path}") +async def get_dashboard_t2i_template( + name: str, + _username: str = Depends(require_dashboard_user), + service: T2iService = Depends(get_service), +): + return await _run(lambda: service.get_template(name)) + + +@legacy_router.put("/templates/{name:path}") +async def update_dashboard_t2i_template( + name: str, + request: Request, + _username: str = Depends(require_dashboard_user), + service: T2iService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run(lambda: service.update_template(name, body.get("content"))) + + +@legacy_router.delete("/templates/{name:path}") +async def delete_dashboard_t2i_template( + name: str, + _username: str = Depends(require_dashboard_user), + service: T2iService = Depends(get_service), +): + return await _run( + lambda: service.delete_template(name), + message="Template deleted successfully.", + ) diff --git a/astrbot/dashboard/api/tools.py b/astrbot/dashboard/api/tools.py new file mode 100644 index 000000000..2a253225d --- /dev/null +++ b/astrbot/dashboard/api/tools.py @@ -0,0 +1,426 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Query, Request + +from astrbot.dashboard.async_utils import run_maybe_async +from astrbot.dashboard.responses import ApiError, ok +from astrbot.dashboard.schemas import ( + McpServerByNameRequest, + McpServerRequest, + ModelScopeSyncRequest, + ToolEnabledRequest, + ToolPermissionRequest, +) +from astrbot.dashboard.services.tools_service import ToolsService, ToolsServiceError + +from .auth import AuthContext, require_dashboard_user, require_scope + +router = APIRouter(tags=["Extension Components"]) +legacy_router = APIRouter( + prefix="/api", + tags=["Dashboard Extension Components"], + include_in_schema=False, +) + + +def get_service(request: Request) -> ToolsService: + return request.app.state.services.tools + + +async def require_tool_scope(request: Request) -> AuthContext: + return await require_scope(request, "tool") + + +async def require_mcp_scope(request: Request) -> AuthContext: + return await require_scope(request, "mcp") + + +async def _json_or_empty(request: Request) -> dict[str, Any]: + try: + data = await request.json() + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _required_text(value: object, name: str) -> str: + text = str(value or "").strip() + if not text: + raise ApiError(f"Missing key: {name}") + return text + + +def _model_dict(payload: McpServerRequest | McpServerByNameRequest) -> dict[str, Any]: + return payload.model_dump(exclude_none=True) + + +def _normalize_server_config(body: dict[str, Any], id_key: str) -> dict[str, Any]: + config = body.get("config") + if isinstance(config, dict): + normalized = dict(config) + else: + normalized = { + key: value + for key, value in body.items() + if key not in {id_key, "config", "enabled", "mcp_server_config"} + } + if "enabled" in body and "active" not in normalized: + normalized["active"] = body["enabled"] + return normalized + + +def _server_name_from_body(body: dict[str, Any]) -> str: + return _required_text(body.get("server_name") or body.get("name"), "server_name") + + +def _test_config_body( + service: ToolsService, + server_name: str, + body: dict[str, Any], +) -> dict[str, Any]: + config = body.get("mcp_server_config") or body.get("config") + if isinstance(config, dict): + return dict(config) + + stored_config = service.get_mcp_server_config(server_name) + if stored_config is not None: + return stored_config + + return {"name": server_name} + + +def _raise_tools_error(exc: ToolsServiceError) -> None: + raise ApiError(str(exc)) from exc + + +async def _run( + operation, *, result_as_message: bool = False, message: str | None = None +): + try: + result = await run_maybe_async(operation) + if result_as_message: + return ok(None, str(result)) + return ok(result, message) + except ToolsServiceError as exc: + _raise_tools_error(exc) + + +async def _toggle_tool( + tool_id: str, + enabled: bool, + service: ToolsService, +): + return await _run( + lambda: service.toggle_tool({"name": tool_id, "activate": enabled}), + result_as_message=True, + ) + + +async def _create_mcp_server(body: dict[str, Any], service: ToolsService): + if "enabled" in body and "active" not in body: + body["active"] = body.pop("enabled") + return await _run( + lambda: service.add_mcp_server(body), + result_as_message=True, + ) + + +async def _update_mcp_server( + server_name: str, + body: dict[str, Any], + service: ToolsService, +): + config = _normalize_server_config(body, "server_name") + config.setdefault("name", server_name) + config.setdefault("oldName", server_name) + return await _run( + lambda: service.update_mcp_server(config), + result_as_message=True, + ) + + +async def _delete_mcp_server(server_name: str, service: ToolsService): + return await _run( + lambda: service.delete_mcp_server({"name": server_name}), + result_as_message=True, + ) + + +async def _test_mcp_server( + server_name: str, + body: dict[str, Any], + service: ToolsService, +): + config = _test_config_body(service, server_name, body) + return await _run( + lambda: service.test_mcp_connection( + {"name": server_name, "mcp_server_config": config} + ), + message="🎉 MCP server is available!", + ) + + +async def _sync_modelscope_mcp_servers( + access_token: str, + service: ToolsService, +): + return await _run( + lambda: service.sync_provider( + { + "name": "modelscope", + "access_token": access_token, + } + ), + result_as_message=True, + ) + + +@router.get("/tools") +async def list_tools( + _auth: AuthContext = Depends(require_tool_scope), + service: ToolsService = Depends(get_service), +): + return await _run(service.get_tool_list) + + +@router.patch("/tools/{tool_id:path}/enabled") +async def set_tool_enabled( + tool_id: str, + payload: ToolEnabledRequest, + _auth: AuthContext = Depends(require_tool_scope), + service: ToolsService = Depends(get_service), +): + return await _toggle_tool(tool_id, payload.enabled, service) + + +@router.patch("/tools/{tool_id:path}/permission") +async def set_tool_permission( + tool_id: str, + payload: ToolPermissionRequest, + _auth: AuthContext = Depends(require_tool_scope), + service: ToolsService = Depends(get_service), +): + return await _run( + lambda: service.update_tool_permission( + {"name": tool_id, "permission": payload.permission} + ), + result_as_message=True, + ) + + +@router.get("/mcp/servers") +async def list_mcp_servers( + _auth: AuthContext = Depends(require_mcp_scope), + service: ToolsService = Depends(get_service), +): + return await _run(service.get_mcp_servers) + + +@router.post("/mcp/servers") +async def create_mcp_server( + payload: McpServerRequest, + _auth: AuthContext = Depends(require_mcp_scope), + service: ToolsService = Depends(get_service), +): + return await _create_mcp_server(_model_dict(payload), service) + + +@router.put("/mcp/servers/by-name") +async def update_mcp_server_by_name( + payload: McpServerByNameRequest, + _auth: AuthContext = Depends(require_mcp_scope), + service: ToolsService = Depends(get_service), +): + body = _model_dict(payload) + return await _update_mcp_server(payload.server_name, body, service) + + +@router.delete("/mcp/servers/by-name") +async def delete_mcp_server_by_name( + server_name: str = Query(...), + _auth: AuthContext = Depends(require_mcp_scope), + service: ToolsService = Depends(get_service), +): + return await _delete_mcp_server(server_name, service) + + +@router.patch("/mcp/servers/enabled") +async def set_mcp_server_enabled_by_name( + payload: McpServerByNameRequest, + _auth: AuthContext = Depends(require_mcp_scope), + service: ToolsService = Depends(get_service), +): + body = _model_dict(payload) + return await _update_mcp_server(payload.server_name, body, service) + + +@router.post("/mcp/servers/test") +async def test_mcp_server_by_name( + payload: McpServerByNameRequest, + _auth: AuthContext = Depends(require_mcp_scope), + service: ToolsService = Depends(get_service), +): + body = _model_dict(payload) + return await _test_mcp_server(payload.server_name, body, service) + + +@router.patch("/mcp/servers/{server_name:path}/enabled") +async def set_mcp_server_enabled( + server_name: str, + payload: ToolEnabledRequest, + _auth: AuthContext = Depends(require_mcp_scope), + service: ToolsService = Depends(get_service), +): + return await _update_mcp_server( + server_name, + {"server_name": server_name, "enabled": payload.enabled}, + service, + ) + + +@router.post("/mcp/servers/{server_name:path}/test") +async def test_mcp_server( + server_name: str, + payload: McpServerRequest | None = None, + _auth: AuthContext = Depends(require_mcp_scope), + service: ToolsService = Depends(get_service), +): + body = _model_dict(payload) if payload is not None else {} + return await _test_mcp_server(server_name, body, service) + + +@router.put("/mcp/servers/{server_name:path}") +async def update_mcp_server( + server_name: str, + payload: McpServerRequest, + _auth: AuthContext = Depends(require_mcp_scope), + service: ToolsService = Depends(get_service), +): + body = _model_dict(payload) + return await _update_mcp_server(server_name, body, service) + + +@router.delete("/mcp/servers/{server_name:path}") +async def delete_mcp_server( + server_name: str, + _auth: AuthContext = Depends(require_mcp_scope), + service: ToolsService = Depends(get_service), +): + return await _delete_mcp_server(server_name, service) + + +@router.post("/mcp/providers/modelscope/sync") +async def sync_modelscope_mcp_servers( + payload: ModelScopeSyncRequest | None = None, + _auth: AuthContext = Depends(require_mcp_scope), + service: ToolsService = Depends(get_service), +): + access_token = payload.access_token if payload is not None else "" + return await _sync_modelscope_mcp_servers(access_token or "", service) + + +@legacy_router.get("/tools/list") +async def list_dashboard_tools( + _username: str = Depends(require_dashboard_user), + service: ToolsService = Depends(get_service), +): + return await _run(service.get_tool_list) + + +@legacy_router.post("/tools/toggle-tool") +async def toggle_dashboard_tool( + request: Request, + _username: str = Depends(require_dashboard_user), + service: ToolsService = Depends(get_service), +): + body = await _json_or_empty(request) + tool_id = _required_text(body.get("name"), "name") + return await _toggle_tool(tool_id, bool(body.get("activate")), service) + + +@legacy_router.post("/tools/permission") +async def update_dashboard_tool_permission( + request: Request, + _username: str = Depends(require_dashboard_user), + service: ToolsService = Depends(get_service), +): + body = await _json_or_empty(request) + tool_id = _required_text(body.get("name"), "name") + return await _run( + lambda: service.update_tool_permission( + {"name": tool_id, "permission": body.get("permission")} + ), + result_as_message=True, + ) + + +@legacy_router.get("/tools/mcp/servers") +async def list_dashboard_mcp_servers( + _username: str = Depends(require_dashboard_user), + service: ToolsService = Depends(get_service), +): + return await _run(service.get_mcp_servers) + + +@legacy_router.post("/tools/mcp/add") +async def add_dashboard_mcp_server( + request: Request, + _username: str = Depends(require_dashboard_user), + service: ToolsService = Depends(get_service), +): + return await _create_mcp_server(await _json_or_empty(request), service) + + +@legacy_router.post("/tools/mcp/update") +async def update_dashboard_mcp_server( + request: Request, + _username: str = Depends(require_dashboard_user), + service: ToolsService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _update_mcp_server(_server_name_from_body(body), body, service) + + +@legacy_router.post("/tools/mcp/delete") +async def delete_dashboard_mcp_server( + request: Request, + _username: str = Depends(require_dashboard_user), + service: ToolsService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _delete_mcp_server(_required_text(body.get("name"), "name"), service) + + +@legacy_router.post("/tools/mcp/test") +async def test_dashboard_mcp_connection( + request: Request, + _username: str = Depends(require_dashboard_user), + service: ToolsService = Depends(get_service), +): + body = await _json_or_empty(request) + server_name = str(body.get("name") or "") + config = body.get("mcp_server_config") or body.get("config") or body + return await _run( + lambda: service.test_mcp_connection( + { + "name": server_name, + "mcp_server_config": config, + } + ), + message="🎉 MCP server is available!", + ) + + +@legacy_router.post("/tools/mcp/sync-provider") +async def sync_dashboard_mcp_provider( + request: Request, + _username: str = Depends(require_dashboard_user), + service: ToolsService = Depends(get_service), +): + body = await _json_or_empty(request) + return await _run( + lambda: service.sync_provider(body), + result_as_message=True, + ) diff --git a/astrbot/dashboard/api/updates.py b/astrbot/dashboard/api/updates.py new file mode 100644 index 000000000..ab07cd25c --- /dev/null +++ b/astrbot/dashboard/api/updates.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import JSONResponse + +from astrbot.core import logger +from astrbot.dashboard.async_utils import run_maybe_async +from astrbot.dashboard.schemas import PipInstallRequest, UpdateRequest +from astrbot.dashboard.services.update_service import ( + UpdateService, + UpdateServiceError, + UpdateServiceResult, +) + +from .auth import AuthContext, require_dashboard_user, require_scope + +router = APIRouter(tags=["Updates"]) +legacy_router = APIRouter( + prefix="/api/update", + tags=["Dashboard Updates"], + include_in_schema=False, +) + + +def get_service(request: Request) -> UpdateService: + return request.app.state.services.updates + + +async def require_system_scope(request: Request) -> AuthContext: + return await require_scope(request, "system") + + +def _model_dict(payload) -> dict: + return payload.model_dump(exclude_none=True) + + +def _result_payload(result: UpdateServiceResult) -> dict: + if result.status == "success": + return { + "status": "success", + "message": result.message, + "data": result.data, + } + return { + "status": "ok", + "message": result.message, + "data": {} if result.data is None else result.data, + } + + +def _service_response(result: UpdateServiceResult) -> JSONResponse: + return JSONResponse( + _result_payload(result), + status_code=200, + headers=result.headers or None, + ) + + +def _service_error(exc: UpdateServiceError) -> JSONResponse: + logger.error(f"Dashboard update operation failed: {exc}", exc_info=True) + return JSONResponse( + {"status": "error", "message": "An internal error has occurred.", "data": None}, + status_code=200, + ) + + +async def _run(operation) -> JSONResponse: + try: + result = await run_maybe_async(operation) + return _service_response(result) + except UpdateServiceError as exc: + return _service_error(exc) + + +@router.get("/updates/check") +async def check_updates( + update_type: str | None = Query(default=None, alias="type"), + _auth: AuthContext = Depends(require_system_scope), + service: UpdateService = Depends(get_service), +): + return await _run(lambda: service.check_update(update_type)) + + +@legacy_router.get("/check") +async def check_dashboard_updates( + update_type: str | None = Query(default=None, alias="type"), + _username: str = Depends(require_dashboard_user), + service: UpdateService = Depends(get_service), +): + return await _run(lambda: service.check_update(update_type)) + + +@router.get("/updates/releases") +async def update_releases( + _auth: AuthContext = Depends(require_system_scope), + service: UpdateService = Depends(get_service), +): + return await _run(service.get_releases) + + +@legacy_router.get("/releases") +async def dashboard_update_releases( + _username: str = Depends(require_dashboard_user), + service: UpdateService = Depends(get_service), +): + return await _run(service.get_releases) + + +@router.get("/updates/progress/{task_id}") +async def update_progress( + task_id: str, + _auth: AuthContext = Depends(require_system_scope), + service: UpdateService = Depends(get_service), +): + return await _run(lambda: service.get_update_progress(task_id)) + + +@legacy_router.get("/progress") +async def dashboard_update_progress( + progress_id: str | None = Query(default=None, alias="id"), + _username: str = Depends(require_dashboard_user), + service: UpdateService = Depends(get_service), +): + return await _run(lambda: service.get_update_progress(progress_id or "")) + + +@router.post("/updates/core") +async def update_core( + payload: UpdateRequest, + _auth: AuthContext = Depends(require_system_scope), + service: UpdateService = Depends(get_service), +): + return await _run(lambda: service.update_project(_model_dict(payload))) + + +@legacy_router.post("/do") +async def update_dashboard_core( + payload: UpdateRequest, + _username: str = Depends(require_dashboard_user), + service: UpdateService = Depends(get_service), +): + return await _run(lambda: service.update_project(_model_dict(payload))) + + +@router.post("/updates/dashboard") +async def update_dashboard( + _auth: AuthContext = Depends(require_system_scope), + service: UpdateService = Depends(get_service), +): + return await _run(service.update_dashboard) + + +@legacy_router.post("/dashboard") +async def update_dashboard_assets( + _username: str = Depends(require_dashboard_user), + service: UpdateService = Depends(get_service), +): + return await _run(service.update_dashboard) + + +@router.post("/pip/install") +async def install_pip_package( + payload: PipInstallRequest, + _auth: AuthContext = Depends(require_system_scope), + service: UpdateService = Depends(get_service), +): + return await _run(lambda: service.install_pip_package(_model_dict(payload))) + + +@legacy_router.post("/pip-install") +async def install_dashboard_pip_package( + payload: PipInstallRequest, + _username: str = Depends(require_dashboard_user), + service: UpdateService = Depends(get_service), +): + return await _run(lambda: service.install_pip_package(_model_dict(payload))) diff --git a/astrbot/dashboard/asgi_runtime.py b/astrbot/dashboard/asgi_runtime.py new file mode 100644 index 000000000..b2dbef020 --- /dev/null +++ b/astrbot/dashboard/asgi_runtime.py @@ -0,0 +1,711 @@ +from __future__ import annotations + +import contextvars +import inspect +import re +from collections.abc import Callable, Iterable +from contextlib import asynccontextmanager, contextmanager +from pathlib import Path +from typing import Any + +import httpx +from fastapi import FastAPI, HTTPException, Request, WebSocket +from fastapi.encoders import jsonable_encoder +from fastapi.responses import FileResponse, JSONResponse, Response +from starlette.datastructures import UploadFile as StarletteUploadFile +from starlette.responses import StreamingResponse + +_request_var: contextvars.ContextVar[DashboardRequest] = contextvars.ContextVar( + "dashboard_request" +) +_websocket_var: contextvars.ContextVar[DashboardWebSocket] = contextvars.ContextVar( + "dashboard_websocket" +) +_g_var: contextvars.ContextVar[DashboardRequestState] = contextvars.ContextVar( + "dashboard_g" +) +_app_var: contextvars.ContextVar[FastAPIAppAdapter] = contextvars.ContextVar( + "dashboard_app" +) + + +class RequestArgs: + def __init__(self, values) -> None: + self._values = values + + def get(self, key: str, default: Any = None, type: Callable | None = None): + value = self._values.get(key, default) + if value is default or type is None: + return value + try: + return type(value) + except (TypeError, ValueError): + return default + + +class RequestMultiDict: + def __init__(self, pairs: list[tuple[str, Any]]) -> None: + self._pairs = pairs + + def get(self, key: str, default: Any = None, type: Callable | None = None): + for item_key, item_value in reversed(self._pairs): + if item_key != key: + continue + if type is None: + return item_value + try: + return type(item_value) + except (TypeError, ValueError): + return default + return default + + def getlist(self, key: str) -> list[Any]: + return [item_value for item_key, item_value in self._pairs if item_key == key] + + def keys(self): + return dict.fromkeys(item_key for item_key, _ in self._pairs).keys() + + def values(self): + return [self[key] for key in self.keys()] + + def items(self): + return [(key, self[key]) for key in self.keys()] + + def __contains__(self, key: str) -> bool: + return any(item_key == key for item_key, _ in self._pairs) + + def __getitem__(self, key: str): + value = self.get(key) + if value is None and key not in self: + raise KeyError(key) + return value + + def __bool__(self) -> bool: + return bool(self._pairs) + + +class RequestUploadFile: + def __init__(self, upload_file: StarletteUploadFile) -> None: + self._upload_file = upload_file + self.filename = upload_file.filename + self.content_type = upload_file.content_type + self.headers = upload_file.headers + self.content_length = self._resolve_content_length() + + def _resolve_content_length(self) -> int | None: + try: + raw = self.headers.get("content-length") + return int(raw) if raw else None + except (TypeError, ValueError): + return None + + async def save(self, destination: str | Path) -> None: + path = Path(destination) + try: + await self._upload_file.seek(0) + except Exception: + pass + with path.open("wb") as output: + while True: + chunk = await self._upload_file.read(1024 * 1024) + if not chunk: + break + output.write(chunk) + + def __getattr__(self, key: str): + return getattr(self._upload_file, key) + + +class DashboardRequestState: + def __init__(self) -> None: + self._values: dict[str, Any] = {} + + def get(self, key: str, default: Any = None): + return self._values.get(key, default) + + def __getattr__(self, key: str): + try: + return self._values[key] + except KeyError as exc: + raise AttributeError(key) from exc + + def __setattr__(self, key: str, value: Any) -> None: + if key == "_values": + super().__setattr__(key, value) + return + self._values[key] = value + + +class DashboardRequest: + def __init__(self, request: Request) -> None: + self._request = request + self.args = RequestArgs(request.query_params) + self.headers = request.headers + self.cookies = request.cookies + self.method = request.method + self.path = request.url.path + self.content_type = request.headers.get("content-type") + self.remote_addr = request.client.host if request.client else None + self._form_cache: RequestMultiDict | None = None + self._files_cache: RequestMultiDict | None = None + + @property + def json(self): + return self.get_json() + + @property + def files(self): + return self._load_files() + + @property + def form(self): + return self._load_form() + + async def get_json(self, silent: bool = False): + try: + return await self._request.json() + except Exception: + if silent: + return None + raise + + async def _load_form_parts(self) -> None: + if self._form_cache is not None and self._files_cache is not None: + return + form = await self._request.form() + form_pairs: list[tuple[str, Any]] = [] + file_pairs: list[tuple[str, Any]] = [] + for key, value in form.multi_items(): + if isinstance(value, StarletteUploadFile): + file_pairs.append((key, RequestUploadFile(value))) + else: + form_pairs.append((key, value)) + self._form_cache = RequestMultiDict(form_pairs) + self._files_cache = RequestMultiDict(file_pairs) + + async def _load_form(self) -> RequestMultiDict: + await self._load_form_parts() + assert self._form_cache is not None + return self._form_cache + + async def _load_files(self) -> RequestMultiDict: + await self._load_form_parts() + assert self._files_cache is not None + return self._files_cache + + +class DashboardWebSocket: + def __init__(self, websocket: WebSocket) -> None: + self._websocket = websocket + self.args = RequestArgs(websocket.query_params) + self.headers = websocket.headers + + async def accept(self) -> None: + await self._websocket.accept() + + async def receive_json(self): + return await self._websocket.receive_json() + + async def send_json(self, payload: Any) -> None: + await self._websocket.send_json(payload) + + async def close(self, code: int = 1000, reason: str | None = None) -> None: + await self._websocket.close(code=code, reason=reason or "") + + +class AdapterTestHeaders: + def __init__(self, headers: httpx.Headers) -> None: + self._headers = headers + + def getlist(self, key: str) -> list[str]: + values = self._headers.get_list(key) + if key.lower() == "set-cookie": + return [value.replace('=""', "=") for value in values] + return values + + def get(self, key: str, default: Any = None): + value = self._headers.get(key, default) + if isinstance(value, str) and key.lower() == "set-cookie": + return value.replace('=""', "=") + return value + + def __getitem__(self, key: str): + return self._headers[key] + + def __contains__(self, key: str) -> bool: + return key in self._headers + + +class AdapterTestResponse: + def __init__(self, response: httpx.Response) -> None: + self._response = response + self.status_code = response.status_code + self.headers = AdapterTestHeaders(response.headers) + self.data = response.content + self.content = response.content + self.text = response.text + + async def get_json(self): + return self._response.json() + + async def get_data(self): + return self._response.content + + +class AdapterTestClient: + def __init__(self, app: FastAPI) -> None: + self._client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) + + @staticmethod + def _is_file_storage(value: Any) -> bool: + return hasattr(value, "stream") and hasattr(value, "filename") + + @classmethod + def _file_tuple(cls, value: Any): + stream = value.stream + if hasattr(stream, "seek"): + stream.seek(0) + content = stream.read() + filename = getattr(value, "filename", "upload.bin") + content_type = getattr(value, "content_type", None) + return filename, content, content_type + + @classmethod + def _normalize_data(cls, data: Any): + if not isinstance(data, dict): + return data, None + + form: dict[str, Any] = {} + files: list[tuple[str, tuple]] = [] + for key, value in data.items(): + if cls._is_file_storage(value): + files.append((key, cls._file_tuple(value))) + continue + if isinstance(value, Iterable) and not isinstance( + value, str | bytes | dict + ): + values = list(value) + if values and all(cls._is_file_storage(item) for item in values): + files.extend((key, cls._file_tuple(item)) for item in values) + continue + form[key] = value + return form, files or None + + @classmethod + def _normalize_files(cls, files: Any): + if isinstance(files, dict): + items = files.items() + elif isinstance(files, Iterable) and not isinstance(files, str | bytes): + items = files + else: + return files + + normalized_files: list[tuple[str, Any]] = [] + for key, value in items: + if cls._is_file_storage(value): + normalized_files.append((key, cls._file_tuple(value))) + continue + if isinstance(value, Iterable) and not isinstance( + value, str | bytes | dict + ): + values = list(value) + if values and all(cls._is_file_storage(item) for item in values): + normalized_files.extend( + (key, cls._file_tuple(item)) for item in values + ) + continue + normalized_files.append((key, value)) + return normalized_files + + async def request(self, method: str, url: str, **kwargs): + data = kwargs.pop("data", None) + if data is not None and "files" not in kwargs: + normalized_data, files = self._normalize_data(data) + kwargs["data"] = normalized_data + if files: + kwargs["files"] = files + elif data is not None: + kwargs["data"] = data + if "files" in kwargs: + kwargs["files"] = self._normalize_files(kwargs["files"]) + response = await self._client.request(method, url, **kwargs) + return AdapterTestResponse(response) + + async def get(self, url: str, **kwargs): + return await self.request("GET", url, **kwargs) + + async def post(self, url: str, **kwargs): + return await self.request("POST", url, **kwargs) + + async def put(self, url: str, **kwargs): + return await self.request("PUT", url, **kwargs) + + async def patch(self, url: str, **kwargs): + return await self.request("PATCH", url, **kwargs) + + async def delete(self, url: str, **kwargs): + return await self.request("DELETE", url, **kwargs) + + +class _ContextProxy: + def __init__(self, var) -> None: + self._var = var + + def __getattr__(self, key: str): + return getattr(self._var.get(), key) + + def __setattr__(self, key: str, value: Any) -> None: + if key == "_var": + super().__setattr__(key, value) + return + setattr(self._var.get(), key, value) + + +request = _ContextProxy(_request_var) +websocket = _ContextProxy(_websocket_var) +g = _ContextProxy(_g_var) +current_app = _ContextProxy(_app_var) + + +@contextmanager +def bind_request_context( + request_: Request, + app: FastAPIAppAdapter, + g_obj: DashboardRequestState | None = None, +): + token_request = _request_var.set(DashboardRequest(request_)) + token_g = _g_var.set( + g_obj or getattr(request_.state, "dashboard_g", DashboardRequestState()) + ) + token_app = _app_var.set(app) + try: + yield _g_var.get() + finally: + _app_var.reset(token_app) + _g_var.reset(token_g) + _request_var.reset(token_request) + + +@contextmanager +def bind_websocket_context( + websocket_: WebSocket, + app: FastAPIAppAdapter, + g_obj: DashboardRequestState | None = None, +): + token_websocket = _websocket_var.set(DashboardWebSocket(websocket_)) + token_g = _g_var.set( + g_obj or getattr(websocket_.state, "dashboard_g", DashboardRequestState()) + ) + token_app = _app_var.set(app) + try: + yield + finally: + _app_var.reset(token_app) + _g_var.reset(token_g) + _websocket_var.reset(token_websocket) + + +def jsonify(payload: Any = None): + return JSONResponse(payload if payload is not None else {}) + + +async def make_response(*args): + if not args: + return Response() + content = args[0] + status_code = args[1] if len(args) > 1 and isinstance(args[1], int) else None + headers = args[1] if len(args) > 1 and isinstance(args[1], dict) else None + if len(args) > 2 and isinstance(args[2], dict): + headers = args[2] + if isinstance(content, Response): + if status_code is not None: + content.status_code = status_code + if headers: + content.headers.update(headers) + return content + if hasattr(content, "__aiter__"): + return StreamingResponse( + content, + status_code=status_code or 200, + headers=headers, + ) + return Response( + content=content, + status_code=status_code or 200, + headers=headers, + ) + + +async def send_file(path: str | Path, mimetype: str | None = None, **kwargs): + filename = kwargs.get("attachment_filename") or kwargs.get("download_name") + as_attachment = bool(kwargs.get("as_attachment")) + return FileResponse( + path, + media_type=mimetype, + filename=filename if as_attachment else None, + ) + + +def abort(status_code: int): + raise HTTPException(status_code=status_code) + + +def _convert_rule(path: str) -> str: + converted = re.sub(r"", r"{\1:path}", path) + converted = re.sub(r"<([A-Za-z_][A-Za-z0-9_]*)>", r"{\1}", converted) + return converted + + +async def _call_view(view_func: Callable, path_params: dict[str, Any]): + result = view_func(**path_params) + if inspect.isawaitable(result): + result = await result + return await _coerce_view_result(result) + + +async def _coerce_view_result(result: Any): + if isinstance(result, Response): + return result + if _is_quart_response(result): + return await _quart_response_to_starlette(result) + + if isinstance(result, tuple): + content = result[0] if result else None + status_code = next((item for item in result[1:] if isinstance(item, int)), 200) + headers = next( + (item for item in result[1:] if isinstance(item, dict)), + None, + ) + if content is not None and isinstance(content, Response): + content.status_code = status_code + if headers: + content.headers.update(headers) + return content + if _is_quart_response(content): + return await _quart_response_to_starlette( + content, + status_code=status_code, + extra_headers=headers, + ) + return _response_from_content(content, status_code=status_code, headers=headers) + + if isinstance(result, dict | list): + return JSONResponse(jsonable_encoder(result)) + return result + + +def _response_from_content( + content: Any, + *, + status_code: int, + headers: dict[str, str] | None = None, +): + if isinstance(content, dict | list): + return JSONResponse( + jsonable_encoder(content), + status_code=status_code, + headers=headers, + ) + return Response( + content=content, + status_code=status_code, + headers=headers, + ) + + +def _is_quart_response(value: Any) -> bool: + return ( + hasattr(value, "get_data") + and inspect.iscoroutinefunction(value.get_data) + and hasattr(value, "headers") + and hasattr(value, "status_code") + ) + + +def _response_header_pairs(headers: Any) -> list[tuple[str, str]]: + if headers is None: + return [] + if hasattr(headers, "to_wsgi_list"): + return [(str(key), str(value)) for key, value in headers.to_wsgi_list()] + if hasattr(headers, "items"): + return [(str(key), str(value)) for key, value in headers.items()] + return [(str(key), str(value)) for key, value in headers] + + +async def _quart_response_to_starlette( + quart_response: Any, + *, + status_code: int | None = None, + extra_headers: dict[str, str] | None = None, +) -> Response: + content = await quart_response.get_data() + response = Response( + content=content, + status_code=status_code or int(quart_response.status_code), + ) + pairs = _response_header_pairs(quart_response.headers) + if extra_headers: + pairs.extend((str(key), str(value)) for key, value in extra_headers.items()) + response.raw_headers = [ + (key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in pairs + ] + return response + + +@asynccontextmanager +async def bind_quart_request_context( + request_: Request, + app: FastAPIAppAdapter, + *, + path: str | None = None, + g_obj: DashboardRequestState | None = None, +): + try: + from quart import g as quart_g + except ImportError: + yield + return + + quart_app = app.get_quart_compat_app() + headers = { + key.decode("latin-1"): value.decode("latin-1") + for key, value in request_.scope.get("headers", []) + } + body = await request_.body() + request_path = path or str(request_.url.path) + if "?" not in request_path and request_.url.query: + request_path = f"{request_path}?{request_.url.query}" + + async with quart_app.test_request_context( + request_path, + method=request_.method, + headers=headers, + data=body, + scheme=request_.url.scheme, + root_path=request_.scope.get("root_path", ""), + scope_base={ + "client": request_.scope.get("client"), + "server": request_.scope.get("server"), + }, + ): + if g_obj is not None: + for key, value in getattr(g_obj, "_values", {}).items(): + setattr(quart_g, key, value) + yield + + +async def call_request_view( + request_: Request, + app: FastAPIAppAdapter, + view_func: Callable, + path_params: dict[str, Any] | None = None, + g_obj: DashboardRequestState | None = None, + quart_compat_path: str | None = None, +): + with bind_request_context(request_, app, g_obj): + async with bind_quart_request_context( + request_, + app, + path=quart_compat_path, + g_obj=g_obj, + ): + return await _call_view(view_func, path_params or {}) + + +async def call_websocket_view( + websocket_: WebSocket, + app: FastAPIAppAdapter, + view_func: Callable, + path_params: dict[str, Any] | None = None, + *, + accept: bool = True, +): + if accept: + await websocket_.accept() + with bind_websocket_context(websocket_, app): + return await _call_view(view_func, path_params or {}) + + +class FastAPIAppAdapter: + def __init__(self, app: FastAPI, static_folder: str | None = None) -> None: + self._app = app + app.state.dashboard_app_adapter = self + self.static_folder = static_folder + self._dashboard_server: Any | None = None + self.config: dict[str, Any] = {} + self.debug = False + self.testing = False + self.name = "dashboard" + self._quart_compat_app: Any | None = None + + def get_quart_compat_app(self): + if self._quart_compat_app is None: + from quart import Quart + + self._quart_compat_app = Quart("astrbot_dashboard_plugin_compat") + self._quart_compat_app.json.sort_keys = False + return self._quart_compat_app + + def add_url_rule( + self, + path: str, + view_func: Callable, + methods: list[str] | None = None, + endpoint: str | None = None, + ) -> None: + route_path = _convert_rule(path) + methods = methods or ["GET"] + + async def endpoint_func(request_: Request): + with bind_request_context(request_, self): + return await _call_view(view_func, dict(request_.path_params)) + + self._app.add_api_route( + route_path, + endpoint_func, + methods=methods, + name=endpoint, + include_in_schema=False, + ) + + def websocket(self, path: str): + route_path = _convert_rule(path) + + def decorator(view_func: Callable): + async def endpoint_func(websocket_: WebSocket): + return await call_websocket_view( + websocket_, + self, + view_func, + dict(websocket_.path_params), + ) + + self._app.add_api_websocket_route( + route_path, + endpoint_func, + name=getattr(view_func, "__name__", None), + ) + return view_func + + return decorator + + def errorhandler(self, _status_code: int): + def decorator(func: Callable): + return func + + return decorator + + async def send_static_file(self, filename: str): + if not self.static_folder: + raise HTTPException(status_code=404) + return FileResponse(Path(self.static_folder) / filename) + + def test_client(self): + self.testing = True + return AdapterTestClient(self._app) + + +AdapterResponse = Response diff --git a/astrbot/dashboard/async_utils.py b/astrbot/dashboard/async_utils.py new file mode 100644 index 000000000..fbad60c9c --- /dev/null +++ b/astrbot/dashboard/async_utils.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import inspect +from collections.abc import Awaitable, Callable +from typing import Any, TypeVar, cast + +T = TypeVar("T") + + +async def resolve_maybe_awaitable(value: T | Awaitable[T]) -> T: + while inspect.isawaitable(value): + value = await cast(Awaitable[T], value) + return cast(T, value) + + +async def run_maybe_async( + operation: Callable[[], T | Awaitable[T]] | T | Awaitable[T], +) -> T: + result: Any = operation() if callable(operation) else operation + return await resolve_maybe_awaitable(result) diff --git a/astrbot/dashboard/password_state.py b/astrbot/dashboard/password_state.py index 5a0873cba..61c22ca2e 100644 --- a/astrbot/dashboard/password_state.py +++ b/astrbot/dashboard/password_state.py @@ -2,8 +2,8 @@ from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.db import BaseDatabase from astrbot.core.utils.auth_password import ( hash_dashboard_password, - hash_legacy_dashboard_password, - is_legacy_dashboard_password, + hash_md5_dashboard_password, + is_md5_dashboard_password, ) PASSWORD_STORAGE_UPGRADED_KEY = "password_storage_upgraded" @@ -92,12 +92,12 @@ def get_dashboard_password_hash(config: AstrBotConfig, *, upgraded: bool) -> str if upgraded and _has_usable_pbkdf2_password(config): return config["dashboard"].get("pbkdf2_password", "") - legacy_password = config["dashboard"].get("password", "") - if upgraded and not is_legacy_dashboard_password(legacy_password): + md5_password = config["dashboard"].get("password", "") + if upgraded and not is_md5_dashboard_password(md5_password): return "" - return legacy_password + return md5_password def set_dashboard_password_hashes(config: AstrBotConfig, raw_password: str) -> None: config["dashboard"]["pbkdf2_password"] = hash_dashboard_password(raw_password) - config["dashboard"]["password"] = hash_legacy_dashboard_password(raw_password) + config["dashboard"]["password"] = hash_md5_dashboard_password(raw_password) diff --git a/astrbot/dashboard/plugin_page_auth.py b/astrbot/dashboard/plugin_page_auth.py index fadfbd50f..ee52b191d 100644 --- a/astrbot/dashboard/plugin_page_auth.py +++ b/astrbot/dashboard/plugin_page_auth.py @@ -1,7 +1,5 @@ from urllib.parse import unquote -from quart import request - PLUGIN_PAGE_CONTENT_PREFIX = "/api/plugin/page/content/" PLUGIN_PAGE_BRIDGE_PATH = "/api/plugin/page/bridge-sdk.js" PLUGIN_PAGE_TOKEN_TYPE = "plugin_page_asset" @@ -19,8 +17,8 @@ class PluginPageAuth: return payload.get("token_type") == PLUGIN_PAGE_TOKEN_TYPE @staticmethod - def extract_asset_token() -> str | None: - query_asset_token = request.args.get("asset_token", "").strip() + def extract_asset_token(query_params) -> str | None: + query_asset_token = query_params.get("asset_token", "").strip() return query_asset_token or None @staticmethod diff --git a/astrbot/dashboard/plugin_page_bridge.js b/astrbot/dashboard/plugin_page_bridge.js index 650e30e44..f1ecc198d 100644 --- a/astrbot/dashboard/plugin_page_bridge.js +++ b/astrbot/dashboard/plugin_page_bridge.js @@ -40,7 +40,7 @@ return origin === SELF_ORIGIN; } - function send(kind, payload) { + function send(kind, payload, transfer) { window.parent.postMessage( { channel: CHANNEL, @@ -48,19 +48,24 @@ ...(payload || {}), }, getTargetOrigin(), + transfer || [], ); } - function makeRequest(action, payload) { + function makeRequest(action, payload, transfer) { return new Promise((resolve, reject) => { requestCounter += 1; const requestId = `plugin_req_${requestCounter}`; pendingRequests.set(requestId, { resolve, reject }); - send("request", { - requestId, - action, - ...(payload || {}), - }); + send( + "request", + { + requestId, + action, + ...(payload || {}), + }, + transfer, + ); }); } @@ -181,6 +186,7 @@ handlers.onMessage({ raw: message.data, parsed: parseMaybeJson(message.data), + eventType: message.eventType || "message", lastEventId: message.lastEventId, }); } @@ -235,12 +241,23 @@ apiPost(endpoint, body) { return makeRequest("api:post", { endpoint, body }); }, - upload(endpoint, file) { - return makeRequest("files:upload", { - endpoint, - file, - fileName: file?.name || "upload.bin", - }); + async upload(endpoint, file) { + if (!file || typeof file.arrayBuffer !== "function") { + throw new Error("Missing uploaded file payload."); + } + const fileBuffer = await file.arrayBuffer(); + return makeRequest( + "files:upload", + { + endpoint, + fileName: file?.name || "upload.bin", + fileType: file?.type || "application/octet-stream", + fileLastModified: + typeof file?.lastModified === "number" ? file.lastModified : null, + fileBuffer, + }, + [fileBuffer], + ); }, download(endpoint, params, filename) { return makeRequest("files:download", { endpoint, params, filename }); diff --git a/astrbot/dashboard/responses.py b/astrbot/dashboard/responses.py new file mode 100644 index 000000000..7eea77383 --- /dev/null +++ b/astrbot/dashboard/responses.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class ApiError(Exception): + message: str + status_code: int = 400 + data: Any = None + + +def ok(data: Any = None, message: str | None = None) -> dict[str, Any]: + return {"status": "ok", "message": message, "data": {} if data is None else data} + + +def error(message: str, data: Any = None) -> dict[str, Any]: + payload: dict[str, Any] = {"status": "error", "message": message} + if data is not None: + payload["data"] = data + return payload diff --git a/astrbot/dashboard/routes/auth.py b/astrbot/dashboard/routes/auth.py deleted file mode 100644 index 19d26ff9c..000000000 --- a/astrbot/dashboard/routes/auth.py +++ /dev/null @@ -1,568 +0,0 @@ -import asyncio -import datetime -import os -import secrets - -import jwt -import pyotp -from quart import current_app, g, jsonify, make_response, request - -from astrbot import logger -from astrbot.core import DEMO_MODE -from astrbot.core.utils.auth_password import ( - get_dashboard_login_challenge, - is_default_dashboard_password, - is_legacy_dashboard_password, - validate_dashboard_password, - verify_dashboard_login_proof, - verify_dashboard_password, -) -from astrbot.core.utils.totp import ( - TOTP_TRUSTED_DEVICE_COOKIE_NAME, - TOTP_TRUSTED_DEVICE_MAX_AGE, - TwoFactorCodeType, - consume_configured_totp_code, - consume_rotation_verified, - consume_totp_code, - generate_recovery_code, - is_totp_enabled, - is_totp_trusted_device_valid, - issue_totp_trusted_device, - revoke_user_trusted_devices, - set_pending_totp_secret, - set_rotation_verified, - verify_configured_2fa_code, -) -from astrbot.dashboard.password_state import ( - get_dashboard_password_hash, - is_password_change_required, - is_password_storage_upgraded, - set_dashboard_password_hashes, - set_password_change_required, - set_password_storage_upgraded, -) - -from .route import Response, Route, RouteContext - -DASHBOARD_JWT_COOKIE_NAME = "astrbot_dashboard_jwt" -DASHBOARD_JWT_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 -SKIP_DEFAULT_PASSWORD_AUTH_ENV = "ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH" -SKIP_DEFAULT_PASSWORD_AUTH_ENV_LEGACY = "DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH" -LOCAL_DASHBOARD_HOSTS = {"127.0.0.1", "localhost", "::1"} -DEFAULT_PASSWORD_LOGIN_FAILURE_MESSAGE = ( - "Login failed. If this is your first time using AstrBot, the old default " - "astrbot password has been replaced by a random strong password printed in " - "the startup logs. Check the initial password in the logs and try again. " - "Learn more: https://docs.astrbot.app/en/faq.html\n\n" - "登录失败。如果您是初次使用,旧版默认 astrbot 密码已改为启动日志中输出的" - "随机强密码。请使用日志中提供的的初始密码来登录。了解更多:" - "https://docs.astrbot.app/faq.html" -) -LEGACY_PASSWORD_LOGIN_FAILURE_MESSAGE = ( - "Incorrect username or password. If you cannot log in after upgrading " - "AstrBot even though the password is correct, see " - "https://docs.astrbot.app/en/faq.html\n\n" - "用户名或密码错误。如果你在升级 AstrBot 后遇到了密码正确但无法登录的情况," - "请参考 https://docs.astrbot.app/faq.html" -) - - -class AuthRoute(Route): - def __init__(self, context: RouteContext, db) -> None: - super().__init__(context) - self.db = db - self._login_challenges: dict[str, dict[str, object]] = {} - self.routes = { - "/auth/login/challenge": ("POST", self.login_challenge), - "/auth/login": ("POST", self.login), - "/auth/logout": ("POST", self.logout), - "/auth/setup-status": ("GET", self.setup_status), - "/auth/setup": ("POST", self.setup), - "/auth/setup-authenticated": ("POST", self.setup_authenticated), - "/auth/totp/setup": ("POST", self.totp_setup), - "/auth/totp/recovery": ("POST", self.totp_recovery), - "/auth/account/edit": ("POST", self.edit_account), - } - self.register_routes() - - async def login_challenge(self): - password = self.config["dashboard"]["password"] - self._prune_login_challenges() - - try: - challenge = get_dashboard_login_challenge(password) - except ValueError as exc: - logger.error("Failed to create dashboard login challenge: %s", exc) - return ( - Response() - .error("Unsupported dashboard password configuration") - .__dict__ - ) - - challenge_id = secrets.token_hex(16) - nonce = secrets.token_hex(32) - self._login_challenges[challenge_id] = { - "nonce": nonce, - "expires_at": datetime.datetime.now(datetime.UTC) - + datetime.timedelta(minutes=1), - } - - return ( - Response() - .ok( - { - "challenge_id": challenge_id, - "nonce": nonce, - **challenge, - }, - ) - .__dict__ - ) - - async def setup_status(self): - return ( - Response() - .ok( - { - "setup_required": await self._is_setup_required(), - "skip_default_password_auth": self._can_skip_default_password_auth(), - "password_upgrade_required": not await is_password_storage_upgraded( - self.db, - self.config, - ), - }, - ) - .__dict__ - ) - - async def totp_setup(self): - post_data = await request.json - - if isinstance(post_data, dict) and post_data.get("secret"): - secret = post_data["secret"] - code = post_data.get("code") - if not isinstance(secret, str) or not secret.strip(): - return Response().error("Invalid request payload").__dict__ - - if not isinstance(code, str) or not code.strip(): - return Response().error("TOTP 验证码是必需的").__dict__ - if not await consume_totp_code(secret, code): - return Response().error("TOTP 验证码无效").__dict__ - - if is_totp_enabled(self.config) and not consume_rotation_verified(): - return Response().error("需要先验证当前 TOTP").__dict__ - - set_pending_totp_secret(secret) - recovery_code, recovery_code_hash = generate_recovery_code() - return ( - Response() - .ok( - { - "recovery_code": recovery_code, - "recovery_code_hash": recovery_code_hash, - }, - "TOTP verified", - ) - .__dict__ - ) - - if is_totp_enabled(self.config): - if not isinstance(post_data, dict): - return Response().error("Invalid request payload").__dict__ - - set_rotation_verified(False) - - code = post_data.get("code") - if isinstance(code, str) and code.strip(): - if await consume_configured_totp_code(self.config, code): - set_rotation_verified(True) - return Response().ok({"secret": pyotp.random_base32()}).__dict__ - return Response().error("当前 TOTP 验证码无效").__dict__ - - return Response().error("需要提供 TOTP 验证码或新密钥").__dict__ - - return Response().ok({"secret": pyotp.random_base32()}).__dict__ - - async def totp_recovery(self): - # This endpoint MUST NOT persist the recovery code. - recovery_code, recovery_code_hash = generate_recovery_code() - return ( - Response() - .ok( - { - "recovery_code": recovery_code, - "recovery_code_hash": recovery_code_hash, - } - ) - .__dict__ - ) - - async def setup(self): - if not self._can_skip_default_password_auth(): - return Response().error("Setup without password is not enabled").__dict__ - if not await self._is_setup_required(): - return Response().error("Setup is not required").__dict__ - - return await self._complete_setup() - - async def setup_authenticated(self): - if not await self._is_setup_required(): - return Response().error("Setup is not required").__dict__ - if not isinstance(getattr(g, "username", None), str): - return Response().error("未授权").__dict__ - - return await self._complete_setup() - - async def _complete_setup(self): - post_data = await request.json - if not isinstance(post_data, dict): - return Response().error("Invalid request payload").__dict__ - - new_username = post_data.get("username") - new_password = post_data.get("password") - confirm_password = post_data.get("confirm_password") - if not isinstance(new_username, str) or len(new_username.strip()) < 3: - return Response().error("用户名长度至少3位").__dict__ - if not isinstance(new_password, str): - return Response().error("新密码无效").__dict__ - if not isinstance(confirm_password, str) or confirm_password != new_password: - return Response().error("两次输入的新密码不一致").__dict__ - - try: - validate_dashboard_password(new_password) - except ValueError as e: - return Response().error(str(e)).__dict__ - - username = new_username.strip() - self.config["dashboard"]["username"] = username - set_dashboard_password_hashes(self.config, new_password) - await set_password_storage_upgraded(self.db, self.config, True) - await set_password_change_required(self.db, self.config, False) - self.config.save_config() - - token = self.generate_jwt(username) - payload = Response().ok( - { - "token": token, - "username": username, - "change_pwd_hint": False, - "legacy_pwd_hint": False, - "password_upgrade_required": False, - }, - "Setup completed successfully", - ) - response = await make_response(jsonify(payload.__dict__)) - self._set_dashboard_jwt_cookie(response, token) - return response - - async def login(self): - username = self.config["dashboard"]["username"] - storage_upgraded = await is_password_storage_upgraded(self.db, self.config) - password = get_dashboard_password_hash(self.config, upgraded=storage_upgraded) - post_data = await request.json - - req_username = ( - post_data.get("username") if isinstance(post_data, dict) else None - ) - req_password = ( - post_data.get("password") if isinstance(post_data, dict) else None - ) - req_challenge_id = ( - post_data.get("challenge_id") if isinstance(post_data, dict) else None - ) - req_password_proof = ( - post_data.get("password_proof") if isinstance(post_data, dict) else None - ) - totp_code = post_data.get("code") if isinstance(post_data, dict) else None - trust_device_flag = ( - post_data.get("trust_device_flag") is True - if isinstance(post_data, dict) - else False - ) - if not isinstance(req_username, str): - return Response().error("Invalid request payload").__dict__ - has_password = isinstance(req_password, str) - has_challenge = isinstance(req_challenge_id, str) and isinstance( - req_password_proof, - str, - ) - if not has_password and not has_challenge: - return Response().error("Invalid request payload").__dict__ - - login_verified = False - if has_password: - login_verified = req_username == username and verify_dashboard_password( - password, - req_password, - ) - if not login_verified and has_challenge: - challenge_nonce = self._consume_login_challenge(req_challenge_id) - login_verified = ( - req_username == username - and isinstance(challenge_nonce, str) - and verify_dashboard_login_proof( - password, - challenge_nonce, - req_password_proof, - ) - ) - - if not login_verified: - await asyncio.sleep(3) - if req_password == "astrbot": - return Response().error(DEFAULT_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__ - if is_legacy_dashboard_password(password): - return Response().error(LEGACY_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__ - return await self._error_response( - "用户名或密码错误", - 401, - ) - - totp_verified = False - - if is_totp_enabled(self.config): - cookie_token = request.cookies.get( - TOTP_TRUSTED_DEVICE_COOKIE_NAME, "" - ).strip() - if not await is_totp_trusted_device_valid( - self.config, self.db, cookie_token - ): - if not isinstance(totp_code, str) or not totp_code.strip(): - response = await make_response( - jsonify( - { - "status": "error", - "message": "需要 TOTP 验证", - "data": {"totp_required": True}, - } - ) - ) - response.status_code = 401 - return response - verified_type = await verify_configured_2fa_code( - self.config, totp_code, allow_recovery=True - ) - if verified_type is TwoFactorCodeType.TOTP: - totp_verified = True - elif verified_type is TwoFactorCodeType.RECOVERY: - self.config["dashboard"]["totp"] = { - "enable": False, - "secret": "", - "recovery_code_hash": "", - } - await revoke_user_trusted_devices(self.db) - self.config.save_config() - elif len(totp_code) == 6 and totp_code.isdigit(): - return await self._error_response("TOTP 验证码无效", 401) - else: - return await self._error_response("恢复码无效", 401) - - change_pwd_hint = False - legacy_pwd_hint = is_legacy_dashboard_password(password) - password_change_required = await is_password_change_required( - self.db, - self.config, - ) - if ( - storage_upgraded - and username == "astrbot" - and is_default_dashboard_password(password) - and not DEMO_MODE - ): - change_pwd_hint = True - legacy_pwd_hint = True - logger.warning("为了保证安全,请尽快修改默认密码。") - if password_change_required and not DEMO_MODE: - change_pwd_hint = True - token = self.generate_jwt(username) - login_data = { - "token": token, - "username": username, - "change_pwd_hint": change_pwd_hint, - "legacy_pwd_hint": legacy_pwd_hint, - "password_upgrade_required": not storage_upgraded, - } - payload = Response().ok(login_data) - response = await make_response(jsonify(payload.__dict__)) - self._set_dashboard_jwt_cookie(response, token) - - if totp_verified and trust_device_flag: - raw_token = await issue_totp_trusted_device(self.config, self.db) - if raw_token: - response.set_cookie( - TOTP_TRUSTED_DEVICE_COOKIE_NAME, - raw_token, - max_age=TOTP_TRUSTED_DEVICE_MAX_AGE, - httponly=True, - samesite="Strict", - secure=AuthRoute._use_secure_dashboard_jwt_cookie(), - path="/api/auth", - ) - return response - - async def logout(self): - response = await make_response( - jsonify(Response().ok(None, "已退出登录").__dict__), - ) - self._clear_dashboard_jwt_cookie(response) - return response - - async def edit_account(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - - storage_upgraded = await is_password_storage_upgraded(self.db, self.config) - password = get_dashboard_password_hash(self.config, upgraded=storage_upgraded) - post_data = await request.json - if not isinstance(post_data, dict): - return Response().error("Invalid request payload").__dict__ - - req_password = post_data.get("password") - if not isinstance(req_password, str): - return Response().error("Invalid request payload").__dict__ - - if not verify_dashboard_password(password, req_password): - return Response().error("原密码错误").__dict__ - - new_pwd = post_data.get("new_password", None) - new_username = post_data.get("new_username", None) - password_change_required = await is_password_change_required( - self.db, - self.config, - ) - if (not storage_upgraded or password_change_required) and not new_pwd: - return Response().error("请设置新密码以完成安全升级").__dict__ - if not new_pwd and not new_username: - return Response().error("新用户名和新密码不能同时为空").__dict__ - - # Verify password confirmation - if new_pwd: - if not isinstance(new_pwd, str): - return Response().error("新密码无效").__dict__ - confirm_pwd = post_data.get("confirm_password", None) - if not isinstance(confirm_pwd, str) or confirm_pwd != new_pwd: - return Response().error("两次输入的新密码不一致").__dict__ - try: - validate_dashboard_password(new_pwd) - except ValueError as e: - return Response().error(str(e)).__dict__ - set_dashboard_password_hashes(self.config, new_pwd) - await set_password_storage_upgraded(self.db, self.config, True) - await set_password_change_required(self.db, self.config, False) - if is_totp_enabled(self.config): - await revoke_user_trusted_devices(self.db) - if new_username: - self.config["dashboard"]["username"] = new_username - - self.config.save_config() - - return Response().ok(None, "Updated account successfully").__dict__ - - def generate_jwt(self, username): - payload = { - "username": username, - "exp": datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=7), - } - jwt_token = self.config["dashboard"].get("jwt_secret", None) - if not jwt_token: - raise ValueError("JWT secret is not set in the cmd_config.") - token = jwt.encode(payload, jwt_token, algorithm="HS256") - return token - - async def _is_setup_required(self) -> bool: - if DEMO_MODE: - return False - - dashboard_config = self.config["dashboard"] - password_change_required = await is_password_change_required( - self.db, - self.config, - ) - if password_change_required: - return True - - storage_upgraded = await is_password_storage_upgraded(self.db, self.config) - if not storage_upgraded: - return False - - return dashboard_config.get( - "username", - ) == "astrbot" and is_default_dashboard_password( - dashboard_config.get("pbkdf2_password", ""), - ) - - @staticmethod - async def _error_response(message: str, status_code: int): - response = await make_response(jsonify(Response().error(message).__dict__)) - response.status_code = status_code - return response - - def _can_skip_default_password_auth(self) -> bool: - if not self._env_flag_enabled(SKIP_DEFAULT_PASSWORD_AUTH_ENV): - return False - host = ( - os.environ.get("DASHBOARD_HOST") - or os.environ.get("ASTRBOT_DASHBOARD_HOST") - or self.config["dashboard"].get("host", "") - ) - return str(host).strip().lower() in LOCAL_DASHBOARD_HOSTS - - @staticmethod - def _env_flag_enabled(name: str) -> bool: - value = os.environ.get(name) - if value is None and name == SKIP_DEFAULT_PASSWORD_AUTH_ENV: - value = os.environ.get(SKIP_DEFAULT_PASSWORD_AUTH_ENV_LEGACY) - return str(value or "").strip().lower() in {"1", "true", "yes", "on"} - - @staticmethod - def _use_secure_dashboard_jwt_cookie() -> bool: - return bool( - current_app.config.get( - "DASHBOARD_JWT_COOKIE_SECURE", - not current_app.debug and not current_app.testing, - ), - ) - - @staticmethod - def _set_dashboard_jwt_cookie(response, token: str) -> None: - response.set_cookie( - DASHBOARD_JWT_COOKIE_NAME, - token, - max_age=DASHBOARD_JWT_COOKIE_MAX_AGE, - httponly=True, - samesite="Strict", - secure=AuthRoute._use_secure_dashboard_jwt_cookie(), - path="/", - ) - - @staticmethod - def _clear_dashboard_jwt_cookie(response) -> None: - response.delete_cookie( - DASHBOARD_JWT_COOKIE_NAME, - httponly=True, - samesite="Strict", - secure=AuthRoute._use_secure_dashboard_jwt_cookie(), - path="/", - ) - - def _prune_login_challenges(self) -> None: - now = datetime.datetime.now(datetime.UTC) - expired_ids = [ - challenge_id - for challenge_id, challenge in self._login_challenges.items() - if challenge.get("expires_at") <= now - ] - for challenge_id in expired_ids: - self._login_challenges.pop(challenge_id, None) - - def _consume_login_challenge(self, challenge_id: str) -> str | None: - self._prune_login_challenges() - challenge = self._login_challenges.pop(challenge_id, None) - if not isinstance(challenge, dict): - return None - nonce = challenge.get("nonce") - return nonce if isinstance(nonce, str) else None diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py deleted file mode 100644 index 48568a37b..000000000 --- a/astrbot/dashboard/routes/chat.py +++ /dev/null @@ -1,1731 +0,0 @@ -import asyncio -import json -import os -import re -import uuid -from contextlib import asynccontextmanager -from copy import deepcopy -from typing import Any, cast - -from quart import Response as QuartResponse -from quart import g, make_response, request, send_file - -from astrbot.core import logger, sp -from astrbot.core.agent.message import get_checkpoint_id, is_checkpoint_message -from astrbot.core.core_lifecycle import AstrBotCoreLifecycle -from astrbot.core.db import BaseDatabase -from astrbot.core.platform.message_type import MessageType -from astrbot.core.platform.sources.webchat.message_parts_helper import ( - build_webchat_message_parts, - create_attachment_part_from_existing_file, - strip_message_parts_path_fields, - webchat_message_parts_have_content, -) -from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr -from astrbot.core.utils.active_event_registry import active_event_registry -from astrbot.core.utils.astrbot_path import get_astrbot_data_path -from astrbot.core.utils.datetime_utils import to_utc_isoformat - -from .route import Response, Route, RouteContext - -# SSE heartbeat message to keep the connection alive during long-running operations -SSE_HEARTBEAT = ": heartbeat\n\n" - - -@asynccontextmanager -async def track_conversation(convs: dict, conv_id: str): - convs[conv_id] = True - try: - yield - finally: - convs.pop(conv_id, None) - - -async def _poll_webchat_stream_result(back_queue, username: str): - try: - result = await asyncio.wait_for(back_queue.get(), timeout=1) - except TimeoutError: - # Return a sentinel so the caller can send an SSE heartbeat to - # keep the connection alive during long-running operations (e.g. - # context compression with reasoning models). See #6938. - return None, False - except asyncio.CancelledError: - logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。") - return None, True - except Exception as e: - logger.error(f"WebChat stream error: {e}") - return None, False - return result, False - - -def extract_reasoning_from_message_parts(message_parts: list[dict]) -> str: - reasoning_parts: list[str] = [] - for part in message_parts: - if part.get("type") != "think": - continue - think = part.get("think") - if isinstance(think, str) and think: - reasoning_parts.append(think) - return "".join(reasoning_parts) - - -def collect_plain_text_from_message_parts(message_parts: list[dict]) -> str: - text_parts: list[str] = [] - for part in message_parts: - if part.get("type") != "plain": - continue - text = part.get("text") - if isinstance(text, str) and text: - text_parts.append(text) - return "".join(text_parts) - - -def _sanitize_upload_filename(filename: str) -> str: - """Sanitize an uploaded filename by removing path traversal, fakepath, null bytes.""" - if not filename: - import uuid - - return uuid.uuid4().hex[:16] - # Remove null bytes - filename = filename.replace("\x00", "") - # Strip Windows drive and fakepath prefix - filename = re.sub(r"^[A-Za-z]:\\+fakepath\\+", "", filename, flags=re.IGNORECASE) - # Strip any remaining path components (both POSIX and Windows) - filename = filename.replace("\\", "/") - filename = filename.rstrip("/") - filename = filename.split("/")[-1] - if not filename or filename in (".", ".."): - import uuid - - return uuid.uuid4().hex[:16] - return filename - - -class BotMessageAccumulator: - def __init__(self) -> None: - self.parts: list[dict] = [] - self.pending_text = "" - self.pending_tool_calls: dict[str, Any] = {} - - def has_content(self) -> bool: - return bool(self.parts or self.pending_text or self.pending_tool_calls) - - def add_plain( - self, - result_text: str, - *, - chain_type: str | None, - streaming: bool, - ) -> None: - if chain_type == "tool_call": - self._flush_pending_text() - self._store_tool_call(result_text) - return - if chain_type == "tool_call_result": - self._flush_pending_text() - self._store_tool_call_result(result_text) - return - if chain_type == "reasoning": - self._flush_pending_text() - self._append_think_part(result_text) - return - if streaming: - self.pending_text += result_text - else: - self.pending_text = result_text - - def add_attachment(self, part: dict | None) -> None: - if not part: - return - self._flush_pending_text() - self.parts.append(part) - - def build_message_parts( - self, - *, - include_pending_tool_calls: bool = False, - ) -> list[dict]: - self._flush_pending_text() - if include_pending_tool_calls and self.pending_tool_calls: - for tool_call in self.pending_tool_calls.values(): - self.parts.append({"type": "tool_call", "tool_calls": [tool_call]}) - self.pending_tool_calls = {} - return self.parts - - def plain_text(self) -> str: - return collect_plain_text_from_message_parts(self.build_message_parts()) - - def reasoning_text(self) -> str: - return extract_reasoning_from_message_parts(self.build_message_parts()) - - def _flush_pending_text(self) -> None: - if not self.pending_text: - return - if self.parts and self.parts[-1].get("type") == "plain": - last_text = self.parts[-1].get("text") - self.parts[-1]["text"] = f"{last_text or ''}{self.pending_text}" - else: - self.parts.append({"type": "plain", "text": self.pending_text}) - self.pending_text = "" - - def _append_think_part(self, text: str) -> None: - if not text: - return - if self.parts and self.parts[-1].get("type") == "think": - last_text = self.parts[-1].get("think") - self.parts[-1]["think"] = f"{last_text or ''}{text}" - else: - self.parts.append({"type": "think", "think": text}) - - def _store_tool_call(self, result_text: str) -> None: - tool_call = self._parse_json_object(result_text) - if not tool_call: - return - tool_call_id = str(tool_call.get("id") or "") - if not tool_call_id: - return - self.pending_tool_calls[tool_call_id] = tool_call - - def _store_tool_call_result(self, result_text: str) -> None: - tool_result = self._parse_json_object(result_text) - if not tool_result: - return - tool_call_id = str(tool_result.get("id") or "") - if not tool_call_id: - return - existing = self.pending_tool_calls.pop(tool_call_id, None) - tool_call: dict[str, Any] = ( - existing if existing is not None else {"id": tool_call_id} - ) - tool_call["result"] = tool_result.get("result") - tool_call["finished_ts"] = tool_result.get("ts") - self.parts.append({"type": "tool_call", "tool_calls": [tool_call]}) - - @staticmethod - def _parse_json_object(raw_text: str) -> dict | None: - try: - parsed = json.loads(raw_text) - except json.JSONDecodeError: - return None - return parsed if isinstance(parsed, dict) else None - - -class ChatRoute(Route): - def __init__( - self, - context: RouteContext, - db: BaseDatabase, - core_lifecycle: AstrBotCoreLifecycle, - ) -> None: - super().__init__(context) - self.routes = { - "/chat/send": ("POST", self.chat), - "/chat/new_session": ("GET", self.new_session), - "/chat/sessions": ("GET", self.get_sessions), - "/chat/get_session": ("GET", self.get_session), - "/chat/stop": ("POST", self.stop_session), - "/chat/delete_session": ("GET", self.delete_webchat_session), - "/chat/batch_delete_sessions": ("POST", self.batch_delete_sessions), - "/chat/update_session_display_name": ( - "POST", - self.update_session_display_name, - ), - "/chat/message/edit": ("POST", self.update_message), - "/chat/message/regenerate": ("POST", self.regenerate_message), - "/chat/thread/create": ("POST", self.create_thread), - "/chat/thread/get": ("GET", self.get_thread), - "/chat/thread/send": ("POST", self.send_thread_message), - "/chat/thread/delete": ("POST", self.delete_thread), - "/chat/get_file": ("GET", self.get_file), - "/chat/get_attachment": ("GET", self.get_attachment), - "/chat/post_file": ("POST", self.post_file), - } - self.core_lifecycle = core_lifecycle - self.register_routes() - self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments") - self.legacy_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") - os.makedirs(self.attachments_dir, exist_ok=True) - - self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"] - self.conv_mgr = core_lifecycle.conversation_manager - self.platform_history_mgr = core_lifecycle.platform_message_history_manager - self.db = db - self.umop_config_router = core_lifecycle.umop_config_router - - self.running_convs: dict[str, bool] = {} - - async def get_file(self): - filename = request.args.get("filename") - if not filename: - return Response().error("Missing key: filename").__dict__ - - try: - file_path = os.path.join(self.attachments_dir, os.path.basename(filename)) - real_file_path = await asyncio.to_thread(os.path.realpath, file_path) - real_imgs_dir = await asyncio.to_thread( - os.path.realpath, - self.attachments_dir, - ) - - if not await asyncio.to_thread(os.path.exists, real_file_path): - # try legacy - file_path = os.path.join( - self.legacy_img_dir, - os.path.basename(filename), - ) - if await asyncio.to_thread(os.path.exists, file_path): - real_file_path = await asyncio.to_thread( - os.path.realpath, - file_path, - ) - real_imgs_dir = await asyncio.to_thread( - os.path.realpath, - self.legacy_img_dir, - ) - - if not real_file_path.startswith(real_imgs_dir): - return Response().error("Invalid file path").__dict__ - - filename_ext = os.path.splitext(filename)[1].lower() - if filename_ext == ".wav": - return await send_file(real_file_path, mimetype="audio/wav") - if filename_ext[1:] in self.supported_imgs: - return await send_file(real_file_path, mimetype="image/jpeg") - return await send_file(real_file_path) - - except (FileNotFoundError, OSError): - return Response().error("File access error").__dict__ - - async def get_attachment(self): - """Get attachment file by attachment_id.""" - attachment_id = request.args.get("attachment_id") - if not attachment_id: - return Response().error("Missing key: attachment_id").__dict__ - - try: - attachment = await self.db.get_attachment_by_id(attachment_id) - if not attachment: - return Response().error("Attachment not found").__dict__ - - file_path = attachment.path - real_file_path = await asyncio.to_thread(os.path.realpath, file_path) - - return await send_file(real_file_path, mimetype=attachment.mime_type) - - except (FileNotFoundError, OSError): - return Response().error("File access error").__dict__ - - async def post_file(self): - """Upload a file and create an attachment record, return attachment_id.""" - post_data = await request.files - if "file" not in post_data: - return Response().error("Missing key: file").__dict__ - - file = post_data["file"] - filename = file.filename or f"{uuid.uuid4()!s}" - content_type = file.content_type or "application/octet-stream" - - # 根据 content_type 判断文件类型并添加扩展名 - if content_type.startswith("image"): - attach_type = "image" - elif content_type.startswith("audio"): - attach_type = "record" - elif content_type.startswith("video"): - attach_type = "video" - else: - attach_type = "file" - - path = os.path.join(self.attachments_dir, filename) - await file.save(path) - - # 创建 attachment 记录 - attachment = await self.db.insert_attachment( - path=path, - type=attach_type, - mime_type=content_type, - ) - - if not attachment: - return Response().error("Failed to create attachment").__dict__ - - filename = os.path.basename(attachment.path) - - return ( - Response() - .ok( - data={ - "attachment_id": attachment.attachment_id, - "filename": filename, - "type": attach_type, - }, - ) - .__dict__ - ) - - async def _build_user_message_parts(self, message: str | list) -> list[dict]: - """构建用户消息的部分列表。""" - return await build_webchat_message_parts( - message, - get_attachment_by_id=self.db.get_attachment_by_id, - strict=False, - ) - - async def _create_attachment_from_file( - self, - filename: str, - attach_type: str, - ) -> dict | None: - """从本地文件创建 attachment 并返回消息部分。""" - return await create_attachment_part_from_existing_file( - filename, - attach_type=attach_type, - insert_attachment=self.db.insert_attachment, - attachments_dir=self.attachments_dir, - fallback_dirs=[self.legacy_img_dir], - ) - - def _extract_web_search_refs( - self, - accumulated_text: str, - accumulated_parts: list, - ) -> dict: - """从消息中提取 web_search_tavily 的引用 - - Args: - accumulated_text: 累积的文本内容 - accumulated_parts: 累积的消息部分列表 - - Returns: - 包含 used 列表的字典,记录被引用的搜索结果 - - """ - supported = [ - "web_search_baidu", - "web_search_tavily", - "web_search_bocha", - "web_search_brave", - ] - # 从 accumulated_parts 中找到所有 web_search_tavily 的工具调用结果 - web_search_results = {} - tool_call_parts = [ - p - for p in accumulated_parts - if p.get("type") == "tool_call" and p.get("tool_calls") - ] - - for part in tool_call_parts: - for tool_call in part["tool_calls"]: - if tool_call.get("name") not in supported or not tool_call.get( - "result", - ): - continue - try: - result_data = json.loads(tool_call["result"]) - for item in result_data.get("results", []): - if idx := item.get("index"): - web_search_results[idx] = { - "url": item.get("url"), - "title": item.get("title"), - "snippet": item.get("snippet"), - } - except (json.JSONDecodeError, KeyError): - pass - - if not web_search_results: - return {} - - # 从文本中提取所有 xxx 标签并去重 - ref_indices = { - m.strip() for m in re.findall(r"(.*?)", accumulated_text) - } - - # 构建被引用的结果列表 - used_refs = [] - for ref_index in ref_indices: - if ref_index not in web_search_results: - continue - payload = {"index": ref_index, **web_search_results[ref_index]} - if favicon := sp.temporary_cache.get("_ws_favicon", {}).get(payload["url"]): - payload["favicon"] = favicon - used_refs.append(payload) - - return {"used": used_refs} if used_refs else {} - - def _sanitize_message_content(self, content: dict) -> dict: - """Normalize editable WebChat message content before persisting.""" - if not isinstance(content, dict): - raise ValueError("Missing key: content") - - normalized = deepcopy(content) - message_type = normalized.get("type") - if message_type not in {"user", "bot"}: - raise ValueError("Invalid key: content.type") - - message_parts = normalized.get("message") - if not isinstance(message_parts, list): - raise ValueError("Missing key: content.message") - normalized["message"] = strip_message_parts_path_fields(message_parts) - return normalized - - def _extract_platform_message_text(self, content: dict | None) -> str: - if not isinstance(content, dict): - return "" - message_parts = content.get("message") - if not isinstance(message_parts, list): - return "" - texts: list[str] = [] - for part in message_parts: - if isinstance(part, dict) and part.get("type") == "plain": - text = part.get("text") - if isinstance(text, str): - texts.append(text) - return "".join(texts) - - def _build_webchat_unified_msg_origin(self, session) -> str: - message_type = ( - MessageType.GROUP_MESSAGE.value - if session.is_group - else MessageType.FRIEND_MESSAGE.value - ) - return ( - f"{session.platform_id}:{message_type}:" - f"{session.platform_id}!{session.creator}!{session.session_id}" - ) - - def _build_thread_unified_msg_origin(self, creator: str, thread_id: str) -> str: - return ( - f"webchat:{MessageType.FRIEND_MESSAGE.value}:webchat!{creator}!{thread_id}" - ) - - def _serialize_thread(self, thread) -> dict: - return { - "thread_id": thread.thread_id, - "parent_session_id": thread.parent_session_id, - "parent_message_id": thread.parent_message_id, - "base_checkpoint_id": thread.base_checkpoint_id, - "selected_text": thread.selected_text, - "created_at": to_utc_isoformat(thread.created_at), - "updated_at": to_utc_isoformat(thread.updated_at), - } - - async def _delete_threads_by_ids(self, thread_ids: list[str], creator: str) -> None: - for thread_id in thread_ids: - unified_msg_origin = self._build_thread_unified_msg_origin( - creator, - thread_id, - ) - active_event_registry.request_agent_stop_all(unified_msg_origin) - await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin) - await self.platform_history_mgr.delete( - platform_id="webchat_thread", - user_id=thread_id, - offset_sec=99999999, - ) - webchat_queue_mgr.remove_queues(thread_id) - self.running_convs.pop(thread_id, None) - - async def _load_current_conversation_history(self, session) -> tuple[str, list]: - unified_msg_origin = self._build_webchat_unified_msg_origin(session) - conversation_id = await self.conv_mgr.get_curr_conversation_id( - unified_msg_origin, - ) - if not conversation_id: - return "", [] - - conversation = await self.conv_mgr.get_conversation( - unified_msg_origin=unified_msg_origin, - conversation_id=conversation_id, - ) - if not conversation: - return "", [] - - try: - history = json.loads(conversation.history or "[]") - except json.JSONDecodeError: - return "", [] - return conversation_id, history if isinstance(history, list) else [] - - def _find_checkpoint_index( - self, - history: list[dict], - checkpoint_id: str, - ) -> int | None: - for index, message in enumerate(history): - if get_checkpoint_id(message) == checkpoint_id: - return index - return None - - def _find_turn_range( - self, - history: list[dict], - checkpoint_id: str, - ) -> tuple[int, int] | None: - checkpoint_index = self._find_checkpoint_index(history, checkpoint_id) - if checkpoint_index is None: - return None - - start = 0 - for index in range(checkpoint_index - 1, -1, -1): - if is_checkpoint_message(history[index]): - start = index + 1 - break - return start, checkpoint_index - - def _is_latest_checkpoint(self, history: list[dict], checkpoint_id: str) -> bool: - for message in reversed(history): - current_checkpoint_id = get_checkpoint_id(message) - if current_checkpoint_id: - return current_checkpoint_id == checkpoint_id - return False - - def _replace_user_conversation_content(self, original_content, edited_text: str): - if isinstance(original_content, str): - return edited_text - if not isinstance(original_content, list): - return edited_text - - result: list[dict] = [] - inserted_text = False - for part in original_content: - if not isinstance(part, dict): - result.append(part) - continue - if part.get("type") != "text": - result.append(part) - continue - text = part.get("text") - if isinstance(text, str) and text.startswith(""): - result.append(part) - continue - if not inserted_text and edited_text: - result.append({"type": "text", "text": edited_text}) - inserted_text = True - - if not inserted_text and edited_text: - result.insert(0, {"type": "text", "text": edited_text}) - return result - - def _replace_assistant_conversation_content( - self, - original_content, - edited_text: str, - reasoning: str, - ): - if isinstance(original_content, str): - return edited_text - if not isinstance(original_content, list): - return [{"type": "text", "text": edited_text}] if edited_text else [] - - result: list[dict] = [] - inserted_text = False - inserted_think = False - for part in original_content: - if not isinstance(part, dict): - result.append(part) - continue - if part.get("type") == "text": - if not inserted_text and edited_text: - result.append({"type": "text", "text": edited_text}) - inserted_text = True - continue - if part.get("type") == "think": - if not inserted_think and reasoning: - result.append({"type": "think", "think": reasoning}) - inserted_think = True - continue - result.append(part) - - if reasoning and not inserted_think: - result.insert(0, {"type": "think", "think": reasoning}) - if edited_text and not inserted_text: - result.append({"type": "text", "text": edited_text}) - return result - - def _find_turn_user_index( - self, - history: list[dict], - start: int, - end: int, - ) -> int | None: - for index in range(start, end): - message = history[index] - if isinstance(message, dict) and message.get("role") == "user": - return index - return None - - def _find_turn_final_assistant_index( - self, - history: list[dict], - start: int, - end: int, - ) -> int | None: - for index in range(end - 1, start - 1, -1): - message = history[index] - if not isinstance(message, dict) or message.get("role") != "assistant": - continue - if message.get("tool_calls") and not message.get("content"): - continue - return index - return None - - async def _get_sorted_platform_history(self, session) -> list: - history_list = await self.platform_history_mgr.get( - platform_id=session.platform_id, - user_id=session.session_id, - page=1, - page_size=100000, - ) - history_list.sort(key=lambda item: (item.created_at, item.id)) - return history_list - - async def _delete_platform_history_after( - self, - session, - message_id: int, - ) -> list[int]: - history_list = await self._get_sorted_platform_history(session) - should_delete = False - deleted_ids: list[int] = [] - for item in history_list: - if should_delete: - if item.id is not None: - deleted_ids.append(item.id) - await self.platform_history_mgr.delete_by_id(item.id) - continue - if item.id == message_id: - should_delete = True - return deleted_ids - - async def _save_bot_message( - self, - webchat_conv_id: str, - text: str, - media_parts: list, - reasoning: str, - agent_stats: dict, - refs: dict, - llm_checkpoint_id: str | None = None, - platform_history_id: str = "webchat", - ): - """保存 bot 消息到历史记录,返回保存的记录""" - bot_message_parts: list[dict[str, str]] = [] - bot_message_parts.extend(media_parts) - if text: - bot_message_parts.append({"type": "plain", "text": text}) - - new_his: dict[str, str | list[dict[str, str]] | dict[str, str] | None] = { - "type": "bot", - "message": bot_message_parts, - } - if reasoning: - new_his["reasoning"] = reasoning - if agent_stats: - new_his["agent_stats"] = agent_stats - if refs: - new_his["refs"] = refs - - record = await self.platform_history_mgr.insert( - platform_id=platform_history_id, - user_id=webchat_conv_id, - content=new_his, - sender_id="bot", - sender_name="bot", - llm_checkpoint_id=llm_checkpoint_id, - ) - return record - - async def chat(self, post_data: dict | None = None): - username = g.get("username", "guest") - - if post_data is None: - post_data = await request.json - if post_data is None: - return Response().error("Missing JSON body").__dict__ - if "message" not in post_data and "files" not in post_data: - return Response().error("Missing key: message or files").__dict__ - - if "session_id" not in post_data and "conversation_id" not in post_data: - return ( - Response().error("Missing key: session_id or conversation_id").__dict__ - ) - - message = post_data["message"] - session_id = post_data.get("session_id", post_data.get("conversation_id")) - selected_provider = post_data.get("selected_provider") - selected_model = post_data.get("selected_model") - enable_streaming = post_data.get("enable_streaming", True) - platform_history_id = post_data.get("_platform_history_id") or "webchat" - thread_selected_text = post_data.get("_thread_selected_text") - - if not session_id: - return Response().error("session_id is empty").__dict__ - - webchat_conv_id = session_id - - # 构建用户消息段(包含 path 用于传递给 adapter) - message_parts = await self._build_user_message_parts(message) - if not webchat_message_parts_have_content(message_parts): - return ( - Response() - .error("Message content is empty (reply only is not allowed)") - .__dict__ - ) - - message_id = str(uuid.uuid4()) - llm_checkpoint_id = post_data.get("_llm_checkpoint_id") or str(uuid.uuid4()) - skip_user_history = bool(post_data.get("_skip_user_history")) - back_queue = webchat_queue_mgr.get_or_create_back_queue( - message_id, - webchat_conv_id, - ) - saved_user_record = None - - async def stream(): - client_disconnected = False - accumulated_parts = [] - accumulated_text = "" - accumulated_reasoning = "" - tool_calls = {} - agent_stats = {} - refs = {} - try: - # Emit session_id first so clients can bind the stream immediately. - session_info = { - "type": "session_id", - "data": None, - "session_id": webchat_conv_id, - } - yield f"data: {json.dumps(session_info, ensure_ascii=False)}\n\n" - if saved_user_record and not client_disconnected: - user_saved_info = { - "type": "user_message_saved", - "data": { - "id": saved_user_record.id, - "created_at": to_utc_isoformat( - saved_user_record.created_at, - ), - "llm_checkpoint_id": llm_checkpoint_id, - }, - } - yield f"data: {json.dumps(user_saved_info, ensure_ascii=False)}\n\n" - - async with track_conversation(self.running_convs, webchat_conv_id): - while True: - result, should_break = await _poll_webchat_stream_result( - back_queue, - username, - ) - if should_break: - client_disconnected = True - break - if not result: - # Send an SSE comment as keep-alive so the client - # doesn't time out during slow backend ops like - # context compression with reasoning models (#6938). - if not client_disconnected: - yield SSE_HEARTBEAT - continue - - if ( - "message_id" in result - and result["message_id"] != message_id - ): - logger.warning("webchat stream message_id mismatch") - continue - - result_text = result["data"] - msg_type = result.get("type") - streaming = result.get("streaming", False) - chain_type = result.get("chain_type") - - if chain_type == "agent_stats": - stats_info = { - "type": "agent_stats", - "data": json.loads(result_text), - } - yield f"data: {json.dumps(stats_info, ensure_ascii=False)}\n\n" - agent_stats = stats_info["data"] - continue - - # 发送 SSE 数据 - try: - if not client_disconnected: - yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n" - except Exception as e: - if not client_disconnected: - logger.debug( - f"[WebChat] 用户 {username} 断开聊天长连接。 {e}", - ) - client_disconnected = True - - try: - if not client_disconnected: - await asyncio.sleep(0.05) - except asyncio.CancelledError: - logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。") - client_disconnected = True - - # 累积消息部分 - if msg_type == "plain": - chain_type = result.get("chain_type") - if chain_type == "tool_call": - tool_call = json.loads(result_text) - tool_calls[tool_call.get("id")] = tool_call - if accumulated_text: - # 如果累积了文本,则先保存文本 - accumulated_parts.append( - {"type": "plain", "text": accumulated_text}, - ) - accumulated_text = "" - elif chain_type == "tool_call_result": - tcr = json.loads(result_text) - tc_id = tcr.get("id") - if tc_id in tool_calls: - tool_calls[tc_id]["result"] = tcr.get("result") - tool_calls[tc_id]["finished_ts"] = tcr.get("ts") - accumulated_parts.append( - { - "type": "tool_call", - "tool_calls": [tool_calls[tc_id]], - }, - ) - tool_calls.pop(tc_id, None) - elif chain_type == "reasoning": - accumulated_reasoning += result_text - elif streaming: - accumulated_text += result_text - else: - accumulated_text = result_text - elif msg_type == "image": - filename = result_text.replace("[IMAGE]", "") - part = await self._create_attachment_from_file( - filename, - "image", - ) - if part: - accumulated_parts.append(part) - elif msg_type == "record": - filename = result_text.replace("[RECORD]", "") - part = await self._create_attachment_from_file( - filename, - "record", - ) - if part: - accumulated_parts.append(part) - elif msg_type == "file": - # 格式: [FILE]filename - filename = result_text.replace("[FILE]", "") - part = await self._create_attachment_from_file( - filename, - "file", - ) - if part: - accumulated_parts.append(part) - - # 消息结束处理 - if msg_type == "end": - break - elif ( - (streaming and msg_type == "complete") or not streaming - # or msg_type == "break" - ): - if ( - chain_type == "tool_call" - or chain_type == "tool_call_result" - ): - continue - - # 提取 web_search_tavily 引用 - try: - refs = self._extract_web_search_refs( - accumulated_text, - accumulated_parts, - ) - except Exception as e: - logger.exception( - f"Failed to extract web search refs: {e}", - exc_info=True, - ) - - saved_record = await self._save_bot_message( - webchat_conv_id, - accumulated_text, - accumulated_parts, - accumulated_reasoning, - agent_stats, - refs, - llm_checkpoint_id, - platform_history_id, - ) - # 发送保存的消息信息给前端 - if saved_record and not client_disconnected: - saved_info = { - "type": "message_saved", - "data": { - "id": saved_record.id, - "created_at": to_utc_isoformat( - saved_record.created_at, - ), - "llm_checkpoint_id": llm_checkpoint_id, - }, - } - try: - yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n" - except Exception: - pass - accumulated_parts = [] - accumulated_text = "" - accumulated_reasoning = "" - # tool_calls = {} - agent_stats = {} - refs = {} - except BaseException as e: - logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True) - finally: - webchat_queue_mgr.remove_back_queue(message_id) - - # 将消息放入会话特定的队列 - chat_queue = webchat_queue_mgr.get_or_create_queue(webchat_conv_id) - await chat_queue.put( - ( - username, - webchat_conv_id, - { - "message": message_parts, - "selected_provider": selected_provider, - "selected_model": selected_model, - "enable_streaming": enable_streaming, - "message_id": message_id, - "llm_checkpoint_id": llm_checkpoint_id, - "thread_selected_text": thread_selected_text, - }, - ), - ) - - message_parts_for_storage = strip_message_parts_path_fields(message_parts) - - if not skip_user_history: - saved_user_record = await self.platform_history_mgr.insert( - platform_id=platform_history_id, - user_id=webchat_conv_id, - content={"type": "user", "message": message_parts_for_storage}, - sender_id=username, - sender_name=username, - llm_checkpoint_id=llm_checkpoint_id, - ) - - response = cast( - "QuartResponse", - await make_response( - stream(), - { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Transfer-Encoding": "chunked", - "Connection": "keep-alive", - }, - ), - ) - response.timeout = None # fix SSE auto disconnect issue - return response - - async def stop_session(self): - """Stop active agent runs for a session.""" - post_data = await request.json - if post_data is None: - return Response().error("Missing JSON body").__dict__ - - session_id = post_data.get("session_id") - if not session_id: - return Response().error("Missing key: session_id").__dict__ - - username = g.get("username", "guest") - session = await self.db.get_platform_session_by_id(session_id) - if not session: - return Response().error(f"Session {session_id} not found").__dict__ - if session.creator != username: - return Response().error("Permission denied").__dict__ - - message_type = ( - MessageType.GROUP_MESSAGE.value - if session.is_group - else MessageType.FRIEND_MESSAGE.value - ) - umo = ( - f"{session.platform_id}:{message_type}:" - f"{session.platform_id}!{username}!{session_id}" - ) - stopped_count = active_event_registry.request_agent_stop_all(umo) - - return Response().ok(data={"stopped_count": stopped_count}).__dict__ - - async def _delete_session_internal(self, session, username: str) -> None: - """Delete a single session and all its related data.""" - session_id = session.session_id - - # 删除该会话下的所有对话 - message_type = "GroupMessage" if session.is_group else "FriendMessage" - unified_msg_origin = f"{session.platform_id}:{message_type}:{session.platform_id}!{username}!{session_id}" - await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin) - - # 获取消息历史中的所有附件 ID 并删除附件 - history_list = await self.platform_history_mgr.get( - platform_id=session.platform_id, - user_id=session_id, - page=1, - page_size=100000, # 获取足够多的记录 - ) - attachment_ids = self._extract_attachment_ids(history_list) - if attachment_ids: - await self._delete_attachments(attachment_ids) - - # 删除消息历史 - await self.platform_history_mgr.delete( - platform_id=session.platform_id, - user_id=session_id, - offset_sec=99999999, - ) - thread_ids = await self.db.delete_webchat_threads_by_parent_session(session_id) - await self._delete_threads_by_ids(thread_ids, username) - - # 删除与会话关联的配置路由 - try: - await self.umop_config_router.delete_route(unified_msg_origin) - except ValueError as exc: - logger.warning( - "Failed to delete UMO route %s during session cleanup: %s", - unified_msg_origin, - exc, - ) - - # 清理队列(仅对 webchat) - if session.platform_id == "webchat": - webchat_queue_mgr.remove_queues(session_id) - - # 删除会话 - await self.db.delete_platform_session(session_id) - - async def delete_webchat_session(self): - """Delete a Platform session and all its related data.""" - session_id = request.args.get("session_id") - if not session_id: - return Response().error("Missing key: session_id").__dict__ - username = g.get("username", "guest") - - session = await self.db.get_platform_session_by_id(session_id) - if not session: - return Response().error(f"Session {session_id} not found").__dict__ - if session.creator != username: - return Response().error("Permission denied").__dict__ - - await self._delete_session_internal(session, username) - - return Response().ok().__dict__ - - async def batch_delete_sessions(self): - """Batch delete multiple Platform sessions.""" - post_data = await request.json - if post_data is None: - return Response().error("Missing JSON body").__dict__ - if not isinstance(post_data, dict): - return Response().error("Invalid JSON body: expected object").__dict__ - - session_ids = post_data.get("session_ids") - if not session_ids or not isinstance(session_ids, list): - return Response().error("Missing or invalid key: session_ids").__dict__ - - username = g.get("username", "guest") - sessions = await self.db.get_platform_sessions_by_ids(session_ids) - sessions_by_id = {session.session_id: session for session in sessions} - deleted_count = 0 - failed_items = [] - - for sid in session_ids: - session = sessions_by_id.get(sid) - if not session: - failed_items.append({"session_id": sid, "reason": "not found"}) - continue - if session.creator != username: - failed_items.append({"session_id": sid, "reason": "permission denied"}) - continue - - try: - await self._delete_session_internal(session, username) - deleted_count += 1 - sessions_by_id.pop(sid, None) - except Exception: - logger.warning("Failed to delete session %s", sid) - failed_items.append({"session_id": sid, "reason": "internal_error"}) - - return ( - Response() - .ok( - data={ - "deleted_count": deleted_count, - "failed_count": len(failed_items), - "failed_items": failed_items, - }, - ) - .__dict__ - ) - - def _extract_attachment_ids(self, history_list) -> list[str]: - """从消息历史中提取所有 attachment_id""" - attachment_ids = [] - for history in history_list: - content = history.content - if not content or "message" not in content: - continue - message_parts = content.get("message", []) - for part in message_parts: - if isinstance(part, dict) and "attachment_id" in part: - attachment_ids.append(part["attachment_id"]) - return attachment_ids - - async def _delete_attachments(self, attachment_ids: list[str]) -> None: - """删除附件(包括数据库记录和磁盘文件)""" - try: - attachments = await self.db.get_attachments(attachment_ids) - for attachment in attachments: - if not await asyncio.to_thread(os.path.exists, attachment.path): - continue - try: - await asyncio.to_thread(os.remove, attachment.path) - except OSError as e: - logger.warning( - f"Failed to delete attachment file {attachment.path}: {e}", - ) - except Exception as e: - logger.warning(f"Failed to get attachments: {e}") - - # 批量删除数据库记录 - try: - await self.db.delete_attachments(attachment_ids) - except Exception as e: - logger.warning(f"Failed to delete attachments: {e}") - - async def new_session(self): - """Create a new Platform session (default: webchat).""" - username = g.get("username", "guest") - - # 获取可选的 platform_id 参数,默认为 webchat - platform_id = request.args.get("platform_id", "webchat") - - # 创建新会话 - session = await self.db.create_platform_session( - creator=username, - platform_id=platform_id, - is_group=0, - ) - - return ( - Response() - .ok( - data={ - "session_id": session.session_id, - "platform_id": session.platform_id, - }, - ) - .__dict__ - ) - - async def get_sessions(self): - """Get all Platform sessions for the current user.""" - username = g.get("username", "guest") - - # 获取可选的 platform_id 参数 - platform_id = request.args.get("platform_id") - - sessions, _ = await self.db.get_platform_sessions_by_creator_paginated( - creator=username, - platform_id=platform_id, - page=1, - page_size=100, # 暂时返回前100个 - exclude_project_sessions=True, - ) - - # 转换为字典格式 - sessions_data = [] - for item in sessions: - session = item["session"] - - sessions_data.append( - { - "session_id": session.session_id, - "platform_id": session.platform_id, - "creator": session.creator, - "display_name": session.display_name, - "is_group": session.is_group, - "created_at": to_utc_isoformat(session.created_at), - "updated_at": to_utc_isoformat(session.updated_at), - }, - ) - - return Response().ok(data=sessions_data).__dict__ - - async def get_session(self): - """Get session information and message history by session_id.""" - session_id = request.args.get("session_id") - if not session_id: - return Response().error("Missing key: session_id").__dict__ - - # 获取会话信息以确定 platform_id - session = await self.db.get_platform_session_by_id(session_id) - platform_id = session.platform_id if session else "webchat" - - # 获取项目信息(如果会话属于某个项目) - username = g.get("username", "guest") - project_info = await self.db.get_project_by_session( - session_id=session_id, - creator=username, - ) - - # Get platform message history using session_id - history_ls = await self.platform_history_mgr.get( - platform_id=platform_id, - user_id=session_id, - page=1, - page_size=1000, - ) - - history_res = [history.model_dump() for history in history_ls] - threads = await self.db.get_webchat_threads_by_parent_session( - parent_session_id=session_id, - creator=username, - ) - - response_data: dict[str, Any] = { - "history": history_res, - "threads": [self._serialize_thread(thread) for thread in threads], - "is_running": self.running_convs.get(session_id, False), - } - - # 如果会话属于项目,添加项目信息 - if project_info: - response_data["project"] = { - "project_id": project_info.project_id, - "title": project_info.title, - "emoji": project_info.emoji, - } - - return Response().ok(data=response_data).__dict__ - - async def create_thread(self): - """Create or reuse a side thread from a selected assistant message.""" - post_data = await request.json - if post_data is None: - return Response().error("Missing JSON body").__dict__ - - session_id = post_data.get("session_id") - parent_message_id = post_data.get("parent_message_id") - selected_text = str(post_data.get("selected_text") or "").strip() - if not session_id: - return Response().error("Missing key: session_id").__dict__ - if parent_message_id is None: - return Response().error("Missing key: parent_message_id").__dict__ - if not selected_text: - return Response().error("Missing key: selected_text").__dict__ - - try: - parent_message_id = int(parent_message_id) - except (TypeError, ValueError): - return Response().error("Invalid key: parent_message_id").__dict__ - - username = g.get("username", "guest") - session = await self.db.get_platform_session_by_id(session_id) - if not session: - return Response().error(f"Session {session_id} not found").__dict__ - if session.creator != username: - return Response().error("Permission denied").__dict__ - - parent_record = await self.db.get_platform_message_history_by_id( - parent_message_id, - ) - if ( - not parent_record - or parent_record.platform_id != session.platform_id - or parent_record.user_id != session_id - ): - return Response().error("Parent message not found").__dict__ - if not isinstance(parent_record.content, dict): - return Response().error("Invalid parent message content").__dict__ - if parent_record.content.get("type") != "bot": - return Response().error("Only bot messages can create threads").__dict__ - - checkpoint_id = parent_record.llm_checkpoint_id - if not checkpoint_id: - return ( - Response().error("Parent message is not linked to LLM history").__dict__ - ) - - existing = await self.db.get_webchat_thread_by_parent_message_and_text( - parent_session_id=session_id, - parent_message_id=parent_message_id, - selected_text=selected_text, - creator=username, - ) - if existing: - return Response().ok(data=self._serialize_thread(existing)).__dict__ - - conversation_id, history = await self._load_current_conversation_history( - session, - ) - turn_range = self._find_turn_range(history, checkpoint_id) - if not conversation_id or not turn_range: - return Response().error("Linked checkpoint not found").__dict__ - - _start, end = turn_range - base_history = history[: end + 1] - thread = await self.db.create_webchat_thread( - creator=username, - parent_session_id=session_id, - parent_message_id=parent_message_id, - base_checkpoint_id=checkpoint_id, - selected_text=selected_text, - ) - await self.conv_mgr.new_conversation( - unified_msg_origin=self._build_thread_unified_msg_origin( - username, - thread.thread_id, - ), - platform_id="webchat", - content=base_history, - ) - return Response().ok(data=self._serialize_thread(thread)).__dict__ - - async def get_thread(self): - """Get a side thread and its message history.""" - thread_id = request.args.get("thread_id") - if not thread_id: - return Response().error("Missing key: thread_id").__dict__ - - username = g.get("username", "guest") - thread = await self.db.get_webchat_thread_by_id(thread_id) - if not thread: - return Response().error(f"Thread {thread_id} not found").__dict__ - if thread.creator != username: - return Response().error("Permission denied").__dict__ - - history_ls = await self.platform_history_mgr.get( - platform_id="webchat_thread", - user_id=thread_id, - page=1, - page_size=1000, - ) - return ( - Response() - .ok( - data={ - "thread": self._serialize_thread(thread), - "history": [history.model_dump() for history in history_ls], - "is_running": self.running_convs.get(thread_id, False), - }, - ) - .__dict__ - ) - - async def send_thread_message(self): - """Send a message inside a WebChat side thread.""" - post_data = await request.json - if post_data is None: - return Response().error("Missing JSON body").__dict__ - - thread_id = post_data.get("thread_id") - if not thread_id: - return Response().error("Missing key: thread_id").__dict__ - - username = g.get("username", "guest") - thread = await self.db.get_webchat_thread_by_id(thread_id) - if not thread: - return Response().error(f"Thread {thread_id} not found").__dict__ - if thread.creator != username: - return Response().error("Permission denied").__dict__ - - return await self.chat( - { - "session_id": thread.thread_id, - "message": post_data.get("message", []), - "enable_streaming": post_data.get("enable_streaming", True), - "selected_provider": post_data.get("selected_provider"), - "selected_model": post_data.get("selected_model"), - "_platform_history_id": "webchat_thread", - "_thread_selected_text": thread.selected_text, - }, - ) - - async def delete_thread(self): - """Delete a WebChat side thread and its isolated history.""" - post_data = await request.json - if post_data is None: - return Response().error("Missing JSON body").__dict__ - - thread_id = post_data.get("thread_id") - if not thread_id: - return Response().error("Missing key: thread_id").__dict__ - - username = g.get("username", "guest") - thread = await self.db.get_webchat_thread_by_id(thread_id) - if not thread: - return Response().error(f"Thread {thread_id} not found").__dict__ - if thread.creator != username: - return Response().error("Permission denied").__dict__ - - await self.db.delete_webchat_thread(thread_id) - await self._delete_threads_by_ids([thread_id], username) - return Response().ok(data={"thread_id": thread_id}).__dict__ - - async def update_message(self): - """Update a persisted WebChat message and its linked LLM turn.""" - post_data = await request.json - if post_data is None: - return Response().error("Missing JSON body").__dict__ - - session_id = post_data.get("session_id") - message_id = post_data.get("message_id") - content = post_data.get("content") - if not session_id: - return Response().error("Missing key: session_id").__dict__ - if message_id is None: - return Response().error("Missing key: message_id").__dict__ - - try: - message_id = int(message_id) - content = self._sanitize_message_content(content) - except (TypeError, ValueError) as exc: - return Response().error(str(exc)).__dict__ - - username = g.get("username", "guest") - session = await self.db.get_platform_session_by_id(session_id) - if not session: - return Response().error(f"Session {session_id} not found").__dict__ - if session.creator != username: - return Response().error("Permission denied").__dict__ - - record = await self.db.get_platform_message_history_by_id(message_id) - if not record: - return Response().error(f"Message {message_id} not found").__dict__ - if record.platform_id != session.platform_id or record.user_id != session_id: - return Response().error("Message does not belong to the session").__dict__ - if not isinstance(record.content, dict): - return Response().error("Invalid message content").__dict__ - if record.content.get("type") != content.get("type"): - return Response().error("Message type cannot be changed").__dict__ - if content.get("type") != "user": - return Response().error("Only user messages can be edited").__dict__ - - platform_history = await self._get_sorted_platform_history(session) - latest_user_record = next( - ( - item - for item in reversed(platform_history) - if isinstance(item.content, dict) and item.content.get("type") == "user" - ), - None, - ) - if not latest_user_record or latest_user_record.id != message_id: - return ( - Response().error("Only the latest user message can be edited").__dict__ - ) - - checkpoint_id = record.llm_checkpoint_id - if not checkpoint_id: - return ( - Response() - .error("This message is not linked to LLM history and cannot be edited") - .__dict__ - ) - - conversation_id, history = await self._load_current_conversation_history( - session, - ) - turn_range = self._find_turn_range(history, checkpoint_id) - if not conversation_id or not turn_range: - return Response().error("Linked checkpoint not found").__dict__ - if not self._is_latest_checkpoint(history, checkpoint_id): - return Response().error("Only the latest turn can be edited").__dict__ - - start, _end = turn_range - - target_index = self._find_turn_user_index(history, start, _end) - if target_index is None: - return Response().error("Linked user message not found").__dict__ - - new_checkpoint_id = str(uuid.uuid4()) - truncated_history = history[:start] - await self.platform_history_mgr.update( - message_id=message_id, - content=content, - llm_checkpoint_id=new_checkpoint_id, - ) - deleted_message_ids = await self._delete_platform_history_after( - session, - message_id, - ) - thread_ids = await self.db.delete_webchat_threads_by_parent_message_ids( - session_id, - deleted_message_ids, - ) - await self._delete_threads_by_ids(thread_ids, username) - await self.conv_mgr.update_conversation( - unified_msg_origin=self._build_webchat_unified_msg_origin(session), - conversation_id=conversation_id, - history=truncated_history, - ) - await self.db.update_platform_session(session_id=session_id) - updated = await self.db.get_platform_message_history_by_id(message_id) - return ( - Response() - .ok( - data={ - "message": updated.model_dump() if updated else None, - "needs_regenerate": True, - "truncated_after_message": True, - }, - ) - .__dict__ - ) - - async def regenerate_message(self): - """Regenerate the latest bot message linked to an LLM checkpoint.""" - post_data = await request.json - if post_data is None: - return Response().error("Missing JSON body").__dict__ - - session_id = post_data.get("session_id") - message_id = post_data.get("message_id") - if not session_id: - return Response().error("Missing key: session_id").__dict__ - if message_id is None: - return Response().error("Missing key: message_id").__dict__ - - try: - message_id = int(message_id) - except (TypeError, ValueError): - return Response().error("Invalid key: message_id").__dict__ - - username = g.get("username", "guest") - session = await self.db.get_platform_session_by_id(session_id) - if not session: - return Response().error(f"Session {session_id} not found").__dict__ - if session.creator != username: - return Response().error("Permission denied").__dict__ - - target_record = await self.db.get_platform_message_history_by_id(message_id) - if not target_record: - return Response().error(f"Message {message_id} not found").__dict__ - if ( - target_record.platform_id != session.platform_id - or target_record.user_id != session_id - ): - return Response().error("Message does not belong to the session").__dict__ - if not isinstance(target_record.content, dict): - return Response().error("Invalid message content").__dict__ - if target_record.content.get("type") != "bot": - return Response().error("Only bot messages can be regenerated").__dict__ - - checkpoint_id = target_record.llm_checkpoint_id - if not checkpoint_id: - return Response().error("Message is not linked to LLM history").__dict__ - - conversation_id, history = await self._load_current_conversation_history( - session, - ) - turn_range = self._find_turn_range(history, checkpoint_id) - if not conversation_id or not turn_range: - return Response().error("Linked checkpoint not found").__dict__ - if not self._is_latest_checkpoint(history, checkpoint_id): - return ( - Response().error("Regenerating older turns requires branching").__dict__ - ) - - start, end = turn_range - user_index = self._find_turn_user_index(history, start, end) - if user_index is None: - return Response().error("Linked user message not found").__dict__ - - platform_history = await self._get_sorted_platform_history(session) - source_user_record = next( - ( - item - for item in reversed(platform_history) - if item.llm_checkpoint_id == checkpoint_id - and isinstance(item.content, dict) - and item.content.get("type") == "user" - ), - None, - ) - if not source_user_record: - return Response().error("Linked user display message not found").__dict__ - - old_bot_record_ids = [ - item.id - for item in platform_history - if item.id is not None - and item.llm_checkpoint_id == checkpoint_id - and isinstance(item.content, dict) - and item.content.get("type") == "bot" - ] - if not old_bot_record_ids: - return Response().error("Linked bot display message not found").__dict__ - - new_checkpoint_id = str(uuid.uuid4()) - # The WebChat send path adds the current user message from the prompt. - # Remove the whole old turn here to avoid duplicating that user message. - new_history = history[:start] + history[end + 1 :] - await self.conv_mgr.update_conversation( - unified_msg_origin=self._build_webchat_unified_msg_origin(session), - conversation_id=conversation_id, - history=new_history, - ) - thread_ids = await self.db.delete_webchat_threads_by_parent_message_ids( - session_id, - old_bot_record_ids, - ) - await self._delete_threads_by_ids(thread_ids, username) - for old_bot_record_id in old_bot_record_ids: - await self.platform_history_mgr.delete_by_id(old_bot_record_id) - await self.platform_history_mgr.update( - message_id=source_user_record.id, - llm_checkpoint_id=new_checkpoint_id, - ) - - return await self.chat( - { - "session_id": session_id, - "message": source_user_record.content.get("message", []), - "enable_streaming": post_data.get("enable_streaming", True), - "selected_provider": post_data.get("selected_provider"), - "selected_model": post_data.get("selected_model"), - "_skip_user_history": True, - "_llm_checkpoint_id": new_checkpoint_id, - }, - ) - - async def update_session_display_name(self): - """Update a Platform session's display name.""" - post_data = await request.json - - session_id = post_data.get("session_id") - display_name = post_data.get("display_name") - - if not session_id: - return Response().error("Missing key: session_id").__dict__ - if display_name is None: - return Response().error("Missing key: display_name").__dict__ - - username = g.get("username", "guest") - - # 验证会话是否存在且属于当前用户 - session = await self.db.get_platform_session_by_id(session_id) - if not session: - return Response().error(f"Session {session_id} not found").__dict__ - if session.creator != username: - return Response().error("Permission denied").__dict__ - - # 更新 display_name - await self.db.update_platform_session( - session_id=session_id, - display_name=display_name, - ) - - return Response().ok().__dict__ diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py deleted file mode 100644 index aebe26047..000000000 --- a/astrbot/dashboard/routes/config.py +++ /dev/null @@ -1,1657 +0,0 @@ -import asyncio -import copy -import inspect -import os -import traceback -from pathlib import Path -from typing import Any - -from quart import jsonify, make_response, request - -from astrbot.core import astrbot_config, file_token_service, logger -from astrbot.core.config.astrbot_config import AstrBotConfig -from astrbot.core.config.default import ( - CONFIG_METADATA_2, - CONFIG_METADATA_3, - CONFIG_METADATA_3_SYSTEM, - DEFAULT_CONFIG, - DEFAULT_VALUE_MAP, -) -from astrbot.core.config.i18n_utils import ConfigMetadataI18n -from astrbot.core.core_lifecycle import AstrBotCoreLifecycle -from astrbot.core.platform.register import platform_cls_map, platform_registry -from astrbot.core.provider import Provider -from astrbot.core.provider.register import provider_registry -from astrbot.core.star.star import StarMetadata, star_registry -from astrbot.core.utils.astrbot_path import ( - get_astrbot_plugin_data_path, -) -from astrbot.core.utils.llm_metadata import LLM_METADATAS -from astrbot.core.utils.totp import ( - TwoFactorCodeType, - is_totp_enabled, - revoke_user_trusted_devices, - set_pending_totp_secret, - verify_configured_2fa_code, -) -from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config - -from .route import Response, Route, RouteContext -from .util import ( - config_key_to_folder, - get_schema_item, - normalize_rel_path, - sanitize_filename, -) - -MAX_FILE_BYTES = 500 * 1024 * 1024 -PROTECTED_2FA_CONFIG_PATHS = ( - ("dashboard", "totp", "enable"), - ("dashboard", "totp", "secret"), - ("dashboard", "totp", "recovery_code_hash"), -) -TWO_FACTOR_CODE_HEADER = "X-2FA-Code" - - -def try_cast(value: Any, type_: str): - if type_ == "int": - try: - return int(value) - except (ValueError, TypeError): - return None - elif ( - type_ == "float" - and isinstance(value, str) - and value.replace(".", "", 1).isdigit() - ) or (type_ == "float" and isinstance(value, int)): - return float(value) - elif type_ == "float": - try: - return float(value) - except (ValueError, TypeError): - return None - - -def _expect_type(value, expected_type, path_key, errors, expected_name=None) -> bool: - if not isinstance(value, expected_type): - errors.append( - f"错误的类型 {path_key}: 期望是 {expected_name or expected_type.__name__}, " - f"得到了 {type(value).__name__}" - ) - return False - return True - - -def _validate_template_list(value, meta, path_key, errors, validate_fn) -> None: - if not _expect_type(value, list, path_key, errors, "list"): - return - - templates = meta.get("templates") - if not isinstance(templates, dict): - templates = {} - - for idx, item in enumerate(value): - item_path = f"{path_key}[{idx}]" - if not _expect_type(item, dict, item_path, errors, "dict"): - continue - - template_key = item.get("__template_key") or item.get("template") - if not template_key: - errors.append(f"缺少模板选择 {item_path}: 需要 __template_key") - continue - - template_meta = templates.get(template_key) - if not template_meta: - errors.append(f"未知模板 {item_path}: {template_key}") - continue - - validate_fn( - item, - template_meta.get("items", {}), - path=f"{path_key}.templates.{template_key}.", - ) - - -def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]: - errors = [] - - def validate(data: dict, metadata: dict = schema, path="") -> None: - for key, value in data.items(): - if key not in metadata: - continue - meta = metadata[key] - if "type" not in meta: - logger.debug(f"配置项 {path}{key} 没有类型定义, 跳过校验") - continue - # null 转换 - if value is None: - data[key] = DEFAULT_VALUE_MAP[meta["type"]] - continue - - if meta["type"] == "template_list": - _validate_template_list(value, meta, f"{path}{key}", errors, validate) - continue - - if meta["type"] == "file": - if not _expect_type(value, list, f"{path}{key}", errors, "list"): - continue - for idx, item in enumerate(value): - if not isinstance(item, str): - errors.append( - f"Invalid type {path}{key}[{idx}]: expected string, got {type(item).__name__}", - ) - continue - normalized = normalize_rel_path(item) - if not normalized or not normalized.startswith("files/"): - errors.append( - f"Invalid file path {path}{key}[{idx}]: {item}", - ) - continue - key_path = f"{path}{key}" - expected_folder = config_key_to_folder(key_path) - expected_prefix = f"files/{expected_folder}/" - if not normalized.startswith(expected_prefix): - errors.append( - f"Invalid file path {path}{key}[{idx}]: {item}", - ) - continue - value[idx] = normalized - continue - - if meta["type"] == "list" and not isinstance(value, list): - errors.append( - f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}", - ) - elif ( - meta["type"] == "list" - and isinstance(value, list) - and value - and "items" in meta - and isinstance(value[0], dict) - ): - # 当前仅针对 list[dict] 的情况进行类型校验,以适配 AstrBot 中 platform、provider 的配置 - for item in value: - validate(item, meta["items"], path=f"{path}{key}.") - elif meta["type"] == "object" and isinstance(value, dict): - validate(value, meta["items"], path=f"{path}{key}.") - - if meta["type"] == "int" and not isinstance(value, int): - casted = try_cast(value, "int") - if casted is None: - errors.append( - f"错误的类型 {path}{key}: 期望是 int, 得到了 {type(value).__name__}", - ) - data[key] = casted - elif meta["type"] == "float" and not isinstance(value, float): - casted = try_cast(value, "float") - if casted is None: - errors.append( - f"错误的类型 {path}{key}: 期望是 float, 得到了 {type(value).__name__}", - ) - data[key] = casted - elif meta["type"] == "bool" and not isinstance(value, bool): - errors.append( - f"错误的类型 {path}{key}: 期望是 bool, 得到了 {type(value).__name__}", - ) - elif meta["type"] in ["string", "text"] and not isinstance(value, str): - errors.append( - f"错误的类型 {path}{key}: 期望是 string, 得到了 {type(value).__name__}", - ) - elif meta["type"] == "list" and not isinstance(value, list): - errors.append( - f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}", - ) - elif meta["type"] == "object" and not isinstance(value, dict): - errors.append( - f"错误的类型 {path}{key}: 期望是 dict, 得到了 {type(value).__name__}", - ) - - if is_core: - meta_all = { - **schema["platform_group"]["metadata"], - **schema["provider_group"]["metadata"], - **schema["misc_config_group"]["metadata"], - } - validate(data, meta_all) - else: - validate(data, schema) - - return errors, data - - -def _log_computer_config_changes(old_config: dict, new_config: dict) -> None: - """Compare and log Computer/sandbox configuration changes.""" - old_ps = old_config.get("provider_settings", {}) - new_ps = new_config.get("provider_settings", {}) - - # Check computer_use_runtime - old_runtime = old_ps.get("computer_use_runtime", "none") - new_runtime = new_ps.get("computer_use_runtime", "none") - if old_runtime != new_runtime: - logger.info( - "[Computer] Config changed: computer_use_runtime %s -> %s", - old_runtime, - new_runtime, - ) - - # Check sandbox sub-keys - old_sandbox = old_ps.get("sandbox", {}) - new_sandbox = new_ps.get("sandbox", {}) - all_keys = set(old_sandbox.keys()) | set(new_sandbox.keys()) - for key in sorted(all_keys): - old_val = old_sandbox.get(key) - new_val = new_sandbox.get(key) - if old_val != new_val: - # Mask tokens/secrets in log output - if "token" in key or "secret" in key: - old_display = "***" if old_val else "(empty)" - new_display = "***" if new_val else "(empty)" - else: - old_display = old_val - new_display = new_val - logger.info( - "[Computer] Config changed: sandbox.%s %s -> %s", - key, - old_display, - new_display, - ) - - -def _get_nested_value(data: dict, path: tuple[str, ...]) -> Any: - current = data - for key in path: - if not isinstance(current, dict) or key not in current: - return None - current = current[key] - return current - - -def _set_nested_value(data: dict, path: tuple[str, ...], value: Any) -> None: - current = data - for key in path[:-1]: - next_value = current.get(key) - if not isinstance(next_value, dict): - next_value = {} - current[key] = next_value - current = next_value - current[path[-1]] = value - - -def _protected_2fa_config_changed(old_config: dict, new_config: dict) -> bool: - return any( - _get_nested_value(old_config, path) != _get_nested_value(new_config, path) - for path in PROTECTED_2FA_CONFIG_PATHS - ) - - -async def _validate_neo_connectivity( - post_config: dict, -) -> str | None: - """Check if Bay is reachable when Shipyard Neo sandbox is configured. - - Returns a warning message string if Bay isn't reachable, or None if - everything looks fine (or Neo isn't configured). - """ - ps = post_config.get("provider_settings", {}) - runtime = ps.get("computer_use_runtime", "none") - sandbox = ps.get("sandbox", {}) - booter = sandbox.get("booter", "") - - # Only check when sandbox mode + shipyard_neo is selected - if runtime != "sandbox" or booter != "shipyard_neo": - return None - - endpoint = sandbox.get("shipyard_neo_endpoint", "").rstrip("/") - if not endpoint: - return "⚠️ Shipyard Neo endpoint 未设置" - - access_token = sandbox.get("shipyard_neo_access_token", "") - if not access_token: - # Try auto-discovery - from astrbot.core.computer.computer_client import _discover_bay_credentials - - access_token = _discover_bay_credentials(endpoint) - - if not access_token: - return ( - "⚠️ 未找到 Bay API Key。请填写访问令牌," - "或确保 Bay 的 credentials.json 可被自动发现。" - ) - - # Connectivity check - import aiohttp - - health_url = f"{endpoint}/health" - try: - async with aiohttp.ClientSession() as session: - async with session.get( - health_url, - timeout=aiohttp.ClientTimeout(total=5), - ) as resp: - if resp.status != 200: - return ( - f"⚠️ Bay 健康检查失败 (HTTP {resp.status})," - f"请确认 Bay 正在运行: {endpoint}" - ) - except Exception: - return f"⚠️ 无法连接 Bay ({endpoint}),请确认 Bay 已启动。" - - return None - - -def save_config( - post_config: dict, config: AstrBotConfig, is_core: bool = False -) -> None: - """验证并保存配置""" - errors = None - - # Snapshot old Computer config for change detection - if is_core: - _log_computer_config_changes(dict(config), post_config) - - try: - if is_core: - errors, post_config = validate_config( - post_config, - CONFIG_METADATA_2, - is_core, - ) - else: - errors, post_config = validate_config( - post_config, getattr(config, "schema", {}), is_core - ) - except BaseException as e: - logger.error(traceback.format_exc()) - logger.warning(f"验证配置时出现异常: {e}") - raise ValueError(f"验证配置时出现异常: {e}") - if errors: - raise ValueError(f"格式校验未通过: {errors}") - - config.save_config(post_config) - - -class ConfigRoute(Route): - def __init__( - self, - context: RouteContext, - core_lifecycle: AstrBotCoreLifecycle, - ) -> None: - super().__init__(context) - self.core_lifecycle = core_lifecycle - self.config: AstrBotConfig = core_lifecycle.astrbot_config - self.db = core_lifecycle.db - self._logo_token_cache = {} # 缓存logo token,避免重复注册 - self.acm = core_lifecycle.astrbot_config_mgr - self.ucr = core_lifecycle.umop_config_router - self.routes = { - "/config/abconf/new": ("POST", self.create_abconf), - "/config/abconf": ("GET", self.get_abconf), - "/config/abconfs": ("GET", self.get_abconf_list), - "/config/abconf/delete": ("POST", self.delete_abconf), - "/config/abconf/update": ("POST", self.update_abconf), - "/config/umo_abconf_routes": ("GET", self.get_uc_table), - "/config/umo_abconf_route/update_all": ("POST", self.update_ucr_all), - "/config/umo_abconf_route/update": ("POST", self.update_ucr), - "/config/umo_abconf_route/delete": ("POST", self.delete_ucr), - "/config/get": ("GET", self.get_configs), - "/config/default": ("GET", self.get_default_config), - "/config/astrbot/update": ("POST", self.post_astrbot_configs), - "/config/plugin/update": ("POST", self.post_plugin_configs), - "/config/file/upload": ("POST", self.upload_config_file), - "/config/file/delete": ("POST", self.delete_config_file), - "/config/file/get": ("GET", self.get_config_file_list), - "/config/platform/new": ("POST", self.post_new_platform), - "/config/platform/update": ("POST", self.post_update_platform), - "/config/platform/delete": ("POST", self.post_delete_platform), - "/config/platform/list": ("GET", self.get_platform_list), - "/config/provider/new": ("POST", self.post_new_provider), - "/config/provider/update": ("POST", self.post_update_provider), - "/config/provider/delete": ("POST", self.post_delete_provider), - "/config/provider/template": ("GET", self.get_provider_template), - "/config/provider/check_one": ("GET", self.check_one_provider_status), - "/config/provider/list": ("GET", self.get_provider_config_list), - "/config/provider/model_list": ("GET", self.get_provider_model_list), - "/config/provider/get_embedding_dim": ("POST", self.get_embedding_dim), - "/config/provider_sources/models": ( - "GET", - self.get_provider_source_models, - ), - "/config/provider_sources/update": ( - "POST", - self.update_provider_source, - ), - "/config/provider_sources/delete": ( - "POST", - self.delete_provider_source, - ), - } - self.register_routes() - - async def delete_provider_source(self): - """删除 provider_source,并更新关联的 providers""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - - provider_source_id = post_data.get("id") - if not provider_source_id: - return Response().error("缺少 provider_source_id").__dict__ - - provider_sources = self.config.get("provider_sources", []) - target_idx = next( - ( - i - for i, ps in enumerate(provider_sources) - if ps.get("id") == provider_source_id - ), - -1, - ) - - if target_idx == -1: - return Response().error("未找到对应的 provider source").__dict__ - - # 删除 provider_source - del provider_sources[target_idx] - - # 写回配置 - self.config["provider_sources"] = provider_sources - - # 删除引用了该 provider_source 的 providers - await self.core_lifecycle.provider_manager.delete_provider( - provider_source_id=provider_source_id - ) - - try: - save_config(self.config, self.config, is_core=True) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ - - return Response().ok(message="删除 provider source 成功").__dict__ - - async def update_provider_source(self): - """更新或新增 provider_source,并重载关联的 providers""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - - new_source_config = post_data.get("config") or post_data - original_id = post_data.get("original_id") - if not original_id: - return Response().error("缺少 original_id").__dict__ - - if not isinstance(new_source_config, dict): - return Response().error("缺少或错误的配置数据").__dict__ - - # 确保配置中有 id 字段 - if not new_source_config.get("id"): - new_source_config["id"] = original_id - - provider_sources = self.config.get("provider_sources", []) - - for ps in provider_sources: - if ps.get("id") == new_source_config["id"] and ps.get("id") != original_id: - return ( - Response() - .error( - f"Provider source ID '{new_source_config['id']}' exists already, please try another ID.", - ) - .__dict__ - ) - - # 查找旧的 provider_source,若不存在则追加为新配置 - target_idx = next( - (i for i, ps in enumerate(provider_sources) if ps.get("id") == original_id), - -1, - ) - - old_id = original_id - if target_idx == -1: - provider_sources.append(new_source_config) - else: - old_id = provider_sources[target_idx].get("id") - provider_sources[target_idx] = new_source_config - - # 更新引用了该 provider_source 的 providers - affected_providers = [] - for provider in self.config.get("provider", []): - if provider.get("provider_source_id") == old_id: - provider["provider_source_id"] = new_source_config["id"] - affected_providers.append(provider) - - # 写回配置 - self.config["provider_sources"] = provider_sources - - try: - save_config(self.config, self.config, is_core=True) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ - - # 重载受影响的 providers,使新的 source 配置生效 - reload_errors = [] - prov_mgr = self.core_lifecycle.provider_manager - for provider in affected_providers: - try: - await prov_mgr.reload(provider) - except Exception as e: - logger.error(traceback.format_exc()) - reload_errors.append(f"{provider.get('id')}: {e}") - - if reload_errors: - return ( - Response() - .error("更新成功,但部分提供商重载失败: " + ", ".join(reload_errors)) - .__dict__ - ) - - return Response().ok(message="更新 provider source 成功").__dict__ - - async def get_provider_template(self): - provider_metadata = ConfigMetadataI18n.convert_to_i18n_keys( - { - "provider_group": { - "metadata": { - "provider": CONFIG_METADATA_2["provider_group"]["metadata"][ - "provider" - ] - } - } - } - ) - config_schema = { - "provider": provider_metadata["provider_group"]["metadata"]["provider"] - } - data = { - "config_schema": config_schema, - "providers": astrbot_config["provider"], - "provider_sources": astrbot_config["provider_sources"], - } - return Response().ok(data=data).__dict__ - - async def get_uc_table(self): - """获取 UMOP 配置路由表""" - return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__ - - async def update_ucr_all(self): - """更新 UMOP 配置路由表的全部内容""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - - new_routing = post_data.get("routing", None) - - if not new_routing or not isinstance(new_routing, dict): - return Response().error("缺少或错误的路由表数据").__dict__ - - try: - await self.ucr.update_routing_data(new_routing) - return Response().ok(message="更新成功").__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"更新路由表失败: {e!s}").__dict__ - - async def update_ucr(self): - """更新 UMOP 配置路由表""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - - umo = post_data.get("umo", None) - conf_id = post_data.get("conf_id", None) - - if not umo or not conf_id: - return Response().error("缺少 UMO 或配置文件 ID").__dict__ - - try: - await self.ucr.update_route(umo, conf_id) - return Response().ok(message="更新成功").__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"更新路由表失败: {e!s}").__dict__ - - async def delete_ucr(self): - """删除 UMOP 配置路由表中的一项""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - - umo = post_data.get("umo", None) - - if not umo: - return Response().error("缺少 UMO").__dict__ - - try: - if umo in self.ucr.umop_to_conf_id: - del self.ucr.umop_to_conf_id[umo] - await self.ucr.update_routing_data(self.ucr.umop_to_conf_id) - return Response().ok(message="删除成功").__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"删除路由表项失败: {e!s}").__dict__ - - async def get_default_config(self): - """获取默认配置文件""" - metadata = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3) - return Response().ok({"config": DEFAULT_CONFIG, "metadata": metadata}).__dict__ - - async def get_abconf_list(self): - """获取所有 AstrBot 配置文件的列表""" - abconf_list = self.acm.get_conf_list() - return Response().ok({"info_list": abconf_list}).__dict__ - - async def create_abconf(self): - """创建新的 AstrBot 配置文件""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - name = post_data.get("name", None) - config = post_data.get("config", DEFAULT_CONFIG) - - try: - conf_id = self.acm.create_conf(name=name, config=config) - await self.core_lifecycle.reload_pipeline_scheduler(conf_id) - return Response().ok(message="创建成功", data={"conf_id": conf_id}).__dict__ - except ValueError as e: - return Response().error(str(e)).__dict__ - - async def get_abconf(self): - """获取指定 AstrBot 配置文件""" - abconf_id = request.args.get("id") - system_config = request.args.get("system_config", "0").lower() == "1" - if not abconf_id and not system_config: - return Response().error("缺少配置文件 ID").__dict__ - - try: - if system_config: - abconf = self.acm.confs["default"] - metadata = ConfigMetadataI18n.convert_to_i18n_keys( - CONFIG_METADATA_3_SYSTEM - ) - return Response().ok({"config": abconf, "metadata": metadata}).__dict__ - if abconf_id is None: - raise ValueError("abconf_id cannot be None") - abconf = self.acm.confs[abconf_id] - metadata = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3) - return Response().ok({"config": abconf, "metadata": metadata}).__dict__ - except ValueError as e: - return Response().error(str(e)).__dict__ - - async def delete_abconf(self): - """删除指定 AstrBot 配置文件""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - - conf_id = post_data.get("id") - if not conf_id: - return Response().error("缺少配置文件 ID").__dict__ - - try: - success = self.acm.delete_conf(conf_id) - if success: - self.core_lifecycle.pipeline_scheduler_mapping.pop(conf_id, None) - return Response().ok(message="删除成功").__dict__ - return Response().error("删除失败").__dict__ - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"删除配置文件失败: {e!s}").__dict__ - - async def update_abconf(self): - """更新指定 AstrBot 配置文件信息""" - post_data = await request.json - if not post_data: - return Response().error("缺少配置数据").__dict__ - - conf_id = post_data.get("id") - if not conf_id: - return Response().error("缺少配置文件 ID").__dict__ - - name = post_data.get("name") - - try: - success = self.acm.update_conf_info(conf_id, name=name) - if success: - return Response().ok(message="更新成功").__dict__ - return Response().error("更新失败").__dict__ - except ValueError as e: - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"更新配置文件失败: {e!s}").__dict__ - - async def _test_single_provider(self, provider): - """辅助函数:测试单个 provider 的可用性""" - meta = provider.meta() - provider_name = provider.provider_config.get("id", "Unknown Provider") - provider_capability_type = meta.provider_type - - status_info = { - "id": getattr(meta, "id", "Unknown ID"), - "model": getattr(meta, "model", "Unknown Model"), - "type": provider_capability_type.value, - "name": provider_name, - "status": "unavailable", # 默认为不可用 - "error": None, - } - logger.debug( - f"Attempting to check provider: {status_info['name']} (ID: {status_info['id']}, Type: {status_info['type']}, Model: {status_info['model']})", - ) - - try: - await provider.test() - status_info["status"] = "available" - logger.info( - f"Provider {status_info['name']} (ID: {status_info['id']}) is available.", - ) - except Exception as e: - error_message = str(e) - status_info["error"] = error_message - logger.warning( - f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}", - ) - logger.debug( - f"Traceback for {status_info['name']}:\n{traceback.format_exc()}", - ) - - return status_info - - def _error_response( - self, - message: str, - status_code: int = 500, - log_fn=logger.error, - ): - log_fn(message) - # 记录更详细的traceback信息,但只在是严重错误时 - if status_code == 500: - log_fn(traceback.format_exc()) - return Response().error(message).__dict__ - - async def check_one_provider_status(self): - """API: check a single LLM Provider's status by id""" - provider_id = request.args.get("id") - if not provider_id: - return self._error_response( - "Missing provider_id parameter", - 400, - logger.warning, - ) - - logger.info(f"API call: /config/provider/check_one id={provider_id}") - try: - prov_mgr = self.core_lifecycle.provider_manager - target = prov_mgr.inst_map.get(provider_id) - - if not target: - logger.warning( - f"Provider with id '{provider_id}' not found in provider_manager.", - ) - return ( - Response() - .error(f"Provider with id '{provider_id}' not found") - .__dict__ - ) - - result = await self._test_single_provider(target) - return Response().ok(result).__dict__ - - except Exception as e: - return self._error_response( - f"Critical error checking provider {provider_id}: {e}", - 500, - ) - - async def get_configs(self): - # plugin_name 为空时返回 AstrBot 配置 - # 否则返回指定 plugin_name 的插件配置 - plugin_name = request.args.get("plugin_name", None) - if not plugin_name: - return Response().ok(await self._get_astrbot_config()).__dict__ - return Response().ok(await self._get_plugin_config(plugin_name)).__dict__ - - async def get_provider_config_list(self): - provider_type = request.args.get("provider_type", None) - if not provider_type: - return Response().error("缺少参数 provider_type").__dict__ - provider_type_ls = provider_type.split(",") - provider_list = [] - ps = self.core_lifecycle.provider_manager.providers_config - p_source_pt = { - psrc["id"]: psrc.get("provider_type", "chat_completion") - for psrc in self.core_lifecycle.provider_manager.provider_sources_config - } - for provider in ps: - ps_id = provider.get("provider_source_id", None) - if ( - ps_id - and ps_id in p_source_pt - and p_source_pt[ps_id] in provider_type_ls - ): - # chat - prov = self.core_lifecycle.provider_manager.get_merged_provider_config( - provider - ) - provider_list.append(prov) - elif not ps_id and provider.get("provider_type", "") in provider_type_ls: - # agent runner, embedding, etc - provider_list.append(provider) - return Response().ok(provider_list).__dict__ - - async def get_provider_model_list(self): - """获取指定提供商的模型列表""" - provider_id = request.args.get("provider_id", None) - if not provider_id: - return Response().error("缺少参数 provider_id").__dict__ - - prov_mgr = self.core_lifecycle.provider_manager - provider = prov_mgr.inst_map.get(provider_id, None) - if not provider: - return Response().error(f"未找到 ID 为 {provider_id} 的提供商").__dict__ - if not isinstance(provider, Provider): - return ( - Response() - .error(f"提供商 {provider_id} 类型不支持获取模型列表") - .__dict__ - ) - - try: - models = await provider.get_models() - models = models or [] - - metadata_map = {} - for model_id in models: - meta = LLM_METADATAS.get(model_id) - if meta: - metadata_map[model_id] = meta - - ret = { - "models": models, - "provider_id": provider_id, - "model_metadata": metadata_map, - } - return Response().ok(ret).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ - - async def get_embedding_dim(self): - """获取嵌入模型的维度""" - post_data = await request.json - provider_config = post_data.get("provider_config", None) - if not provider_config: - return Response().error("缺少参数 provider_config").__dict__ - - try: - # 动态导入 EmbeddingProvider - from astrbot.core.provider.provider import EmbeddingProvider - from astrbot.core.provider.register import provider_cls_map - - # 获取 provider 类型 - provider_type = provider_config.get("type", None) - if not provider_type: - return Response().error("provider_config 缺少 type 字段").__dict__ - - # 首次添加某类提供商时,provider_cls_map 可能尚未注册该适配器 - if provider_type not in provider_cls_map: - try: - self.core_lifecycle.provider_manager.dynamic_import_provider( - provider_type, - ) - except ImportError: - logger.error(traceback.format_exc()) - return ( - Response() - .error( - "提供商适配器加载失败,请检查提供商类型配置或查看服务端日志" - ) - .__dict__ - ) - - # 获取对应的 provider 类 - if provider_type not in provider_cls_map: - return ( - Response() - .error(f"未找到适用于 {provider_type} 的提供商适配器") - .__dict__ - ) - - provider_metadata = provider_cls_map[provider_type] - cls_type = provider_metadata.cls_type - - if not cls_type: - return Response().error(f"无法找到 {provider_type} 的类").__dict__ - - # 实例化 provider - inst = cls_type(provider_config, {}) - - # 检查是否是 EmbeddingProvider - if not isinstance(inst, EmbeddingProvider): - return Response().error("提供商不是 EmbeddingProvider 类型").__dict__ - - init_fn = getattr(inst, "initialize", None) - if inspect.iscoroutinefunction(init_fn): - await init_fn() - - # 通过实际请求验证当前 embedding_dimensions 是否可用 - vec = await inst.get_embedding("echo") - dim = len(vec) - - logger.info( - f"检测到 {provider_config.get('id', 'unknown')} 的嵌入向量维度为 {dim}", - ) - - return Response().ok({"embedding_dimensions": dim}).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"获取嵌入维度失败: {e!s}").__dict__ - - async def get_provider_source_models(self): - """获取指定 provider_source 支持的模型列表 - - 本质上会临时初始化一个 Provider 实例,调用 get_models() 获取模型列表,然后销毁实例 - """ - provider_source_id = request.args.get("source_id") - if not provider_source_id: - return Response().error("缺少参数 source_id").__dict__ - - try: - from astrbot.core.provider.register import provider_cls_map - - # 从配置中查找对应的 provider_source - provider_sources = self.config.get("provider_sources", []) - provider_source = None - for ps in provider_sources: - if ps.get("id") == provider_source_id: - provider_source = ps - break - - if not provider_source: - return ( - Response() - .error(f"未找到 ID 为 {provider_source_id} 的 provider_source") - .__dict__ - ) - - # 获取 provider 类型 - provider_type = provider_source.get("type", None) - if not provider_type: - return Response().error("provider_source 缺少 type 字段").__dict__ - - try: - self.core_lifecycle.provider_manager.dynamic_import_provider( - provider_type - ) - except ImportError as e: - logger.error(traceback.format_exc()) - return Response().error(f"动态导入提供商适配器失败: {e!s}").__dict__ - - # 获取对应的 provider 类 - if provider_type not in provider_cls_map: - return ( - Response() - .error(f"未找到适用于 {provider_type} 的提供商适配器") - .__dict__ - ) - - provider_metadata = provider_cls_map[provider_type] - cls_type = provider_metadata.cls_type - - if not cls_type: - return Response().error(f"无法找到 {provider_type} 的类").__dict__ - - # 检查是否是 Provider 类型 - if not issubclass(cls_type, Provider): - return ( - Response() - .error(f"提供商 {provider_type} 不支持获取模型列表") - .__dict__ - ) - - # 临时实例化 provider - inst = cls_type(provider_source, {}) - - # 如果有 initialize 方法,调用它 - init_fn = getattr(inst, "initialize", None) - if inspect.iscoroutinefunction(init_fn): - await init_fn() - - # 获取模型列表 - models = await inst.get_models() - models = models or [] - - metadata_map = {} - for model_id in models: - meta = LLM_METADATAS.get(model_id) - if meta: - metadata_map[model_id] = meta - - # 销毁实例(如果有 terminate 方法) - terminate_fn = getattr(inst, "terminate", None) - if inspect.iscoroutinefunction(terminate_fn): - await terminate_fn() - - return ( - Response() - .ok({"models": models, "model_metadata": metadata_map}) - .__dict__ - ) - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(f"获取模型列表失败: {e!s}").__dict__ - - async def get_platform_list(self): - """获取所有平台的列表""" - platform_list = [] - for platform in self.config["platform"]: - platform_list.append(platform) - return Response().ok({"platforms": platform_list}).__dict__ - - async def post_astrbot_configs(self): - data = await request.json - if not isinstance(data, dict): - return Response().error("Invalid request payload").__dict__ - config = data.get("config", None) - conf_id = data.get("conf_id", None) - - try: - if not isinstance(config, dict): - return Response().error("Invalid config payload").__dict__ - - if conf_id not in self.acm.confs: - raise ValueError(f"Config file {conf_id} does not exist") - - # 不更新 provider_sources, provider, platform - # 这些配置有单独的接口进行更新 - if conf_id == "default": - no_update_keys = ["provider_sources", "provider", "platform"] - for key in no_update_keys: - config[key] = self.acm.default_conf[key] - - current_config = self.acm.confs[conf_id] - protected_2fa_changed = _protected_2fa_config_changed( - current_config, config - ) - verified_2fa = None - if await self._requires_config_2fa(current_config, protected_2fa_changed): - verified_2fa = await self._verify_config_2fa(current_config) - if not verified_2fa: - return await self._config_2fa_required_response() - - if not _get_nested_value(config, ("dashboard", "totp", "enable")): - _set_nested_value(config, ("dashboard", "totp", "secret"), "") - _set_nested_value( - config, ("dashboard", "totp", "recovery_code_hash"), "" - ) - - set_pending_totp_secret(None) - await self._save_astrbot_configs(config, conf_id) - if protected_2fa_changed: - await revoke_user_trusted_devices(self.db) - await self.core_lifecycle.reload_pipeline_scheduler(conf_id) - - # Non-blocking Bay connectivity check - warning = await _validate_neo_connectivity(config) - if warning: - return Response().ok(None, f"保存成功。{warning}").__dict__ - return Response().ok(None, "保存成功~").__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ - - async def _requires_config_2fa( - self, current_config: dict, protected_2fa_changed: bool - ) -> bool: - if not is_totp_enabled(current_config): - return False - if not protected_2fa_changed: - return False - return True - - async def _verify_config_2fa( - self, current_config: dict - ) -> TwoFactorCodeType | None: - code = request.headers.get(TWO_FACTOR_CODE_HEADER, "").strip() - if not code: - return None - - return await verify_configured_2fa_code( - current_config, code, include_pending=True, allow_recovery=False - ) - - async def _config_2fa_required_response(self): - response = await make_response( - jsonify( - { - "status": "error", - "data": { - "totp_required": True, - }, - } - ) - ) - response.status_code = 401 - return response - - async def post_plugin_configs(self): - post_configs = await request.json - plugin_name = request.args.get("plugin_name", "unknown") - try: - await self._save_plugin_configs(post_configs, plugin_name) - await self.core_lifecycle.plugin_manager.reload(plugin_name) - return ( - Response() - .ok(None, f"保存插件 {plugin_name} 成功~ 机器人正在热重载插件。") - .__dict__ - ) - except Exception as e: - return Response().error(str(e)).__dict__ - - def _get_plugin_metadata_by_name(self, plugin_name: str) -> StarMetadata | None: - for plugin_md in star_registry: - if plugin_md.name == plugin_name: - return plugin_md - return None - - def _resolve_config_file_scope( - self, - ) -> tuple[str, str, str, StarMetadata, AstrBotConfig]: - """将请求参数解析为一个明确的配置作用域。 - - 当前支持的 scope: - - scope=plugin:name=,key= - """ - - scope = request.args.get("scope") or "plugin" - name = request.args.get("name") - key_path = request.args.get("key") - - if scope != "plugin": - raise ValueError(f"Unsupported scope: {scope}") - if not name or not key_path: - raise ValueError("Missing name or key parameter") - - md = self._get_plugin_metadata_by_name(name) - if not md or not md.config: - raise ValueError(f"Plugin {name} not found or has no config") - - return scope, name, key_path, md, md.config - - async def upload_config_file(self): - """上传文件到插件数据目录(用于某个 file 类型配置项)。""" - - try: - scope, name, key_path, md, config = self._resolve_config_file_scope() - except ValueError as e: - return Response().error(str(e)).__dict__ - - meta = get_schema_item(getattr(config, "schema", None), key_path) - if not meta or meta.get("type") != "file": - return Response().error("Config item not found or not file type").__dict__ - - file_types = meta.get("file_types") - allowed_exts: list[str] = [] - if isinstance(file_types, list): - allowed_exts = [ - str(ext).lstrip(".").lower() for ext in file_types if str(ext).strip() - ] - - files = await request.files - if not files: - return Response().error("No files uploaded").__dict__ - - storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False) - plugin_root_path = (storage_root_path / name).resolve(strict=False) - try: - plugin_root_path.relative_to(storage_root_path) - except ValueError: - return Response().error("Invalid name parameter").__dict__ - plugin_root_path.mkdir(parents=True, exist_ok=True) - - uploaded: list[str] = [] - folder = config_key_to_folder(key_path) - errors: list[str] = [] - for file in files.values(): - filename = sanitize_filename(file.filename or "") - if not filename: - errors.append("Invalid filename") - continue - - file_size = getattr(file, "content_length", None) - if isinstance(file_size, int) and file_size > MAX_FILE_BYTES: - errors.append(f"File too large: {filename}") - continue - - ext = os.path.splitext(filename)[1].lstrip(".").lower() - if allowed_exts and ext not in allowed_exts: - errors.append(f"Unsupported file type: {filename}") - continue - - rel_path = f"files/{folder}/{filename}" - save_path = (plugin_root_path / rel_path).resolve(strict=False) - try: - save_path.relative_to(plugin_root_path) - except ValueError: - errors.append(f"Invalid path: {filename}") - continue - - save_path.parent.mkdir(parents=True, exist_ok=True) - await file.save(str(save_path)) - if save_path.is_file() and save_path.stat().st_size > MAX_FILE_BYTES: - save_path.unlink() - errors.append(f"File too large: {filename}") - continue - uploaded.append(rel_path) - - if not uploaded: - return ( - Response() - .error( - "Upload failed: " + ", ".join(errors) - if errors - else "Upload failed", - ) - .__dict__ - ) - - return Response().ok({"uploaded": uploaded, "errors": errors}).__dict__ - - async def delete_config_file(self): - """删除插件数据目录中的文件。""" - - scope = request.args.get("scope") or "plugin" - name = request.args.get("name") - if not name: - return Response().error("Missing name parameter").__dict__ - if scope != "plugin": - return Response().error(f"Unsupported scope: {scope}").__dict__ - - data = await request.get_json() - rel_path = data.get("path") if isinstance(data, dict) else None - rel_path = normalize_rel_path(rel_path) - if not rel_path or not rel_path.startswith("files/"): - return Response().error("Invalid path parameter").__dict__ - - md = self._get_plugin_metadata_by_name(name) - if not md: - return Response().error(f"Plugin {name} not found").__dict__ - - storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False) - plugin_root_path = (storage_root_path / name).resolve(strict=False) - try: - plugin_root_path.relative_to(storage_root_path) - except ValueError: - return Response().error("Invalid name parameter").__dict__ - target_path = (plugin_root_path / rel_path).resolve(strict=False) - try: - target_path.relative_to(plugin_root_path) - except ValueError: - return Response().error("Invalid path parameter").__dict__ - if target_path.is_file(): - target_path.unlink() - - return Response().ok(None, "Deleted").__dict__ - - async def get_config_file_list(self): - """获取配置项对应目录下的文件列表。""" - - try: - _, name, key_path, _, config = self._resolve_config_file_scope() - except ValueError as e: - return Response().error(str(e)).__dict__ - - meta = get_schema_item(getattr(config, "schema", None), key_path) - if not meta or meta.get("type") != "file": - return Response().error("Config item not found or not file type").__dict__ - - storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False) - plugin_root_path = (storage_root_path / name).resolve(strict=False) - try: - plugin_root_path.relative_to(storage_root_path) - except ValueError: - return Response().error("Invalid name parameter").__dict__ - - folder = config_key_to_folder(key_path) - target_dir = (plugin_root_path / "files" / folder).resolve(strict=False) - try: - target_dir.relative_to(plugin_root_path) - except ValueError: - return Response().error("Invalid path parameter").__dict__ - - if not target_dir.exists() or not target_dir.is_dir(): - return Response().ok({"files": []}).__dict__ - - files: list[str] = [] - for path in target_dir.rglob("*"): - if not path.is_file(): - continue - try: - rel_path = path.relative_to(plugin_root_path).as_posix() - except ValueError: - continue - if rel_path.startswith("files/"): - files.append(rel_path) - - return Response().ok({"files": files}).__dict__ - - async def post_new_platform(self): - new_platform_config = await request.json - - # 如果是支持统一 webhook 模式的平台,生成 webhook_uuid - ensure_platform_webhook_config(new_platform_config) - - self.config["platform"].append(new_platform_config) - try: - save_config(self.config, self.config, is_core=True) - await self.core_lifecycle.platform_manager.load_platform( - new_platform_config, - ) - except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "新增平台配置成功~").__dict__ - - async def post_new_provider(self): - new_provider_config = await request.json - - try: - await self.core_lifecycle.provider_manager.create_provider( - new_provider_config - ) - except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "新增服务提供商配置成功").__dict__ - - async def post_update_platform(self): - update_platform_config = await request.json - origin_platform_id = update_platform_config.get("id", None) - new_config = update_platform_config.get("config", None) - if not origin_platform_id or not new_config: - return Response().error("参数错误").__dict__ - - if origin_platform_id != new_config.get("id", None): - return Response().error("机器人名称不允许修改").__dict__ - - # 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid - ensure_platform_webhook_config(new_config) - - for i, platform in enumerate(self.config["platform"]): - if platform["id"] == origin_platform_id: - self.config["platform"][i] = new_config - break - else: - return Response().error("未找到对应平台").__dict__ - - try: - save_config(self.config, self.config, is_core=True) - await self.core_lifecycle.platform_manager.reload(new_config) - except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "更新平台配置成功~").__dict__ - - async def post_update_provider(self): - update_provider_config = await request.json - origin_provider_id = update_provider_config.get("id", None) - new_config = update_provider_config.get("config", None) - if not origin_provider_id or not new_config: - return Response().error("参数错误").__dict__ - - try: - await self.core_lifecycle.provider_manager.update_provider( - origin_provider_id, new_config - ) - except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "更新成功,已经实时生效~").__dict__ - - async def post_delete_platform(self): - platform_id = await request.json - platform_id = platform_id.get("id") - for i, platform in enumerate(self.config["platform"]): - if platform["id"] == platform_id: - del self.config["platform"][i] - break - else: - return Response().error("未找到对应平台").__dict__ - try: - save_config(self.config, self.config, is_core=True) - await self.core_lifecycle.platform_manager.terminate_platform(platform_id) - except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "删除平台配置成功~").__dict__ - - async def post_delete_provider(self): - provider_id = await request.json - provider_id = provider_id.get("id", "") - if not provider_id: - return Response().error("缺少参数 id").__dict__ - - try: - await self.core_lifecycle.provider_manager.delete_provider( - provider_id=provider_id - ) - except Exception as e: - return Response().error(str(e)).__dict__ - return Response().ok(None, "删除成功,已经实时生效。").__dict__ - - async def get_llm_tools(self): - """获取函数调用工具。包含了本地加载的以及 MCP 服务的工具""" - tool_mgr = self.core_lifecycle.provider_manager.llm_tools - tools = tool_mgr.get_func_desc_openai_style() - return Response().ok(tools).__dict__ - - async def _register_platform_logo(self, platform, platform_default_tmpl) -> None: - """注册平台logo文件并生成访问令牌""" - if not platform.logo_path: - return - - try: - # 检查缓存 - cache_key = f"{platform.name}:{platform.logo_path}" - if cache_key in self._logo_token_cache: - cached_token = self._logo_token_cache[cache_key] - # 确保platform_default_tmpl[platform.name]存在且为字典 - if platform.name not in platform_default_tmpl or not isinstance( - platform_default_tmpl[platform.name], dict - ): - platform_default_tmpl[platform.name] = {} - platform_default_tmpl[platform.name]["logo_token"] = cached_token - logger.debug(f"Using cached logo token for platform {platform.name}") - return - - # 获取平台适配器类 - platform_cls = platform_cls_map.get(platform.name) - if not platform_cls: - logger.warning(f"Platform class not found for {platform.name}") - return - - # 获取插件目录路径 - module_file = inspect.getfile(platform_cls) - plugin_dir = os.path.dirname(module_file) - - # 解析logo文件路径 - logo_file_path = os.path.join(plugin_dir, platform.logo_path) - - # 检查文件是否存在并注册令牌 - if os.path.exists(logo_file_path): - logo_token = await file_token_service.register_file( - logo_file_path, - timeout=3600, - ) - - # 确保platform_default_tmpl[platform.name]存在且为字典 - if platform.name not in platform_default_tmpl or not isinstance( - platform_default_tmpl[platform.name], dict - ): - platform_default_tmpl[platform.name] = {} - - platform_default_tmpl[platform.name]["logo_token"] = logo_token - - # 缓存token - self._logo_token_cache[cache_key] = logo_token - - logger.debug(f"Logo token registered for platform {platform.name}") - else: - logger.warning( - f"Platform {platform.name} logo file not found: {logo_file_path}", - ) - - except (ImportError, AttributeError) as e: - logger.warning( - f"Failed to import required modules for platform {platform.name}: {e}", - ) - except OSError as e: - logger.warning(f"File system error for platform {platform.name} logo: {e}") - except Exception as e: - logger.warning( - f"Unexpected error registering logo for platform {platform.name}: {e}", - ) - - def _inject_platform_metadata_with_i18n( - self, platform, metadata, platform_i18n_translations: dict - ): - """将配置元数据注入到 metadata 中并处理国际化键转换。""" - metadata["platform_group"]["metadata"]["platform"].setdefault("items", {}) - platform_items_to_inject = copy.deepcopy(platform.config_metadata) - - if platform.i18n_resources: - i18n_prefix = f"platform_group.platform.{platform.name}" - - for lang, lang_data in platform.i18n_resources.items(): - platform_i18n_translations.setdefault(lang, {}).setdefault( - "platform_group", {} - ).setdefault("platform", {})[platform.name] = lang_data - - for field_key, field_value in platform_items_to_inject.items(): - for key in ("description", "hint", "labels"): - if key in field_value: - field_value[key] = f"{i18n_prefix}.{field_key}.{key}" - - metadata["platform_group"]["metadata"]["platform"]["items"].update( - platform_items_to_inject - ) - - async def _get_astrbot_config(self): - config = self.config - metadata = copy.deepcopy(CONFIG_METADATA_2) - platform_i18n = ConfigMetadataI18n.convert_to_i18n_keys( - { - "platform_group": { - "metadata": { - "platform": metadata["platform_group"]["metadata"]["platform"] - } - } - } - ) - metadata["platform_group"]["metadata"]["platform"] = platform_i18n[ - "platform_group" - ]["metadata"]["platform"] - - # 平台适配器的默认配置模板注入 - platform_default_tmpl = metadata["platform_group"]["metadata"]["platform"][ - "config_template" - ] - - # 收集平台的 i18n 翻译数据 - platform_i18n_translations = {} - - # 收集需要注册logo的平台 - logo_registration_tasks = [] - for platform in platform_registry: - if platform.default_config_tmpl: - platform_default_tmpl[platform.name] = copy.deepcopy( - platform.default_config_tmpl - ) - - # 注入配置元数据(在 convert_to_i18n_keys 之后,使用国际化键) - if platform.config_metadata: - self._inject_platform_metadata_with_i18n( - platform, metadata, platform_i18n_translations - ) - - # 收集logo注册任务 - if platform.logo_path: - logo_registration_tasks.append( - self._register_platform_logo(platform, platform_default_tmpl), - ) - - # 并行执行logo注册 - if logo_registration_tasks: - await asyncio.gather(*logo_registration_tasks, return_exceptions=True) - - # 服务提供商的默认配置模板注入 - provider_default_tmpl = metadata["provider_group"]["metadata"]["provider"][ - "config_template" - ] - for provider in provider_registry: - if provider.default_config_tmpl: - provider_default_tmpl[provider.type] = provider.default_config_tmpl - - return { - "metadata": metadata, - "config": config, - "platform_i18n_translations": platform_i18n_translations, - } - - async def _get_plugin_config(self, plugin_name: str): - ret: dict = {"metadata": None, "config": None, "i18n": {}} - - for plugin_md in star_registry: - if plugin_md.name == plugin_name: - if not plugin_md.config: - break - ret["config"] = ( - plugin_md.config - ) # 这是自定义的 Dict 类(AstrBotConfig) - ret["metadata"] = { - plugin_name: { - "description": f"{plugin_name} 配置", - "type": "object", - "items": plugin_md.config.schema, # 初始化时通过 __setattr__ 存入了 schema - }, - } - ret["i18n"] = plugin_md.i18n - break - - return ret - - async def _save_astrbot_configs( - self, post_configs: dict, conf_id: str | None = None - ) -> None: - try: - if conf_id not in self.acm.confs: - raise ValueError(f"配置文件 {conf_id} 不存在") - astrbot_config = self.acm.confs[conf_id] - - # 保留服务端的 t2i_active_template 值 - if "t2i_active_template" in astrbot_config: - post_configs["t2i_active_template"] = astrbot_config[ - "t2i_active_template" - ] - - save_config(post_configs, astrbot_config, is_core=True) - except Exception as e: - raise e - - async def _save_plugin_configs(self, post_configs: dict, plugin_name: str) -> None: - md = None - for plugin_md in star_registry: - if plugin_md.name == plugin_name: - md = plugin_md - - if not md: - raise ValueError(f"插件 {plugin_name} 不存在") - if not md.config: - raise ValueError(f"插件 {plugin_name} 没有注册配置") - assert md.config is not None - - try: - errors, post_configs = validate_config( - post_configs, getattr(md.config, "schema", {}), is_core=False - ) - if errors: - raise ValueError(f"格式校验未通过: {errors}") - md.config.save_config(post_configs) - except Exception as e: - raise e diff --git a/astrbot/dashboard/routes/file.py b/astrbot/dashboard/routes/file.py deleted file mode 100644 index 1880150bf..000000000 --- a/astrbot/dashboard/routes/file.py +++ /dev/null @@ -1,24 +0,0 @@ -from quart import abort, send_file - -from astrbot.core import file_token_service - -from .route import Route, RouteContext - - -class FileRoute(Route): - def __init__( - self, - context: RouteContext, - ) -> None: - super().__init__(context) - self.routes = { - "/file/": ("GET", self.serve_file), - } - self.register_routes() - - async def serve_file(self, file_token: str): - try: - file_path = await file_token_service.handle_file(file_token) - return await send_file(file_path) - except (FileNotFoundError, KeyError): - return abort(404) diff --git a/astrbot/dashboard/routes/util.py b/astrbot/dashboard/routes/util.py deleted file mode 100644 index d08af03ee..000000000 --- a/astrbot/dashboard/routes/util.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Dashboard 路由工具集。 - -这里放一些 dashboard routes 可复用的小工具函数。 - -目前主要用于「配置文件上传(file 类型配置项)」功能: -- 清洗/规范化用户可控的文件名与相对路径 -- 将配置 key 映射到配置项独立子目录 -""" - -import os - - -def get_schema_item(schema: dict | None, key_path: str) -> dict | None: - """按 dot-path 获取 schema 的节点。 - - 同时支持: - - 扁平 schema(直接 key 命中) - - 嵌套 object schema({type: "object", items: {...}}) - - template_list schema(.templates.