Files
AstrBot/astrbot/dashboard/routes/open_api.py
T
Soulter a404436f2c 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>
2026-02-21 17:20:26 +08:00

389 lines
14 KiB
Python

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__