Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0d11dea
chore: bump version to 4.23.2
Soulter Apr 19, 2026
696defa
Merge branch 'AstrBotDevs:master' into master
senaairi Apr 21, 2026
84cdcf2
Merge branch 'AstrBotDevs:master' into master
senaairi Apr 23, 2026
d7a3ee4
feat: 新增 chatWidget 网页挂件接口
senaairi Apr 23, 2026
fdb020e
Merge remote-tracking branch 'origin/master'
senaairi Apr 23, 2026
8c0f95e
Merge branch 'AstrBotDevs:master' into master
senaairi Apr 24, 2026
97ed458
feat: chatWidget 增加文件上传接口
senaairi Apr 24, 2026
75a5902
Merge branch 'AstrBotDevs:master' into master
senaairi Apr 24, 2026
3f44dd1
Merge branch 'AstrBotDevs:master' into master
senaairi Apr 25, 2026
1005e51
代码格式修正
senaairi Apr 25, 2026
cc3353e
Merge branch 'master' into feat/add-openapi-chat-widget
senaairi Apr 25, 2026
769b182
fix: 移除调试代
senaairi Apr 25, 2026
b9a8060
优化代码
senaairi Apr 25, 2026
44ee261
优化代码
senaairi Apr 25, 2026
ce8ec6e
优化代码
senaairi Apr 25, 2026
2c01a73
哈希长度扩展攻击修复
senaairi Apr 25, 2026
4d6f966
修改请求参数不足时前端的显示信息
senaairi Apr 25, 2026
8496998
Merge branch 'AstrBotDevs:master' into master
senaairi Apr 26, 2026
7d0d7af
页面编译错误修复
senaairi Apr 26, 2026
282e82b
优化代码,直接用StandaloneChat.vue组件
senaairi Apr 27, 2026
7192c1e
Merge branch 'AstrBotDevs:master' into master
senaairi Apr 27, 2026
e11ca8c
修复文件读取权限、优化附件存储
senaairi Apr 29, 2026
05f1005
Merge branch 'AstrBotDevs:master' into master
senaairi Apr 29, 2026
c5e06c6
api package工具优化
senaairi Apr 30, 2026
b243c22
Merge branch 'AstrBotDevs:master' into master
senaairi Apr 30, 2026
f36a478
1. 增加打印md文档打印页面
senaairi May 2, 2026
f9fd8d9
Merge branch 'AstrBotDevs:master' into master
senaairi May 2, 2026
e6e2635
打印页面数据传递方式修复
senaairi May 5, 2026
884589e
Merge branch 'AstrBotDevs:master' into master
senaairi May 11, 2026
7030ee5
Merge branch 'master' into feat/add-openapi-chat-widget
senaairi May 11, 2026
4c6c18b
open_api增加统计信息查询接口
senaairi May 12, 2026
b34b973
Merge branch 'master' into feat/add-openapi-chat-widget
senaairi May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions astrbot/core/db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@ async def insert_attachment(
path: str,
type: str,
mime_type: str,
*,
original_filename: str | None = None,
creator: str | None = None,
session_id: str | None = None,
):
"""Insert a new attachment record."""
...
Expand Down
5 changes: 4 additions & 1 deletion astrbot/core/db/po.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,9 +333,12 @@ class Attachment(TimestampMixin, SQLModel, table=True):
unique=True,
default_factory=lambda: str(uuid.uuid4()),
)
path: str = Field(nullable=False) # Path to the file on disk
path: str = Field(nullable=False) # Relative path to the file (e.g., 2026/01/06/xxxx.jpg)
type: str = Field(nullable=False) # Type of the file (e.g., 'image', 'file')
mime_type: str = Field(nullable=False) # MIME type of the file
original_filename: str = Field(nullable=True, max_length=255) # Original filename before renaming
creator: str | None = Field(default=None, max_length=255) # Username of the uploader
session_id: str | None = Field(default=None, max_length=100) # Session ID that created this attachment

__table_args__ = (
UniqueConstraint(
Expand Down
37 changes: 36 additions & 1 deletion astrbot/core/db/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ async def initialize(self) -> None:
await self._ensure_persona_skills_column(conn)
await self._ensure_persona_custom_error_message_column(conn)
await self._ensure_platform_message_history_checkpoint_column(conn)
await self._ensure_attachment_columns(conn)
await conn.commit()

async def _ensure_persona_folder_columns(self, conn) -> None:
Expand Down Expand Up @@ -757,7 +758,36 @@ async def delete_webchat_threads_by_parent_message_ids(
)
return thread_ids

async def insert_attachment(self, path, type, mime_type):
async def _ensure_attachment_columns(self, conn) -> None:
"""Ensure attachments table has original_filename, creator, session_id."""
result = await conn.execute(text("PRAGMA table_info(attachments)"))
columns = {row[1] for row in result.fetchall()}

if "original_filename" not in columns:
await conn.execute(
text(
"ALTER TABLE attachments ADD COLUMN original_filename VARCHAR(255)"
)
)
if "creator" not in columns:
await conn.execute(
text("ALTER TABLE attachments ADD COLUMN creator VARCHAR(255)")
)
if "session_id" not in columns:
await conn.execute(
text("ALTER TABLE attachments ADD COLUMN session_id VARCHAR(100)")
)

async def insert_attachment(
self,
path,
type,
mime_type,
*,
original_filename=None,
creator=None,
session_id=None,
):
"""Insert a new attachment record."""
async with self.get_db() as session:
session: AsyncSession
Expand All @@ -766,8 +796,13 @@ async def insert_attachment(self, path, type, mime_type):
path=path,
type=type,
mime_type=mime_type,
original_filename=original_filename,
creator=creator,
session_id=session_id,
)
session.add(new_attachment)
await session.flush()
await session.refresh(new_attachment)
return new_attachment

async def get_attachment_by_id(self, attachment_id):
Expand Down
55 changes: 48 additions & 7 deletions astrbot/core/platform/sources/webchat/message_parts_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from astrbot.core.message.message_event_result import MessageChain

AttachmentGetter = Callable[[str], Awaitable[Attachment | None]]
AttachmentInserter = Callable[[str, str, str], Awaitable[Attachment | None]]
AttachmentInserter = Callable[..., Awaitable[Attachment | None]]
ReplyHistoryGetter = Callable[
[Any],
Awaitable[tuple[list[dict], str | None, str | None] | None],
Expand Down Expand Up @@ -166,6 +166,7 @@ async def build_webchat_message_parts(
*,
get_attachment_by_id: AttachmentGetter,
strict: bool = False,
attachments_dir: str | Path | None = None,
) -> list[dict]:
if isinstance(message_payload, str):
text = message_payload.strip()
Expand Down Expand Up @@ -223,11 +224,15 @@ async def build_webchat_message_parts(
continue

attachment_path = Path(attachment.path)
display_name = attachment.original_filename or attachment_path.name
# Resolve relative paths to absolute for runtime usage
if attachments_dir is not None and not attachment_path.is_absolute():
attachment_path = Path(attachments_dir) / attachment_path
message_parts.append(
{
"type": attachment.type,
"attachment_id": attachment.attachment_id,
"filename": attachment_path.name,
"filename": display_name,
"path": str(attachment_path),
}
)
Expand Down Expand Up @@ -312,11 +317,13 @@ async def build_message_chain_from_payload(
*,
get_attachment_by_id: AttachmentGetter,
strict: bool = True,
attachments_dir: str | Path | None = None,
) -> MessageChain:
message_parts = await build_webchat_message_parts(
message_payload,
get_attachment_by_id=get_attachment_by_id,
strict=strict,
attachments_dir=attachments_dir,
)
components, _, has_content = await parse_webchat_message_parts(
message_parts,
Expand All @@ -334,28 +341,44 @@ async def create_attachment_part_from_existing_file(
insert_attachment: AttachmentInserter,
attachments_dir: str | Path,
fallback_dirs: Sequence[str | Path] = (),
creator: str | None = None,
session_id: str | None = None,
) -> dict | None:
basename = Path(filename).name
candidate_paths = [Path(attachments_dir) / basename]
attachments_dir = Path(attachments_dir)

# Search in attachments_dir (including subdirectories) and fallback_dirs
candidate_paths: list[Path] = []
if attachments_dir.exists():
candidate_paths.extend(attachments_dir.rglob(basename))
candidate_paths.extend(Path(p) / basename for p in fallback_dirs)

file_path = next((path for path in candidate_paths if path.exists()), None)
if not file_path:
return None

# Compute relative path if inside attachments_dir, otherwise absolute
try:
rel_path = str(file_path.relative_to(attachments_dir))
except ValueError:
rel_path = str(file_path)

mime_type, _ = mimetypes.guess_type(str(file_path))
attachment = await insert_attachment(
str(file_path),
rel_path,
attach_type,
mime_type or "application/octet-stream",
original_filename=basename,
creator=creator,
session_id=session_id,
)
if not attachment:
return None

return {
"type": attach_type,
"attachment_id": attachment.attachment_id,
"filename": file_path.name,
"filename": basename,
}


Expand All @@ -364,6 +387,7 @@ async def message_chain_to_storage_message_parts(
*,
insert_attachment: AttachmentInserter,
attachments_dir: str | Path,
conversation_id: str,
) -> list[dict]:
target_dir = Path(attachments_dir)
target_dir.mkdir(parents=True, exist_ok=True)
Expand All @@ -388,6 +412,7 @@ async def message_chain_to_storage_message_parts(
attach_type="image",
insert_attachment=insert_attachment,
attachments_dir=target_dir,
session_id=conversation_id,
)
if attachment_part:
parts.append(attachment_part)
Expand All @@ -400,6 +425,7 @@ async def message_chain_to_storage_message_parts(
attach_type="record",
insert_attachment=insert_attachment,
attachments_dir=target_dir,
session_id=conversation_id,
)
if attachment_part:
parts.append(attachment_part)
Expand All @@ -412,6 +438,7 @@ async def message_chain_to_storage_message_parts(
attach_type="video",
insert_attachment=insert_attachment,
attachments_dir=target_dir,
session_id=conversation_id,
)
if attachment_part:
parts.append(attachment_part)
Expand All @@ -425,6 +452,7 @@ async def message_chain_to_storage_message_parts(
insert_attachment=insert_attachment,
attachments_dir=target_dir,
display_name=comp.name,
session_id=conversation_id,
)
if attachment_part:
parts.append(attachment_part)
Expand All @@ -440,20 +468,33 @@ async def _copy_file_to_attachment_part(
insert_attachment: AttachmentInserter,
attachments_dir: Path,
display_name: str | None = None,
creator: str | None = None,
session_id: str | None = None,
) -> dict | None:
import datetime

src_path = Path(file_path)
if not src_path.exists() or not src_path.is_file():
return None

suffix = src_path.suffix
target_path = attachments_dir / f"{uuid.uuid4().hex}{suffix}"
random_name = f"{uuid.uuid4().hex}{suffix}"
date_dir = datetime.datetime.now().strftime("%Y/%m/%d")
target_dir = attachments_dir / date_dir
target_dir.mkdir(parents=True, exist_ok=True)

target_path = target_dir / random_name
shutil.copy2(src_path, target_path)

rel_path = str(Path(date_dir) / random_name)
mime_type, _ = mimetypes.guess_type(target_path.name)
attachment = await insert_attachment(
str(target_path),
rel_path,
attach_type,
mime_type or "application/octet-stream",
original_filename=display_name or src_path.name,
creator=creator,
session_id=session_id,
)
if not attachment:
return None
Expand Down
1 change: 1 addition & 0 deletions astrbot/core/platform/sources/webchat/webchat_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ async def _save_proactive_message(
message_chain,
insert_attachment=db_helper.insert_attachment,
attachments_dir=self.attachments_dir,
conversation_id=conversation_id,
)
if not message_parts:
return
Expand Down
99 changes: 99 additions & 0 deletions astrbot/core/utils/api_package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import base64
import hashlib
import hmac
import json
import secrets
from datetime import datetime, timedelta, timezone

from quart import request


class InvalidSignatureError(Exception):
pass


def de_package(apikey: str, data: str, noise: str, expiry_date: str, signature: str) -> dict:
"""验证签名,解包请求参数"""
if not data:
raise InvalidSignatureError("data is empty")
if not noise:
raise InvalidSignatureError("noise is empty")
if not expiry_date:
raise InvalidSignatureError("expiry_date is empty")
if not signature:
raise InvalidSignatureError("signature is empty")

date = datetime.fromisoformat(expiry_date)
if date.tzinfo is None:
date = date.astimezone()
Comment on lines +26 to +28
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Naive datetime handling in de_package can raise or behave unexpectedly

datetime.fromisoformat(expiry_date) can return a naive datetime, and calling astimezone() on it may raise ValueError or assume local time implicitly. You’re then comparing the result to datetime.now(timezone.utc), which is always aware, so behavior is inconsistent.

Instead, normalize explicitly to a known timezone before comparing, for example:

expiry = datetime.fromisoformat(expiry_date)
if expiry.tzinfo is None:
    expiry = expiry.replace(tzinfo=timezone.utc)  # or local tz if that’s required
if expiry < datetime.now(timezone.utc):
    ...

Or otherwise ensure expiry_date is parsed as an aware datetime with a consistent timezone assumption.

if date < datetime.now(timezone.utc):
raise InvalidSignatureError("expiry_date is expired")

payload = f"{data}{noise}{expiry_date}{apikey}"
computed = hmac.new(apikey.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()

if not hmac.compare_digest(computed, signature):
raise InvalidSignatureError("signature error")

try:
decoded_bytes = base64.b64decode(data)
decoded_str = decoded_bytes.decode("utf-8")
result = json.loads(decoded_str)
except Exception as e:
raise InvalidSignatureError(f"failed to decode data: {e}")

return result


def apikey_hash(apikey: str) -> str:
"""获取原始apikey的hash值"""
return hashlib.pbkdf2_hmac(
"sha256",
apikey.encode("utf-8"),
b"astrbot_api_key",
100_000,
).hex()


def en_package(appid: str, apikey: str, data: dict) -> dict:
"""apikey需要先用`apikey_hash`后才能传入使用"""
encode_data = base64.b64encode(
json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
).decode("utf-8")
noise = secrets.token_urlsafe(32)
expiry_date = (datetime.now().astimezone() + timedelta(days=1)).replace(microsecond=0).isoformat()
payload = f"{encode_data}{noise}{expiry_date}{apikey}"
signature = hmac.new(apikey.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()

return {
"appid": appid,
"data": encode_data,
"noise": noise,
"expiry_date": expiry_date,
"signature": signature,
}



async def request_input(name: list) -> dict:
"""按顺序获取输入参数:json -> form -> query -> header"""

json_data = await request.get_json(silent=True) or {}
form_data = (await request.form).to_dict() or {}

return_data = {}
for item in name:
if request.method == "POST":
if item in json_data:
return_data[item] = json_data.get(item)
continue
if item in form_data:
return_data[item] = form_data[item]
continue
if item in request.args:
return_data[item] = request.args.get(item)
continue
if item in request.headers:
return_data[item] = request.headers.get(item)
continue
return return_data
2 changes: 2 additions & 0 deletions astrbot/dashboard/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .subagent import SubAgentRoute
from .tools import ToolsRoute
from .update import UpdateRoute
from .widget import ChatWidget

__all__ = [
"ApiKeyRoute",
Expand All @@ -46,4 +47,5 @@
"ToolsRoute",
"SkillsRoute",
"UpdateRoute",
"ChatWidget",
]
2 changes: 1 addition & 1 deletion astrbot/dashboard/routes/api_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from .route import Response, Route, RouteContext

ALL_OPEN_API_SCOPES = ("chat", "config", "file", "im")
ALL_OPEN_API_SCOPES = ("chat", "config", "file", "im", "chat_widget", "stats")


class ApiKeyRoute(Route):
Expand Down
Loading