Compare commits

...

18 Commits

Author SHA1 Message Date
Soulter
2c8f38c886 chore: bump version to 4.26.0-beta.8 2026-06-18 00:13:50 +08:00
Soulter
12b1b27825 fix: harden upgrade restart recovery 2026-06-18 00:13:18 +08:00
Soulter
79d787c692 chore: bump version to 4.26.0-beta.7 2026-06-17 23:43:55 +08:00
Soulter
08fc565175 fix: handle legacy login upgrade recovery 2026-06-17 23:43:04 +08:00
Soulter
96474d3d84 chore: bump version to 4.26.0-beta.6 2026-06-17 23:16:02 +08:00
Weilong Liao
d5f5631287 fix: prefer v1 auth with legacy recovery fallback 2026-06-17 23:15:03 +08:00
Soulter
6a85405105 chore: bump version to 4.26.0-beta.5 2026-06-17 22:53:25 +08:00
C₂₂H₂₅NO₆
59fdd96627 fix: 修正人格编辑重名校验 (#8843)
Co-authored-by: C₂₂H₂₅NO₆ <Sisyphbaous-DT-Project@users.noreply.github.com>
2026-06-17 22:51:24 +08:00
Weilong Liao
19864b3f85 fix: recover interrupted dashboard upgrades 2026-06-17 22:48:47 +08:00
エイカク
2c8736fe42 fix: harden sandbox file transfers (#8840)
* fix: harden sandbox file transfers

* fix: check CUA sandbox availability with shell probe

* fix: address sandbox transfer review feedback

* fix: preserve CUA health check cancellation

* fix: tighten CUA health probe checks
2026-06-17 22:25:51 +09:00
Weilong Liao
55af880369 feat(qqofficial): allow QQ Official Webhook adapters to proactively send group messages without requiring a cached msg_id (#8841)
* feat(qqofficial): support webhook QR setup

* docs(qqofficial): simplify webhook QR setup step
2026-06-17 21:13:05 +08:00
letr
30ae18a8f0 feat(qqofficial): support group message create type (#8838)
* feat(qqofficial): support group message create

* chore: remove temporary qq official capture script

* feat(qqofficial): allow ws segmented replies

* fix(qqofficial): guard missing group mentions

* feat(qqofficial): enhance group message handling with debug logging and sender username

* feat(qqofficial): add recommended group chat settings and update bot creation instructions

* feat(qqofficial): enhance error handling for QQ Official API message sending

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-06-17 19:11:48 +08:00
0xa7973908
2cafa217f2 chore: Change faiss-cpu version baseline from 1.12.0 to 1.14.3. (#8837)
Co-authored-by: SkyCanvas <maximwang110@gmail.com>
2026-06-17 16:21:30 +08:00
Soulter
2c5165e929 chore: bump version to 4.26.0-beta.4 2026-06-17 11:13:22 +08:00
Weilong Liao
fda5161451 fix: preserve persona default tool selection
Fix https://github.com/AstrBotDevs/AstrBot/issues/8828
2026-06-17 11:08:46 +08:00
Weilong Liao
d3b52356a6 fix: repair onboarding platform and backup upload (#8834) 2026-06-17 10:54:57 +08:00
Weilong Liao
33cab38c30 fix(gemini): tool definition does not pass back to gemini properly, causing repeated tool calls. (#8833)
* fix: tool definition does not pass back to gemini properly, causing repeated tool calls.

fixes: #8789
fixes: #8773
fixes: #7111
fixes: #6402
fixes: #7684

* fix: remove unnecessary logging and improve log messages in Gemini source

* fix: improve type checking for tool calls in Google Gemini provider
2026-06-17 10:47:22 +08:00
Weilong Liao
4f5075e608 feat: add startup reset password flag 2026-06-17 00:32:37 +08:00
58 changed files with 2738 additions and 306 deletions

View File

@@ -9,6 +9,8 @@ from filelock import FileLock, Timeout
from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root
DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_RESET_DASHBOARD_PASSWORD"
async def run_astrbot(astrbot_root: Path) -> None:
"""Run AstrBot"""
@@ -28,8 +30,13 @@ async def run_astrbot(astrbot_root: Path) -> None:
@click.option("--reload", "-r", is_flag=True, help="Auto-reload plugins")
@click.option("--port", "-p", help="AstrBot Dashboard port", required=False, type=str)
@click.option(
"--reset-password",
is_flag=True,
help="Reset dashboard initial password on startup",
)
@click.command()
def run(reload: bool, port: str) -> None:
def run(reload: bool, port: str | None, reset_password: bool) -> None:
"""Run AstrBot"""
try:
os.environ["ASTRBOT_CLI"] = "1"
@@ -50,6 +57,9 @@ def run(reload: bool, port: str) -> None:
click.echo("Plugin auto-reload enabled")
os.environ["ASTRBOT_RELOAD"] = "1"
if reset_password:
os.environ[DASHBOARD_RESET_PASSWORD_ENV] = "1"
lock_file = astrbot_root / "astrbot.lock"
lock = FileLock(lock_file, timeout=5)
with lock.acquire():

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import base64
import inspect
import shlex
@@ -15,6 +16,7 @@ from .cua_defaults import CUA_CONFIG_KEYS, CUA_DEFAULT_CONFIG
from .shipyard_search_file_util import search_files_via_shell
_POSIX_OS_TYPES = {"linux", "darwin", "macos"}
_CUA_SANDBOX_HEALTH_PROBE = "_astrbot_cua_ok_"
_CUA_BACKGROUND_LAUNCHER = """
import subprocess, sys, time
@@ -55,10 +57,18 @@ async def _write_base64_via_shell(
encoded = base64.b64encode(data).decode("ascii")
decoder = (
"import base64,pathlib,sys; "
"pathlib.Path(sys.argv[1]).write_bytes(base64.b64decode(sys.stdin.read()))"
"path=pathlib.Path(sys.argv[1]); "
"path.parent.mkdir(parents=True, exist_ok=True); "
"path.write_bytes(base64.b64decode(sys.stdin.read()))"
)
chunk_size = 60_000
encoded_lines = "\n".join(
encoded[index : index + chunk_size]
for index in range(0, len(encoded), chunk_size)
)
return await shell.exec(
f"python3 -c {shlex.quote(decoder)} {shlex.quote(path)} <<'EOF'\n{encoded}\nEOF"
f"python3 -c {shlex.quote(decoder)} {shlex.quote(path)} <<'EOF'\n"
f"{encoded_lines}\nEOF"
)
@@ -882,4 +892,17 @@ class CuaBooter(ComputerBooter):
Path(local_path).write_bytes(base64.b64decode(result.get("stdout", "")))
async def available(self) -> bool:
return self._runtime is not None
if self._runtime is None:
return False
try:
result = await self._runtime.shell.exec(
f"echo {_CUA_SANDBOX_HEALTH_PROBE}", timeout=10
)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.debug("[Computer] CUA sandbox health check failed: %s", exc)
return False
if result.get("exit_code") != 0:
return False
return _CUA_SANDBOX_HEALTH_PROBE in str(result.get("stdout", ""))

View File

@@ -16,6 +16,7 @@ from .default import DEFAULT_CONFIG, DEFAULT_VALUE_MAP
ASTRBOT_CONFIG_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
DASHBOARD_INITIAL_PASSWORD_ENV = "ASTRBOT_DASHBOARD_INITIAL_PASSWORD"
DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_RESET_DASHBOARD_PASSWORD"
logger = logging.getLogger("astrbot")
@@ -77,7 +78,11 @@ class AstrBotConfig(dict):
)
# 检查配置完整性,并插入
has_new = self.check_config_integrity(default_config, conf)
if (
reset_dashboard_password = self._consume_reset_dashboard_password_flag()
if reset_dashboard_password and "dashboard" in conf:
self._reset_generated_dashboard_password(conf)
has_new = True
elif (
"dashboard" in conf
and isinstance(conf["dashboard"], dict)
and not conf["dashboard"].get("pbkdf2_password")
@@ -118,6 +123,11 @@ class AstrBotConfig(dict):
True,
)
@staticmethod
def _consume_reset_dashboard_password_flag() -> bool:
raw_value = os.environ.pop(DASHBOARD_RESET_PASSWORD_ENV, "")
return raw_value.strip().lower() in {"1", "true", "yes", "on"}
@staticmethod
def _resolve_initial_dashboard_password() -> str:
env_password = os.environ.get(DASHBOARD_INITIAL_PASSWORD_ENV)

View File

@@ -5,7 +5,7 @@ import os
from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.26.0-beta.3"
VERSION = "4.26.0-beta.8"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {

View File

@@ -27,6 +27,7 @@ from astrbot.core.db.po import (
UmoAlias,
WebChatThread,
)
from astrbot.core.sentinels import NOT_GIVEN
@dataclass
@@ -444,11 +445,23 @@ class BaseDatabase(abc.ABC):
persona_id: str,
system_prompt: str | None = None,
begin_dialogs: list[str] | None = None,
tools: list[str] | None = None,
skills: list[str] | None = None,
custom_error_message: str | None = None,
tools: list[str] | None | object = NOT_GIVEN,
skills: list[str] | None | object = NOT_GIVEN,
custom_error_message: str | None | object = NOT_GIVEN,
) -> Persona | None:
"""Update a persona's system prompt or begin dialogs."""
"""Update a persona record.
Args:
persona_id: Persona ID to update.
system_prompt: Optional replacement system prompt.
begin_dialogs: Optional replacement begin dialogs.
tools: Tool names, None for all tools, or NOT_GIVEN to leave unchanged.
skills: Skill names, None for all skills, or NOT_GIVEN to leave unchanged.
custom_error_message: Custom fallback message, None to clear, or NOT_GIVEN to leave unchanged.
Returns:
Updated persona, or None when no fields were updated.
"""
...
@abc.abstractmethod

View File

@@ -138,7 +138,7 @@ class RespondStage(Stage):
return False
if event.get_platform_name() in [
"qq_official",
"qq_official_webhook",
"weixin_official_account",
"dingtalk",
]:

View File

@@ -186,7 +186,6 @@ class ResultDecorateStage(Stage):
# 流式输出不执行下面的逻辑
if is_stream:
logger.info("流式输出已启用,跳过结果装饰阶段")
return
# 需要再获取一次。插件可能直接对 chain 进行了替换。
@@ -204,7 +203,7 @@ class ResultDecorateStage(Stage):
# 分段回复
if self.enable_segmented_reply and event.get_platform_name() not in [
"qq_official",
"qq_official_webhook",
"weixin_official_account",
"dingtalk",
]:

View File

@@ -90,7 +90,7 @@ class PipelineScheduler:
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
await event.send(None)
logger.debug("pipeline 执行完毕。")
logger.debug("pipeline execution completed.")
finally:
event.cleanup_temporary_local_files()
active_event_registry.unregister(event)

View File

@@ -65,6 +65,14 @@ _qqofficial_retry = retry(
reraise=True,
)
_QQOFFICIAL_SEND_API_ERRORS = (
botpy.errors.ForbiddenError,
botpy.errors.MethodNotAllowedError,
botpy.errors.NotFoundError,
botpy.errors.SequenceNumberError,
botpy.errors.ServerError,
)
class QQOfficialMessageEvent(AstrMessageEvent):
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
@@ -482,7 +490,21 @@ class QQOfficialMessageEvent(AstrMessageEvent):
):
try:
return await send_func(payload)
except botpy.errors.ServerError as err:
except _QQOFFICIAL_SEND_API_ERRORS as err:
logger.info("[QQOfficial] 回复消息失败: %s, 尝试使用主动发送接口。", err)
if payload.get("msg_id"):
fallback_payload = payload.copy()
try:
ret = await send_func(fallback_payload)
logger.info("[QQOfficial] 使用主动发送接口发送成功。")
return ret
except _QQOFFICIAL_SEND_API_ERRORS as fallback_err:
err = fallback_err
payload = fallback_payload
if not isinstance(err, botpy.errors.ServerError):
raise
# QQ 流式 markdown 分片校验:内容必须以换行结尾。
# 某些边界场景服务端仍可能判定失败,这里做一次修正重试。
if stream and self.STREAM_MARKDOWN_NEWLINE_ERROR in str(err):
@@ -649,6 +671,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
) -> message.Message | None:
payload = locals()
payload.pop("self", None)
if payload.get("msg_id") is None:
payload.pop("msg_id", None)
# QQ API does not accept stream.id=None; remove it when not yet assigned
if "stream" in payload and payload["stream"] is not None:
stream_data = dict(payload["stream"])

View File

@@ -12,6 +12,7 @@ from typing import Any, cast
import botpy
import botpy.message
from botpy import Client
from botpy.connection import ConnectionState
from botpy.gateway import BotWebSocket
from astrbot import logger
@@ -36,6 +37,39 @@ for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
class PatchedGroupMessage(botpy.message.GroupMessage):
class _User:
def __init__(self, data: dict[str, Any]) -> None:
self.id = data.get("id", None)
self.username = data.get("username", None)
self.bot = data.get("bot", None)
self.avatar = data.get("avatar", None)
self.member_openid = data.get("member_openid", None)
self.user_openid = data.get("user_openid", None)
self.is_you = data.get("is_you", None)
def __repr__(self) -> str:
return str(self.__dict__)
def _ensure_group_message_create_parser() -> None:
"""Register the missing qq-botpy parser for GROUP_MESSAGE_CREATE."""
if hasattr(ConnectionState, "parse_group_message_create"):
return
def parse_group_message_create(self, payload: dict[str, Any]) -> None:
group_message = PatchedGroupMessage(
self.api,
payload.get("id", None),
payload.get("d", {}),
)
logger.debug("[QQOfficial] Received group message: %s", group_message)
self._dispatch("group_message_create", group_message)
setattr(ConnectionState, "parse_group_message_create", parse_group_message_create)
class ManagedBotWebSocket(BotWebSocket):
def __init__(self, session, connection: Any, client: botClient):
super().__init__(session, connection)
@@ -70,6 +104,19 @@ class botClient(Client):
# 收到群消息
async def on_group_at_message_create(
self, message: botpy.message.GroupMessage
) -> None:
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
force_group_mention=True,
)
abm.group_id = cast(str, message.group_openid)
abm.session_id = abm.group_id
self.platform.remember_session_scene(abm.session_id, "group")
self._commit(abm)
async def on_group_message_create(
self, message: botpy.message.GroupMessage
) -> None:
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
@@ -176,8 +223,11 @@ class QQOfficialPlatformAdapter(Platform):
self.client.set_platform(self)
_ensure_group_message_create_parser()
self._session_last_message_id: dict[str, str] = {}
self._session_scene: dict[str, str] = {}
self._allow_group_proactive_send = True
self.test_mode = os.environ.get("TEST_MODE", "off") == "on"
@@ -220,21 +270,32 @@ class QQOfficialPlatformAdapter(Platform):
):
return
# 私聊主动推送不需要 msg_id见 https://github.com/AstrBotDevs/AstrBot/issues/7904
# 主动推送不需要 msg_id见 https://github.com/AstrBotDevs/AstrBot/issues/7904
msg_id = self._session_last_message_id.get(session.session_id)
if not msg_id and session.message_type != MessageType.FRIEND_MESSAGE:
scene = self._session_scene.get(session.session_id)
allow_group_proactive_send = (
session.message_type == MessageType.GROUP_MESSAGE
and scene == "group"
and getattr(self, "_allow_group_proactive_send", False)
)
if (
not msg_id
and session.message_type != MessageType.FRIEND_MESSAGE
and not allow_group_proactive_send
):
logger.warning(
"[QQOfficial] No cached msg_id for session: %s, skip send_by_session",
session.session_id,
)
return
payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id}
payload: dict[str, Any] = {"content": plain_text}
if msg_id and not allow_group_proactive_send:
payload["msg_id"] = msg_id
ret: Any = None
send_helper = SimpleNamespace(bot=self.client)
if session.message_type == MessageType.GROUP_MESSAGE:
scene = self._session_scene.get(session.session_id)
if scene == "group":
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
@@ -352,7 +413,7 @@ class QQOfficialPlatformAdapter(Platform):
sent_message_id = self._extract_message_id(ret)
if sent_message_id:
self.remember_session_message_id(session.session_id, sent_message_id)
await super().send_by_session(session, message_chain)
await Platform.send_by_session(self, session, message_chain)
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
if not session_id or not message_id:
@@ -537,6 +598,7 @@ class QQOfficialPlatformAdapter(Platform):
| botpy.message.DirectMessage
| botpy.message.C2CMessage,
message_type: MessageType,
force_group_mention: bool = False,
) -> AstrBotMessage:
abm = AstrBotMessage()
abm.type = message_type
@@ -551,16 +613,47 @@ class QQOfficialPlatformAdapter(Platform):
botpy.message.C2CMessage,
):
if isinstance(message, botpy.message.GroupMessage):
abm.sender = MessageMember(message.author.member_openid, "")
abm.sender = MessageMember(
message.author.member_openid,
getattr(message.author, "username", "") or "",
)
abm.group_id = message.group_openid
bot_mentions = [
mention
for mention in (getattr(message, "mentions", None) or [])
if getattr(mention, "is_you", False) is True
and getattr(mention, "id", None) is not None
]
bot_mention_ids = [str(mention.id) for mention in bot_mentions]
group_mentioned = bool(bot_mention_ids) or force_group_mention
plain_content_raw = message.content or ""
for mention_id in bot_mention_ids:
plain_content_raw = plain_content_raw.replace(
f"<@{mention_id}>",
"",
).replace(
f"<@!{mention_id}>",
"",
)
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
plain_content_raw.strip()
)
abm.self_id = bot_mention_ids[0] if bot_mention_ids else "qq_official"
if group_mentioned:
mention_name = (
getattr(bot_mentions[0], "username", "") if bot_mentions else ""
)
msg.append(At(qq=abm.self_id, name=mention_name))
else:
abm.sender = MessageMember(message.author.user_openid, "")
# Parse face messages to readable text
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
message.content.strip()
)
abm.self_id = "unknown_selfid"
msg.append(At(qq="qq_official"))
abm.sender = MessageMember(
message.author.user_openid,
getattr(message.author, "username", "") or "",
)
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
(message.content or "").strip()
)
abm.self_id = "unknown_selfid"
msg.append(At(qq="qq_official"))
msg.append(Plain(abm.message_str))
await QQOfficialPlatformAdapter._append_attachments(
msg, message.attachments
@@ -599,7 +692,8 @@ class QQOfficialPlatformAdapter(Platform):
abm.group_id = message.channel_id
else:
raise ValueError(f"Unknown message type: {message_type}")
abm.self_id = "qq_official"
if not abm.self_id:
abm.self_id = "qq_official"
return abm
def run(self):

View File

@@ -13,7 +13,10 @@ from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.utils.webhook_utils import log_webhook_info
from ...register import register_platform_adapter
from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter
from ..qqofficial.qqofficial_platform_adapter import (
QQOfficialPlatformAdapter,
_ensure_group_message_create_parser,
)
from .qo_webhook_event import QQOfficialWebhookMessageEvent
from .qo_webhook_server import QQOfficialWebhook
@@ -30,6 +33,19 @@ class botClient(Client):
# 收到群消息
async def on_group_at_message_create(
self, message: botpy.message.GroupMessage
) -> None:
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
force_group_mention=True,
)
abm.group_id = cast(str, message.group_openid)
abm.session_id = abm.group_id
self.platform.remember_session_scene(abm.session_id, "group")
self._commit(abm)
async def on_group_message_create(
self, message: botpy.message.GroupMessage
) -> None:
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
@@ -103,9 +119,11 @@ class QQOfficialWebhookPlatformAdapter(Platform):
timeout=20,
)
self.client.set_platform(self)
_ensure_group_message_create_parser()
self.webhook_helper = None
self._session_last_message_id: dict[str, str] = {}
self._session_scene: dict[str, str] = {}
self._allow_group_proactive_send = True
async def send_by_session(
self,

View File

@@ -12,6 +12,8 @@ from cryptography.hazmat.primitives.asymmetric import ed25519
from astrbot.api import logger
from astrbot.core.platform.webhook_server import FastAPIWebhookServer
from ..qqofficial.qqofficial_platform_adapter import _ensure_group_message_create_parser
# remove logger handler
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
@@ -120,6 +122,7 @@ class QQOfficialWebhook:
def _setup_connection(self) -> None:
if self._connection is not None:
return
_ensure_group_message_create_parser()
self.client.api = self.api
self.client.http = self.http
@@ -163,7 +166,7 @@ class QQOfficialWebhook:
"""内部服务器的回调入口"""
return await self.handle_callback(request)
async def handle_callback(self, request) -> dict:
async def handle_callback(self, request) -> dict | tuple[dict[str, str], int]:
"""处理 webhook 回调,可被统一 webhook 入口复用
Args:
@@ -226,6 +229,7 @@ class QQOfficialWebhook:
"creating parser connection lazily.",
)
self._setup_connection()
connection = cast(ConnectionSession, self._connection)
# Extract extra fields from raw payload before botpy parses and discards them
if data:
@@ -240,7 +244,7 @@ class QQOfficialWebhook:
if extra:
self._extra_data_cache[msg_id] = extra
try:
func = self._connection.parser[event]
func = connection.parser[event]
except KeyError:
logger.error("_parser unknown event %s.", event)
if data:

View File

@@ -97,7 +97,6 @@ class ProviderGoogleGenAI(Provider):
if proxy:
async_client_kwargs["proxy"] = proxy
async_client_kwargs["trust_env"] = False
logger.info("[Gemini] 使用代理")
else:
async_client_kwargs["trust_env"] = True
@@ -137,15 +136,18 @@ class ProviderGoogleGenAI(Provider):
keys.remove(self.chosen_api_key)
if len(keys) > 0:
self.set_key(random.choice(keys))
logger.info(
f"检测到 Key 异常({e.message}),正在尝试更换 API Key 重试... 当前 Key: {self.chosen_api_key[:12]}...",
logger.warning(
"Retrying with a different API key due to detected key issue: %s. Current key: %s...",
e.message,
self.chosen_api_key[:12],
)
await asyncio.sleep(1)
return True
logger.error(
f"检测到 Key 异常({e.message}),且已没有可用的 Key。 当前 Key: {self.chosen_api_key[:12]}...",
"No valid API keys remaining. Current key: %s...",
self.chosen_api_key[:12],
)
raise Exception("达到了 Gemini 速率限制, 请稍后再试...")
raise Exception("Gemini API rate limit reached or API key issue detected.")
# 连接错误处理
if is_connection_error(e):
@@ -172,7 +174,9 @@ class ProviderGoogleGenAI(Provider):
self.provider_settings.get("streaming_response", False)
and "IMAGE" in modalities
):
logger.warning("流式输出不支持图片模态,已自动降级为文本模态")
logger.warning(
"Streaming responses do not support IMAGE modality, falling back to TEXT modality."
)
modalities = ["TEXT"]
tool_list: list[types.Tool] | None = []
@@ -181,60 +185,28 @@ class ProviderGoogleGenAI(Provider):
native_search = self.provider_config.get("gm_native_search", False)
url_context = self.provider_config.get("gm_url_context", False)
if "gemini-2.5" in model_name:
if native_coderunner:
tool_list.append(types.Tool(code_execution=types.ToolCodeExecution()))
if native_search:
logger.warning("代码执行工具与搜索工具互斥,已忽略搜索工具")
if url_context:
logger.warning(
"代码执行工具与URL上下文工具互斥已忽略URL上下文工具",
)
else:
if native_search:
tool_list.append(types.Tool(google_search=types.GoogleSearch()))
if url_context:
if hasattr(types, "UrlContext"):
tool_list.append(types.Tool(url_context=types.UrlContext()))
else:
logger.warning(
"当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包",
)
elif "gemini-2.0-lite" in model_name:
if "gemini-2.0-lite" in model_name:
if native_coderunner or native_search or url_context:
logger.warning(
"gemini-2.0-lite 不支持代码执行、搜索工具和URL上下文将忽略这些设置",
"gemini-2.0-lite does not support native code execution, search, or URL context tools. These settings will be ignored.",
)
tool_list = None
else:
if native_coderunner:
tool_list.append(types.Tool(code_execution=types.ToolCodeExecution()))
if native_search:
logger.warning("代码执行工具与搜索工具互斥,已忽略搜索工具")
elif native_search:
if native_search:
tool_list.append(types.Tool(google_search=types.GoogleSearch()))
if url_context:
tool_list.append(types.Tool(url_context=types.UrlContext()))
if url_context and not native_coderunner:
if hasattr(types, "UrlContext"):
tool_list.append(types.Tool(url_context=types.UrlContext()))
else:
logger.warning(
"当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包",
)
if tools:
func_desc = tools.get_func_desc_google_genai_style()
tool_list.append(
types.Tool(function_declarations=func_desc["function_declarations"]),
)
if not tool_list:
tool_list = None
if tools and tool_list:
logger.warning("已启用原生工具,函数工具将被忽略")
elif tools and (func_desc := tools.get_func_desc_google_genai_style()):
tool_list = [
types.Tool(function_declarations=func_desc["function_declarations"]),
]
tool_config = None
has_func_decl = tool_list and any(t.function_declarations for t in tool_list)
if has_func_decl:
@@ -322,7 +294,7 @@ class ProviderGoogleGenAI(Provider):
def create_text_part(text: str) -> types.Part:
content_a = text if text else " "
if not text:
logger.warning("文本内容为空,已添加空格占位")
logger.warning("Text content is empty, added a space as placeholder.")
return types.Part.from_text(text=content_a)
def process_image_url(image_url_dict: dict) -> types.Part:
@@ -349,12 +321,6 @@ class ProviderGoogleGenAI(Provider):
contents.append(content_cls(parts=part))
gemini_contents: list[types.Content] = []
native_tool_enabled = any(
[
self.provider_config.get("gm_native_coderunner", False),
self.provider_config.get("gm_native_search", False),
],
)
for message in payloads["messages"]:
role, content = message["role"], message.get("content")
@@ -377,11 +343,10 @@ class ProviderGoogleGenAI(Provider):
append_or_extend(gemini_contents, parts, types.UserContent)
elif role == "assistant":
parts = []
if isinstance(content, str):
parts = [types.Part.from_text(text=content)]
append_or_extend(gemini_contents, parts, types.ModelContent)
parts.append(types.Part.from_text(text=content))
elif isinstance(content, list):
parts = []
thinking_signature = None
text = ""
for part in content:
@@ -400,16 +365,31 @@ class ProviderGoogleGenAI(Provider):
exc_info=True,
)
thinking_signature = None
parts.append(
types.Part(
text=text,
thought_signature=thinking_signature,
)
)
append_or_extend(gemini_contents, parts, types.ModelContent)
elif not native_tool_enabled and "tool_calls" in message:
parts = []
if (
not text
and thinking_signature
and "tool_calls" in message
and any(
isinstance(tool, dict)
and isinstance(tool.get("extra_content"), dict)
and isinstance(tool["extra_content"].get("google"), dict)
and tool["extra_content"]["google"].get("thought_signature")
for tool in message["tool_calls"]
)
):
# If the main content is empty but tool calls have thought signatures,
# skip adding an empty text part to deduplicate the thinking signature in the main content and tool calls.
pass
else:
parts.append(
types.Part(
text=text,
thought_signature=thinking_signature,
)
)
if "tool_calls" in message:
for tool in message["tool_calls"]:
part = types.Part.from_function_call(
name=tool["function"]["name"],
@@ -427,17 +407,13 @@ class ProviderGoogleGenAI(Provider):
if ts_bs64:
part.thought_signature = base64.b64decode(ts_bs64)
parts.append(part)
append_or_extend(gemini_contents, parts, types.ModelContent)
else:
logger.warning("assistant 角色的消息内容为空,已添加空格占位")
if native_tool_enabled and "tool_calls" in message:
logger.warning(
"检测到启用Gemini原生工具且上下文中存在函数调用建议使用 /reset 重置上下文",
)
parts = [types.Part.from_text(text=" ")]
append_or_extend(gemini_contents, parts, types.ModelContent)
elif role == "tool" and not native_tool_enabled:
if not parts:
parts = [types.Part.from_text(text=" ")]
append_or_extend(gemini_contents, parts, types.ModelContent)
elif role == "tool":
func_name = message.get("name", message["tool_call_id"])
part = types.Part.from_function_response(
name=func_name,
@@ -501,7 +477,7 @@ class ProviderGoogleGenAI(Provider):
) -> MessageChain:
"""处理内容部分并构建消息链"""
if not candidate.content:
logger.warning(f"收到的 candidate.content 为空: {candidate}")
logger.warning(f"Gemini candidate.content is empty: {candidate}")
if validate_output:
raise EmptyModelOutputError(
"Gemini candidate content is empty. "
@@ -514,22 +490,22 @@ class ProviderGoogleGenAI(Provider):
result_parts: list[types.Part] | None = candidate.content.parts
if finish_reason == types.FinishReason.SAFETY:
raise Exception("模型生成内容未通过 Gemini 平台的安全检查")
raise Exception("The model output failed Gemini platform safety checks.")
if finish_reason in {
types.FinishReason.PROHIBITED_CONTENT,
types.FinishReason.SPII,
types.FinishReason.BLOCKLIST,
}:
raise Exception("模型生成内容违反 Gemini 平台政策")
raise Exception("The model output violates Gemini platform policy.")
# 防止旧版本SDK不存在IMAGE_SAFETY
if hasattr(types.FinishReason, "IMAGE_SAFETY"):
if finish_reason == types.FinishReason.IMAGE_SAFETY:
raise Exception("模型生成内容违反 Gemini 平台政策")
raise Exception("The model output violates Gemini platform policy.")
if not result_parts:
logger.warning(f"收到的 candidate.content.parts 为空: {candidate}")
logger.warning(f"Gemini candidate.content.parts is empty: {candidate}")
if validate_output:
raise EmptyModelOutputError(
"Gemini candidate content parts are empty. "
@@ -636,15 +612,19 @@ class ProviderGoogleGenAI(Provider):
logger.debug(f"genai result: {result}")
if not result.candidates:
logger.error(f"请求失败, 返回的 candidates 为空: {result}")
raise Exception("请求失败, 返回的 candidates 为空。")
logger.error(
f"Gemini request failed: candidates is empty: {result}"
)
raise Exception("Gemini request failed: candidates is empty.")
if result.candidates[0].finish_reason == types.FinishReason.RECITATION:
if temperature > 2:
raise Exception("温度参数已超过最大值2仍然发生recitation")
raise Exception(
"Temperature exceeded the maximum value of 2, but Gemini recitation still occurred."
)
temperature += 0.2
logger.warning(
f"发生了recitation,正在提高温度至{temperature:.1f}重试...",
f"Gemini recitation detected; increasing temperature to {temperature:.1f} and retrying...",
)
continue
@@ -655,11 +635,13 @@ class ProviderGoogleGenAI(Provider):
e.message = ""
if "Developer instruction is not enabled" in e.message:
logger.warning(
f"{model} 不支持 system prompt已自动去除(影响人格设置)",
f"{model} does not support system prompts; removing it automatically. This may affect persona settings.",
)
system_instruction = None
elif "Function calling is not enabled" in e.message:
logger.warning(f"{model} 不支持函数调用,已自动去除")
logger.warning(
f"{model} does not support function calling; removing tools automatically."
)
tools = None
elif (
"Multi-modal output is not supported" in e.message
@@ -668,7 +650,7 @@ class ProviderGoogleGenAI(Provider):
or "only supports text output" in e.message
):
logger.warning(
f"{model} 不支持多模态输出,降级为文本模态",
f"{model} does not support multimodal output; falling back to TEXT modality.",
)
modalities = ["TEXT"]
else:
@@ -719,11 +701,13 @@ class ProviderGoogleGenAI(Provider):
e.message = ""
if "Developer instruction is not enabled" in e.message:
logger.warning(
f"{model} 不支持 system prompt已自动去除(影响人格设置)",
f"{model} does not support system prompts; removing it automatically. This may affect persona settings.",
)
system_instruction = None
elif "Function calling is not enabled" in e.message:
logger.warning(f"{model} 不支持函数调用,已自动去除")
logger.warning(
f"{model} does not support function calling; removing tools automatically."
)
tools = None
else:
raise
@@ -738,10 +722,10 @@ class ProviderGoogleGenAI(Provider):
llm_response = LLMResponse("assistant", is_chunk=True)
if not chunk.candidates:
logger.warning(f"收到的 chunk 中 candidates 为空: {chunk}")
logger.warning(f"Gemini stream chunk has empty candidates: {chunk}")
continue
if not chunk.candidates[0].content:
logger.warning(f"收到的 chunk 中 content 为空: {chunk}")
logger.warning(f"Gemini stream chunk has empty content: {chunk}")
continue
if chunk.candidates[0].content.parts and any(
@@ -872,7 +856,7 @@ class ProviderGoogleGenAI(Provider):
continue
break
raise Exception("请求失败。")
raise Exception("Gemini request failed.")
async def text_chat_stream(
self,
@@ -947,7 +931,7 @@ class ProviderGoogleGenAI(Provider):
and m.name
]
except APIError as e:
raise Exception(f"获取模型列表失败: {e.message}")
raise Exception(f"Failed to fetch Gemini model list: {e.message}")
def get_current_key(self) -> str:
return self.chosen_api_key
@@ -974,7 +958,7 @@ class ProviderGoogleGenAI(Provider):
media_type="image",
)
if not image_data:
logger.warning("图片预处理结果为空,将忽略。")
logger.warning("Image preprocessing returned no data; ignoring it.")
return None
return {
"type": "image_url",
@@ -989,11 +973,13 @@ class ProviderGoogleGenAI(Provider):
strict=True,
)
except Exception as exc:
logger.warning("音频预处理失败,将忽略。错误: %s", exc)
logger.warning(
"Audio preprocessing failed; ignoring it. Error: %s", exc
)
return None
if not audio_data:
logger.warning("音频预处理结果为空,将忽略。")
logger.warning("Audio preprocessing returned no data; ignoring it.")
return None
return {
"type": "audio_url",
@@ -1029,7 +1015,9 @@ class ProviderGoogleGenAI(Provider):
if audio_part:
content_blocks.append(audio_part)
else:
raise ValueError(f"不支持的额外内容块类型: {type(part)}")
raise ValueError(
f"Unsupported extra content part type: {type(part)}"
)
# 3. 图片内容
if image_urls:

View File

@@ -70,6 +70,12 @@ _SANDBOX_RUNTIME_TOOL_CONFIG = {
_IMAGE_FILE_SUFFIXES = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
def _remote_basename(path: str) -> str:
# Sandbox paths may come from POSIX or Windows runtimes; normalize separators
# without interpreting the path against the host filesystem.
return path.replace("\\", "/").rstrip("/").split("/")[-1]
def _restricted_env_path_labels(umo: str, *, include_plugin_skills: bool) -> list[str]:
"""Labels for the allowed directories in a local(not sandbox) and restricted(not admin) environment"""
normalized_umo = normalize_umo_for_workspace(umo)
@@ -772,7 +778,7 @@ class FileDownloadTool(FunctionTool):
context.context.event.unified_msg_origin,
)
try:
name = os.path.basename(remote_path)
name = _remote_basename(remote_path) or os.path.basename(remote_path)
local_path = os.path.join(
get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}"
@@ -784,7 +790,7 @@ class FileDownloadTool(FunctionTool):
if also_send_to_user:
try:
name = os.path.basename(local_path)
name = _remote_basename(remote_path) or os.path.basename(local_path)
if Path(local_path).suffix.lower() in _IMAGE_FILE_SUFFIXES:
message_component = Image.fromFileSystem(local_path)
sent_as = "image"

View File

@@ -15,6 +15,7 @@ from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.computer_client import get_booter
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.tools.computer_tools.fs import _remote_basename
from astrbot.core.tools.computer_tools.util import (
check_admin_permission,
is_local_runtime,
@@ -173,7 +174,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
quoted_path = shlex.quote(path)
result = await sb.shell.exec(f"test -f {quoted_path} && echo '_&exists_'")
if "_&exists_" in json.dumps(result):
name = os.path.basename(path)
name = _remote_basename(path) or os.path.basename(path)
local_path = os.path.join(
get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}"
)
@@ -259,7 +260,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
url = msg.get("url")
name = (
msg.get("text")
or (os.path.basename(path) if path else "")
or (_remote_basename(path) if path else "")
or (os.path.basename(url) if url else "")
or "file"
)

View File

@@ -45,7 +45,15 @@ async def _json_or_empty(request: Request) -> dict[str, Any]:
def _model_dict(payload) -> dict[str, Any]:
return payload.model_dump(exclude_none=True)
"""Serialize a request model while preserving explicit null updates.
Args:
payload: Pydantic request model.
Returns:
Request data without fields omitted by the caller.
"""
return payload.model_dump(exclude_unset=True)
def _raise_persona_error(exc: PersonaServiceError | ValueError) -> None:

View File

@@ -40,8 +40,12 @@ class PersonaService:
async def create_persona(self, data: object) -> dict:
payload = self._payload(data)
persona_id = str(payload.get("persona_id", "")).strip()
system_prompt = str(payload.get("system_prompt", "")).strip()
raw_persona_id = payload.get("persona_id")
raw_system_prompt = payload.get("system_prompt")
persona_id = str(raw_persona_id).strip() if raw_persona_id is not None else ""
system_prompt = (
str(raw_system_prompt).strip() if raw_system_prompt is not None else ""
)
begin_dialogs = payload.get("begin_dialogs", [])
tools = payload.get("tools")
skills = payload.get("skills")

View File

@@ -99,7 +99,7 @@ class PlatformService:
)
if platform_type == "dingtalk":
return await self._handle_dingtalk_registration(action, payload)
if platform_type == "qq_official":
if platform_type in {"qq_official", "qq_official_webhook"}:
return await self._handle_qqofficial_registration(
action,
payload,

View File

@@ -92,6 +92,7 @@ class UpdateService:
self.demo_mode = demo_mode
self.clear_site_data_headers = clear_site_data_headers
self.update_progress: dict[str, dict] = {}
self._update_tasks: dict[str, asyncio.Task] = {}
def get_update_progress(self, progress_id: str) -> UpdateServiceResult:
if not progress_id:
@@ -156,7 +157,43 @@ class UpdateService:
if proxy:
proxy = proxy.removesuffix("/")
existing_task = self._update_tasks.get(progress_id)
if existing_task and not existing_task.done():
return UpdateServiceResult(
data={"id": progress_id, "status": "running"},
message="更新任务正在进行中。",
headers=self.clear_site_data_headers,
)
self._init_update_progress(progress_id, version)
task = asyncio.create_task(
self._run_update_project(progress_id, version, latest, reboot, proxy)
)
self._update_tasks[progress_id] = task
task.add_done_callback(lambda _task: self._update_tasks.pop(progress_id, None))
return UpdateServiceResult(
data={"id": progress_id, "status": "running"},
message="更新任务已开始。",
headers=self.clear_site_data_headers,
)
async def _run_update_project(
self,
progress_id: str,
version: str,
latest: bool,
reboot: bool,
proxy: str | None,
) -> None:
"""Run the long core update outside the request lifecycle.
Args:
progress_id: Progress record id reported to the frontend.
version: Target version without the latest sentinel.
latest: Whether to install the latest release.
reboot: Whether to restart AstrBot after applying files.
proxy: Optional GitHub proxy URL.
"""
update_temp_dir = Path(get_astrbot_system_tmp_path()) / "updates"
update_temp_dir.mkdir(parents=True, exist_ok=True)
update_token = uuid.uuid4().hex
@@ -308,10 +345,16 @@ class UpdateService:
"overall_percent": 100,
},
)
return UpdateServiceResult(
message=message,
headers=self.clear_site_data_headers,
logger.info(message)
except asyncio.CancelledError:
self.update_progress[progress_id].update(
{
"status": "error",
"message": "更新任务已取消。",
},
)
logger.warning(f"Update task was cancelled: {progress_id}")
raise
except Exception as exc:
self.update_progress[progress_id].update(
{
@@ -320,7 +363,7 @@ class UpdateService:
},
)
logger.error(f"/api/update_project: {traceback.format_exc()}")
raise UpdateServiceError(exc.__str__()) from exc
logger.debug(f"Update task failed: {exc!s}")
finally:
for zip_path in (dashboard_zip_path, core_zip_path):
try:

View File

@@ -0,0 +1,26 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
- 修复 Gemini Provider 工具定义没有正确传回模型,导致重复工具调用的问题。([#8833](https://github.com/AstrBotDevs/AstrBot/pull/8833))
- 修复 onboarding 平台配置与备份上传相关问题。([#8834](https://github.com/AstrBotDevs/AstrBot/pull/8834))
- 修复人格设定中将工具和 Skills 从指定列表切回“默认使用全部”后不生效的问题。([#8835](https://github.com/AstrBotDevs/AstrBot/pull/8835))
- 新增启动时重置 WebUI 密码的命令行开关,便于无法登录时恢复访问。([commit](https://github.com/AstrBotDevs/AstrBot/commit/4f5075e60))
<a id="english"></a>
## What's Changed (EN)
### Highlights
- Added a startup flag to reset the WebUI password, making it easier to recover access when login is unavailable. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/4f5075e60))
### Bug Fixes
- Fixed Gemini Provider tool definitions not being passed back to the model correctly, which could cause repeated tool calls. ([#8833](https://github.com/AstrBotDevs/AstrBot/pull/8833))
- Fixed onboarding platform configuration and backup upload issues. ([#8834](https://github.com/AstrBotDevs/AstrBot/pull/8834))
- Fixed persona tool and Skill settings not taking effect after switching from selected items back to “use all by default”. ([#8835](https://github.com/AstrBotDevs/AstrBot/pull/8835))

View File

@@ -0,0 +1,38 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
### 重点更新
- 优化 WebUI 升级流程Core 更新改为后台任务,并在检测到 WebUI 与 Core 版本错配时提供自动恢复重启引导,避免刷新页面后卡在 `Missing API key`。([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
- 增强 QQ 官方机器人群聊能力,支持群消息创建类型,并允许 Webhook 适配器在无缓存 `msg_id` 时主动发送群消息。([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
### 修复
- 修复人格编辑时重名校验不准确的问题。([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
- 加固沙箱文件传输与 CUA 健康检查流程,降低异常环境下的文件操作风险。([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
### 其他
-`faiss-cpu` 版本基线从 `1.12.0` 调整为 `1.14.3`。([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))
<a id="english"></a>
## What's Changed (EN)
### Highlights
- Improved the WebUI upgrade flow by running Core updates as background tasks and adding guided recovery when WebUI/Core version mismatches are detected, preventing refreshes from leaving users stuck at `Missing API key`. ([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
- Enhanced QQ Official Bot group chat support with group message create events and proactive Webhook group sends without requiring a cached `msg_id`. ([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
### Bug Fixes
- Fixed duplicate-name validation when editing personas. ([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
- Hardened sandbox file transfers and CUA health checks to reduce file-operation risk in abnormal environments. ([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
### Other
- Changed the `faiss-cpu` version baseline from `1.12.0` to `1.14.3`. ([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))

View File

@@ -0,0 +1,38 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
### 重点更新
- 优化 WebUI 升级流程Core 更新改为后台任务,并在检测到 WebUI 与 Core 版本错配时提供自动恢复重启引导,避免刷新页面后卡在 `Missing API key`。([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
- 增强 QQ 官方机器人群聊能力,支持群消息创建类型,并允许 Webhook 适配器在无缓存 `msg_id` 时主动发送群消息。([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
### 修复
- 修复人格编辑时重名校验不准确的问题。([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
- 加固沙箱文件传输与 CUA 健康检查流程,降低异常环境下的文件操作风险。([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
### 其他
-`faiss-cpu` 版本基线从 `1.12.0` 调整为 `1.14.3`。([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))
<a id="english"></a>
## What's Changed (EN)
### Highlights
- Improved the WebUI upgrade flow by running Core updates as background tasks and adding guided recovery when WebUI/Core version mismatches are detected, preventing refreshes from leaving users stuck at `Missing API key`. ([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
- Enhanced QQ Official Bot group chat support with group message create events and proactive Webhook group sends without requiring a cached `msg_id`. ([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
### Bug Fixes
- Fixed duplicate-name validation when editing personas. ([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
- Hardened sandbox file transfers and CUA health checks to reduce file-operation risk in abnormal environments. ([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
### Other
- Changed the `faiss-cpu` version baseline from `1.12.0` to `1.14.3`. ([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))

View File

@@ -0,0 +1,38 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
### 重点更新
- 优化 WebUI 升级流程Core 更新改为后台任务,并在检测到 WebUI 与 Core 版本错配时提供自动恢复重启引导,避免刷新页面后卡在 `Missing API key`。([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
- 增强 QQ 官方机器人群聊能力,支持群消息创建类型,并允许 Webhook 适配器在无缓存 `msg_id` 时主动发送群消息。([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
### 修复
- 修复人格编辑时重名校验不准确的问题。([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
- 加固沙箱文件传输与 CUA 健康检查流程,降低异常环境下的文件操作风险。([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
### 其他
-`faiss-cpu` 版本基线从 `1.12.0` 调整为 `1.14.3`。([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))
<a id="english"></a>
## What's Changed (EN)
### Highlights
- Improved the WebUI upgrade flow by running Core updates as background tasks and adding guided recovery when WebUI/Core version mismatches are detected, preventing refreshes from leaving users stuck at `Missing API key`. ([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
- Enhanced QQ Official Bot group chat support with group message create events and proactive Webhook group sends without requiring a cached `msg_id`. ([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
### Bug Fixes
- Fixed duplicate-name validation when editing personas. ([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
- Hardened sandbox file transfers and CUA health checks to reduce file-operation risk in abnormal environments. ([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
### Other
- Changed the `faiss-cpu` version baseline from `1.12.0` to `1.14.3`. ([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))

View File

@@ -0,0 +1,38 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
### 重点更新
- 优化 WebUI 升级流程Core 更新改为后台任务,并在检测到 WebUI 与 Core 版本错配时提供自动恢复重启引导,避免刷新页面后卡在 `Missing API key`。([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
- 增强 QQ 官方机器人群聊能力,支持群消息创建类型,并允许 Webhook 适配器在无缓存 `msg_id` 时主动发送群消息。([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
### 修复
- 修复人格编辑时重名校验不准确的问题。([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
- 加固沙箱文件传输与 CUA 健康检查流程,降低异常环境下的文件操作风险。([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
### 其他
-`faiss-cpu` 版本基线从 `1.12.0` 调整为 `1.14.3`。([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))
<a id="english"></a>
## What's Changed (EN)
### Highlights
- Improved the WebUI upgrade flow by running Core updates as background tasks and adding guided recovery when WebUI/Core version mismatches are detected, preventing refreshes from leaving users stuck at `Missing API key`. ([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
- Enhanced QQ Official Bot group chat support with group message create events and proactive Webhook group sends without requiring a cached `msg_id`. ([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
### Bug Fixes
- Fixed duplicate-name validation when editing personas. ([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
- Hardened sandbox file transfers and CUA health checks to reduce file-operation risk in abnormal environments. ([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
### Other
- Changed the `faiss-cpu` version baseline from `1.12.0` to `1.14.3`. ([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))

View File

@@ -1,6 +1,7 @@
<template>
<RouterView></RouterView>
<WaitingForRestart ref="globalWaitingRef" />
<UpgradeRecoveryDialog />
<!-- 全局唯一 snackbar -->
<v-snackbar v-if="toastStore.current" v-model="snackbarShow" :color="toastStore.current.color"
@@ -18,6 +19,7 @@ import { RouterView } from 'vue-router';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useToastStore } from '@/stores/toast'
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue'
import UpgradeRecoveryDialog from '@/components/shared/UpgradeRecoveryDialog.vue'
const toastStore = useToastStore()
const globalWaitingRef = ref(null)

View File

@@ -2,10 +2,12 @@ import type { AxiosRequestConfig, AxiosResponse } from 'axios';
import * as openApiV1 from './generated/openapi-v1';
import {
type BackupChunkUploadRequest,
client as openApiV1Client,
type BackupExportRequest,
type BackupRenameRequest,
type BackupUploadInitRequest,
type BackupUploadRequest,
type BackupUploadSessionRequest,
type BotConfigRequest,
type BotRegistrationRequest,
@@ -64,6 +66,9 @@ export interface ApiEnvelope<T> {
data: T;
}
export const UPGRADE_RECOVERY_EVENT = 'astrbot-upgrade-recovery';
export const UPGRADE_RECOVERY_TOKEN_KEY = 'astrbot-upgrade-recovery-token';
export type OpenConfig = DynamicConfig;
export interface ProviderSchemaData {
@@ -100,6 +105,10 @@ export interface VersionData {
[key: string]: unknown;
}
type StartTimeData = {
start_time?: number | string | null;
};
export interface CommandListData {
items?: any[];
wake_prefix?: string[];
@@ -167,13 +176,80 @@ const PROVIDER_TYPE_TO_CAPABILITY: Record<string, ProviderCapability> = {
rerank: 'rerank',
};
type V1Response<T> = Promise<AxiosResponse<ApiEnvelope<T>>>;
type V1Response<T> = Promise<
AxiosResponse<ApiEnvelope<T>> & { legacyFallback?: boolean }
>;
type ListConversationsQuery = NonNullable<ListConversationsData['query']>;
function typed<T>(response: Promise<unknown>): V1Response<T> {
return response as unknown as V1Response<T>;
}
export function isLegacyFallbackError(error: unknown): boolean {
const axiosError = error as {
response?: { status?: number; data?: { message?: string } | string };
message?: string;
};
if (axiosError.response?.status === 404) {
return true;
}
const data = axiosError.response?.data;
const message =
typeof data === 'string' ? data : data?.message || axiosError.message || '';
return message.toLowerCase().includes('missing api key');
}
function withLegacyFallback<T>(
primary: Promise<unknown>,
legacy: () => Promise<AxiosResponse<ApiEnvelope<T>>>,
): V1Response<T> {
const legacyRequest = () =>
legacy().then((response) => {
const legacyResponse = response as AxiosResponse<ApiEnvelope<T>> & {
legacyFallback?: boolean;
};
legacyResponse.legacyFallback = true;
return legacyResponse;
});
return typed<T>(primary).then((response) => {
const message = response.data?.message || '';
if (
response.data?.status === 'error' &&
message.toLowerCase().includes('missing api key')
) {
return legacyRequest();
}
return response;
}).catch((error) => {
if (isLegacyFallbackError(error)) {
return legacyRequest();
}
throw error;
});
}
function firstSuccessfulResponse<T>(
requests: Array<Promise<AxiosResponse<ApiEnvelope<T>>>>,
): V1Response<T> {
return new Promise<AxiosResponse<ApiEnvelope<T>>>((resolve, reject) => {
let pending = requests.length;
let firstError: unknown;
requests.forEach((request) => {
request.then(resolve).catch((error) => {
if (firstError === undefined) {
firstError = error;
}
pending -= 1;
if (pending === 0) {
reject(firstError);
}
});
});
});
}
function generatedOptions(
options: Record<string, unknown>,
requestConfig?: AxiosRequestConfig,
@@ -187,7 +263,21 @@ function generatedQuery<T extends object>(
return params as (T & Record<string, unknown>) | undefined;
}
function generatedFormData(formData: FormData) {
function generatedFormData(formData: FormData | Record<string, unknown>) {
if (typeof FormData !== 'undefined' && formData instanceof FormData) {
const body: Record<string, unknown> = {};
formData.forEach((value, key) => {
const existing = body[key];
if (existing === undefined) {
body[key] = value;
} else if (Array.isArray(existing)) {
existing.push(value);
} else {
body[key] = [existing, value];
}
});
return body as any;
}
return formData as any;
}
@@ -489,26 +579,39 @@ export const providerApi = {
export const authApi = {
login(payload: LoginRequest) {
return typed<any>(openApiV1.login({ body: payload }));
return withLegacyFallback<any>(openApiV1.login({ body: payload }), () =>
httpClient.post<ApiEnvelope<any>>('/api/auth/login', payload),
);
},
logout() {
return typed<OpenConfig>(openApiV1.logout());
return withLegacyFallback<OpenConfig>(openApiV1.logout(), () =>
httpClient.post<ApiEnvelope<OpenConfig>>('/api/auth/logout'),
);
},
setupStatus() {
return typed<any>(openApiV1.getAuthSetupStatus());
return withLegacyFallback<any>(openApiV1.getAuthSetupStatus(), () =>
httpClient.get<ApiEnvelope<any>>('/api/auth/setup-status'),
);
},
setup(payload: SetupAuthRequest) {
return typed<OpenConfig>(openApiV1.setupAuth({ body: payload }));
return withLegacyFallback<OpenConfig>(openApiV1.setupAuth({ body: payload }), () =>
httpClient.post<ApiEnvelope<OpenConfig>>('/api/auth/setup', payload),
);
},
setupTotp(payload?: TotpSetupRequest) {
return typed<any>(openApiV1.setupTotp({ body: payload }));
return withLegacyFallback<any>(openApiV1.setupTotp({ body: payload }), () =>
httpClient.post<ApiEnvelope<any>>('/api/auth/totp/setup', payload),
);
},
recoverTotp() {
return typed<any>(openApiV1.recoverTotp());
return withLegacyFallback<any>(openApiV1.recoverTotp(), () =>
httpClient.post<ApiEnvelope<any>>('/api/auth/totp/recovery'),
);
},
updateAccount(payload: UpdateAccountRequest) {
return typed<OpenConfig>(
return withLegacyFallback<OpenConfig>(
openApiV1.updateAuthAccount({ body: payload }),
() => httpClient.post<ApiEnvelope<OpenConfig>>('/api/auth/account/edit', payload),
);
},
};
@@ -545,31 +648,45 @@ export const traceApi = {
export const updatesApi = {
check() {
return typed<any>(openApiV1.checkUpdate());
return withLegacyFallback<any>(openApiV1.checkUpdate(), () =>
httpClient.get<ApiEnvelope<any>>('/api/update/check'),
);
},
releases(type?: 'core' | 'dashboard') {
return typed<any[]>(
return withLegacyFallback<any[]>(
openApiV1.listReleases({
query: type ? { type } : undefined,
}),
() =>
httpClient.get<ApiEnvelope<any[]>>('/api/update/releases', {
params: type ? { type } : undefined,
}),
);
},
core(payload?: UpdateRequest) {
return typed<OpenConfig>(openApiV1.updateCore({ body: payload }));
return withLegacyFallback<OpenConfig>(openApiV1.updateCore({ body: payload }), () =>
httpClient.post<ApiEnvelope<OpenConfig>>('/api/update/do', payload),
);
},
dashboard(payload?: UpdateRequest) {
return typed<OpenConfig>(
return withLegacyFallback<OpenConfig>(
openApiV1.updateDashboard({ body: payload }),
() => httpClient.post<ApiEnvelope<OpenConfig>>('/api/update/dashboard', payload),
);
},
progress(taskId: string) {
return typed<any>(
return withLegacyFallback<any>(
openApiV1.getUpdateProgress({ path: { task_id: taskId } }),
() =>
httpClient.get<ApiEnvelope<any>>('/api/update/progress', {
params: { id: taskId },
}),
);
},
installPip(payload: PipInstallRequest) {
return typed<OpenConfig>(
return withLegacyFallback<OpenConfig>(
openApiV1.installPipPackage({ body: payload }),
() => httpClient.post<ApiEnvelope<OpenConfig>>('/api/update/pip-install', payload),
);
},
};
@@ -586,7 +703,7 @@ export const backupApi = {
openApiV1.getBackupProgress({ path: { task_id: taskId } }),
);
},
upload(formData: FormData) {
upload(formData: FormData | BackupUploadRequest) {
return typed<any>(
openApiV1.uploadBackup({ body: generatedFormData(formData) }),
);
@@ -594,7 +711,7 @@ export const backupApi = {
initUpload(payload: BackupUploadInitRequest) {
return typed<any>(openApiV1.initBackupUpload({ body: payload }));
},
uploadChunk(formData: FormData) {
uploadChunk(formData: FormData | BackupChunkUploadRequest) {
return typed<any>(
openApiV1.uploadBackupChunk({ body: generatedFormData(formData) }),
);
@@ -1559,7 +1676,9 @@ export const statsApi = {
);
},
version() {
return typed<VersionData>(openApiV1.getVersion());
return withLegacyFallback<VersionData>(openApiV1.getVersion(), () =>
httpClient.get<ApiEnvelope<VersionData>>('/api/stat/version'),
);
},
firstNotice(locale?: string) {
return typed<{ content?: string | null }>(
@@ -1569,15 +1688,28 @@ export const statsApi = {
);
},
testGhproxy(payload: GhproxyTestRequest) {
return typed<{ latency?: number }>(
return withLegacyFallback<{ latency?: number }>(
openApiV1.testGhproxyConnection({ body: payload }),
() =>
httpClient.post<ApiEnvelope<{ latency?: number }>>(
'/api/stat/test-ghproxy-connection',
payload,
),
);
},
startTime() {
return typed<{ start_time?: number | string | null }>(openApiV1.getStartTime());
const v1Request = typed<StartTimeData>(openApiV1.getStartTime());
const legacyRequest = httpClient.get<ApiEnvelope<StartTimeData>>(
'/api/stat/start-time',
);
// Restart polling must also work after downgrading to backends without v1 stats routes.
return firstSuccessfulResponse<StartTimeData>([v1Request, legacyRequest]);
},
restart() {
return typed<OpenConfig>(openApiV1.restartCore());
return withLegacyFallback<OpenConfig>(openApiV1.restartCore(), () =>
httpClient.post<ApiEnvelope<OpenConfig>>('/api/stat/restart-core'),
);
},
storage() {
return typed<OpenConfig>(openApiV1.getStorageStatus());

View File

@@ -1045,7 +1045,9 @@ export default {
return this.selectedPlatformConfig?.type === "dingtalk";
},
isQqOfficialPlatform() {
return this.selectedPlatformConfig?.type === "qq_official";
return ["qq_official", "qq_official_webhook"].includes(
this.selectedPlatformConfig?.type,
);
},
},
watch: {

View File

@@ -77,6 +77,13 @@ const REGISTRATION_ACTIONS = {
successKey: 'registrationAction.qqOfficial.created',
statusKeyPrefix: 'registrationAction.qqOfficial.status',
},
qq_official_webhook: {
icon: 'mdi-qrcode',
titleKey: 'registrationAction.qqOfficial.title',
scanTitleKey: 'registrationAction.qqOfficial.scanTitle',
successKey: 'registrationAction.qqOfficial.created',
statusKeyPrefix: 'registrationAction.qqOfficial.status',
},
};
export default {

View File

@@ -574,12 +574,11 @@ const uploadChunksInParallel = async (file, totalChunks, currentUploadId, curren
const end = Math.min(start + currentChunkSize, file.size)
const chunk = file.slice(start, end)
const formData = new FormData()
formData.append('upload_id', currentUploadId)
formData.append('chunk_index', chunkIndex.toString())
formData.append('chunk', chunk)
const response = await backupApi.uploadChunk(formData)
const response = await backupApi.uploadChunk({
upload_id: currentUploadId,
chunk_index: chunkIndex,
chunk
})
if (response.data.status !== 'ok') {
throw new Error(response.data.message)

View File

@@ -416,7 +416,7 @@ export default {
personaIdRules: [
v => !!v || this.tm('validation.required'),
v => (v && v.length >= 1) || this.tm('validation.minLength', { min: 1 }),
v => !this.existingPersonaIds.includes(v) || this.tm('validation.personaIdExists'),
v => this.editingPersona?.persona_id === v || !this.existingPersonaIds.includes(v) || this.tm('validation.personaIdExists'),
],
systemPromptRules: [
v => !!v || this.tm('validation.required'),

View File

@@ -0,0 +1,284 @@
<template>
<v-dialog v-model="visible" max-width="520" :persistent="blockingRecovery || restarting">
<v-card>
<v-card-title class="upgrade-recovery-title">
<span>{{ t('core.common.upgradeRecovery.title') }}</span>
</v-card-title>
<v-card-text>
<p class="mb-3">
{{
t('core.common.upgradeRecovery.description', {
coreVersion,
dashboardVersion,
})
}}
</p>
<v-alert type="warning" variant="tonal" density="comfortable" class="mb-3">
{{ t('core.common.upgradeRecovery.hint') }}
</v-alert>
<v-progress-linear
v-if="restarting"
indeterminate
color="primary"
class="mb-2"
/>
<div v-if="statusMessage" class="text-medium-emphasis">
{{ statusMessage }}
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn v-if="!blockingRecovery" variant="text" :disabled="restarting" @click="dismiss">
{{ t('core.common.upgradeRecovery.laterButton') }}
</v-btn>
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-restart"
:loading="restarting"
@click="restartCore"
>
{{ t('core.common.upgradeRecovery.restartButton') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import axios, { type AxiosRequestConfig } from 'axios';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import {
UPGRADE_RECOVERY_EVENT,
UPGRADE_RECOVERY_TOKEN_KEY,
type ApiEnvelope,
type VersionData,
} from '@/api/v1';
import { useI18n } from '@/i18n/composables';
type StartTimeData = {
start_time?: number | string | null;
};
type RecoveryEventDetail = VersionData & {
blocking?: boolean;
};
const { t } = useI18n();
const route = useRoute();
const visible = ref(false);
const restarting = ref(false);
const blockingRecovery = ref(false);
const statusMessage = ref('');
const coreVersion = ref('');
const dashboardVersion = ref('');
const initialStartTime = ref<number | string | null>(null);
let restartTimer: ReturnType<typeof setInterval> | null = null;
let detecting = false;
const recoveryClient = axios.create();
function normalizeVersion(version?: string | null) {
return (version || '').trim().replace(/^v/i, '');
}
function displayVersion(version?: string | null) {
const normalized = normalizeVersion(version);
return normalized ? `v${normalized}` : 'unknown';
}
function versionsMismatch(core?: string | null, dashboard?: string | null) {
const normalizedCore = normalizeVersion(core);
const normalizedDashboard = normalizeVersion(dashboard);
return Boolean(
normalizedCore &&
normalizedDashboard &&
normalizedCore !== normalizedDashboard,
);
}
function isMissingApiKeyResponse(response: {
data?: { message?: string | null } | string;
}) {
const message =
typeof response.data === 'string'
? response.data
: response.data?.message || '';
return message.toLowerCase().includes('missing api key');
}
function getDismissKey() {
return `astrbot-upgrade-recovery-dismissed:${coreVersion.value}:${dashboardVersion.value}`;
}
function recoveryRequestConfig(validateStatus = false): AxiosRequestConfig {
const headers: Record<string, string> = {};
const token =
localStorage.getItem('token') ||
sessionStorage.getItem(UPGRADE_RECOVERY_TOKEN_KEY);
const locale = localStorage.getItem('astrbot-locale');
if (token) {
headers.Authorization = `Bearer ${token}`;
}
if (locale) {
headers['Accept-Language'] = locale;
}
return {
headers,
...(validateStatus ? { validateStatus: () => true } : {}),
};
}
async function fetchLegacyStartTime() {
const response = await recoveryClient.get<ApiEnvelope<StartTimeData>>(
'/api/stat/start-time',
recoveryRequestConfig(),
);
return response.data?.data?.start_time ?? null;
}
function clearRestartTimer() {
if (restartTimer !== null) {
clearInterval(restartTimer);
restartTimer = null;
}
}
function dismiss() {
sessionStorage.setItem(getDismissKey(), '1');
sessionStorage.removeItem(UPGRADE_RECOVERY_TOKEN_KEY);
blockingRecovery.value = false;
visible.value = false;
}
function waitForRestart() {
clearRestartTimer();
let attempts = 0;
restartTimer = setInterval(async () => {
attempts += 1;
try {
const nextStartTime = await fetchLegacyStartTime();
if (
nextStartTime !== null &&
String(nextStartTime) !== String(initialStartTime.value)
) {
clearRestartTimer();
sessionStorage.removeItem(UPGRADE_RECOVERY_TOKEN_KEY);
window.location.reload();
}
} catch (_error) {
// The backend may be temporarily unavailable during restart.
}
if (attempts >= 90) {
clearRestartTimer();
restarting.value = false;
statusMessage.value = t('core.common.upgradeRecovery.failed');
}
}, 1000);
}
async function restartCore() {
restarting.value = true;
statusMessage.value = t('core.common.upgradeRecovery.restarting');
try {
initialStartTime.value =
initialStartTime.value ?? (await fetchLegacyStartTime());
await recoveryClient.post<ApiEnvelope<unknown>>(
'/api/stat/restart-core',
undefined,
recoveryRequestConfig(),
);
statusMessage.value = t('core.common.upgradeRecovery.waiting');
waitForRestart();
} catch (_error) {
restarting.value = false;
statusMessage.value = t('core.common.upgradeRecovery.failed');
}
}
async function showRecoveryDialog(versionData: VersionData, blocking = false) {
if (visible.value || restarting.value) {
return;
}
if (!versionsMismatch(versionData.version, versionData.dashboard_version)) {
return;
}
coreVersion.value = displayVersion(versionData.version);
dashboardVersion.value = displayVersion(versionData.dashboard_version);
if (!blocking && sessionStorage.getItem(getDismissKey())) {
return;
}
blockingRecovery.value = blocking;
initialStartTime.value = await fetchLegacyStartTime().catch(() => null);
visible.value = true;
}
function handleRecoveryEvent(event: Event) {
const versionData = (event as CustomEvent<RecoveryEventDetail>).detail || {};
void showRecoveryDialog(versionData, !!versionData.blocking);
}
async function detectUpgradeMismatch() {
if (detecting || visible.value || restarting.value) {
return;
}
detecting = true;
try {
const v1Response = await recoveryClient.get<ApiEnvelope<unknown>>(
'/api/v1/auth/setup-status',
recoveryRequestConfig(true),
);
if (!isMissingApiKeyResponse(v1Response)) {
return;
}
const legacyResponse = await recoveryClient.get<ApiEnvelope<VersionData>>(
'/api/stat/version',
recoveryRequestConfig(true),
);
if (legacyResponse.status === 401 || legacyResponse.status >= 400) {
return;
}
await showRecoveryDialog(legacyResponse.data?.data || {});
} catch (_error) {
// This recovery dialog is best-effort and should never block the app.
} finally {
detecting = false;
}
}
onMounted(() => {
window.addEventListener(UPGRADE_RECOVERY_EVENT, handleRecoveryEvent);
void detectUpgradeMismatch();
});
watch(
() => route.fullPath,
() => {
void detectUpgradeMismatch();
},
);
onBeforeUnmount(() => {
window.removeEventListener(UPGRADE_RECOVERY_EVENT, handleRecoveryEvent);
clearRestartTimer();
});
</script>
<style scoped>
.upgrade-recovery-title {
align-items: center;
display: flex;
white-space: normal;
word-break: break-word;
}
</style>

View File

@@ -48,6 +48,16 @@
"waiting": "Waiting for AstrBot to restart...",
"maxRetriesReached": "Maximum retry attempts reached, please check manually."
},
"upgradeRecovery": {
"title": "Upgrade Needs Restart",
"description": "The WebUI has been updated to {dashboardVersion}, but AstrBot Core is still {coreVersion}. This usually means the restart flow was interrupted by refreshing the page during upgrade.",
"hint": "Restart the backend to finish the upgrade. This page will reload automatically after AstrBot is back.",
"restartButton": "Restart Backend",
"laterButton": "Later",
"restarting": "Requesting backend restart...",
"waiting": "Restart requested. Waiting for the backend to recover...",
"failed": "Restart request failed. Please restart AstrBot manually."
},
"readme": {
"title": "Extension Documentation",
"buttons": {

View File

@@ -48,6 +48,16 @@
"waiting": "Ожидание перезагрузки AstrBot...",
"maxRetriesReached": "Превышено количество попыток проверки статуса. Пожалуйста, проверьте вручную."
},
"upgradeRecovery": {
"title": "Требуется завершить обновление",
"description": "WebUI обновлен до {dashboardVersion}, но AstrBot Core все еще {coreVersion}. Обычно это означает, что процесс перезапуска был прерван обновлением страницы во время обновления.",
"hint": "Перезапустите backend, чтобы завершить обновление. Страница автоматически обновится после восстановления AstrBot.",
"restartButton": "Перезапустить backend",
"laterButton": "Позже",
"restarting": "Запрашивается перезапуск backend...",
"waiting": "Перезапуск запрошен. Ожидание восстановления backend...",
"failed": "Не удалось отправить запрос на перезапуск. Перезапустите AstrBot вручную."
},
"readme": {
"title": "Документация плагина",
"buttons": {
@@ -130,4 +140,4 @@
"subtitle": "Файл FIRST_NOTICE.md не найден или пуст."
}
}
}
}

View File

@@ -48,6 +48,16 @@
"waiting": "正在等待 AstrBot 重启...",
"maxRetriesReached": "拉取状态达到最大次数,请手动检查。"
},
"upgradeRecovery": {
"title": "检测到升级未完成",
"description": "当前 WebUI 已更新到 {dashboardVersion},但 AstrBot Core 仍是 {coreVersion}。这通常是升级过程中刷新页面导致重启流程被打断。",
"hint": "请重启后端以完成升级,重启完成后页面会自动刷新。",
"restartButton": "立即重启后端",
"laterButton": "稍后处理",
"restarting": "正在请求后端重启...",
"waiting": "已发起重启,正在等待后端恢复...",
"failed": "重启请求失败,请手动重启 AstrBot。"
},
"readme": {
"title": "插件说明文档",
"buttons": {

View File

@@ -17,7 +17,7 @@ import StyledMenu from "@/components/shared/StyledMenu.vue";
import { useLanguageSwitcher } from "@/i18n/composables";
import type { Locale } from "@/i18n/types";
import AboutPage from "@/views/AboutPage.vue";
import { authApi, statsApi, updatesApi } from "@/api/v1";
import { authApi, isLegacyFallbackError, statsApi, updatesApi } from "@/api/v1";
import { getDesktopRuntimeInfo } from "@/utils/desktopRuntime";
enableKatex();
@@ -60,6 +60,7 @@ let restartCompleted = ref(false);
let restartReloadCountdown = ref(3);
let restartReloadTimer: ReturnType<typeof setInterval> | null = null;
const RESTART_FEEDBACK_DELAY_SECONDS = 3;
const RESTART_START_TIME_POLL_INTERVAL_MS = 2000;
type DownloadStageStatus = "pending" | "running" | "done" | "error";
type DownloadStage = {
status: DownloadStageStatus;
@@ -479,6 +480,10 @@ function checkUpdate() {
: res.data.data.dashboard_has_new_version;
})
.catch((err) => {
if (isLegacyFallbackError(err)) {
console.log(err);
return;
}
if (err.response && err.response.status == 401) {
console.log("401");
const authStore = useAuthStore();
@@ -602,6 +607,7 @@ function showRestartCompleted() {
if (restartCompleted.value) {
return;
}
stopUpdateProgressPolling();
stopRestartReloadTimer();
restartWaiting.value = false;
restartCompleted.value = true;
@@ -622,20 +628,29 @@ function showRestartCompleted() {
}, 1000);
}
function waitForAstrBotRestart(initialStartTime: number | string | null) {
if (restartWaiting.value || restartCompleted.value) {
function waitForAstrBotRestart(
initialStartTime: number | string | null,
showWaiting = true,
) {
if (restartCompleted.value) {
return;
}
stopRestartPolling();
restartWaiting.value = true;
if (showWaiting && !restartWaiting.value) {
restartWaiting.value = true;
restartStartTime.value = initialStartTime;
updateProgress.value = {
...updateProgress.value,
stage: "restart",
status: "success",
message: t("core.header.updateDialog.progress.restarting"),
overall_percent: 100,
};
}
if (restartPollTimer) {
return;
}
restartStartTime.value = initialStartTime;
updateProgress.value = {
...updateProgress.value,
stage: "restart",
status: "success",
message: t("core.header.updateDialog.progress.restarting"),
overall_percent: 100,
};
const poll = async () => {
try {
@@ -653,9 +668,10 @@ function waitForAstrBotRestart(initialStartTime: number | string | null) {
}
};
void poll();
restartPollTimer = setInterval(() => {
void poll();
}, 1000);
}, RESTART_START_TIME_POLL_INTERVAL_MS);
}
function applyUpdateProgress(payload: UpdateProgress) {
@@ -682,6 +698,9 @@ function applyUpdateProgress(payload: UpdateProgress) {
if (payload.status === "success" || payload.status === "error") {
stopUpdateProgressPolling();
}
if (payload.status === "error") {
stopRestartPolling();
}
if (payload.status === "success") {
waitForAstrBotRestart(restartStartTime.value);
}
@@ -710,7 +729,10 @@ async function switchVersion(targetVersion: string) {
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
let initialStartTime: number | string | null = null;
let initialStartTime: number | string | null = commonStore.getStartTime();
if (initialStartTime === -1) {
initialStartTime = null;
}
updateProgress.value = {
...createEmptyUpdateProgress(),
id: progressId,
@@ -722,12 +744,15 @@ async function switchVersion(targetVersion: string) {
updateStatus.value = t("core.header.updateDialog.status.switching");
installLoading.value = true;
try {
initialStartTime = await fetchAstrBotStartTime();
} catch (_error) {
initialStartTime = commonStore.getStartTime();
if (initialStartTime === null) {
try {
initialStartTime = await fetchAstrBotStartTime();
} catch (_error) {
initialStartTime = null;
}
}
restartStartTime.value = initialStartTime;
waitForAstrBotRestart(initialStartTime, false);
startUpdateProgressPolling(progressId);
updatesApi
@@ -738,20 +763,27 @@ async function switchVersion(targetVersion: string) {
})
.then((res) => {
updateStatus.value = res.data.message || "";
updateProgress.value = {
...updateProgress.value,
status:
res.data.status === "ok" ? "success" : updateProgress.value.status,
message: res.data.message || "",
overall_percent:
res.data.status === "ok" ? 100 : updateProgress.value.overall_percent,
};
if (res.data.status === "ok") {
waitForAstrBotRestart(initialStartTime);
if (res.data.status === "error") {
stopUpdateProgressPolling();
stopRestartPolling();
updateProgress.value = {
...updateProgress.value,
status: "error",
message:
res.data.message ||
t("core.header.updateDialog.progress.failed"),
};
}
})
.catch((err) => {
console.log(err);
stopUpdateProgressPolling();
if (!err?.response && restartPollTimer) {
waitForAstrBotRestart(restartStartTime.value);
updateStatus.value = t("core.header.updateDialog.progress.restarting");
return;
}
stopRestartPolling();
updateStatus.value = err;
updateProgress.value = {
...updateProgress.value,
@@ -764,7 +796,6 @@ async function switchVersion(targetVersion: string) {
})
.finally(() => {
installLoading.value = false;
stopUpdateProgressPolling();
});
}

View File

@@ -22,7 +22,7 @@ interface AuthStore {
password: string,
code?: string,
trustDeviceToken?: boolean,
): Promise<void | 'totp_required'>;
): Promise<void | 'totp_required' | 'upgrade_recovery_required'>;
logout(): void;
has_token(): boolean;
}

View File

@@ -1,6 +1,15 @@
import { defineStore } from 'pinia';
import { router } from '@/router';
import { authApi, providerApi, systemConfigApi } from '@/api/v1';
import {
authApi,
providerApi,
systemConfigApi,
UPGRADE_RECOVERY_EVENT,
UPGRADE_RECOVERY_TOKEN_KEY,
type ApiEnvelope,
type VersionData,
} from '@/api/v1';
import { httpClient } from '@/api/http';
export const useAuthStore = defineStore("auth", {
state: () => ({
@@ -52,7 +61,7 @@ export const useAuthStore = defineStore("auth", {
password: string,
code?: string,
trustDeviceToken = false,
): Promise<'totp_required' | void> {
): Promise<'totp_required' | 'upgrade_recovery_required' | void> {
try {
const res = await authApi.login({
username,
@@ -65,6 +74,44 @@ export const useAuthStore = defineStore("auth", {
return Promise.reject(res.data.message);
}
const legacyToken = String(res.data.data?.token || '');
if (res.legacyFallback && legacyToken) {
const versionRes = await httpClient.get<ApiEnvelope<VersionData>>(
'/api/stat/version',
{
headers: {
Authorization: `Bearer ${legacyToken}`,
},
validateStatus: () => true,
},
);
const versionData = versionRes.data?.data || {};
const coreVersion = String(versionData.version || '')
.trim()
.replace(/^v/i, '');
const dashboardVersion = String(versionData.dashboard_version || '')
.trim()
.replace(/^v/i, '');
if (
versionRes.status < 400 &&
coreVersion &&
dashboardVersion &&
coreVersion !== dashboardVersion
) {
sessionStorage.setItem(UPGRADE_RECOVERY_TOKEN_KEY, legacyToken);
window.dispatchEvent(
new CustomEvent(UPGRADE_RECOVERY_EVENT, {
detail: {
version: versionData.version,
dashboard_version: versionData.dashboard_version,
blocking: true,
},
}),
);
return 'upgrade_recovery_required';
}
}
await this.finishAuthenticatedSession(res.data.data);
} catch (error: any) {
if (error?.response?.status === 401 && error.response?.data?.data?.totp_required) {

View File

@@ -329,7 +329,7 @@ const greetingText = computed(() => {
});
async function loadPlatformConfigBase() {
const res = await systemConfigApi.get();
const res = await systemConfigApi.runtime();
const payload = (res.data.data || {}) as any;
platformMetadata.value = payload.metadata || {};
platformConfigData.value = payload.config || {};

View File

@@ -52,6 +52,8 @@ async function submitAccountStage() {
const res = await authStore.login(username.value, password.value);
if (res === 'totp_required') {
goToTotpStage();
} else if (res === 'upgrade_recovery_required') {
return;
}
} catch (err) {
apiError.value = String(err || '') || 'Login failed';

View File

@@ -2,8 +2,8 @@
> [!WARNING]
> 1. QQ Official Bot currently requires an IP whitelist.
> 2. It supports group chat, private chat, channel chat, and channel private chat.
> 3. You need a server with a public IP and a domain.
> 2. Webhook mode requires a server with a public IP, a domain, and HTTPS access.
> 3. It supports group chat, private chat, channel chat, and channel private chat.
## Supported Basic Message Types
@@ -19,7 +19,43 @@
Proactive message push: Supported.
## Apply for a Bot
## Create a QQ Bot in AstrBot with One-click QR Setup (Recommended)
### Setup Flow
1. In AstrBot WebUI, click `Bots` in the left sidebar, then click `+ Create Bot`.
2. Select `QQ Official Bot (Webhook)`.
3. Under `Choose setup method`, select `One-click QR setup`, click start, then scan the QR code with mobile QQ.
4. After confirming the QR binding, click `Save`.
5. Configure DNS and a reverse proxy for your server so HTTPS requests are forwarded to AstrBot's `6185` port.
6. Go back to the QQ Open Platform bot management page, open `Development -> Callback Configuration`, and enter the Webhook callback URL generated by AstrBot.
7. Select the callback events you need. To receive full group messages, make sure the group event `GROUP_MESSAGE_CREATE` is selected.
8. Save the callback configuration, then restart AstrBot.
> [!TIP]
> With `Unified Webhook Mode`, AstrBot generates a unique Webhook callback URL automatically. You can find it in the logs or on the bot card in WebUI.
![unified_webhook](https://files.astrbot.app/docs/source/images/use/unified-webhook.png)
### Use in Group Chats
#### Add to a Group Chat
Open the created QQ bot profile page (mobile QQ -> Contacts -> Bots tab). You can find `Add to group chat` near the bottom. Currently, the bot can only be added to groups where you are the group owner.
#### Set Message Access Scope and Proactive Speaking
In mobile QQ group settings, open the bot settings page. We recommend setting `Messages the bot can access` to `All group messages`, and enabling `Allow the bot to proactively speak in the group`.
With this configuration, the bot can receive full group messages and proactively push messages to the group, such as scheduled task notifications and plugin notifications.
Webhook mode also requires selecting the group event `GROUP_MESSAGE_CREATE` in QQ Open Platform callback configuration. Otherwise, AstrBot cannot receive full group message events.
![QQ Official Bot recommended group chat settings](/qqofficial-group-recommended-config.png)
## Manually Apply for a QQ Bot (Not Recommended)
### Apply for a Bot
Open [QQ Official Bot](https://q.qq.com) and sign in.
@@ -29,7 +65,7 @@ Open the created bot to enter its management page:
![image](https://files.astrbot.app/docs/source/images/qqofficial/image.png)
## Allow Bot in Channel / Group / Private Chat
### Allow Bot in Channel / Group / Private Chat
Open `Sandbox Configuration` to set a sandbox channel / QQ group / QQ private chat (up to 20 members).
@@ -37,41 +73,62 @@ Then configure QQ groups, private chat QQ accounts, and QQ channels as needed.
![image](https://files.astrbot.app/docs/source/images/qqofficial/image-1.png)
## Get `appid` and `secret`
### Get `appid` and `secret`
After adding the bot where you need it, open `Development -> Development Settings`, then copy `appid` and `secret`.
## Add IP Whitelist
If you use AstrBot WebUI's `One-click QR setup`, you can skip this step. AstrBot fills in `appid` and `secret` automatically after QR binding succeeds.
### Add IP Whitelist
Open `Development -> Development Settings`, find IP whitelist, and add your server IP.
![image](https://files.astrbot.app/docs/source/images/qqofficial/image-3.png)
## Configure in AstrBot
> [!TIP]
> If you do not know your server IP, run `curl ifconfig.me` or check [ip138.com](https://ip138.com/).
>
> In NAT environments without a public IP, the observed IP may change depending on your carrier. Use proxy/tunnel if needed.
### Configure in AstrBot
1. Open AstrBot Dashboard.
2. Click `Bots` in the left sidebar.
3. Click `+ Create Bot`.
4. Select `qq_official_webhook`.
4. Select `QQ Official Bot (Webhook)`.
Fill in:
Recommended: use `One-click QR setup`.
1. Under `Choose setup method`, select `One-click QR setup`.
2. Click start, then scan and confirm the QR code with mobile QQ.
3. Wait until the page shows binding success. AstrBot fills in `appid` and `secret` automatically.
4. Keep `Unified Webhook Mode` enabled, adjust `ID` and other options as needed, then click `Save`.
If QR setup is unavailable, choose `Manual setup` and fill in:
- ID (`id`): any unique identifier.
- Enable (`enable`): checked.
- `appid`: from QQ Official Bot platform.
- `secret`: from QQ Official Bot platform.
- Unified Webhook Mode (`unified_webhook_mode`): keep enabled.
Click `Save`.
## Configure Callback URL
### Configure Reverse Proxy
In `Development -> Callback Configuration`, configure callback URL.
After saving, configure DNS and reverse proxy for your server. Forward requests to AstrBot's `6185` port. If `Unified Webhook Mode` is disabled, forward requests to the port configured in the previous step instead.
Set request URL to `<your-domain>/astrbot-qo-webhook/callback`.
The Webhook callback URL must be reachable from QQ Open Platform over the public internet and must use HTTPS.
Your domain should reverse-proxy traffic to AstrBot port `6196` using `Caddy`, `Nginx`, or `Apache`.
### Configure Callback URL and Events
Then add callback events and select all four event categories (private, group, channel, etc.).
Open `Development -> Callback Configuration`.
After you save the bot in AstrBot, AstrBot generates a unique Webhook callback URL. You can find it in the logs or on the bot card in WebUI.
Use that URL as the request URL.
Then add callback events. To receive full group messages, select the group event `GROUP_MESSAGE_CREATE`; also select private chat events, channel events, and other events as needed.
![image](https://files.astrbot.app/docs/source/images/webhook/image.png)
@@ -79,10 +136,6 @@ After entering values, move focus out of the input box to trigger validation. If
Then restart AstrBot.
## Done
AstrBot should now be connected. If messages do not respond immediately, wait 1-2 minutes, restart AstrBot, and test again.
## Appendix: Reverse Proxy Setup
If you are new to reverse proxy, Caddy is recommended:

View File

@@ -14,26 +14,37 @@
Proactive message push: Supported.
## Quick Deployment Steps
## Create a QQ Bot in AstrBot with One-click QR Setup (Recommended)
> Updated: `2026/03/06`. This method only supports `private chat`.
### Setup Flow
1. Open [QQ Open Platform](https://q.qq.com/qqbot/openclaw/). Register an account if you don't have one.
2. Click the `Create Bot` button on the right.
3. Obtain your `AppID` and `AppSecret`.
4. In AstrBot WebUI, click `Bots` in the left sidebar, then click `+ Create Bot`, select `QQ Official Bot (WebSocket)`, paste the `AppID` and `AppSecret` into the form, click `Enable`, then click `Save`.
1. In AstrBot WebUI, click `Bots` in the left sidebar, then click `+ Create Bot`.
2. Select `QQ Official Bot (WebSocket)`.
3. Under `Choose setup method`, select `One-click QR setup`, click start, then scan the QR code with mobile QQ.
4. After you confirm the QR binding, AstrBot automatically fills in `AppID` and `AppSecret`. Make sure `Enable` is checked, then click `Save`.
5. Back on the QQ Open Platform page, click `Scan QR Code to Chat` next to your bot, then scan with your mobile QQ to start chatting.
To use the bot in group chats, refer to the `Allow Bot in Channel / Group / Private Chat` section below.
### Use in Group Chats
---
#### Add to a Group Chat
## Apply for a Bot
Open the created QQ bot profile page (mobile QQ -> Contacts -> Bots tab). You can find `Add to group chat` near the bottom. Currently, the bot can only be added to groups where you are the group owner.
#### Set Message Access Scope and Proactive Speaking
In mobile QQ group settings, open the bot settings page. We recommend setting `Messages the bot can access` to `All group messages`, and enabling `Allow the bot to proactively speak in the group`.
With this configuration, the bot can receive full group messages and proactively push messages to the group, such as scheduled task notifications and plugin notifications.
![QQ Official Bot recommended group chat settings](/qqofficial-group-recommended-config.png)
## Manually Apply for a QQ Bot (Not Recommended)
### Apply for a Bot
> [!WARNING]
> 1. QQ Official Bot currently requires an IP whitelist.
> 2. It supports group chat, private chat, channel chat, and channel private chat.
> 3. Tencent is phasing out Websockets access, so this method is no longer recommended. Please use [Webhook](/en/platform/qqofficial/webhook) instead.
Open [QQ Official Bot](https://q.qq.com) and sign in.
@@ -43,7 +54,7 @@ Open the created bot to enter its management page:
![image](https://files.astrbot.app/docs/source/images/qqofficial/image.png)
## Allow Bot in Channel / Group / Private Chat
### Allow Bot in Channel / Group / Private Chat
Open `Sandbox Configuration` to set a sandbox channel / QQ group / QQ private chat (up to 20 members).
@@ -51,11 +62,13 @@ Then configure QQ groups, private chat QQ accounts, and QQ channels as needed.
![image](https://files.astrbot.app/docs/source/images/qqofficial/image-1.png)
## Get `appid` and `secret`
### Get `appid` and `secret`
After adding the bot where you need it, open `Development -> Development Settings`, then copy `appid` and `secret`.
## Add IP Whitelist
If you use AstrBot WebUI's `One-click QR setup`, you can skip this step. AstrBot fills in `appid` and `secret` automatically after QR binding succeeds.
### Add IP Whitelist
Open `Development -> Development Settings`, find IP whitelist, and add your server IP.
@@ -66,22 +79,27 @@ Open `Development -> Development Settings`, find IP whitelist, and add your serv
>
> In NAT environments without a public IP, the observed IP may change depending on your carrier. Use proxy/tunnel if needed.
## Configure in AstrBot
### Configure in AstrBot
1. Open AstrBot Dashboard.
2. Click `Bots` in the left sidebar.
3. Click `+ Create Bot`.
4. Select `qq_official`.
Fill in:
Recommended: use `One-click QR setup`.
1. Under `Choose setup method`, select `One-click QR setup`.
2. Click start, then scan and confirm the QR code with mobile QQ.
3. Wait until the page shows binding success. AstrBot fills in `appid` and `secret` automatically.
4. Adjust `ID`, `Enable group/C2C message list`, `Enable guild direct message`, and other options as needed, then click `Save`.
If QR setup is unavailable, choose `Manual setup` and fill in:
- ID (`id`): any unique identifier.
- Enable (`enable`): checked.
- `appid`: from QQ Official Bot platform.
- `secret`: from QQ Official Bot platform.
- Enable group/C2C message list (`enable_group_c2c`): keep enabled if you need QQ message-list private chat.
- Enable guild direct message (`enable_guild_direct_message`): keep enabled if you need guild direct messages.
Click `Save`.
## Done
AstrBot should now be connected. Send `/help` to the bot in QQ private chat to verify.

169
docs/en/use/cli.md Normal file
View File

@@ -0,0 +1,169 @@
# CLI Commands
The AstrBot CLI initializes instances, starts AstrBot, updates common config values, and manages plugins.
If you install AstrBot with `uv`:
```bash
uv tool install astrbot --python 3.12
```
`uv` creates the `astrbot` executable and puts it on `PATH`. You can inspect the path with:
::: code-group
```bash [Linux / macOS]
which astrbot
```
```powershell [Windows]
where.exe astrbot
```
:::
> [!TIP]
> Run the commands below from the AstrBot working directory.
## Quick Start
Initialize the directory once, then start AstrBot:
```bash
astrbot init
astrbot run
```
`astrbot init` creates the data directories and configuration files required by AstrBot. After initialization, use `astrbot run` for later starts.
## Top-Level Commands
| Command | Purpose |
| --- | --- |
| `astrbot init` | Initialize the current directory as an AstrBot working directory. |
| `astrbot run` | Start AstrBot in the foreground. |
| `astrbot conf` | Read or update common config values. |
| `astrbot password` | Change the WebUI login password interactively. |
| `astrbot plug` | Create, install, update, remove, or search plugins. |
| `astrbot help` | Show CLI help. |
| `astrbot --version` | Show the AstrBot CLI version. |
## Start AstrBot
```bash
astrbot run
```
Common options:
| Option | Purpose |
| --- | --- |
| `-p, --port <PORT>` | Set the WebUI port. |
| `-r, --reload` | Enable plugin auto-reload for plugin development. |
| `--reset-password` | Reset the WebUI initial password on startup and print the new password in startup logs. |
Examples:
```bash
astrbot run --port 6185
astrbot run --reload
astrbot run --reset-password
```
If you forget the WebUI login password, run this from the AstrBot working directory:
```bash
astrbot run --reset-password
```
AstrBot regenerates the initial password during startup and prints it in startup logs. After logging in, change the password in the WebUI immediately.
When starting directly from source, you can also run:
```bash
python main.py --reset-password
```
## Config
`astrbot conf` reads and updates common config values.
```bash
astrbot conf get
astrbot conf get dashboard.port
astrbot conf set dashboard.port 6185
```
Supported keys:
| Key | Description |
| --- | --- |
| `timezone` | Time zone, for example `Asia/Shanghai`. |
| `log_level` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL`. |
| `dashboard.port` | WebUI port. |
| `dashboard.username` | WebUI username. |
| `dashboard.password` | WebUI password. |
| `callback_api_base` | Callback API base URL. Must start with `http://` or `https://`. |
Changing the dashboard password writes the current password hashes automatically:
```bash
astrbot conf set dashboard.password "new-password"
```
You can also use the dedicated interactive password command:
```bash
astrbot password
astrbot password --username admin
```
## Plugins
`astrbot plug` manages plugins under `data/plugins`.
| Command | Purpose |
| --- | --- |
| `astrbot plug list` | List installed plugins. |
| `astrbot plug list --all` | Also show uninstalled plugins. |
| `astrbot plug search <QUERY>` | Search plugins. |
| `astrbot plug install <NAME>` | Install a plugin. |
| `astrbot plug update [NAME]` | Update one plugin, or all updatable plugins if no name is given. |
| `astrbot plug remove <NAME>` | Remove an installed plugin. |
| `astrbot plug new <NAME>` | Create a new plugin from the template. |
Use a GitHub proxy when installing or updating plugins:
```bash
astrbot plug install example-plugin --proxy https://gh-proxy.example.com/
astrbot plug update --proxy https://gh-proxy.example.com/
```
Creating a new plugin asks for the author, description, version, and repository URL:
```bash
astrbot plug new my-plugin
```
## Help
Show general CLI help:
```bash
astrbot help
```
Show help for a specific command:
```bash
astrbot help run
astrbot run --help
astrbot help conf
astrbot plug --help
```
Show the version:
```bash
astrbot --version
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -1,12 +1,10 @@
# 通过 QQ官方机器人 接入 QQ (Webhook)
> [!WARNING]
>
> 1. 截至目前QQ 官方机器人需要设置 IP 白名单。
> 2. 支持群聊、私聊、频道聊天、频道私聊
>
> **需要**一台带有公网 IP 的服务器和域名(如果没备案,需要服务器在海外或者中国港澳台地区)
> 2. Webhook 模式需要一台带公网 IP 的服务器、域名和 HTTPS 访问能力
> 3. 支持群聊、私聊、频道聊天、频道私聊。
## 支持的基本消息类型
@@ -22,7 +20,43 @@
主动消息推送:支持。
## 申请一个机器人
## 在 AstrBot 中扫码一键创建 QQ 机器人(推荐)
### 配置流程
1. 进入 AstrBot 的 WebUI点击左边栏 `机器人`,然后点击 `+ 创建机器人`
2. 选择 `QQ 官方机器人Webhook`
3.`选择创建方式` 中选择 `扫码一键创建`,点击开始创建后,用手机 QQ 扫描页面中的二维码。
4. 扫码确认后,点击 `保存`
5. 根据服务器环境配置域名 DNS 解析和反向代理,将 HTTPS 请求转发到 AstrBot 所在服务器的 `6185` 端口。
6. 回到 QQ 开放平台的机器人管理页,在 `开发 -> 回调配置` 中填写 AstrBot 生成的 Webhook 回调地址。
7. 在回调事件中勾选需要接收的事件。如果需要接收群聊全量消息,请确保勾选群事件 `GROUP_MESSAGE_CREATE`
8. 保存回调配置后,重启 AstrBot。
> [!TIP]
> 使用 `统一 Webhook 模式` 时AstrBot 会自动生成唯一的 Webhook 回调链接。你可以在日志中,或者 WebUI 的机器人卡片上找到该链接。
![unified_webhook](https://files.astrbot.app/docs/source/images/use/unified-webhook.png)
### 在群聊中使用
#### 添加到群聊
进入创建的 QQ 机器人的资料页手机QQ -> 联系人 -> 机器人页签),在下方可以找到 “添加到群聊”。目前只能添加到自己为群主的群聊。
#### 设置机器人可获取的群聊消息范围和主动发言
在手机 QQ 的群聊设置中打开机器人设置,推荐将 `机器人可获取的群聊消息范围` 设置为 `获取群内全部消息`,并开启 `机器人主动在群聊内发言`
这样机器人可以接收群聊全量消息,也可以在群聊中主动推送消息,例如定时任务推送、插件主动通知等。
Webhook 模式还需要在 QQ 开放平台的回调配置中勾选群事件 `GROUP_MESSAGE_CREATE`,否则 AstrBot 无法收到群聊全量消息事件。
![QQ 官方机器人推荐群聊配置](/qqofficial-group-recommended-config.png)
## 手动申请 QQ 机器人(不推荐)
### 申请一个机器人
首先,打开 [QQ官方机器人](https://q.qq.com) 并登录。
@@ -32,7 +66,7 @@
![image](https://files.astrbot.app/docs/source/images/qqofficial/image.png)
## 允许机器人加入频道/群/私聊
### 允许机器人加入频道/群/私聊
点击`沙箱配置`,这允许你立即设置一个沙箱频道/QQ群/QQ私聊用于拉入机器人需要小于等于20个人
@@ -40,26 +74,40 @@
![image](https://files.astrbot.app/docs/source/images/qqofficial/image-1.png)
## 获取 appid、secret
### 获取 appid、secret
添加机器人到你想用的地方后。
点击 `开发->开发设置`,找到 appid、secret。复制并保存它们。
## 添加 IP 白名单
如果你使用 AstrBot WebUI 的 `扫码一键创建`这一步可以跳过。扫码绑定成功后AstrBot 会自动填入 `appid``secret`
### 添加 IP 白名单
点击 `开发->开发设置`,找到 IP 白名单。添加你的服务器 IP 地址。
![image](https://files.astrbot.app/docs/source/images/qqofficial/image-3.png)
## 在 AstrBot 配置
> [!TIP]
> 如果你不知道你的服务器 IP 地址,可以在终端中输入 `curl ifconfig.me` 来获取。或者登录 [ip138.com](https://ip138.com/) 查看。
>
> 如果你在没有公网 IP 的环境下,你看到的 IP 是运营商 NAT 的 IP这个 IP 根据你的运营商的情况可能会随时变化。如有必要,可以配置代理。
### 在 AstrBot 配置
1. 进入 AstrBot 的管理面板
2. 点击左边栏 `机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
4. 选择 `qq_official_webhook`
4. 选择 `QQ 官方机器人Webhook`
弹出的配置项填写
推荐使用 `扫码一键创建`
1.`选择创建方式` 中选择 `扫码一键创建`
2. 点击开始创建,用手机 QQ 扫描二维码并确认。
3. 等待页面显示绑定成功。AstrBot 会自动填入 `appid``secret`
4. 保持 `统一 Webhook 模式` 开启,根据需要调整 `ID` 等配置,然后点击 `保存`
如果扫码不可用,也可以选择 `手动创建`。弹出的配置项填写:
- ID(id):随意填写,用于区分不同的消息平台实例。
- 启用(enable): 勾选。
@@ -69,24 +117,21 @@
点击 `保存`
## 反向代理
### 配置反向代理
保存之后,请根据你的服务器环境,配置域名 DNS 解析和反向代理,将请求转发到 AstrBot 所在服务器的 `6185` 端口 (如果没有开启统一 Webhook 模式,将请求转发到上一步配置指定的端口)
保存之后,请根据你的服务器环境,配置域名 DNS 解析和反向代理,将请求转发到 AstrBot 所在服务器的 `6185` 端口如果没有开启统一 Webhook 模式,将请求转发到上一步配置指定的端口
## 设置回调地址
Webhook 回调地址必须可以被 QQ 开放平台公网访问,并且需要使用 HTTPS。
`开发->回调配置` 处,配置回调地址。
### 设置回调地址和事件
`开发 -> 回调配置` 处,配置回调地址。
上一步点击保存之后AstrBot 将会自动为你生成唯一的 Webhook 回调链接,你可以在日志中或者 WebUI 的机器人页的卡片上找到。
![unified_webhook](https://files.astrbot.app/docs/source/images/use/unified-webhook.png)
将请求地址填写为该地址。
> [!TIP]
> v4.8.0 之前没有 `统一 Webhook 模式`,则请求地址填写 `<你的域名>/astrbot-qo-webhook/callback`。
填写好之后,添加事件,四个事件类型都全选:单聊事件、群事件、频道事件等,如下图。
填写好之后,添加事件。需要接收群聊全量消息时,请勾选群事件 `GROUP_MESSAGE_CREATE`;同时按需勾选单聊事件、频道事件等。
![image](https://files.astrbot.app/docs/source/images/webhook/image.png)
@@ -94,10 +139,6 @@
接着重启 AstrBot。
## 🎉 大功告成
此时,你的 AstrBot 应该已经连接成功。如果发送消息没有反应,请等待一两分钟后重启 AstrBot 再进行确认(测试时发现回调地址不会立即生效)。
## 附录:如何配置反向代理
如果你还没有相关经验,这里推荐使用 Caddy 作为反向代理的工具,请参考:

View File

@@ -15,21 +15,33 @@
主动消息推送:支持。
## 快速部署通道
## 在 AstrBot 中扫码一键创建 QQ 机器人(推荐)
> 更新自: `2026/03/06`。该方法仅支持 `私聊`。
### 配置流程
1. 打开 [QQ 开放平台](https://q.qq.com/qqbot/openclaw/)。如果没注册,需要先注册
2. 点击右侧 `创建机器人` 按钮
3. 获取 `AppID``AppSecret`
4. 进入 AstrBot 的 WebUI点击左边栏 `机器人`,然后在右边的界面中,点击 `+ 创建机器人`,选择 `QQ 官方机器人WebSocket`,将之前得到的 `AppID``AppSecret` 复制到这里的表单中,然后 `启用`,然后点击保存
1. 进入 AstrBot 的 WebUI点击左边栏 `机器人`,然后点击 `+ 创建机器人`
2. 选择 `QQ 官方机器人WebSocket`
3. `选择创建方式` 中选择 `扫码一键创建`,点击开始创建后,用手机 QQ 扫描页面中的二维码
4. 扫码确认后,AstrBot 会自动写入 `AppID``AppSecret`。确认 `启用` 已勾选,然后点击 `保存`
5. 回到 QQ 开放平台页面,点击机器人右边的 `扫码聊天`。用手机 QQ 扫码即可聊天。
如果要在群聊中使用,参考下面文档的 `允许机器人加入频道/群/私聊` 一节。
### 在群聊中使用
---
#### 添加到群聊
## 申请一个机器人
进入创建的 QQ 机器人的资料页手机QQ -> 联系人 -> 机器人页签),在下方可以找到 “添加到群聊”。目前只能添加到自己为群主的群聊。
#### 设置机器人可获取的群聊消息范围和主动发言
在手机 QQ 的群聊设置中打开机器人设置,推荐将 `机器人可获取的群聊消息范围` 设置为 `获取群内全部消息`,并开启 `机器人主动在群聊内发言`
这样机器人可以接收群聊全量消息,也可以在群聊中主动推送消息,例如定时任务推送、插件主动通知等。
![QQ 官方机器人推荐群聊配置](/qqofficial-group-recommended-config.png)
## 手动申请 QQ 机器人(不推荐)
### 申请一个机器人
> [!WARNING]
>
@@ -44,7 +56,7 @@
![image](https://files.astrbot.app/docs/source/images/qqofficial/image.png)
## 允许机器人加入频道/群/私聊
### 允许机器人加入频道/群/私聊
点击`沙箱配置`,这允许你立即设置一个沙箱频道/QQ群/QQ私聊用于拉入机器人需要小于等于20个人
@@ -52,13 +64,15 @@
![image](https://files.astrbot.app/docs/source/images/qqofficial/image-1.png)
## 获取 appid、secret
### 获取 appid、secret
添加机器人到你想用的地方后。
点击 `开发->开发设置`,找到 appid、secret。复制并保存它们。
## 添加 IP 白名单(可选)
如果你使用 AstrBot WebUI 的 `扫码一键创建`这一步可以跳过。扫码绑定成功后AstrBot 会自动填入 `appid``secret`
### 添加 IP 白名单(可选)
点击 `开发->开发设置`,找到 IP 白名单。添加你的服务器 IP 地址。
@@ -69,22 +83,27 @@
>
> 如果你在没有公网 IP 的环境下,你看到的 IP 是运营商 NAT 的 IP这个 IP 根据你的运营商的情况可能会随时变化。如有必要,可以配置代理。
## 在 AstrBot 配置
### 在 AstrBot 配置
1. 进入 AstrBot 的管理面板
2. 点击左边栏 `机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
4. 选择 `QQ 官方机器人WebSocket`
弹出的配置项填写
推荐使用 `扫码一键创建`
1.`选择创建方式` 中选择 `扫码一键创建`
2. 点击开始创建,用手机 QQ 扫描二维码并确认。
3. 等待页面显示绑定成功。AstrBot 会自动填入 `appid``secret`
4. 根据需要调整 `ID``启用消息列表单聊``启用频道私聊` 等配置,然后点击 `保存`
如果扫码不可用,也可以选择 `手动创建`。弹出的配置项填写:
- ID(id):随意填写,用于区分不同的消息平台实例。
- 启用(enable): 勾选。
- appid: QQ 官方机器人中获取的 appid。
- secret: QQ 官方机器人中获取的 secret。
- 启用消息列表单聊(enable_group_c2c): 如果需要通过 QQ 消息列表私聊机器人,保持开启。
- 启用频道私聊(enable_guild_direct_message): 如果需要频道私聊,保持开启。
点击 `保存`
## 🎉 大功告成
此时,你的 AstrBot 应该已经成功连接 QQ 官方接口。使用 `私聊` 的方式在 QQ 对机器人发送 `/help` 以检查是否连接成功。

169
docs/zh/use/cli.md Normal file
View File

@@ -0,0 +1,169 @@
# CLI 指令
AstrBot CLI 用于初始化实例、启动 AstrBot、修改常用配置和管理插件。
如果你使用 `uv` 安装:
```bash
uv tool install astrbot --python 3.12
```
`uv` 会生成 `astrbot` 可执行文件,并把它放到 `PATH` 中。可以用下面的命令确认路径:
::: code-group
```bash [Linux / macOS]
which astrbot
```
```powershell [Windows]
where.exe astrbot
```
:::
> [!TIP]
> 下面的命令都需要在 AstrBot 工作目录中执行。
## 快速开始
第一次部署时先初始化目录,再启动 AstrBot
```bash
astrbot init
astrbot run
```
`astrbot init` 会在当前目录创建 AstrBot 所需的数据目录和配置文件。初始化完成后,后续启动只需要执行 `astrbot run`。
## 顶层指令
| 指令 | 用途 |
| --- | --- |
| `astrbot init` | 初始化当前目录为 AstrBot 工作目录。 |
| `astrbot run` | 在前台启动 AstrBot。 |
| `astrbot conf` | 查看或修改常用配置项。 |
| `astrbot password` | 交互式修改 WebUI 登录密码。 |
| `astrbot plug` | 创建、安装、更新、删除或搜索插件。 |
| `astrbot help` | 查看 CLI 帮助。 |
| `astrbot --version` | 查看 AstrBot CLI 版本。 |
## 启动 AstrBot
```bash
astrbot run
```
常用选项:
| 选项 | 用途 |
| --- | --- |
| `-p, --port <PORT>` | 指定 WebUI 端口。 |
| `-r, --reload` | 启用插件自动重载,适合插件开发调试。 |
| `--reset-password` | 启动时重置 WebUI 初始密码,并在启动日志中打印新密码。 |
示例:
```bash
astrbot run --port 6185
astrbot run --reload
astrbot run --reset-password
```
如果你忘记了 WebUI 登录密码,可以在 AstrBot 工作目录中执行:
```bash
astrbot run --reset-password
```
AstrBot 会在启动时重新生成初始密码,并在启动日志中打印。登录后请立即在 WebUI 中修改密码。
使用源码方式直接启动时,也可以执行:
```bash
python main.py --reset-password
```
## 配置
`astrbot conf` 用于查看和修改常用配置项。
```bash
astrbot conf get
astrbot conf get dashboard.port
astrbot conf set dashboard.port 6185
```
支持的配置项:
| 配置项 | 说明 |
| --- | --- |
| `timezone` | 时区,例如 `Asia/Shanghai`。 |
| `log_level` | 日志等级:`DEBUG`、`INFO`、`WARNING`、`ERROR`、`CRITICAL`。 |
| `dashboard.port` | WebUI 端口。 |
| `dashboard.username` | WebUI 用户名。 |
| `dashboard.password` | WebUI 密码。 |
| `callback_api_base` | 回调 API 基础地址,需要以 `http://` 或 `https://` 开头。 |
修改密码时会自动写入新版密码哈希:
```bash
astrbot conf set dashboard.password "new-password"
```
也可以使用专门的交互式密码指令:
```bash
astrbot password
astrbot password --username admin
```
## 插件
`astrbot plug` 用于管理 `data/plugins` 下的插件。
| 指令 | 用途 |
| --- | --- |
| `astrbot plug list` | 查看已安装插件。 |
| `astrbot plug list --all` | 同时显示未安装插件。 |
| `astrbot plug search <QUERY>` | 搜索插件。 |
| `astrbot plug install <NAME>` | 安装插件。 |
| `astrbot plug update [NAME]` | 更新指定插件;不传名称时更新所有可更新插件。 |
| `astrbot plug remove <NAME>` | 删除已安装插件。 |
| `astrbot plug new <NAME>` | 基于模板创建新插件。 |
安装或更新插件时可以使用 GitHub 代理:
```bash
astrbot plug install example-plugin --proxy https://gh-proxy.example.com/
astrbot plug update --proxy https://gh-proxy.example.com/
```
创建新插件会交互式询问作者、描述、版本和仓库地址:
```bash
astrbot plug new my-plugin
```
## 帮助
查看全部 CLI 帮助:
```bash
astrbot help
```
查看指定指令帮助:
```bash
astrbot help run
astrbot run --help
astrbot help conf
astrbot plug --help
```
查看版本:
```bash
astrbot --version
```

30
main.py
View File

@@ -9,6 +9,28 @@ import runtime_bootstrap
runtime_bootstrap.initialize_runtime_bootstrap()
DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_RESET_DASHBOARD_PASSWORD"
def _apply_startup_env_flags(argv: list[str]) -> None:
"""Apply startup flags that must take effect before core imports.
Args:
argv: Command-line arguments excluding the executable name.
"""
if "-h" in argv or "--help" in argv:
return
startup_parser = argparse.ArgumentParser(add_help=False)
startup_parser.add_argument("--reset-password", action="store_true")
startup_args, _ = startup_parser.parse_known_args(argv)
if startup_args.reset_password:
os.environ[DASHBOARD_RESET_PASSWORD_ENV] = "1"
_apply_startup_env_flags(sys.argv[1:])
from astrbot.core import LogBroker, LogManager, db_helper, logger # noqa: E402
from astrbot.core.config.default import VERSION # noqa: E402
from astrbot.core.initial_loader import InitialLoader # noqa: E402
@@ -141,6 +163,14 @@ if __name__ == "__main__":
help="Specify the directory path for WebUI static files",
default=None,
)
parser.add_argument(
"--reset-password",
action="store_true",
help=(
"Reset the dashboard initial password on startup and print it in "
"startup logs"
),
)
args = parser.parse_args()
check_env()

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.26.0-beta.3"
version = "4.26.0-beta.8"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
license = { text = "AGPL-3.0-or-later" }
@@ -23,7 +23,7 @@ dependencies = [
"deprecated>=1.2.18",
"dingtalk-stream>=0.22.1",
"docstring-parser>=0.16",
"faiss-cpu>=1.12.0",
"faiss-cpu>=1.14.3",
"fastapi>=0.124.0",
"filelock>=3.18.0",
"google-genai>=1.56.0",

43
tests/test_cli_run.py Normal file
View File

@@ -0,0 +1,43 @@
import os
import sys
from click.testing import CliRunner
from astrbot.cli.commands import cmd_run
def test_run_reset_password_sets_startup_env(monkeypatch, tmp_path):
(tmp_path / ".astrbot").touch()
monkeypatch.chdir(tmp_path)
monkeypatch.delenv(cmd_run.DASHBOARD_RESET_PASSWORD_ENV, raising=False)
original_env = {
"ASTRBOT_CLI": os.environ.get("ASTRBOT_CLI"),
"ASTRBOT_ROOT": os.environ.get("ASTRBOT_ROOT"),
cmd_run.DASHBOARD_RESET_PASSWORD_ENV: os.environ.get(
cmd_run.DASHBOARD_RESET_PASSWORD_ENV
),
}
original_sys_path = list(sys.path)
called = False
async def fake_run_astrbot(astrbot_root):
nonlocal called
called = True
assert astrbot_root == tmp_path
assert os.environ[cmd_run.DASHBOARD_RESET_PASSWORD_ENV] == "1"
monkeypatch.setattr(cmd_run, "run_astrbot", fake_run_astrbot)
try:
result = CliRunner().invoke(cmd_run.run, ["--reset-password"])
finally:
for key, value in original_env.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
sys.path[:] = original_sys_path
assert result.exit_code == 0, result.output
assert called is True

View File

@@ -5,6 +5,7 @@ import io
import zipfile
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
import pytest
from mcp.types import CallToolResult, ImageContent
@@ -41,6 +42,100 @@ def _make_context(
return ContextWrapper(context=astr_ctx)
def _make_sandbox_context(
*,
role: str = "admin",
umo: str = "qq:friend:user-1",
):
config_holder = SimpleNamespace(
get_config=lambda umo=None: {
"provider_settings": {
"computer_use_require_admin": True,
"computer_use_runtime": "sandbox",
}
}
)
event = SimpleNamespace(
role=role,
unified_msg_origin=umo,
send=AsyncMock(),
)
astr_ctx = SimpleNamespace(context=config_holder, event=event)
return ContextWrapper(context=astr_ctx)
@pytest.mark.asyncio
async def test_sandbox_file_download_handles_windows_remote_filename(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
):
temp_root = tmp_path / "temp"
temp_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(
fs_tools,
"get_astrbot_temp_path",
lambda: str(temp_root),
)
async def _download_file(_remote_path, local_path):
assert local_path.endswith("report.txt")
assert "\\" not in local_path
booter = SimpleNamespace(download_file=AsyncMock(side_effect=_download_file))
async def _fake_get_booter(_ctx, _umo):
return booter
monkeypatch.setattr(fs_tools, "get_booter", _fake_get_booter)
context = _make_sandbox_context()
result = await fs_tools.FileDownloadTool().call(
context,
remote_path=r"C:\Users\AstrBot\report.txt",
also_send_to_user=True,
)
assert "report.txt" in result
sent_chain = context.context.event.send.await_args.args[0]
sent_file = sent_chain.chain[0]
assert sent_file.name == "report.txt"
@pytest.mark.asyncio
async def test_sandbox_file_download_strips_trailing_remote_slash(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
):
temp_root = tmp_path / "temp"
temp_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(
fs_tools,
"get_astrbot_temp_path",
lambda: str(temp_root),
)
booter = SimpleNamespace(download_file=AsyncMock())
async def _fake_get_booter(_ctx, _umo):
return booter
monkeypatch.setattr(fs_tools, "get_booter", _fake_get_booter)
context = _make_sandbox_context()
result = await fs_tools.FileDownloadTool().call(
context,
remote_path="reports/export/",
also_send_to_user=True,
)
assert "export" in result
sent_chain = context.context.event.send.await_args.args[0]
sent_file = sent_chain.chain[0]
assert sent_file.name == "export"
def _setup_local_fs_tools(
monkeypatch: pytest.MonkeyPatch,
tmp_path,

View File

@@ -73,6 +73,33 @@ def _assert_cookie_samesite_strict(cookie_header: str) -> None:
assert "samesite=strict" in cookie_header.lower()
async def _wait_for_update_progress(
test_client,
authenticated_header: dict,
progress_id: str,
) -> dict:
"""Wait until a dashboard update task reaches a terminal status.
Args:
test_client: Quart/FastAPI adapter test client.
authenticated_header: Headers for authenticated dashboard requests.
progress_id: Update progress id to poll.
Returns:
The progress response payload.
"""
for _ in range(100):
response = await test_client.get(
f"/api/update/progress?id={progress_id}",
headers=authenticated_header,
)
data = await response.get_json()
if data["data"].get("status") in {"success", "error"}:
return data
await asyncio.sleep(0.01)
pytest.fail(f"Update task did not finish: {progress_id}")
@pytest.fixture
def registered_plugin_page(core_lifecycle_td: AstrBotCoreLifecycle, monkeypatch):
plugin_root = (
@@ -2633,6 +2660,13 @@ async def test_do_update(
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["id"] == "test-progress"
progress_data = await _wait_for_update_progress(
test_client,
authenticated_header,
"test-progress",
)
assert os.path.exists(release_path)
assert calls[:4] == [
"download-dashboard",
@@ -2641,11 +2675,6 @@ async def test_do_update(
"apply-dashboard",
]
progress_response = await test_client.get(
"/api/update/progress?id=test-progress",
headers=authenticated_header,
)
progress_data = await progress_response.get_json()
assert progress_data["status"] == "ok"
assert progress_data["data"]["status"] == "success"
assert progress_data["data"]["overall_percent"] == 100
@@ -2707,7 +2736,13 @@ async def test_do_update_does_not_apply_files_when_core_download_fails(
data = await response.get_json()
assert response.status_code == 200
assert data["status"] == "error"
assert data["status"] == "ok"
progress_data = await _wait_for_update_progress(
test_client,
authenticated_header,
"atomic-fail",
)
assert progress_data["data"]["status"] == "error"
assert calls == ["download-dashboard", "download-core"]
@@ -2769,7 +2804,13 @@ async def test_do_update_does_not_apply_files_when_package_verification_fails(
data = await response.get_json()
assert response.status_code == 200
assert data["status"] == "error"
assert data["status"] == "ok"
progress_data = await _wait_for_update_progress(
test_client,
authenticated_header,
"invalid-zip",
)
assert progress_data["data"]["status"] == "error"
assert calls == ["download-dashboard", "download-core"]
@@ -2812,15 +2853,14 @@ async def test_do_update_hides_internal_error_message_in_response_and_progress(
data = await response.get_json()
assert response.status_code == 200
assert data["status"] == "error"
assert data["message"] == "An internal error has occurred."
assert data["status"] == "ok"
assert "secret stack trace" not in str(data)
progress_response = await test_client.get(
"/api/update/progress?id=failed-progress",
headers=authenticated_header,
progress_data = await _wait_for_update_progress(
test_client,
authenticated_header,
"failed-progress",
)
progress_data = await progress_response.get_json()
assert progress_data["status"] == "ok"
assert progress_data["data"]["status"] == "error"

View File

@@ -473,7 +473,7 @@ class FakePersonaManager:
async def update_persona(self, persona_id: str, **kwargs) -> None:
persona = self.personas[persona_id]
for key, value in kwargs.items():
if value is not None:
if key in ("tools", "skills", "custom_error_message") or value is not None:
setattr(persona, key, value)
async def delete_persona(self, persona_id: str) -> None:
@@ -2476,6 +2476,29 @@ async def test_v1_safe_persona_routes_accept_slash_ids(
assert persona_id not in persona_mgr.personas
@pytest.mark.asyncio
async def test_v1_persona_by_id_update_preserves_explicit_null_tools_and_skills(
asgi_client: httpx.AsyncClient,
fake_core_lifecycle,
):
persona_id = "persona/foo"
headers = _jwt_headers()
persona = fake_core_lifecycle.persona_mgr.personas[persona_id]
persona.tools = ["tool-a"]
persona.skills = ["skill-a"]
response = await asgi_client.put(
"/api/v1/personas/by-id",
json={"persona_id": persona_id, "tools": None, "skills": None},
headers=headers,
)
assert response.status_code == 200
assert response.json()["data"] == {"message": "人格更新成功"}
assert persona.tools is None
assert persona.skills is None
@pytest.mark.asyncio
async def test_v1_im_routes_use_im_scope_and_running_platform(
asgi_client: httpx.AsyncClient,

View File

@@ -10,7 +10,12 @@ from unittest import mock
import pytest
from astrbot.core.utils.io import should_use_bundled_dashboard_dist
from main import check_dashboard_files, check_env
from main import (
DASHBOARD_RESET_PASSWORD_ENV,
_apply_startup_env_flags,
check_dashboard_files,
check_env,
)
class _version_info:
@@ -62,6 +67,30 @@ def test_check_env(monkeypatch):
check_env()
def test_apply_startup_env_flags_sets_reset_password_env(monkeypatch):
monkeypatch.delenv(DASHBOARD_RESET_PASSWORD_ENV, raising=False)
_apply_startup_env_flags(["--webui-dir", "/tmp/webui", "--reset-password"])
assert os.environ[DASHBOARD_RESET_PASSWORD_ENV] == "1"
def test_apply_startup_env_flags_ignores_unrelated_args(monkeypatch):
monkeypatch.delenv(DASHBOARD_RESET_PASSWORD_ENV, raising=False)
_apply_startup_env_flags(["--webui-dir", "/tmp/webui"])
assert DASHBOARD_RESET_PASSWORD_ENV not in os.environ
def test_apply_startup_env_flags_does_not_reset_for_help(monkeypatch):
monkeypatch.delenv(DASHBOARD_RESET_PASSWORD_ENV, raising=False)
_apply_startup_env_flags(["--reset-password", "--help"])
assert DASHBOARD_RESET_PASSWORD_ENV not in os.environ
def test_check_env_appends_user_site_packages_after_runtime_paths(monkeypatch):
astrbot_root = "/tmp/astrbot-root"
site_packages_path = "/tmp/astrbot-site-packages"

View File

@@ -0,0 +1,378 @@
import asyncio
import re
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock
import botpy
import botpy.message
import pytest
from botpy import ConnectionSession
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, Plain
from astrbot.core.message.message_event_result import (
MessageEventResult,
ResultContentType,
)
from astrbot.core.pipeline.respond.stage import RespondStage
from astrbot.core.pipeline.result_decorate.stage import ResultDecorateStage
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.platform.message_type import MessageType
from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import (
QQOfficialPlatformAdapter,
_ensure_group_message_create_parser,
)
from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import (
botClient as QQOfficialBotClient,
)
from astrbot.core.platform.sources.qqofficial_webhook.qo_webhook_adapter import (
QQOfficialWebhookPlatformAdapter,
)
def _make_group_payload(
*,
message_id: str = "msg-1",
content: str = "hello world",
mentions: list[dict] | None = None,
member_openid: str = "member-1",
group_openid: str = "group-1",
) -> dict:
return {
"id": f"event-{message_id}",
"d": {
"id": message_id,
"content": content,
"author": {"member_openid": member_openid},
"group_openid": group_openid,
"mentions": mentions or [],
"attachments": [],
},
}
def _dispatch_group_message(payload: dict) -> tuple[str, botpy.message.GroupMessage]:
dispatched: list[tuple[str, botpy.message.GroupMessage]] = []
_ensure_group_message_create_parser()
connection = ConnectionSession(
max_async=1,
connect=lambda: None,
dispatch=lambda event, message: dispatched.append((event, message)),
loop=asyncio.get_event_loop(),
api=None,
)
connection.parser["group_message_create"](payload)
return dispatched[0]
@pytest.mark.asyncio
async def test_group_message_create_parser_is_registered_and_dispatches_group_message():
QQOfficialPlatformAdapter(
{
"id": "qq-official-test",
"appid": "123",
"secret": "secret",
"enable_group_c2c": True,
"enable_guild_direct_message": False,
},
{},
asyncio.Queue(),
)
event_name, message = _dispatch_group_message(_make_group_payload())
assert event_name == "group_message_create"
assert isinstance(message, botpy.message.GroupMessage)
assert message.group_openid == "group-1"
@pytest.mark.asyncio
async def test_parse_group_message_create_plain_message_has_no_at_component():
_, message = _dispatch_group_message(
_make_group_payload(content="plain group message")
)
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
)
assert abm.type == MessageType.GROUP_MESSAGE
assert abm.sender.user_id == "member-1"
assert abm.group_id == "group-1"
assert abm.message_str == "plain group message"
assert not any(isinstance(component, At) for component in abm.message)
assert [
component.text for component in abm.message if isinstance(component, Plain)
] == ["plain group message"]
@pytest.mark.asyncio
async def test_parse_group_message_create_bot_mention_cleans_plain_text():
_, message = _dispatch_group_message(
_make_group_payload(
content="<@!bot-123> hello there",
mentions=[{"id": "bot-123", "is_you": True}],
)
)
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
)
assert isinstance(abm.message[0], At)
assert abm.message[0].qq == "bot-123"
assert abm.self_id == "bot-123"
assert isinstance(abm.message[1], Plain)
assert abm.message[1].text == "hello there"
assert abm.message_str == "hello there"
assert abm.sender.user_id == "member-1"
assert abm.group_id == "group-1"
@pytest.mark.asyncio
async def test_legacy_group_at_path_forces_bot_mention_when_mentions_missing():
message = botpy.message.GroupMessage(
None,
"event-legacy",
_make_group_payload(content="legacy text", mentions=[])["d"],
)
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
force_group_mention=True,
)
assert isinstance(abm.message[0], At)
assert abm.message[0].qq == "qq_official"
assert abm.self_id == "qq_official"
assert isinstance(abm.message[1], Plain)
assert abm.message[1].text == "legacy text"
@pytest.mark.asyncio
async def test_group_message_create_handler_maps_group_session_and_scene():
_, message = _dispatch_group_message(_make_group_payload())
committed: list = []
remembered_scenes: list[tuple[str, str]] = []
remembered_ids: list[tuple[str, str]] = []
class PlatformStub:
def remember_session_scene(self, session_id: str, scene: str) -> None:
remembered_scenes.append((session_id, scene))
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
remembered_ids.append((session_id, message_id))
def create_event(self, message_obj):
return message_obj
def commit_event(self, event) -> None:
committed.append(event)
client = QQOfficialBotClient(
intents=botpy.Intents(public_messages=True),
bot_log=False,
)
client.set_platform(cast(Any, PlatformStub()))
await client.on_group_message_create(message)
assert remembered_scenes == [("group-1", "group")]
assert remembered_ids == [("group-1", "msg-1")]
assert committed[0].type == MessageType.GROUP_MESSAGE
assert committed[0].group_id == "group-1"
assert committed[0].session_id == "group-1"
@pytest.mark.asyncio
async def test_ws_group_send_by_session_without_cached_msg_id_omits_msg_id():
adapter = QQOfficialPlatformAdapter(
{
"id": "qq-official-test",
"appid": "123",
"secret": "secret",
"enable_group_c2c": True,
"enable_guild_direct_message": False,
},
{},
asyncio.Queue(),
)
adapter.client.api = SimpleNamespace(
post_group_message=AsyncMock(return_value={"id": "sent-1"}),
post_message=AsyncMock(),
)
adapter._session_scene["group-1"] = "group"
await adapter.send_by_session(
MessageSession("qq_official", MessageType.GROUP_MESSAGE, "group-1"),
MessageChain(chain=[Plain("proactive hello")]),
)
adapter.client.api.post_group_message.assert_awaited_once()
kwargs = adapter.client.api.post_group_message.await_args.kwargs
assert kwargs["group_openid"] == "group-1"
assert kwargs["content"] == "proactive hello"
assert "msg_id" not in kwargs
assert "msg_seq" in kwargs
assert adapter._session_last_message_id["group-1"] == "sent-1"
@pytest.mark.asyncio
async def test_ws_group_send_by_session_with_cached_msg_id_still_omits_msg_id():
adapter = QQOfficialPlatformAdapter(
{
"id": "qq-official-test",
"appid": "123",
"secret": "secret",
"enable_group_c2c": True,
"enable_guild_direct_message": False,
},
{},
asyncio.Queue(),
)
adapter.client.api = SimpleNamespace(
post_group_message=AsyncMock(return_value={"id": "sent-2"}),
post_message=AsyncMock(),
)
adapter._session_scene["group-1"] = "group"
adapter._session_last_message_id["group-1"] = "stale-msg-id"
await adapter.send_by_session(
MessageSession("qq_official", MessageType.GROUP_MESSAGE, "group-1"),
MessageChain(chain=[Plain("proactive with cache")]),
)
adapter.client.api.post_group_message.assert_awaited_once()
kwargs = adapter.client.api.post_group_message.await_args.kwargs
assert kwargs["group_openid"] == "group-1"
assert kwargs["content"] == "proactive with cache"
assert "msg_id" not in kwargs
assert "msg_seq" in kwargs
@pytest.mark.asyncio
async def test_webhook_group_send_by_session_without_cached_msg_id_omits_msg_id():
adapter = QQOfficialWebhookPlatformAdapter(
{
"id": "qq-official-webhook-test",
"appid": "123",
"secret": "secret",
},
{},
asyncio.Queue(),
)
adapter.client.api = SimpleNamespace(
post_group_message=AsyncMock(return_value={"id": "sent-1"}),
post_message=AsyncMock(),
)
adapter._session_scene["group-1"] = "group"
await adapter.send_by_session(
MessageSession("qq_official_webhook", MessageType.GROUP_MESSAGE, "group-1"),
MessageChain(chain=[Plain("webhook proactive hello")]),
)
adapter.client.api.post_group_message.assert_awaited_once()
kwargs = adapter.client.api.post_group_message.await_args.kwargs
assert kwargs["group_openid"] == "group-1"
assert kwargs["content"] == "webhook proactive hello"
assert "msg_id" not in kwargs
assert "msg_seq" in kwargs
assert adapter._session_last_message_id["group-1"] == "sent-1"
def test_qqofficial_ws_is_not_excluded_from_segmented_reply():
stage = RespondStage()
stage.enable_seg = True
stage.only_llm_result = False
result = MessageEventResult(chain=[Plain("hello")])
event = SimpleNamespace(
get_result=lambda: result,
get_platform_name=lambda: "qq_official",
)
assert stage.is_seg_reply_required(cast(Any, event)) is True
def test_qqofficial_webhook_remains_excluded_from_segmented_reply():
stage = RespondStage()
stage.enable_seg = True
stage.only_llm_result = False
result = MessageEventResult(chain=[Plain("hello")])
event = SimpleNamespace(
get_result=lambda: result,
get_platform_name=lambda: "qq_official_webhook",
)
assert stage.is_seg_reply_required(cast(Any, event)) is False
@pytest.mark.asyncio
async def test_result_decorate_segments_qqofficial_ws_plain_result():
stage = ResultDecorateStage()
stage.reply_prefix = ""
stage.content_safe_check_reply = False
stage.enable_segmented_reply = True
stage.only_llm_result = False
stage.words_count_threshold = 100
stage.split_mode = "words"
stage.split_words = [""]
stage.split_words_pattern = re.compile(r"(.*?(。)|.+$)", re.DOTALL)
stage.content_cleanup_rule = ""
stage.show_reasoning = False
stage.tts_trigger_probability = 0
stage.reply_with_mention = False
stage.reply_with_quote = False
stage.forward_threshold = 1000
setattr(
stage,
"ctx",
SimpleNamespace(
plugin_manager=SimpleNamespace(
context=SimpleNamespace(get_using_tts_provider=lambda _umo: None)
),
astrbot_config={
"provider_tts_settings": {
"enable": False,
"use_file_service": False,
"dual_output": False,
},
"callback_api_base": "",
"t2i": False,
},
),
)
result = MessageEventResult(
chain=[Plain("第一段。第二段。")],
result_content_type=ResultContentType.LLM_RESULT,
)
event = SimpleNamespace(
plugins_name=None,
unified_msg_origin="qq_official:GroupMessage:group-1",
get_result=lambda: result,
get_platform_name=lambda: "qq_official",
is_stopped=lambda: False,
get_extra=lambda *_args, **_kwargs: None,
)
processed = stage.process(cast(Any, event))
if hasattr(processed, "__aiter__"):
async for _ in cast(Any, processed):
pass
else:
yielded = await cast(Any, processed)
if yielded is not None:
async for _ in cast(Any, yielded):
pass
assert [comp.text for comp in result.chain if isinstance(comp, Plain)] == [
"第一段",
"第二段",
]

View File

@@ -1,4 +1,5 @@
import base64
from types import SimpleNamespace
import pytest
from Crypto.Cipher import AES
@@ -11,6 +12,8 @@ from astrbot.core.platform.sources.qqofficial.login_registration import (
generate_qqofficial_bind_key,
qqofficial_login_result,
)
from astrbot.dashboard.services import platform_service
from astrbot.dashboard.services.platform_service import PlatformService
def test_generate_qqofficial_bind_key_returns_base64_aes_key():
@@ -69,3 +72,40 @@ def test_decrypt_qqofficial_secret_rejects_invalid_payload():
with pytest.raises(ValueError):
decrypt_qqofficial_secret("invalid", bind_key)
@pytest.mark.asyncio
async def test_qqofficial_webhook_registration_reuses_qr_binding(monkeypatch):
async def fake_request_qqofficial_login_qr(platform_config: dict):
assert platform_config["type"] == "qq_official_webhook"
return SimpleNamespace(
task_id="task-1",
bind_key="bind-key",
qrcode="qr-content",
interval=3,
)
monkeypatch.setattr(
platform_service,
"request_qqofficial_login_qr",
fake_request_qqofficial_login_qr,
)
service = PlatformService.__new__(PlatformService)
result = await service.handle_platform_registration(
"qq_official_webhook",
{
"action": "start",
"platform_config": {"type": "qq_official_webhook"},
},
)
assert result == {
"status": "pending",
"registration_code": "task-1",
"task_id": "task-1",
"bind_key": "bind-key",
"qrcode": "qr-content",
"qrcode_img_content": "qr-content",
"interval": 3,
}

View File

@@ -10,6 +10,8 @@ from astrbot.core.config.default import DEFAULT_VALUE_MAP
from astrbot.core.config.i18n_utils import ConfigMetadataI18n
from astrbot.core.utils.auth_password import (
DEFAULT_DASHBOARD_PASSWORD,
hash_dashboard_password,
hash_md5_dashboard_password,
validate_dashboard_password,
verify_dashboard_password,
)
@@ -320,6 +322,53 @@ class TestAstrBotConfigLoad:
config["dashboard"]["password"], generated_password
)
def test_reset_dashboard_password_env_rotates_existing_password(
self, temp_config_path, monkeypatch
):
"""Test startup reset flag rotates an already configured dashboard password."""
old_password = "OldPassword123"
default_config = {
"dashboard": {
"username": "astrbot",
"password": "",
"pbkdf2_password": "",
},
}
with open(temp_config_path, "w", encoding="utf-8") as f:
json.dump(
{
"dashboard": {
"username": "astrbot",
"password": hash_md5_dashboard_password(old_password),
"pbkdf2_password": hash_dashboard_password(old_password),
"password_change_required": False,
"password_storage_upgraded": True,
}
},
f,
)
monkeypatch.setenv("ASTRBOT_RESET_DASHBOARD_PASSWORD", "1")
config = AstrBotConfig(
config_path=temp_config_path,
default_config=default_config,
)
generated_password = getattr(config, "_generated_dashboard_password", None)
assert isinstance(generated_password, str)
assert config["dashboard"]["password_change_required"] is True
assert config["dashboard"]["password_storage_upgraded"] is True
assert "ASTRBOT_RESET_DASHBOARD_PASSWORD" not in os.environ
assert verify_dashboard_password(
config["dashboard"]["pbkdf2_password"], generated_password
)
assert not verify_dashboard_password(
config["dashboard"]["pbkdf2_password"], old_password
)
assert verify_dashboard_password(
config["dashboard"]["password"], generated_password
)
def test_legacy_astrbot_user_without_change_flag_keeps_legacy_password(
self, temp_config_path
):

View File

@@ -8,6 +8,7 @@ import mcp
import pytest
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
from astrbot.core.computer.booters.cua import CuaShellComponent
from astrbot.core.config.default import CONFIG_METADATA_3
from astrbot.core.provider.func_tool_manager import FunctionToolManager
@@ -640,6 +641,35 @@ async def test_cua_write_file_shell_fallback_uses_python_base64_decoder():
assert "base64 -d" not in command
@pytest.mark.asyncio
async def test_cua_write_file_shell_fallback_creates_parent_directory():
from astrbot.core.computer.booters.cua import CuaFileSystemComponent
sandbox = FakeSandbox()
delattr(sandbox, "filesystem")
await CuaFileSystemComponent(sandbox).write_file("reports/summary.txt", "hello")
command = sandbox.shell.commands[0][0]
assert "path.parent.mkdir(parents=True, exist_ok=True)" in command
@pytest.mark.asyncio
async def test_cua_write_file_shell_fallback_chunks_large_payloads():
from astrbot.core.computer.booters.cua import CuaFileSystemComponent
sandbox = FakeSandbox()
delattr(sandbox, "filesystem")
await CuaFileSystemComponent(sandbox).write_file("large.txt", "x" * 100_000)
command = sandbox.shell.commands[0][0]
payload = command.split("<<'EOF'\n", 1)[1].rsplit("\nEOF", 1)[0]
lines = payload.splitlines()
assert len(lines) > 1
assert max(len(line) for line in lines) <= 60_000
@pytest.mark.asyncio
async def test_cua_create_file_reports_mode_as_informational():
from astrbot.core.computer.booters.cua import CuaFileSystemComponent
@@ -902,7 +932,6 @@ async def test_cua_upload_file_fallback_rejects_non_posix_os_type(tmp_path):
CuaFileSystemComponent,
CuaGUIComponent,
CuaPythonComponent,
CuaShellComponent,
_CuaRuntime,
)
@@ -933,7 +962,6 @@ async def test_cua_upload_file_prefers_native_files_upload(tmp_path):
CuaFileSystemComponent,
CuaGUIComponent,
CuaPythonComponent,
CuaShellComponent,
_CuaRuntime,
)
@@ -1302,7 +1330,6 @@ async def test_cua_shutdown_clears_cached_components():
CuaFileSystemComponent,
CuaGUIComponent,
CuaPythonComponent,
CuaShellComponent,
_CuaRuntime,
)
@@ -1330,6 +1357,162 @@ async def test_cua_shutdown_clears_cached_components():
assert booter._runtime is None
@pytest.mark.asyncio
async def test_cua_available_checks_shell_health():
from astrbot.core.computer.booters.cua import (
CuaBooter,
CuaFileSystemComponent,
CuaGUIComponent,
CuaPythonComponent,
CuaShellComponent,
_CuaRuntime,
)
class HealthShell(FakeShell):
async def run(self, command: str, **kwargs):
self.commands.append((command, kwargs))
return {"stdout": "_astrbot_cua_ok_\n", "stderr": "", "exit_code": 0}
sandbox = FakeSandbox()
sandbox.shell = HealthShell()
booter = CuaBooter()
booter._runtime = _CuaRuntime(
sandbox_cm=object(),
sandbox=sandbox,
shell=CuaShellComponent(sandbox),
python=CuaPythonComponent(sandbox),
fs=CuaFileSystemComponent(sandbox),
gui=CuaGUIComponent(sandbox),
)
assert await booter.available() is True
assert sandbox.shell.commands[-1][0] == "echo _astrbot_cua_ok_"
@pytest.mark.asyncio
async def test_cua_available_rejects_unknown_health_exit_code():
from astrbot.core.computer.booters.cua import (
CuaBooter,
CuaFileSystemComponent,
CuaGUIComponent,
CuaPythonComponent,
_CuaRuntime,
)
class UnknownExitCodeRuntimeShell:
async def exec(self, command: str, **kwargs):
return {"stdout": "_astrbot_cua_ok_\n", "stderr": "", "exit_code": None}
sandbox = FakeSandbox()
booter = CuaBooter()
booter._runtime = _CuaRuntime(
sandbox_cm=object(),
sandbox=sandbox,
shell=UnknownExitCodeRuntimeShell(),
python=CuaPythonComponent(sandbox),
fs=CuaFileSystemComponent(sandbox),
gui=CuaGUIComponent(sandbox),
)
assert await booter.available() is False
@pytest.mark.asyncio
async def test_cua_available_returns_false_when_shell_health_fails():
from astrbot.core.computer.booters.cua import (
CuaBooter,
CuaFileSystemComponent,
CuaGUIComponent,
CuaPythonComponent,
CuaShellComponent,
_CuaRuntime,
)
class DisconnectedShell:
async def run(self, command: str, **kwargs):
raise RuntimeError("server disconnected")
sandbox = FakeSandbox()
sandbox.shell = DisconnectedShell()
booter = CuaBooter()
booter._runtime = _CuaRuntime(
sandbox_cm=object(),
sandbox=sandbox,
shell=CuaShellComponent(sandbox),
python=CuaPythonComponent(sandbox),
fs=CuaFileSystemComponent(sandbox),
gui=CuaGUIComponent(sandbox),
)
assert await booter.available() is False
@pytest.mark.asyncio
async def test_cua_available_propagates_cancellation():
from astrbot.core.computer.booters.cua import (
CuaBooter,
CuaFileSystemComponent,
CuaGUIComponent,
CuaPythonComponent,
CuaShellComponent,
_CuaRuntime,
)
class CancellingShell:
async def run(self, command: str, **kwargs):
raise asyncio.CancelledError
sandbox = FakeSandbox()
sandbox.shell = CancellingShell()
booter = CuaBooter()
booter._runtime = _CuaRuntime(
sandbox_cm=object(),
sandbox=sandbox,
shell=CuaShellComponent(sandbox),
python=CuaPythonComponent(sandbox),
fs=CuaFileSystemComponent(sandbox),
gui=CuaGUIComponent(sandbox),
)
with pytest.raises(asyncio.CancelledError):
await booter.available()
@pytest.mark.asyncio
async def test_cua_available_allows_shell_health_warning_on_stderr():
from astrbot.core.computer.booters.cua import (
CuaBooter,
CuaFileSystemComponent,
CuaGUIComponent,
CuaPythonComponent,
CuaShellComponent,
_CuaRuntime,
)
class WarningShell(FakeShell):
async def run(self, command: str, **kwargs):
self.commands.append((command, kwargs))
return {
"stdout": "_astrbot_cua_ok_\n",
"stderr": "profile warning\n",
"exit_code": 0,
}
sandbox = FakeSandbox()
sandbox.shell = WarningShell()
booter = CuaBooter()
booter._runtime = _CuaRuntime(
sandbox_cm=object(),
sandbox=sandbox,
shell=CuaShellComponent(sandbox),
python=CuaPythonComponent(sandbox),
fs=CuaFileSystemComponent(sandbox),
gui=CuaGUIComponent(sandbox),
)
assert await booter.available() is True
def test_cua_tools_are_registered_as_builtin_tools():
from astrbot.core.tools.computer_tools.cua import (
CuaKeyboardTypeTool,

View File

@@ -235,3 +235,97 @@ async def test_non_admin_can_send_temp_file(tmp_path, monkeypatch):
assert "Message sent to session" in result
ctx.context.context.send_message.assert_called_once()
@pytest.mark.asyncio
async def test_send_message_downloads_windows_sandbox_file_with_original_name(
tmp_path, monkeypatch
):
"""Windows sandbox paths keep their basename when sent as files."""
tool = SendMessageToUserTool()
ctx = _make_context(runtime="sandbox")
temp_root = tmp_path / "temp"
temp_root.mkdir()
monkeypatch.setattr(
"astrbot.core.tools.message_tools.get_astrbot_temp_path",
lambda: str(temp_root),
)
async def _exec(_command):
return {"content": "_&exists_"}
async def _download_file(_remote_path, local_path):
assert local_path.endswith("report.txt")
assert "\\" not in local_path
with open(local_path, "w", encoding="utf-8") as file:
file.write("report")
booter = SimpleNamespace(
shell=SimpleNamespace(exec=AsyncMock(side_effect=_exec)),
download_file=AsyncMock(side_effect=_download_file),
)
async def mock_get_booter(*args, **kwargs):
del args, kwargs
return booter
monkeypatch.setattr(
"astrbot.core.tools.message_tools.get_booter",
mock_get_booter,
)
result = await tool.call(
ctx,
messages=[{"type": "file", "path": r"C:\Users\AstrBot\report.txt"}],
)
assert "Message sent to session" in result
sent_chain = ctx.context.context.send_message.await_args.args[1]
sent_file = sent_chain.chain[0]
assert sent_file.name == "report.txt"
@pytest.mark.asyncio
async def test_send_message_downloads_trailing_slash_sandbox_file_with_basename(
tmp_path, monkeypatch
):
tool = SendMessageToUserTool()
ctx = _make_context(runtime="sandbox")
temp_root = tmp_path / "temp"
temp_root.mkdir()
monkeypatch.setattr(
"astrbot.core.tools.message_tools.get_astrbot_temp_path",
lambda: str(temp_root),
)
async def _exec(_command):
return {"content": "_&exists_"}
async def _download_file(_remote_path, local_path):
assert local_path.endswith("export")
with open(local_path, "w", encoding="utf-8") as file:
file.write("export")
booter = SimpleNamespace(
shell=SimpleNamespace(exec=AsyncMock(side_effect=_exec)),
download_file=AsyncMock(side_effect=_download_file),
)
async def mock_get_booter(*args, **kwargs):
del args, kwargs
return booter
monkeypatch.setattr(
"astrbot.core.tools.message_tools.get_booter",
mock_get_booter,
)
result = await tool.call(
ctx,
messages=[{"type": "file", "path": "reports/export/"}],
)
assert "Message sent to session" in result
sent_chain = ctx.context.context.send_message.await_args.args[1]
sent_file = sent_chain.chain[0]
assert sent_file.name == "export"