feat: astrbot http api (#5280)
* feat: astrbot http api * Potential fix for code scanning alert no. 34: Use of a broken or weak cryptographic hashing algorithm on sensitive data Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix: improve error handling for missing attachment path in file upload * feat: implement paginated retrieval of platform sessions for creators * feat: refactor attachment directory handling in ChatRoute * feat: update API endpoint paths for file and message handling * feat: add documentation link to API key management section in settings * feat: update API key scopes and related configurations in API routes and tests * feat: enhance API key expiration options and add warning for permanent keys * feat: add UTC normalization and serialization for API key timestamps * feat: implement chat session management and validation for usernames * feat: ignore session_id type chunks in message processing --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
@@ -8,6 +8,7 @@ from deprecated import deprecated
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from astrbot.core.db.po import (
|
from astrbot.core.db.po import (
|
||||||
|
ApiKey,
|
||||||
Attachment,
|
Attachment,
|
||||||
ChatUIProject,
|
ChatUIProject,
|
||||||
CommandConfig,
|
CommandConfig,
|
||||||
@@ -248,6 +249,55 @@ class BaseDatabase(abc.ABC):
|
|||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def create_api_key(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
key_hash: str,
|
||||||
|
key_prefix: str,
|
||||||
|
scopes: list[str] | None,
|
||||||
|
created_by: str,
|
||||||
|
expires_at: datetime.datetime | None = None,
|
||||||
|
) -> ApiKey:
|
||||||
|
"""Create a new API key record."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def list_api_keys(self) -> list[ApiKey]:
|
||||||
|
"""List all API keys."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def get_api_key_by_id(self, key_id: str) -> ApiKey | None:
|
||||||
|
"""Get an API key by key_id."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None:
|
||||||
|
"""Get an active API key by hash (not revoked, not expired)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def touch_api_key(self, key_id: str) -> None:
|
||||||
|
"""Update last_used_at of an API key."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def revoke_api_key(self, key_id: str) -> bool:
|
||||||
|
"""Revoke an API key.
|
||||||
|
|
||||||
|
Returns True when the key exists and is updated.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def delete_api_key(self, key_id: str) -> bool:
|
||||||
|
"""Delete an API key.
|
||||||
|
|
||||||
|
Returns True when the key exists and is deleted.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
async def insert_persona(
|
async def insert_persona(
|
||||||
self,
|
self,
|
||||||
@@ -608,6 +658,22 @@ class BaseDatabase(abc.ABC):
|
|||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def get_platform_sessions_by_creator_paginated(
|
||||||
|
self,
|
||||||
|
creator: str,
|
||||||
|
platform_id: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
exclude_project_sessions: bool = False,
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
"""Get paginated platform sessions and total count for a creator.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[list[dict], int]: (sessions_with_project_info, total_count)
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
async def update_platform_session(
|
async def update_platform_session(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -288,6 +288,43 @@ class Attachment(TimestampMixin, SQLModel, table=True):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKey(TimestampMixin, SQLModel, table=True):
|
||||||
|
"""API keys used by external developers to access Open APIs."""
|
||||||
|
|
||||||
|
__tablename__: str = "api_keys"
|
||||||
|
|
||||||
|
inner_id: int | None = Field(
|
||||||
|
primary_key=True,
|
||||||
|
sa_column_kwargs={"autoincrement": True},
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
key_id: str = Field(
|
||||||
|
max_length=36,
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
)
|
||||||
|
name: str = Field(max_length=255, nullable=False)
|
||||||
|
key_hash: str = Field(max_length=128, nullable=False, unique=True)
|
||||||
|
key_prefix: str = Field(max_length=24, nullable=False)
|
||||||
|
scopes: list | None = Field(default=None, sa_type=JSON)
|
||||||
|
created_by: str = Field(max_length=255, nullable=False)
|
||||||
|
last_used_at: datetime | None = Field(default=None)
|
||||||
|
expires_at: datetime | None = Field(default=None)
|
||||||
|
revoked_at: datetime | None = Field(default=None)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"key_id",
|
||||||
|
name="uix_api_key_id",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"key_hash",
|
||||||
|
name="uix_api_key_hash",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ChatUIProject(TimestampMixin, SQLModel, table=True):
|
class ChatUIProject(TimestampMixin, SQLModel, table=True):
|
||||||
"""This class represents projects for organizing ChatUI conversations.
|
"""This class represents projects for organizing ChatUI conversations.
|
||||||
|
|
||||||
|
|||||||
+180
-41
@@ -10,6 +10,7 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update
|
|||||||
|
|
||||||
from astrbot.core.db import BaseDatabase
|
from astrbot.core.db import BaseDatabase
|
||||||
from astrbot.core.db.po import (
|
from astrbot.core.db.po import (
|
||||||
|
ApiKey,
|
||||||
Attachment,
|
Attachment,
|
||||||
ChatUIProject,
|
ChatUIProject,
|
||||||
CommandConfig,
|
CommandConfig,
|
||||||
@@ -573,6 +574,100 @@ class SQLiteDatabase(BaseDatabase):
|
|||||||
result = T.cast(CursorResult, await session.execute(query))
|
result = T.cast(CursorResult, await session.execute(query))
|
||||||
return result.rowcount
|
return result.rowcount
|
||||||
|
|
||||||
|
async def create_api_key(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
key_hash: str,
|
||||||
|
key_prefix: str,
|
||||||
|
scopes: list[str] | None,
|
||||||
|
created_by: str,
|
||||||
|
expires_at: datetime | None = None,
|
||||||
|
) -> ApiKey:
|
||||||
|
"""Create a new API key record."""
|
||||||
|
async with self.get_db() as session:
|
||||||
|
session: AsyncSession
|
||||||
|
async with session.begin():
|
||||||
|
api_key = ApiKey(
|
||||||
|
name=name,
|
||||||
|
key_hash=key_hash,
|
||||||
|
key_prefix=key_prefix,
|
||||||
|
scopes=scopes,
|
||||||
|
created_by=created_by,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
session.add(api_key)
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(api_key)
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
async def list_api_keys(self) -> list[ApiKey]:
|
||||||
|
"""List all API keys."""
|
||||||
|
async with self.get_db() as session:
|
||||||
|
session: AsyncSession
|
||||||
|
result = await session.execute(
|
||||||
|
select(ApiKey).order_by(desc(ApiKey.created_at))
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_api_key_by_id(self, key_id: str) -> ApiKey | None:
|
||||||
|
"""Get an API key by key_id."""
|
||||||
|
async with self.get_db() as session:
|
||||||
|
session: AsyncSession
|
||||||
|
result = await session.execute(
|
||||||
|
select(ApiKey).where(ApiKey.key_id == key_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None:
|
||||||
|
"""Get an active API key by hash (not revoked, not expired)."""
|
||||||
|
async with self.get_db() as session:
|
||||||
|
session: AsyncSession
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
query = select(ApiKey).where(
|
||||||
|
ApiKey.key_hash == key_hash,
|
||||||
|
col(ApiKey.revoked_at).is_(None),
|
||||||
|
or_(col(ApiKey.expires_at).is_(None), ApiKey.expires_at > now),
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def touch_api_key(self, key_id: str) -> None:
|
||||||
|
"""Update last_used_at of an API key."""
|
||||||
|
async with self.get_db() as session:
|
||||||
|
session: AsyncSession
|
||||||
|
async with session.begin():
|
||||||
|
await session.execute(
|
||||||
|
update(ApiKey)
|
||||||
|
.where(ApiKey.key_id == key_id)
|
||||||
|
.values(last_used_at=datetime.now(timezone.utc)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def revoke_api_key(self, key_id: str) -> bool:
|
||||||
|
"""Revoke an API key."""
|
||||||
|
async with self.get_db() as session:
|
||||||
|
session: AsyncSession
|
||||||
|
async with session.begin():
|
||||||
|
query = (
|
||||||
|
update(ApiKey)
|
||||||
|
.where(ApiKey.key_id == key_id)
|
||||||
|
.values(revoked_at=datetime.now(timezone.utc))
|
||||||
|
)
|
||||||
|
result = T.cast(CursorResult, await session.execute(query))
|
||||||
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
async def delete_api_key(self, key_id: str) -> bool:
|
||||||
|
"""Delete an API key."""
|
||||||
|
async with self.get_db() as session:
|
||||||
|
session: AsyncSession
|
||||||
|
async with session.begin():
|
||||||
|
result = T.cast(
|
||||||
|
CursorResult,
|
||||||
|
await session.execute(
|
||||||
|
delete(ApiKey).where(ApiKey.key_id == key_id)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return result.rowcount > 0
|
||||||
|
|
||||||
async def insert_persona(
|
async def insert_persona(
|
||||||
self,
|
self,
|
||||||
persona_id,
|
persona_id,
|
||||||
@@ -1317,58 +1412,102 @@ class SQLiteDatabase(BaseDatabase):
|
|||||||
|
|
||||||
Returns a list of dicts containing session info and project info (if session belongs to a project).
|
Returns a list of dicts containing session info and project info (if session belongs to a project).
|
||||||
"""
|
"""
|
||||||
|
(
|
||||||
|
sessions_with_projects,
|
||||||
|
_,
|
||||||
|
) = await self.get_platform_sessions_by_creator_paginated(
|
||||||
|
creator=creator,
|
||||||
|
platform_id=platform_id,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
exclude_project_sessions=False,
|
||||||
|
)
|
||||||
|
return sessions_with_projects
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_platform_sessions_query(
|
||||||
|
creator: str,
|
||||||
|
platform_id: str | None = None,
|
||||||
|
exclude_project_sessions: bool = False,
|
||||||
|
):
|
||||||
|
query = (
|
||||||
|
select(
|
||||||
|
PlatformSession,
|
||||||
|
col(ChatUIProject.project_id),
|
||||||
|
col(ChatUIProject.title).label("project_title"),
|
||||||
|
col(ChatUIProject.emoji).label("project_emoji"),
|
||||||
|
)
|
||||||
|
.outerjoin(
|
||||||
|
SessionProjectRelation,
|
||||||
|
col(PlatformSession.session_id)
|
||||||
|
== col(SessionProjectRelation.session_id),
|
||||||
|
)
|
||||||
|
.outerjoin(
|
||||||
|
ChatUIProject,
|
||||||
|
col(SessionProjectRelation.project_id) == col(ChatUIProject.project_id),
|
||||||
|
)
|
||||||
|
.where(col(PlatformSession.creator) == creator)
|
||||||
|
)
|
||||||
|
|
||||||
|
if platform_id:
|
||||||
|
query = query.where(PlatformSession.platform_id == platform_id)
|
||||||
|
if exclude_project_sessions:
|
||||||
|
query = query.where(col(ChatUIProject.project_id).is_(None))
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rows_to_session_dicts(rows: list[tuple]) -> list[dict]:
|
||||||
|
sessions_with_projects = []
|
||||||
|
for row in rows:
|
||||||
|
platform_session = row[0]
|
||||||
|
project_id = row[1]
|
||||||
|
project_title = row[2]
|
||||||
|
project_emoji = row[3]
|
||||||
|
|
||||||
|
session_dict = {
|
||||||
|
"session": platform_session,
|
||||||
|
"project_id": project_id,
|
||||||
|
"project_title": project_title,
|
||||||
|
"project_emoji": project_emoji,
|
||||||
|
}
|
||||||
|
sessions_with_projects.append(session_dict)
|
||||||
|
|
||||||
|
return sessions_with_projects
|
||||||
|
|
||||||
|
async def get_platform_sessions_by_creator_paginated(
|
||||||
|
self,
|
||||||
|
creator: str,
|
||||||
|
platform_id: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
exclude_project_sessions: bool = False,
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
"""Get paginated Platform sessions for a creator with total count."""
|
||||||
async with self.get_db() as session:
|
async with self.get_db() as session:
|
||||||
session: AsyncSession
|
session: AsyncSession
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
# LEFT JOIN with SessionProjectRelation and ChatUIProject to get project info
|
base_query = self._build_platform_sessions_query(
|
||||||
query = (
|
creator=creator,
|
||||||
select(
|
platform_id=platform_id,
|
||||||
PlatformSession,
|
exclude_project_sessions=exclude_project_sessions,
|
||||||
col(ChatUIProject.project_id),
|
|
||||||
col(ChatUIProject.title).label("project_title"),
|
|
||||||
col(ChatUIProject.emoji).label("project_emoji"),
|
|
||||||
)
|
|
||||||
.outerjoin(
|
|
||||||
SessionProjectRelation,
|
|
||||||
col(PlatformSession.session_id)
|
|
||||||
== col(SessionProjectRelation.session_id),
|
|
||||||
)
|
|
||||||
.outerjoin(
|
|
||||||
ChatUIProject,
|
|
||||||
col(SessionProjectRelation.project_id)
|
|
||||||
== col(ChatUIProject.project_id),
|
|
||||||
)
|
|
||||||
.where(col(PlatformSession.creator) == creator)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if platform_id:
|
total_result = await session.execute(
|
||||||
query = query.where(PlatformSession.platform_id == platform_id)
|
select(func.count()).select_from(base_query.subquery())
|
||||||
|
)
|
||||||
|
total = int(total_result.scalar_one() or 0)
|
||||||
|
|
||||||
query = (
|
result_query = (
|
||||||
query.order_by(desc(PlatformSession.updated_at))
|
base_query.order_by(desc(PlatformSession.updated_at))
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(page_size)
|
.limit(page_size)
|
||||||
)
|
)
|
||||||
result = await session.execute(query)
|
result = await session.execute(result_query)
|
||||||
|
|
||||||
# Convert to list of dicts with session and project info
|
sessions_with_projects = self._rows_to_session_dicts(result.all())
|
||||||
sessions_with_projects = []
|
return sessions_with_projects, total
|
||||||
for row in result.all():
|
|
||||||
platform_session = row[0]
|
|
||||||
project_id = row[1]
|
|
||||||
project_title = row[2]
|
|
||||||
project_emoji = row[3]
|
|
||||||
|
|
||||||
session_dict = {
|
|
||||||
"session": platform_session,
|
|
||||||
"project_id": project_id,
|
|
||||||
"project_title": project_title,
|
|
||||||
"project_emoji": project_emoji,
|
|
||||||
}
|
|
||||||
sessions_with_projects.append(session_dict)
|
|
||||||
|
|
||||||
return sessions_with_projects
|
|
||||||
|
|
||||||
async def update_platform_session(
|
async def update_platform_session(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from .api_key import ApiKeyRoute
|
||||||
from .auth import AuthRoute
|
from .auth import AuthRoute
|
||||||
from .backup import BackupRoute
|
from .backup import BackupRoute
|
||||||
from .chat import ChatRoute
|
from .chat import ChatRoute
|
||||||
@@ -9,6 +10,7 @@ from .cron import CronRoute
|
|||||||
from .file import FileRoute
|
from .file import FileRoute
|
||||||
from .knowledge_base import KnowledgeBaseRoute
|
from .knowledge_base import KnowledgeBaseRoute
|
||||||
from .log import LogRoute
|
from .log import LogRoute
|
||||||
|
from .open_api import OpenApiRoute
|
||||||
from .persona import PersonaRoute
|
from .persona import PersonaRoute
|
||||||
from .platform import PlatformRoute
|
from .platform import PlatformRoute
|
||||||
from .plugin import PluginRoute
|
from .plugin import PluginRoute
|
||||||
@@ -21,6 +23,7 @@ from .tools import ToolsRoute
|
|||||||
from .update import UpdateRoute
|
from .update import UpdateRoute
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"ApiKeyRoute",
|
||||||
"AuthRoute",
|
"AuthRoute",
|
||||||
"BackupRoute",
|
"BackupRoute",
|
||||||
"ChatRoute",
|
"ChatRoute",
|
||||||
@@ -32,6 +35,7 @@ __all__ = [
|
|||||||
"FileRoute",
|
"FileRoute",
|
||||||
"KnowledgeBaseRoute",
|
"KnowledgeBaseRoute",
|
||||||
"LogRoute",
|
"LogRoute",
|
||||||
|
"OpenApiRoute",
|
||||||
"PersonaRoute",
|
"PersonaRoute",
|
||||||
"PlatformRoute",
|
"PlatformRoute",
|
||||||
"PluginRoute",
|
"PluginRoute",
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from quart import g, request
|
||||||
|
|
||||||
|
from astrbot.core.db import BaseDatabase
|
||||||
|
|
||||||
|
from .route import Response, Route, RouteContext
|
||||||
|
|
||||||
|
ALL_OPEN_API_SCOPES = ("chat", "config", "file", "im")
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyRoute(Route):
|
||||||
|
def __init__(self, context: RouteContext, db: BaseDatabase) -> None:
|
||||||
|
super().__init__(context)
|
||||||
|
self.db = db
|
||||||
|
self.routes = {
|
||||||
|
"/apikey/list": ("GET", self.list_api_keys),
|
||||||
|
"/apikey/create": ("POST", self.create_api_key),
|
||||||
|
"/apikey/revoke": ("POST", self.revoke_api_key),
|
||||||
|
"/apikey/delete": ("POST", self.delete_api_key),
|
||||||
|
}
|
||||||
|
self.register_routes()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_utc(dt: datetime | None) -> datetime | None:
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _serialize_datetime(cls, dt: datetime | None) -> str | None:
|
||||||
|
normalized = cls._normalize_utc(dt)
|
||||||
|
if normalized is None:
|
||||||
|
return None
|
||||||
|
return normalized.astimezone().isoformat()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hash_key(raw_key: str) -> str:
|
||||||
|
return hashlib.pbkdf2_hmac(
|
||||||
|
"sha256",
|
||||||
|
raw_key.encode("utf-8"),
|
||||||
|
b"astrbot_api_key",
|
||||||
|
100_000,
|
||||||
|
).hex()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _serialize_api_key(key) -> dict:
|
||||||
|
expires_at = ApiKeyRoute._normalize_utc(key.expires_at)
|
||||||
|
return {
|
||||||
|
"key_id": key.key_id,
|
||||||
|
"name": key.name,
|
||||||
|
"key_prefix": key.key_prefix,
|
||||||
|
"scopes": key.scopes or [],
|
||||||
|
"created_by": key.created_by,
|
||||||
|
"created_at": ApiKeyRoute._serialize_datetime(key.created_at),
|
||||||
|
"updated_at": ApiKeyRoute._serialize_datetime(key.updated_at),
|
||||||
|
"last_used_at": ApiKeyRoute._serialize_datetime(key.last_used_at),
|
||||||
|
"expires_at": ApiKeyRoute._serialize_datetime(key.expires_at),
|
||||||
|
"revoked_at": ApiKeyRoute._serialize_datetime(key.revoked_at),
|
||||||
|
"is_revoked": key.revoked_at is not None,
|
||||||
|
"is_expired": bool(expires_at and expires_at < datetime.now(timezone.utc)),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_api_keys(self):
|
||||||
|
keys = await self.db.list_api_keys()
|
||||||
|
return (
|
||||||
|
Response().ok(data=[self._serialize_api_key(key) for key in keys]).__dict__
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_api_key(self):
|
||||||
|
post_data = await request.json or {}
|
||||||
|
|
||||||
|
name = str(post_data.get("name", "")).strip() or "Untitled API Key"
|
||||||
|
scopes = post_data.get("scopes")
|
||||||
|
if scopes is None:
|
||||||
|
normalized_scopes = list(ALL_OPEN_API_SCOPES)
|
||||||
|
elif isinstance(scopes, list):
|
||||||
|
normalized_scopes = [
|
||||||
|
scope
|
||||||
|
for scope in scopes
|
||||||
|
if isinstance(scope, str) and scope in ALL_OPEN_API_SCOPES
|
||||||
|
]
|
||||||
|
normalized_scopes = list(dict.fromkeys(normalized_scopes))
|
||||||
|
if not normalized_scopes:
|
||||||
|
return Response().error("At least one valid scope is required").__dict__
|
||||||
|
else:
|
||||||
|
return Response().error("Invalid scopes").__dict__
|
||||||
|
|
||||||
|
expires_at = None
|
||||||
|
expires_in_days = post_data.get("expires_in_days")
|
||||||
|
if expires_in_days is not None:
|
||||||
|
try:
|
||||||
|
expires_in_days_int = int(expires_in_days)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Response().error("expires_in_days must be an integer").__dict__
|
||||||
|
if expires_in_days_int <= 0:
|
||||||
|
return (
|
||||||
|
Response().error("expires_in_days must be greater than 0").__dict__
|
||||||
|
)
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||||
|
days=expires_in_days_int
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_key = f"abk_{secrets.token_urlsafe(32)}"
|
||||||
|
key_hash = self._hash_key(raw_key)
|
||||||
|
key_prefix = raw_key[:12]
|
||||||
|
created_by = g.get("username", "unknown")
|
||||||
|
|
||||||
|
api_key = await self.db.create_api_key(
|
||||||
|
name=name,
|
||||||
|
key_hash=key_hash,
|
||||||
|
key_prefix=key_prefix,
|
||||||
|
scopes=normalized_scopes, # type: ignore
|
||||||
|
created_by=created_by,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = self._serialize_api_key(api_key)
|
||||||
|
payload["api_key"] = raw_key
|
||||||
|
return Response().ok(data=payload).__dict__
|
||||||
|
|
||||||
|
async def revoke_api_key(self):
|
||||||
|
post_data = await request.json or {}
|
||||||
|
key_id = post_data.get("key_id")
|
||||||
|
if not key_id:
|
||||||
|
return Response().error("Missing key: key_id").__dict__
|
||||||
|
|
||||||
|
success = await self.db.revoke_api_key(key_id)
|
||||||
|
if not success:
|
||||||
|
return Response().error("API key not found").__dict__
|
||||||
|
return Response().ok().__dict__
|
||||||
|
|
||||||
|
async def delete_api_key(self):
|
||||||
|
post_data = await request.json or {}
|
||||||
|
key_id = post_data.get("key_id")
|
||||||
|
if not key_id:
|
||||||
|
return Response().error("Missing key: key_id").__dict__
|
||||||
|
|
||||||
|
success = await self.db.delete_api_key(key_id)
|
||||||
|
if not success:
|
||||||
|
return Response().error("API key not found").__dict__
|
||||||
|
return Response().ok().__dict__
|
||||||
@@ -52,8 +52,9 @@ class ChatRoute(Route):
|
|||||||
}
|
}
|
||||||
self.core_lifecycle = core_lifecycle
|
self.core_lifecycle = core_lifecycle
|
||||||
self.register_routes()
|
self.register_routes()
|
||||||
self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
|
||||||
os.makedirs(self.imgs_dir, exist_ok=True)
|
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.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
|
||||||
self.conv_mgr = core_lifecycle.conversation_manager
|
self.conv_mgr = core_lifecycle.conversation_manager
|
||||||
@@ -69,9 +70,18 @@ class ChatRoute(Route):
|
|||||||
return Response().error("Missing key: filename").__dict__
|
return Response().error("Missing key: filename").__dict__
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
|
file_path = os.path.join(self.attachments_dir, os.path.basename(filename))
|
||||||
real_file_path = os.path.realpath(file_path)
|
real_file_path = os.path.realpath(file_path)
|
||||||
real_imgs_dir = os.path.realpath(self.imgs_dir)
|
real_imgs_dir = os.path.realpath(self.attachments_dir)
|
||||||
|
|
||||||
|
if not os.path.exists(real_file_path):
|
||||||
|
# try legacy
|
||||||
|
file_path = os.path.join(
|
||||||
|
self.legacy_img_dir, os.path.basename(filename)
|
||||||
|
)
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
real_file_path = os.path.realpath(file_path)
|
||||||
|
real_imgs_dir = os.path.realpath(self.legacy_img_dir)
|
||||||
|
|
||||||
if not real_file_path.startswith(real_imgs_dir):
|
if not real_file_path.startswith(real_imgs_dir):
|
||||||
return Response().error("Invalid file path").__dict__
|
return Response().error("Invalid file path").__dict__
|
||||||
@@ -125,7 +135,7 @@ class ChatRoute(Route):
|
|||||||
else:
|
else:
|
||||||
attach_type = "file"
|
attach_type = "file"
|
||||||
|
|
||||||
path = os.path.join(self.imgs_dir, filename)
|
path = os.path.join(self.attachments_dir, filename)
|
||||||
await file.save(path)
|
await file.save(path)
|
||||||
|
|
||||||
# 创建 attachment 记录
|
# 创建 attachment 记录
|
||||||
@@ -202,7 +212,7 @@ class ChatRoute(Route):
|
|||||||
filename: 存储的文件名
|
filename: 存储的文件名
|
||||||
attach_type: 附件类型 (image, record, file, video)
|
attach_type: 附件类型 (image, record, file, video)
|
||||||
"""
|
"""
|
||||||
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
|
file_path = os.path.join(self.attachments_dir, os.path.basename(filename))
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -317,10 +327,13 @@ class ChatRoute(Route):
|
|||||||
)
|
)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
async def chat(self):
|
async def chat(self, post_data: dict | None = None):
|
||||||
username = g.get("username", "guest")
|
username = g.get("username", "guest")
|
||||||
|
|
||||||
post_data = await request.json
|
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:
|
if "message" not in post_data and "files" not in post_data:
|
||||||
return Response().error("Missing key: message or files").__dict__
|
return Response().error("Missing key: message or files").__dict__
|
||||||
|
|
||||||
@@ -373,6 +386,14 @@ class ChatRoute(Route):
|
|||||||
agent_stats = {}
|
agent_stats = {}
|
||||||
refs = {}
|
refs = {}
|
||||||
try:
|
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"
|
||||||
|
|
||||||
async with track_conversation(self.running_convs, webchat_conv_id):
|
async with track_conversation(self.running_convs, webchat_conv_id):
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -705,23 +726,18 @@ class ChatRoute(Route):
|
|||||||
# 获取可选的 platform_id 参数
|
# 获取可选的 platform_id 参数
|
||||||
platform_id = request.args.get("platform_id")
|
platform_id = request.args.get("platform_id")
|
||||||
|
|
||||||
sessions = await self.db.get_platform_sessions_by_creator(
|
sessions, _ = await self.db.get_platform_sessions_by_creator_paginated(
|
||||||
creator=username,
|
creator=username,
|
||||||
platform_id=platform_id,
|
platform_id=platform_id,
|
||||||
page=1,
|
page=1,
|
||||||
page_size=100, # 暂时返回前100个
|
page_size=100, # 暂时返回前100个
|
||||||
|
exclude_project_sessions=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 转换为字典格式,并添加项目信息
|
# 转换为字典格式
|
||||||
# get_platform_sessions_by_creator 现在返回 list[dict] 包含 session 和项目字段
|
|
||||||
sessions_data = []
|
sessions_data = []
|
||||||
for item in sessions:
|
for item in sessions:
|
||||||
session = item["session"]
|
session = item["session"]
|
||||||
project_id = item["project_id"]
|
|
||||||
|
|
||||||
# 跳过属于项目的会话(在侧边栏对话列表中不显示)
|
|
||||||
if project_id is not None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
sessions_data.append(
|
sessions_data.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from quart import g, request
|
||||||
|
|
||||||
|
from astrbot.core import logger
|
||||||
|
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||||
|
from astrbot.core.db import BaseDatabase
|
||||||
|
from astrbot.core.message.components import File, Image, Plain, Record, Reply, Video
|
||||||
|
from astrbot.core.message.message_event_result import MessageChain
|
||||||
|
from astrbot.core.platform.message_session import MessageSesion
|
||||||
|
|
||||||
|
from .chat import ChatRoute
|
||||||
|
from .route import Response, Route, RouteContext
|
||||||
|
|
||||||
|
|
||||||
|
class OpenApiRoute(Route):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
context: RouteContext,
|
||||||
|
db: BaseDatabase,
|
||||||
|
core_lifecycle: AstrBotCoreLifecycle,
|
||||||
|
chat_route: ChatRoute,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(context)
|
||||||
|
self.db = db
|
||||||
|
self.core_lifecycle = core_lifecycle
|
||||||
|
self.platform_manager = core_lifecycle.platform_manager
|
||||||
|
self.chat_route = chat_route
|
||||||
|
|
||||||
|
self.routes = {
|
||||||
|
"/v1/chat": ("POST", self.chat_send),
|
||||||
|
"/v1/chat/sessions": ("GET", self.get_chat_sessions),
|
||||||
|
"/v1/configs": ("GET", self.get_chat_configs),
|
||||||
|
"/v1/file": ("POST", self.upload_file),
|
||||||
|
"/v1/im/message": ("POST", self.send_message),
|
||||||
|
"/v1/im/bots": ("GET", self.get_bots),
|
||||||
|
}
|
||||||
|
self.register_routes()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_open_username(
|
||||||
|
raw_username: str | None,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
if raw_username is None:
|
||||||
|
return None, "Missing key: username"
|
||||||
|
username = str(raw_username).strip()
|
||||||
|
if not username:
|
||||||
|
return None, "username is empty"
|
||||||
|
return username, None
|
||||||
|
|
||||||
|
def _get_chat_config_list(self) -> list[dict]:
|
||||||
|
conf_list = self.core_lifecycle.astrbot_config_mgr.get_conf_list()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for conf_info in conf_list:
|
||||||
|
conf_id = str(conf_info.get("id", "")).strip()
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"id": conf_id,
|
||||||
|
"name": str(conf_info.get("name", "")).strip(),
|
||||||
|
"path": str(conf_info.get("path", "")).strip(),
|
||||||
|
"is_default": conf_id == "default",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _resolve_chat_config_id(self, post_data: dict) -> tuple[str | None, str | None]:
|
||||||
|
raw_config_id = post_data.get("config_id")
|
||||||
|
raw_config_name = post_data.get("config_name")
|
||||||
|
config_id = str(raw_config_id).strip() if raw_config_id is not None else ""
|
||||||
|
config_name = (
|
||||||
|
str(raw_config_name).strip() if raw_config_name is not None else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
if not config_id and not config_name:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
conf_list = self._get_chat_config_list()
|
||||||
|
conf_map = {item["id"]: item for item in conf_list}
|
||||||
|
|
||||||
|
if config_id:
|
||||||
|
if config_id not in conf_map:
|
||||||
|
return None, f"config_id not found: {config_id}"
|
||||||
|
return config_id, None
|
||||||
|
|
||||||
|
if not config_name:
|
||||||
|
return None, "config_name is empty"
|
||||||
|
|
||||||
|
matched = [item for item in conf_list if item["name"] == config_name]
|
||||||
|
if not matched:
|
||||||
|
return None, f"config_name not found: {config_name}"
|
||||||
|
if len(matched) > 1:
|
||||||
|
return (
|
||||||
|
None,
|
||||||
|
f"config_name is ambiguous, please use config_id: {config_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return matched[0]["id"], None
|
||||||
|
|
||||||
|
async def _ensure_chat_session(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
session_id: str,
|
||||||
|
) -> str | None:
|
||||||
|
session = await self.db.get_platform_session_by_id(session_id)
|
||||||
|
if session:
|
||||||
|
if session.creator != username:
|
||||||
|
return "session_id belongs to another username"
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.db.create_platform_session(
|
||||||
|
creator=username,
|
||||||
|
platform_id="webchat",
|
||||||
|
session_id=session_id,
|
||||||
|
is_group=0,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Handle rare race when same session_id is created concurrently.
|
||||||
|
existing = await self.db.get_platform_session_by_id(session_id)
|
||||||
|
if existing and existing.creator == username:
|
||||||
|
return None
|
||||||
|
logger.error("Failed to create chat session %s: %s", session_id, e)
|
||||||
|
return f"Failed to create session: {e}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def chat_send(self):
|
||||||
|
post_data = await request.get_json(silent=True) or {}
|
||||||
|
effective_username, username_err = self._resolve_open_username(
|
||||||
|
post_data.get("username")
|
||||||
|
)
|
||||||
|
if username_err:
|
||||||
|
return Response().error(username_err).__dict__
|
||||||
|
if not effective_username:
|
||||||
|
return Response().error("Invalid username").__dict__
|
||||||
|
|
||||||
|
raw_session_id = post_data.get("session_id", post_data.get("conversation_id"))
|
||||||
|
session_id = str(raw_session_id).strip() if raw_session_id is not None else ""
|
||||||
|
if not session_id:
|
||||||
|
session_id = str(uuid4())
|
||||||
|
post_data["session_id"] = session_id
|
||||||
|
ensure_session_err = await self._ensure_chat_session(
|
||||||
|
effective_username,
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
if ensure_session_err:
|
||||||
|
return Response().error(ensure_session_err).__dict__
|
||||||
|
|
||||||
|
config_id, resolve_err = self._resolve_chat_config_id(post_data)
|
||||||
|
if resolve_err:
|
||||||
|
return Response().error(resolve_err).__dict__
|
||||||
|
|
||||||
|
original_username = g.get("username", "guest")
|
||||||
|
g.username = effective_username
|
||||||
|
if config_id:
|
||||||
|
umo = f"webchat:FriendMessage:webchat!{effective_username}!{session_id}"
|
||||||
|
try:
|
||||||
|
if config_id == "default":
|
||||||
|
await self.core_lifecycle.umop_config_router.delete_route(umo)
|
||||||
|
else:
|
||||||
|
await self.core_lifecycle.umop_config_router.update_route(
|
||||||
|
umo, config_id
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to update chat config route for %s with %s: %s",
|
||||||
|
umo,
|
||||||
|
config_id,
|
||||||
|
e,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
Response()
|
||||||
|
.error(f"Failed to update chat config route: {e}")
|
||||||
|
.__dict__
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await self.chat_route.chat(post_data=post_data)
|
||||||
|
finally:
|
||||||
|
g.username = original_username
|
||||||
|
|
||||||
|
async def upload_file(self):
|
||||||
|
return await self.chat_route.post_file()
|
||||||
|
|
||||||
|
async def get_chat_sessions(self):
|
||||||
|
username, username_err = self._resolve_open_username(
|
||||||
|
request.args.get("username")
|
||||||
|
)
|
||||||
|
if username_err:
|
||||||
|
return Response().error(username_err).__dict__
|
||||||
|
|
||||||
|
assert username is not None # for type checker
|
||||||
|
|
||||||
|
try:
|
||||||
|
page = int(request.args.get("page", 1))
|
||||||
|
page_size = int(request.args.get("page_size", 20))
|
||||||
|
except ValueError:
|
||||||
|
return Response().error("page and page_size must be integers").__dict__
|
||||||
|
|
||||||
|
if page < 1:
|
||||||
|
page = 1
|
||||||
|
if page_size < 1:
|
||||||
|
page_size = 1
|
||||||
|
if page_size > 100:
|
||||||
|
page_size = 100
|
||||||
|
|
||||||
|
platform_id = request.args.get("platform_id")
|
||||||
|
|
||||||
|
(
|
||||||
|
paginated_sessions,
|
||||||
|
total,
|
||||||
|
) = await self.db.get_platform_sessions_by_creator_paginated(
|
||||||
|
creator=username,
|
||||||
|
platform_id=platform_id,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
exclude_project_sessions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
sessions_data = []
|
||||||
|
for item in paginated_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": session.created_at.astimezone().isoformat(),
|
||||||
|
"updated_at": session.updated_at.astimezone().isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
Response()
|
||||||
|
.ok(
|
||||||
|
data={
|
||||||
|
"sessions": sessions_data,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.__dict__
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_chat_configs(self):
|
||||||
|
conf_list = self._get_chat_config_list()
|
||||||
|
return Response().ok(data={"configs": conf_list}).__dict__
|
||||||
|
|
||||||
|
async def _build_message_chain_from_payload(
|
||||||
|
self,
|
||||||
|
message_payload: str | list,
|
||||||
|
) -> MessageChain:
|
||||||
|
if isinstance(message_payload, str):
|
||||||
|
text = message_payload.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("Message is empty")
|
||||||
|
return MessageChain(chain=[Plain(text=text)])
|
||||||
|
|
||||||
|
if not isinstance(message_payload, list):
|
||||||
|
raise ValueError("message must be a string or list")
|
||||||
|
|
||||||
|
components = []
|
||||||
|
has_content = False
|
||||||
|
|
||||||
|
for part in message_payload:
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
raise ValueError("message part must be an object")
|
||||||
|
|
||||||
|
part_type = str(part.get("type", "")).strip()
|
||||||
|
if part_type == "plain":
|
||||||
|
text = str(part.get("text", ""))
|
||||||
|
if text:
|
||||||
|
has_content = True
|
||||||
|
components.append(Plain(text=text))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if part_type == "reply":
|
||||||
|
message_id = part.get("message_id")
|
||||||
|
if message_id is None:
|
||||||
|
raise ValueError("reply part missing message_id")
|
||||||
|
components.append(
|
||||||
|
Reply(
|
||||||
|
id=str(message_id),
|
||||||
|
message_str=str(part.get("selected_text", "")),
|
||||||
|
chain=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if part_type not in {"image", "record", "file", "video"}:
|
||||||
|
raise ValueError(f"unsupported message part type: {part_type}")
|
||||||
|
|
||||||
|
has_content = True
|
||||||
|
file_path: Path | None = None
|
||||||
|
resolved_type = part_type
|
||||||
|
filename = str(part.get("filename", "")).strip()
|
||||||
|
|
||||||
|
attachment_id = part.get("attachment_id")
|
||||||
|
if attachment_id:
|
||||||
|
attachment = await self.db.get_attachment_by_id(str(attachment_id))
|
||||||
|
if not attachment:
|
||||||
|
raise ValueError(f"attachment not found: {attachment_id}")
|
||||||
|
file_path = Path(attachment.path)
|
||||||
|
resolved_type = attachment.type
|
||||||
|
if not filename:
|
||||||
|
filename = file_path.name
|
||||||
|
else:
|
||||||
|
raise ValueError(f"{part_type} part missing attachment_id")
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise ValueError(f"file not found: {file_path!s}")
|
||||||
|
|
||||||
|
file_path_str = str(file_path.resolve())
|
||||||
|
if resolved_type == "image":
|
||||||
|
components.append(Image.fromFileSystem(file_path_str))
|
||||||
|
elif resolved_type == "record":
|
||||||
|
components.append(Record.fromFileSystem(file_path_str))
|
||||||
|
elif resolved_type == "video":
|
||||||
|
components.append(Video.fromFileSystem(file_path_str))
|
||||||
|
else:
|
||||||
|
components.append(
|
||||||
|
File(name=filename or file_path.name, file=file_path_str)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not components or not has_content:
|
||||||
|
raise ValueError("Message content is empty (reply only is not allowed)")
|
||||||
|
|
||||||
|
return MessageChain(chain=components)
|
||||||
|
|
||||||
|
async def send_message(self):
|
||||||
|
post_data = await request.json or {}
|
||||||
|
message_payload = post_data.get("message", {})
|
||||||
|
umo = post_data.get("umo")
|
||||||
|
|
||||||
|
if message_payload is None:
|
||||||
|
return Response().error("Missing key: message").__dict__
|
||||||
|
if not umo:
|
||||||
|
return Response().error("Missing key: umo").__dict__
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = MessageSesion.from_str(str(umo))
|
||||||
|
except Exception as e:
|
||||||
|
return Response().error(f"Invalid umo: {e}").__dict__
|
||||||
|
|
||||||
|
platform_id = session.platform_name
|
||||||
|
platform_inst = next(
|
||||||
|
(
|
||||||
|
inst
|
||||||
|
for inst in self.platform_manager.platform_insts
|
||||||
|
if inst.meta().id == platform_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not platform_inst:
|
||||||
|
return (
|
||||||
|
Response()
|
||||||
|
.error(f"Bot not found or not running for platform: {platform_id}")
|
||||||
|
.__dict__
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
message_chain = await self._build_message_chain_from_payload(
|
||||||
|
message_payload
|
||||||
|
)
|
||||||
|
await platform_inst.send_by_session(session, message_chain)
|
||||||
|
return Response().ok().__dict__
|
||||||
|
except ValueError as e:
|
||||||
|
return Response().error(str(e)).__dict__
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Open API send_message failed: {e}", exc_info=True)
|
||||||
|
return Response().error(f"Failed to send message: {e}").__dict__
|
||||||
|
|
||||||
|
async def get_bots(self):
|
||||||
|
bot_ids = []
|
||||||
|
for platform in self.core_lifecycle.astrbot_config.get("platform", []):
|
||||||
|
platform_id = platform.get("id") if isinstance(platform, dict) else None
|
||||||
|
if (
|
||||||
|
isinstance(platform_id, str)
|
||||||
|
and platform_id
|
||||||
|
and platform_id not in bot_ids
|
||||||
|
):
|
||||||
|
bot_ids.append(platform_id)
|
||||||
|
return Response().ok(data={"bot_ids": bot_ids}).__dict__
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
@@ -21,6 +22,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
|||||||
from astrbot.core.utils.io import get_local_ip_addresses
|
from astrbot.core.utils.io import get_local_ip_addresses
|
||||||
|
|
||||||
from .routes import *
|
from .routes import *
|
||||||
|
from .routes.api_key import ALL_OPEN_API_SCOPES
|
||||||
from .routes.backup import BackupRoute
|
from .routes.backup import BackupRoute
|
||||||
from .routes.live_chat import LiveChatRoute
|
from .routes.live_chat import LiveChatRoute
|
||||||
from .routes.platform import PlatformRoute
|
from .routes.platform import PlatformRoute
|
||||||
@@ -53,6 +55,7 @@ class AstrBotDashboard:
|
|||||||
) -> None:
|
) -> None:
|
||||||
self.core_lifecycle = core_lifecycle
|
self.core_lifecycle = core_lifecycle
|
||||||
self.config = core_lifecycle.astrbot_config
|
self.config = core_lifecycle.astrbot_config
|
||||||
|
self.db = db
|
||||||
|
|
||||||
# 参数指定webui目录
|
# 参数指定webui目录
|
||||||
if webui_dir and os.path.exists(webui_dir):
|
if webui_dir and os.path.exists(webui_dir):
|
||||||
@@ -88,7 +91,14 @@ class AstrBotDashboard:
|
|||||||
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
|
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
|
||||||
self.sfr = StaticFileRoute(self.context)
|
self.sfr = StaticFileRoute(self.context)
|
||||||
self.ar = AuthRoute(self.context)
|
self.ar = AuthRoute(self.context)
|
||||||
|
self.api_key_route = ApiKeyRoute(self.context, db)
|
||||||
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
||||||
|
self.open_api_route = OpenApiRoute(
|
||||||
|
self.context,
|
||||||
|
db,
|
||||||
|
core_lifecycle,
|
||||||
|
self.chat_route,
|
||||||
|
)
|
||||||
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
|
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
|
||||||
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
||||||
self.subagent_route = SubAgentRoute(self.context, core_lifecycle)
|
self.subagent_route = SubAgentRoute(self.context, core_lifecycle)
|
||||||
@@ -130,6 +140,40 @@ class AstrBotDashboard:
|
|||||||
async def auth_middleware(self):
|
async def auth_middleware(self):
|
||||||
if not request.path.startswith("/api"):
|
if not request.path.startswith("/api"):
|
||||||
return None
|
return None
|
||||||
|
if request.path.startswith("/api/v1"):
|
||||||
|
raw_key = self._extract_raw_api_key()
|
||||||
|
if not raw_key:
|
||||||
|
r = jsonify(Response().error("Missing API key").__dict__)
|
||||||
|
r.status_code = 401
|
||||||
|
return r
|
||||||
|
key_hash = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256",
|
||||||
|
raw_key.encode("utf-8"),
|
||||||
|
b"astrbot_api_key",
|
||||||
|
100_000,
|
||||||
|
).hex()
|
||||||
|
api_key = await self.db.get_active_api_key_by_hash(key_hash)
|
||||||
|
if not api_key:
|
||||||
|
r = jsonify(Response().error("Invalid API key").__dict__)
|
||||||
|
r.status_code = 401
|
||||||
|
return r
|
||||||
|
|
||||||
|
if isinstance(api_key.scopes, list):
|
||||||
|
scopes = api_key.scopes
|
||||||
|
else:
|
||||||
|
scopes = list(ALL_OPEN_API_SCOPES)
|
||||||
|
required_scope = self._get_required_open_api_scope(request.path)
|
||||||
|
if required_scope and "*" not in scopes and required_scope not in scopes:
|
||||||
|
r = jsonify(Response().error("Insufficient API key scope").__dict__)
|
||||||
|
r.status_code = 403
|
||||||
|
return r
|
||||||
|
|
||||||
|
g.api_key_id = api_key.key_id
|
||||||
|
g.api_key_scopes = scopes
|
||||||
|
g.username = f"api_key:{api_key.key_id}"
|
||||||
|
await self.db.touch_api_key(api_key.key_id)
|
||||||
|
return None
|
||||||
|
|
||||||
allowed_endpoints = [
|
allowed_endpoints = [
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
"/api/file",
|
"/api/file",
|
||||||
@@ -158,6 +202,29 @@ class AstrBotDashboard:
|
|||||||
r.status_code = 401
|
r.status_code = 401
|
||||||
return r
|
return r
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_raw_api_key() -> str | None:
|
||||||
|
if key := request.headers.get("X-API-Key"):
|
||||||
|
return key.strip()
|
||||||
|
auth_header = request.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
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_required_open_api_scope(path: str) -> str | None:
|
||||||
|
scope_map = {
|
||||||
|
"/api/v1/chat": "chat",
|
||||||
|
"/api/v1/chat/sessions": "chat",
|
||||||
|
"/api/v1/configs": "config",
|
||||||
|
"/api/v1/file": "file",
|
||||||
|
"/api/v1/im/message": "im",
|
||||||
|
"/api/v1/im/bots": "im",
|
||||||
|
}
|
||||||
|
return scope_map.get(path)
|
||||||
|
|
||||||
def check_port_in_use(self, port: int) -> bool:
|
def check_port_in_use(self, port: int) -> bool:
|
||||||
"""跨平台检测端口是否被占用"""
|
"""跨平台检测端口是否被占用"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -388,6 +388,10 @@ export function useMessages(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chunk_json.type === 'session_id') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const lastMsg = messages.value[messages.value.length - 1];
|
const lastMsg = messages.value[messages.value.length - 1];
|
||||||
if (lastMsg?.content?.isLoading) {
|
if (lastMsg?.content?.isLoading) {
|
||||||
messages.value.pop();
|
messages.value.pop();
|
||||||
|
|||||||
@@ -128,5 +128,53 @@
|
|||||||
"renameFailed": "Rename failed",
|
"renameFailed": "Rename failed",
|
||||||
"ftpHint": "For large backup files, you can also upload directly to the data/backups directory via FTP/SFTP"
|
"ftpHint": "For large backup files, you can also upload directly to the data/backups directory via FTP/SFTP"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"apiKey": {
|
||||||
|
"title": "API Keys",
|
||||||
|
"manageTitle": "Developer Access Keys",
|
||||||
|
"subtitle": "Create API keys for external developers to call open HTTP APIs.",
|
||||||
|
"name": "Key Name",
|
||||||
|
"expiresInDays": "Expiration",
|
||||||
|
"expiryOptions": {
|
||||||
|
"day1": "1 day",
|
||||||
|
"day7": "7 days",
|
||||||
|
"day30": "30 days",
|
||||||
|
"day90": "90 days",
|
||||||
|
"permanent": "Permanent"
|
||||||
|
},
|
||||||
|
"permanentWarning": "Permanent API keys are high risk. Store them securely and use only when necessary.",
|
||||||
|
"scopes": "Scopes",
|
||||||
|
"create": "Create API Key",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"delete": "Delete",
|
||||||
|
"copy": "Copy",
|
||||||
|
"docsLink": "Open docs",
|
||||||
|
"plaintextHint": "Save this key now. The plaintext will not be shown again.",
|
||||||
|
"empty": "No API keys",
|
||||||
|
"status": {
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"name": "Name",
|
||||||
|
"prefix": "Prefix",
|
||||||
|
"scopes": "Scopes",
|
||||||
|
"status": "Status",
|
||||||
|
"lastUsed": "Last Used",
|
||||||
|
"createdAt": "Created At",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"loadFailed": "Failed to load API keys",
|
||||||
|
"scopeRequired": "Please select at least one scope",
|
||||||
|
"createSuccess": "API key created",
|
||||||
|
"createFailed": "Failed to create API key",
|
||||||
|
"revokeSuccess": "API key revoked",
|
||||||
|
"revokeFailed": "Failed to revoke API key",
|
||||||
|
"deleteSuccess": "API key deleted",
|
||||||
|
"deleteFailed": "Failed to delete API key",
|
||||||
|
"copySuccess": "API key copied",
|
||||||
|
"copyFailed": "Failed to copy API key"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,5 +128,53 @@
|
|||||||
"renameFailed": "重命名失败",
|
"renameFailed": "重命名失败",
|
||||||
"ftpHint": "对于较大的备份文件,也可以通过 FTP/SFTP 等方式直接上传到 data/backups 目录"
|
"ftpHint": "对于较大的备份文件,也可以通过 FTP/SFTP 等方式直接上传到 data/backups 目录"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"apiKey": {
|
||||||
|
"title": "API Key",
|
||||||
|
"manageTitle": "开发者访问密钥",
|
||||||
|
"subtitle": "为外部开发者创建 API Key,用于调用开放 HTTP API。",
|
||||||
|
"name": "Key 名称",
|
||||||
|
"expiresInDays": "有效期",
|
||||||
|
"expiryOptions": {
|
||||||
|
"day1": "1 天",
|
||||||
|
"day7": "7 天",
|
||||||
|
"day30": "30 天",
|
||||||
|
"day90": "90 天",
|
||||||
|
"permanent": "永久"
|
||||||
|
},
|
||||||
|
"permanentWarning": "永久有效的 API Key 风险较高,请妥善保存并建议仅在必要场景使用。",
|
||||||
|
"scopes": "权限范围",
|
||||||
|
"create": "创建 API Key",
|
||||||
|
"revoke": "吊销",
|
||||||
|
"delete": "删除",
|
||||||
|
"copy": "复制",
|
||||||
|
"docsLink": "查看文档",
|
||||||
|
"plaintextHint": "请立即保存该 Key,关闭后将无法再次查看明文。",
|
||||||
|
"empty": "暂无 API Key",
|
||||||
|
"status": {
|
||||||
|
"active": "有效",
|
||||||
|
"inactive": "无效"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"name": "名称",
|
||||||
|
"prefix": "前缀",
|
||||||
|
"scopes": "权限",
|
||||||
|
"status": "状态",
|
||||||
|
"lastUsed": "最近使用",
|
||||||
|
"createdAt": "创建时间",
|
||||||
|
"actions": "操作"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"loadFailed": "加载 API Key 失败",
|
||||||
|
"scopeRequired": "请至少选择一个权限",
|
||||||
|
"createSuccess": "API Key 创建成功",
|
||||||
|
"createFailed": "创建 API Key 失败",
|
||||||
|
"revokeSuccess": "API Key 已吊销",
|
||||||
|
"revokeFailed": "吊销 API Key 失败",
|
||||||
|
"deleteSuccess": "API Key 已删除",
|
||||||
|
"deleteFailed": "删除 API Key 失败",
|
||||||
|
"copySuccess": "已复制 API Key",
|
||||||
|
"copyFailed": "复制 API Key 失败"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,10 +63,156 @@
|
|||||||
<v-btn style="margin-top: 16px;" color="error" @click="restartAstrBot">{{ tm('system.restart.button') }}</v-btn>
|
<v-btn style="margin-top: 16px;" color="error" @click="restartAstrBot">{{ tm('system.restart.button') }}</v-btn>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-subheader>{{ tm('apiKey.title') }}</v-list-subheader>
|
||||||
|
|
||||||
|
<v-list-item :subtitle="tm('apiKey.subtitle')">
|
||||||
|
<template #title>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<span>{{ tm('apiKey.manageTitle') }}</span>
|
||||||
|
<v-tooltip location="top">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="props"
|
||||||
|
icon
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
class="ml-2"
|
||||||
|
:aria-label="tm('apiKey.docsLink')"
|
||||||
|
href="https://docs.astrbot.app/dev/openapi.html"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<v-icon size="18">mdi-help-circle-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<span>{{ tm('apiKey.docsLink') }}</span>
|
||||||
|
</v-tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<v-row class="mt-2" dense>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newApiKeyName"
|
||||||
|
:label="tm('apiKey.name')"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="3">
|
||||||
|
<v-select
|
||||||
|
v-model="newApiKeyExpiresInDays"
|
||||||
|
:items="apiKeyExpiryOptions"
|
||||||
|
:label="tm('apiKey.expiresInDays')"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col v-if="newApiKeyExpiresInDays === 'permanent'" cols="12">
|
||||||
|
<v-alert type="warning" variant="tonal" density="comfortable">
|
||||||
|
{{ tm('apiKey.permanentWarning') }}
|
||||||
|
</v-alert>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="5" class="d-flex align-center">
|
||||||
|
<v-btn color="primary" :loading="apiKeyCreating" @click="createApiKey">
|
||||||
|
<v-icon class="mr-2">mdi-key-plus</v-icon>
|
||||||
|
{{ tm('apiKey.create') }}
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12">
|
||||||
|
<div class="text-caption text-medium-emphasis mb-1">{{ tm('apiKey.scopes') }}</div>
|
||||||
|
<v-chip-group v-model="newApiKeyScopes" multiple>
|
||||||
|
<v-chip
|
||||||
|
v-for="scope in availableScopes"
|
||||||
|
:key="scope.value"
|
||||||
|
:value="scope.value"
|
||||||
|
:color="newApiKeyScopes.includes(scope.value) ? 'primary' : undefined"
|
||||||
|
:variant="newApiKeyScopes.includes(scope.value) ? 'flat' : 'tonal'"
|
||||||
|
>
|
||||||
|
{{ scope.label }}
|
||||||
|
</v-chip>
|
||||||
|
</v-chip-group>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col v-if="createdApiKeyPlaintext" cols="12">
|
||||||
|
<v-alert type="warning" variant="tonal">
|
||||||
|
<div class="d-flex align-center justify-space-between flex-wrap">
|
||||||
|
<span>{{ tm('apiKey.plaintextHint') }}</span>
|
||||||
|
<v-btn size="small" variant="text" color="primary" @click="copyCreatedApiKey">
|
||||||
|
<v-icon class="mr-1">mdi-content-copy</v-icon>{{ tm('apiKey.copy') }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
<code style="word-break: break-all;">{{ createdApiKeyPlaintext }}</code>
|
||||||
|
</v-alert>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-table density="compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ tm('apiKey.table.name') }}</th>
|
||||||
|
<th>{{ tm('apiKey.table.prefix') }}</th>
|
||||||
|
<th>{{ tm('apiKey.table.scopes') }}</th>
|
||||||
|
<th>{{ tm('apiKey.table.status') }}</th>
|
||||||
|
<th>{{ tm('apiKey.table.lastUsed') }}</th>
|
||||||
|
<th>{{ tm('apiKey.table.createdAt') }}</th>
|
||||||
|
<th>{{ tm('apiKey.table.actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in apiKeys" :key="item.key_id">
|
||||||
|
<td>{{ item.name }}</td>
|
||||||
|
<td><code>{{ item.key_prefix }}</code></td>
|
||||||
|
<td>{{ (item.scopes || []).join(', ') }}</td>
|
||||||
|
<td>
|
||||||
|
<v-chip
|
||||||
|
size="small"
|
||||||
|
:color="item.is_revoked || item.is_expired ? 'error' : 'success'"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ item.is_revoked || item.is_expired ? tm('apiKey.status.inactive') : tm('apiKey.status.active') }}
|
||||||
|
</v-chip>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDate(item.last_used_at) }}</td>
|
||||||
|
<td>{{ formatDate(item.created_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<v-btn
|
||||||
|
v-if="!item.is_revoked"
|
||||||
|
size="x-small"
|
||||||
|
color="warning"
|
||||||
|
variant="tonal"
|
||||||
|
class="mr-2"
|
||||||
|
@click="revokeApiKey(item.key_id)"
|
||||||
|
>
|
||||||
|
{{ tm('apiKey.revoke') }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
size="x-small"
|
||||||
|
color="error"
|
||||||
|
variant="tonal"
|
||||||
|
@click="deleteApiKey(item.key_id)"
|
||||||
|
>
|
||||||
|
{{ tm('apiKey.delete') }}
|
||||||
|
</v-btn>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="apiKeys.length === 0">
|
||||||
|
<td colspan="7" class="text-center text-medium-emphasis">
|
||||||
|
{{ tm('apiKey.empty') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
<v-list-item :subtitle="tm('system.migration.subtitle')" :title="tm('system.migration.title')">
|
<v-list-item :subtitle="tm('system.migration.subtitle')" :title="tm('system.migration.title')">
|
||||||
<v-btn style="margin-top: 16px;" color="primary" @click="startMigration">{{ tm('system.migration.button') }}</v-btn>
|
<v-btn style="margin-top: 16px;" color="primary" @click="startMigration">{{ tm('system.migration.button') }}</v-btn>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,7 +223,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
||||||
import ProxySelector from '@/components/shared/ProxySelector.vue';
|
import ProxySelector from '@/components/shared/ProxySelector.vue';
|
||||||
import MigrationDialog from '@/components/shared/MigrationDialog.vue';
|
import MigrationDialog from '@/components/shared/MigrationDialog.vue';
|
||||||
@@ -87,8 +234,10 @@ import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot'
|
|||||||
import { useModuleI18n } from '@/i18n/composables';
|
import { useModuleI18n } from '@/i18n/composables';
|
||||||
import { useTheme } from 'vuetify';
|
import { useTheme } from 'vuetify';
|
||||||
import { PurpleTheme } from '@/theme/LightTheme';
|
import { PurpleTheme } from '@/theme/LightTheme';
|
||||||
|
import { useToastStore } from '@/stores/toast';
|
||||||
|
|
||||||
const { tm } = useModuleI18n('features/settings');
|
const { tm } = useModuleI18n('features/settings');
|
||||||
|
const toastStore = useToastStore();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const getStoredColor = (key, fallback) => {
|
const getStoredColor = (key, fallback) => {
|
||||||
@@ -135,6 +284,127 @@ watch(secondaryColor, (value) => {
|
|||||||
const wfr = ref(null);
|
const wfr = ref(null);
|
||||||
const migrationDialog = ref(null);
|
const migrationDialog = ref(null);
|
||||||
const backupDialog = ref(null);
|
const backupDialog = ref(null);
|
||||||
|
const apiKeys = ref([]);
|
||||||
|
const apiKeyCreating = ref(false);
|
||||||
|
const newApiKeyName = ref('');
|
||||||
|
const newApiKeyExpiresInDays = ref(30);
|
||||||
|
const newApiKeyScopes = ref(['chat', 'config', 'file', 'im']);
|
||||||
|
const createdApiKeyPlaintext = ref('');
|
||||||
|
const apiKeyExpiryOptions = computed(() => [
|
||||||
|
{ title: tm('apiKey.expiryOptions.day1'), value: 1 },
|
||||||
|
{ title: tm('apiKey.expiryOptions.day7'), value: 7 },
|
||||||
|
{ title: tm('apiKey.expiryOptions.day30'), value: 30 },
|
||||||
|
{ title: tm('apiKey.expiryOptions.day90'), value: 90 },
|
||||||
|
{ title: tm('apiKey.expiryOptions.permanent'), value: 'permanent' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const availableScopes = [
|
||||||
|
{ value: 'chat', label: 'chat' },
|
||||||
|
{ value: 'config', label: 'config' },
|
||||||
|
{ value: 'file', label: 'file' },
|
||||||
|
{ value: 'im', label: 'im' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const showToast = (message, color = 'success') => {
|
||||||
|
toastStore.add({
|
||||||
|
message,
|
||||||
|
color,
|
||||||
|
timeout: 3000
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
const dt = new Date(value);
|
||||||
|
if (Number.isNaN(dt.getTime())) return '-';
|
||||||
|
return dt.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadApiKeys = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/apikey/list');
|
||||||
|
if (res.data.status !== 'ok') {
|
||||||
|
showToast(res.data.message || tm('apiKey.messages.loadFailed'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiKeys.value = res.data.data || [];
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e?.response?.data?.message || tm('apiKey.messages.loadFailed'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyCreatedApiKey = async () => {
|
||||||
|
if (!createdApiKeyPlaintext.value) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(createdApiKeyPlaintext.value);
|
||||||
|
showToast(tm('apiKey.messages.copySuccess'), 'success');
|
||||||
|
} catch (_) {
|
||||||
|
showToast(tm('apiKey.messages.copyFailed'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createApiKey = async () => {
|
||||||
|
const selectedScopes = availableScopes
|
||||||
|
.map((scope) => scope.value)
|
||||||
|
.filter((scope) => newApiKeyScopes.value.includes(scope));
|
||||||
|
|
||||||
|
if (selectedScopes.length === 0) {
|
||||||
|
showToast(tm('apiKey.messages.scopeRequired'), 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiKeyCreating.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: newApiKeyName.value,
|
||||||
|
scopes: selectedScopes
|
||||||
|
};
|
||||||
|
if (newApiKeyExpiresInDays.value !== 'permanent') {
|
||||||
|
payload.expires_in_days = Number(newApiKeyExpiresInDays.value);
|
||||||
|
}
|
||||||
|
const res = await axios.post('/api/apikey/create', payload);
|
||||||
|
if (res.data.status !== 'ok') {
|
||||||
|
showToast(res.data.message || tm('apiKey.messages.createFailed'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createdApiKeyPlaintext.value = res.data.data?.api_key || '';
|
||||||
|
newApiKeyName.value = '';
|
||||||
|
newApiKeyExpiresInDays.value = 30;
|
||||||
|
showToast(tm('apiKey.messages.createSuccess'), 'success');
|
||||||
|
await loadApiKeys();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e?.response?.data?.message || tm('apiKey.messages.createFailed'), 'error');
|
||||||
|
} finally {
|
||||||
|
apiKeyCreating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeApiKey = async (keyId) => {
|
||||||
|
try {
|
||||||
|
const res = await axios.post('/api/apikey/revoke', { key_id: keyId });
|
||||||
|
if (res.data.status !== 'ok') {
|
||||||
|
showToast(res.data.message || tm('apiKey.messages.revokeFailed'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast(tm('apiKey.messages.revokeSuccess'), 'success');
|
||||||
|
await loadApiKeys();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e?.response?.data?.message || tm('apiKey.messages.revokeFailed'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteApiKey = async (keyId) => {
|
||||||
|
try {
|
||||||
|
const res = await axios.post('/api/apikey/delete', { key_id: keyId });
|
||||||
|
if (res.data.status !== 'ok') {
|
||||||
|
showToast(res.data.message || tm('apiKey.messages.deleteFailed'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast(tm('apiKey.messages.deleteSuccess'), 'success');
|
||||||
|
await loadApiKeys();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e?.response?.data?.message || tm('apiKey.messages.deleteFailed'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const restartAstrBot = async () => {
|
const restartAstrBot = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -170,4 +440,8 @@ const resetThemeColors = () => {
|
|||||||
localStorage.removeItem('themeSecondary');
|
localStorage.removeItem('themeSecondary');
|
||||||
applyThemeColors(primaryColor.value, secondaryColor.value);
|
applyThemeColors(primaryColor.value, secondaryColor.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadApiKeys();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+685
@@ -0,0 +1,685 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.1.0",
|
||||||
|
"info": {
|
||||||
|
"title": "AstrBot Open API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Developer HTTP APIs for AstrBot. Use API Key authentication for /api/v1/* endpoints."
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:6185"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "Open API",
|
||||||
|
"description": "Developer APIs authenticated by API Key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"/api/v1/im/bots": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Open API"
|
||||||
|
],
|
||||||
|
"summary": "List bot IDs",
|
||||||
|
"description": "Returns configured bot/platform IDs.",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyHeader": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiResponseBotList"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/components/responses/Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/file": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Open API"
|
||||||
|
],
|
||||||
|
"summary": "Upload attachment file",
|
||||||
|
"description": "Upload a file and get attachment_id for later use in chat/message APIs.",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyHeader": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"file"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiResponseUpload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/components/responses/Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/chat": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Open API"
|
||||||
|
],
|
||||||
|
"summary": "Send chat message (SSE)",
|
||||||
|
"description": "Send message to AstrBot chat pipeline and receive streaming SSE response. Reuses /api/chat/send behavior. If session_id/conversation_id is omitted, server will create a new UUID session_id.",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyHeader": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ChatSendRequest"
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"plain": {
|
||||||
|
"value": {
|
||||||
|
"message": "Hello",
|
||||||
|
"username": "alice",
|
||||||
|
"session_id": "my_session_001",
|
||||||
|
"enable_streaming": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"multipartMessage": {
|
||||||
|
"value": {
|
||||||
|
"message": [
|
||||||
|
{
|
||||||
|
"type": "plain",
|
||||||
|
"text": "Please analyze this file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"attachment_id": "9a2f8c72-e7af-4c0e-b352-111111111111"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"username": "alice",
|
||||||
|
"session_id": "my_session_001",
|
||||||
|
"selected_provider": "openai_chat_completion",
|
||||||
|
"selected_model": "gpt-4.1-mini",
|
||||||
|
"enable_streaming": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"withConfig": {
|
||||||
|
"value": {
|
||||||
|
"message": "Use a specific config for this session",
|
||||||
|
"username": "alice",
|
||||||
|
"session_id": "my_session_001",
|
||||||
|
"config_id": "default",
|
||||||
|
"enable_streaming": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoSessionWithUsername": {
|
||||||
|
"value": {
|
||||||
|
"message": "hello",
|
||||||
|
"username": "alice",
|
||||||
|
"enable_streaming": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "SSE stream",
|
||||||
|
"content": {
|
||||||
|
"text/event-stream": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/components/responses/Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/chat/sessions": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Open API"
|
||||||
|
],
|
||||||
|
"summary": "List chat sessions with pagination",
|
||||||
|
"description": "List chat sessions for the specified username.",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyHeader": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "page",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 1,
|
||||||
|
"minimum": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page_size",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 20,
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "platform_id",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Optional platform filter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Target username."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiResponseChatSessions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/components/responses/Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/im/message": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Open API"
|
||||||
|
],
|
||||||
|
"summary": "Send proactive message to a platform bot",
|
||||||
|
"description": "Send message directly to platform bot by umo + message chain payload.",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyHeader": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SendMessageRequest"
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"plain": {
|
||||||
|
"value": {
|
||||||
|
"umo": "webchat:FriendMessage:openapi_probe",
|
||||||
|
"message": "ping from api key"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chain": {
|
||||||
|
"value": {
|
||||||
|
"umo": "webchat:FriendMessage:openapi_probe",
|
||||||
|
"message": [
|
||||||
|
{
|
||||||
|
"type": "plain",
|
||||||
|
"text": "hello"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"attachment_id": "9a2f8c72-e7af-4c0e-b352-111111111111"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiResponseEmpty"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/components/responses/Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/configs": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Open API"
|
||||||
|
],
|
||||||
|
"summary": "List available chat config files",
|
||||||
|
"description": "Returns all available AstrBot config files that can be selected by Chat API using config_id/config_name.",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyHeader": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiResponseChatConfigList"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/components/responses/Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"securitySchemes": {
|
||||||
|
"ApiKeyHeader": {
|
||||||
|
"type": "apiKey",
|
||||||
|
"in": "header",
|
||||||
|
"name": "X-API-Key",
|
||||||
|
"description": "Open API key. Authorization: Bearer <api_key> is also accepted."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"Unauthorized": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"Forbidden": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"ApiResponseEmpty": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "ok"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ApiResponseBotList": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "ok"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bot_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ApiResponseUpload": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "ok"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"attachment_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ApiResponseChatSessions": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "ok"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sessions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ChatSessionItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"page_size": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ChatSessionItem": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"session_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"platform_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"creator": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is_group": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MessagePart": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"plain",
|
||||||
|
"reply",
|
||||||
|
"image",
|
||||||
|
"record",
|
||||||
|
"file",
|
||||||
|
"video"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message_id": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"integer"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"selected_text": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"attachment_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ChatSendRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"message",
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"message": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/MessagePart"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional chat session ID. If omitted (and conversation_id is also omitted), server creates a UUID automatically."
|
||||||
|
},
|
||||||
|
"conversation_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Alias of session_id."
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Target username."
|
||||||
|
},
|
||||||
|
"selected_provider": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"selected_model": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"enable_streaming": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"config_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional AstrBot config file ID. If provided, the chat session will use this config file. Use \"default\" to reset to default config."
|
||||||
|
},
|
||||||
|
"config_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional AstrBot config file name. Used only when config_id is not provided."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SendMessageRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"umo",
|
||||||
|
"message"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"umo": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unified message origin. Format: platform:message_type:session_id"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/MessagePart"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ChatConfigFile": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"is_default": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"path",
|
||||||
|
"is_default"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ApiResponseChatConfigList": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "ok"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"configs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ChatConfigFile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from quart import Quart, g, request
|
||||||
|
|
||||||
|
from astrbot.core import LogBroker
|
||||||
|
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||||
|
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||||
|
from astrbot.dashboard.routes.route import Response
|
||||||
|
from astrbot.dashboard.server import AstrBotDashboard
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="module")
|
||||||
|
async def core_lifecycle_td(tmp_path_factory):
|
||||||
|
tmp_db_path = tmp_path_factory.mktemp("data") / "test_data_api_key.db"
|
||||||
|
db = SQLiteDatabase(str(tmp_db_path))
|
||||||
|
log_broker = LogBroker()
|
||||||
|
core_lifecycle = AstrBotCoreLifecycle(log_broker, db)
|
||||||
|
await core_lifecycle.initialize()
|
||||||
|
try:
|
||||||
|
yield core_lifecycle
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
stop_result = core_lifecycle.stop()
|
||||||
|
if asyncio.iscoroutine(stop_result):
|
||||||
|
await stop_result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app(core_lifecycle_td: AstrBotCoreLifecycle):
|
||||||
|
shutdown_event = asyncio.Event()
|
||||||
|
server = AstrBotDashboard(core_lifecycle_td, core_lifecycle_td.db, shutdown_event)
|
||||||
|
return server.app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="module")
|
||||||
|
async def authenticated_header(app: Quart, core_lifecycle_td: AstrBotCoreLifecycle):
|
||||||
|
test_client = app.test_client()
|
||||||
|
response = await test_client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={
|
||||||
|
"username": core_lifecycle_td.astrbot_config["dashboard"]["username"],
|
||||||
|
"password": core_lifecycle_td.astrbot_config["dashboard"]["password"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
data = await response.get_json()
|
||||||
|
token = data["data"]["token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_key_scope_and_revoke(app: Quart, authenticated_header: dict):
|
||||||
|
test_client = app.test_client()
|
||||||
|
|
||||||
|
create_res = await test_client.post(
|
||||||
|
"/api/apikey/create",
|
||||||
|
json={"name": "im-scope-key", "scopes": ["im"]},
|
||||||
|
headers=authenticated_header,
|
||||||
|
)
|
||||||
|
assert create_res.status_code == 200
|
||||||
|
create_data = await create_res.get_json()
|
||||||
|
assert create_data["status"] == "ok"
|
||||||
|
raw_key = create_data["data"]["api_key"]
|
||||||
|
key_id = create_data["data"]["key_id"]
|
||||||
|
|
||||||
|
open_bot_res = await test_client.get(
|
||||||
|
"/api/v1/im/bots",
|
||||||
|
headers={"X-API-Key": raw_key},
|
||||||
|
)
|
||||||
|
assert open_bot_res.status_code == 200
|
||||||
|
open_bot_data = await open_bot_res.get_json()
|
||||||
|
assert open_bot_data["status"] == "ok"
|
||||||
|
assert isinstance(open_bot_data["data"]["bot_ids"], list)
|
||||||
|
|
||||||
|
denied_chat_sessions_res = await test_client.get(
|
||||||
|
"/api/v1/chat/sessions?page=1&page_size=10",
|
||||||
|
headers={"X-API-Key": raw_key},
|
||||||
|
)
|
||||||
|
assert denied_chat_sessions_res.status_code == 403
|
||||||
|
|
||||||
|
denied_chat_configs_res = await test_client.get(
|
||||||
|
"/api/v1/configs",
|
||||||
|
headers={"X-API-Key": raw_key},
|
||||||
|
)
|
||||||
|
assert denied_chat_configs_res.status_code == 403
|
||||||
|
|
||||||
|
denied_res = await test_client.post(
|
||||||
|
"/api/v1/file",
|
||||||
|
data={},
|
||||||
|
headers={"X-API-Key": raw_key},
|
||||||
|
)
|
||||||
|
assert denied_res.status_code == 403
|
||||||
|
|
||||||
|
revoke_res = await test_client.post(
|
||||||
|
"/api/apikey/revoke",
|
||||||
|
json={"key_id": key_id},
|
||||||
|
headers=authenticated_header,
|
||||||
|
)
|
||||||
|
assert revoke_res.status_code == 200
|
||||||
|
revoke_data = await revoke_res.get_json()
|
||||||
|
assert revoke_data["status"] == "ok"
|
||||||
|
|
||||||
|
revoked_access_res = await test_client.get(
|
||||||
|
"/api/v1/im/bots",
|
||||||
|
headers={"X-API-Key": raw_key},
|
||||||
|
)
|
||||||
|
assert revoked_access_res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_open_send_message_with_api_key(app: Quart, authenticated_header: dict):
|
||||||
|
test_client = app.test_client()
|
||||||
|
|
||||||
|
create_res = await test_client.post(
|
||||||
|
"/api/apikey/create",
|
||||||
|
json={"name": "send-message-key", "scopes": ["im"]},
|
||||||
|
headers=authenticated_header,
|
||||||
|
)
|
||||||
|
create_data = await create_res.get_json()
|
||||||
|
assert create_data["status"] == "ok"
|
||||||
|
raw_key = create_data["data"]["api_key"]
|
||||||
|
|
||||||
|
send_res = await test_client.post(
|
||||||
|
"/api/v1/im/message",
|
||||||
|
json={
|
||||||
|
"umo": "webchat:FriendMessage:open_api_test_session",
|
||||||
|
"message": "hello",
|
||||||
|
},
|
||||||
|
headers={"X-API-Key": raw_key},
|
||||||
|
)
|
||||||
|
assert send_res.status_code == 200
|
||||||
|
send_data = await send_res.get_json()
|
||||||
|
assert send_data["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_open_chat_send_auto_session_id_and_username(
|
||||||
|
app: Quart,
|
||||||
|
authenticated_header: dict,
|
||||||
|
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||||
|
):
|
||||||
|
test_client = app.test_client()
|
||||||
|
|
||||||
|
create_res = await test_client.post(
|
||||||
|
"/api/apikey/create",
|
||||||
|
json={"name": "chat-send-key", "scopes": ["chat"]},
|
||||||
|
headers=authenticated_header,
|
||||||
|
)
|
||||||
|
create_data = await create_res.get_json()
|
||||||
|
assert create_data["status"] == "ok"
|
||||||
|
raw_key = create_data["data"]["api_key"]
|
||||||
|
|
||||||
|
rule = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in app.url_map.iter_rules()
|
||||||
|
if item.rule == "/api/v1/chat" and "POST" in item.methods
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert rule is not None
|
||||||
|
open_api_route = app.view_functions[rule.endpoint].__self__
|
||||||
|
|
||||||
|
original_chat = open_api_route.chat_route.chat
|
||||||
|
|
||||||
|
async def fake_chat(post_data: dict | None = None):
|
||||||
|
payload = post_data or await request.get_json()
|
||||||
|
return (
|
||||||
|
Response()
|
||||||
|
.ok(
|
||||||
|
data={
|
||||||
|
"session_id": payload.get("session_id"),
|
||||||
|
"creator": g.get("username"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.__dict__
|
||||||
|
)
|
||||||
|
|
||||||
|
open_api_route.chat_route.chat = fake_chat
|
||||||
|
try:
|
||||||
|
send_res = await test_client.post(
|
||||||
|
"/api/v1/chat",
|
||||||
|
json={
|
||||||
|
"message": "hello",
|
||||||
|
"username": "alice",
|
||||||
|
"enable_streaming": False,
|
||||||
|
},
|
||||||
|
headers={"X-API-Key": raw_key},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
open_api_route.chat_route.chat = original_chat
|
||||||
|
|
||||||
|
assert send_res.status_code == 200
|
||||||
|
send_data = await send_res.get_json()
|
||||||
|
assert send_data["status"] == "ok"
|
||||||
|
created_session_id = send_data["data"]["session_id"]
|
||||||
|
assert isinstance(created_session_id, str)
|
||||||
|
uuid.UUID(created_session_id)
|
||||||
|
assert send_data["data"]["creator"] == "alice"
|
||||||
|
created_session = await core_lifecycle_td.db.get_platform_session_by_id(
|
||||||
|
created_session_id
|
||||||
|
)
|
||||||
|
assert created_session is not None
|
||||||
|
assert created_session.creator == "alice"
|
||||||
|
assert created_session.platform_id == "webchat"
|
||||||
|
|
||||||
|
await core_lifecycle_td.db.create_platform_session(
|
||||||
|
creator="bob",
|
||||||
|
platform_id="webchat",
|
||||||
|
session_id="open_api_existing_bob_session",
|
||||||
|
is_group=0,
|
||||||
|
)
|
||||||
|
another_user_session_res = await test_client.post(
|
||||||
|
"/api/v1/chat",
|
||||||
|
json={
|
||||||
|
"message": "hello",
|
||||||
|
"username": "alice",
|
||||||
|
"session_id": "open_api_existing_bob_session",
|
||||||
|
"enable_streaming": False,
|
||||||
|
},
|
||||||
|
headers={"X-API-Key": raw_key},
|
||||||
|
)
|
||||||
|
another_user_session_data = await another_user_session_res.get_json()
|
||||||
|
assert another_user_session_data["status"] == "error"
|
||||||
|
assert (
|
||||||
|
another_user_session_data["message"]
|
||||||
|
== "session_id belongs to another username"
|
||||||
|
)
|
||||||
|
|
||||||
|
missing_username_res = await test_client.post(
|
||||||
|
"/api/v1/chat",
|
||||||
|
json={"message": "hello"},
|
||||||
|
headers={"X-API-Key": raw_key},
|
||||||
|
)
|
||||||
|
missing_username_data = await missing_username_res.get_json()
|
||||||
|
assert missing_username_data["status"] == "error"
|
||||||
|
assert missing_username_data["message"] == "Missing key: username"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_open_chat_sessions_pagination(
|
||||||
|
app: Quart,
|
||||||
|
authenticated_header: dict,
|
||||||
|
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||||
|
):
|
||||||
|
test_client = app.test_client()
|
||||||
|
|
||||||
|
create_res = await test_client.post(
|
||||||
|
"/api/apikey/create",
|
||||||
|
json={"name": "chat-scope-key", "scopes": ["chat"]},
|
||||||
|
headers=authenticated_header,
|
||||||
|
)
|
||||||
|
create_data = await create_res.get_json()
|
||||||
|
assert create_data["status"] == "ok"
|
||||||
|
raw_key = create_data["data"]["api_key"]
|
||||||
|
|
||||||
|
creator = "alice"
|
||||||
|
for idx in range(3):
|
||||||
|
await core_lifecycle_td.db.create_platform_session(
|
||||||
|
creator=creator,
|
||||||
|
platform_id="webchat",
|
||||||
|
session_id=f"open_api_paginated_{idx}",
|
||||||
|
display_name=f"Open API Session {idx}",
|
||||||
|
is_group=0,
|
||||||
|
)
|
||||||
|
await core_lifecycle_td.db.create_platform_session(
|
||||||
|
creator="bob",
|
||||||
|
platform_id="webchat",
|
||||||
|
session_id="open_api_paginated_bob",
|
||||||
|
display_name="Open API Session Bob",
|
||||||
|
is_group=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
page_1_res = await test_client.get(
|
||||||
|
"/api/v1/chat/sessions?page=1&page_size=2&username=alice",
|
||||||
|
headers={"X-API-Key": raw_key},
|
||||||
|
)
|
||||||
|
assert page_1_res.status_code == 200
|
||||||
|
page_1_data = await page_1_res.get_json()
|
||||||
|
assert page_1_data["status"] == "ok"
|
||||||
|
assert page_1_data["data"]["page"] == 1
|
||||||
|
assert page_1_data["data"]["page_size"] == 2
|
||||||
|
assert page_1_data["data"]["total"] == 3
|
||||||
|
assert len(page_1_data["data"]["sessions"]) == 2
|
||||||
|
assert all(item["creator"] == "alice" for item in page_1_data["data"]["sessions"])
|
||||||
|
|
||||||
|
page_2_res = await test_client.get(
|
||||||
|
"/api/v1/chat/sessions?page=2&page_size=2&username=alice",
|
||||||
|
headers={"X-API-Key": raw_key},
|
||||||
|
)
|
||||||
|
assert page_2_res.status_code == 200
|
||||||
|
page_2_data = await page_2_res.get_json()
|
||||||
|
assert page_2_data["status"] == "ok"
|
||||||
|
assert page_2_data["data"]["page"] == 2
|
||||||
|
assert len(page_2_data["data"]["sessions"]) == 1
|
||||||
|
|
||||||
|
missing_username_res = await test_client.get(
|
||||||
|
"/api/v1/chat/sessions?page=1&page_size=2",
|
||||||
|
headers={"X-API-Key": raw_key},
|
||||||
|
)
|
||||||
|
missing_username_data = await missing_username_res.get_json()
|
||||||
|
assert missing_username_data["status"] == "error"
|
||||||
|
assert missing_username_data["message"] == "Missing key: username"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_open_chat_configs_list(
|
||||||
|
app: Quart,
|
||||||
|
authenticated_header: dict,
|
||||||
|
):
|
||||||
|
test_client = app.test_client()
|
||||||
|
|
||||||
|
create_res = await test_client.post(
|
||||||
|
"/api/apikey/create",
|
||||||
|
json={"name": "chat-config-key", "scopes": ["config"]},
|
||||||
|
headers=authenticated_header,
|
||||||
|
)
|
||||||
|
create_data = await create_res.get_json()
|
||||||
|
assert create_data["status"] == "ok"
|
||||||
|
raw_key = create_data["data"]["api_key"]
|
||||||
|
|
||||||
|
configs_res = await test_client.get(
|
||||||
|
"/api/v1/configs",
|
||||||
|
headers={"X-API-Key": raw_key},
|
||||||
|
)
|
||||||
|
assert configs_res.status_code == 200
|
||||||
|
configs_data = await configs_res.get_json()
|
||||||
|
assert configs_data["status"] == "ok"
|
||||||
|
assert isinstance(configs_data["data"]["configs"], list)
|
||||||
|
assert any(item["id"] == "default" for item in configs_data["data"]["configs"])
|
||||||
Reference in New Issue
Block a user