mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-03 03:00:15 +08:00
Compare commits
18 Commits
codex/rest
...
v4.26.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c8f38c886 | ||
|
|
12b1b27825 | ||
|
|
79d787c692 | ||
|
|
08fc565175 | ||
|
|
96474d3d84 | ||
|
|
d5f5631287 | ||
|
|
6a85405105 | ||
|
|
59fdd96627 | ||
|
|
19864b3f85 | ||
|
|
2c8736fe42 | ||
|
|
55af880369 | ||
|
|
30ae18a8f0 | ||
|
|
2cafa217f2 | ||
|
|
2c5165e929 | ||
|
|
fda5161451 | ||
|
|
d3b52356a6 | ||
|
|
33cab38c30 | ||
|
|
4f5075e608 |
@@ -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():
|
||||
|
||||
@@ -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", ""))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -138,7 +138,7 @@ class RespondStage(Stage):
|
||||
return False
|
||||
|
||||
if event.get_platform_name() in [
|
||||
"qq_official",
|
||||
"qq_official_webhook",
|
||||
"weixin_official_account",
|
||||
"dingtalk",
|
||||
]:
|
||||
|
||||
@@ -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",
|
||||
]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
26
changelogs/v4.26.0-beta.4.md
Normal file
26
changelogs/v4.26.0-beta.4.md
Normal 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))
|
||||
38
changelogs/v4.26.0-beta.5.md
Normal file
38
changelogs/v4.26.0-beta.5.md
Normal 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))
|
||||
38
changelogs/v4.26.0-beta.6.md
Normal file
38
changelogs/v4.26.0-beta.6.md
Normal 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))
|
||||
38
changelogs/v4.26.0-beta.7.md
Normal file
38
changelogs/v4.26.0-beta.7.md
Normal 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))
|
||||
38
changelogs/v4.26.0-beta.8.md
Normal file
38
changelogs/v4.26.0-beta.8.md
Normal 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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
284
dashboard/src/components/shared/UpgradeRecoveryDialog.vue
Normal file
284
dashboard/src/components/shared/UpgradeRecoveryDialog.vue
Normal 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>
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 не найден или пуст."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
## 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:
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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:
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
## 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
169
docs/en/use/cli.md
Normal 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
|
||||
```
|
||||
BIN
docs/public/qqofficial-group-recommended-config.png
Normal file
BIN
docs/public/qqofficial-group-recommended-config.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
@@ -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 的机器人卡片上找到该链接。
|
||||
|
||||

|
||||
|
||||
### 在群聊中使用
|
||||
|
||||
#### 添加到群聊
|
||||
|
||||
进入创建的 QQ 机器人的资料页(手机QQ -> 联系人 -> 机器人页签),在下方可以找到 “添加到群聊”。目前只能添加到自己为群主的群聊。
|
||||
|
||||
#### 设置机器人可获取的群聊消息范围和主动发言
|
||||
|
||||
在手机 QQ 的群聊设置中打开机器人设置,推荐将 `机器人可获取的群聊消息范围` 设置为 `获取群内全部消息`,并开启 `机器人主动在群聊内发言`。
|
||||
|
||||
这样机器人可以接收群聊全量消息,也可以在群聊中主动推送消息,例如定时任务推送、插件主动通知等。
|
||||
|
||||
Webhook 模式还需要在 QQ 开放平台的回调配置中勾选群事件 `GROUP_MESSAGE_CREATE`,否则 AstrBot 无法收到群聊全量消息事件。
|
||||
|
||||

|
||||
|
||||
## 手动申请 QQ 机器人(不推荐)
|
||||
|
||||
### 申请一个机器人
|
||||
|
||||
首先,打开 [QQ官方机器人](https://q.qq.com) 并登录。
|
||||
|
||||
@@ -32,7 +66,7 @@
|
||||
|
||||

|
||||
|
||||
## 允许机器人加入频道/群/私聊
|
||||
### 允许机器人加入频道/群/私聊
|
||||
|
||||
点击`沙箱配置`,这允许你立即设置一个沙箱频道/QQ群/QQ私聊,用于拉入机器人(需要小于等于20个人)。
|
||||
|
||||
@@ -40,26 +74,40 @@
|
||||
|
||||

|
||||
|
||||
## 获取 appid、secret
|
||||
### 获取 appid、secret
|
||||
|
||||
添加机器人到你想用的地方后。
|
||||
|
||||
点击 `开发->开发设置`,找到 appid、secret。复制并保存它们。
|
||||
|
||||
## 添加 IP 白名单
|
||||
如果你使用 AstrBot WebUI 的 `扫码一键创建`,这一步可以跳过。扫码绑定成功后,AstrBot 会自动填入 `appid` 和 `secret`。
|
||||
|
||||
### 添加 IP 白名单
|
||||
|
||||
点击 `开发->开发设置`,找到 IP 白名单。添加你的服务器 IP 地址。
|
||||
|
||||

|
||||
|
||||
## 在 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 的机器人页的卡片上找到。
|
||||
|
||||

|
||||
|
||||
将请求地址填写为该地址。
|
||||
|
||||
> [!TIP]
|
||||
> v4.8.0 之前没有 `统一 Webhook 模式`,则请求地址填写 `<你的域名>/astrbot-qo-webhook/callback`。
|
||||
|
||||
填写好之后,添加事件,四个事件类型都全选:单聊事件、群事件、频道事件等,如下图。
|
||||
填写好之后,添加事件。需要接收群聊全量消息时,请勾选群事件 `GROUP_MESSAGE_CREATE`;同时按需勾选单聊事件、频道事件等。
|
||||
|
||||

|
||||
|
||||
@@ -94,10 +139,6 @@
|
||||
|
||||
接着重启 AstrBot。
|
||||
|
||||
## 🎉 大功告成
|
||||
|
||||
此时,你的 AstrBot 应该已经连接成功。如果发送消息没有反应,请等待一两分钟后重启 AstrBot 再进行确认(测试时发现回调地址不会立即生效)。
|
||||
|
||||
## 附录:如何配置反向代理
|
||||
|
||||
如果你还没有相关经验,这里推荐使用 Caddy 作为反向代理的工具,请参考:
|
||||
|
||||
@@ -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 机器人(不推荐)
|
||||
|
||||
### 申请一个机器人
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
@@ -44,7 +56,7 @@
|
||||
|
||||

|
||||
|
||||
## 允许机器人加入频道/群/私聊
|
||||
### 允许机器人加入频道/群/私聊
|
||||
|
||||
点击`沙箱配置`,这允许你立即设置一个沙箱频道/QQ群/QQ私聊,用于拉入机器人(需要小于等于20个人)。
|
||||
|
||||
@@ -52,13 +64,15 @@
|
||||
|
||||

|
||||
|
||||
## 获取 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
169
docs/zh/use/cli.md
Normal 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
30
main.py
@@ -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()
|
||||
|
||||
@@ -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
43
tests/test_cli_run.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
378
tests/test_qqofficial_group_message_create.py
Normal file
378
tests/test_qqofficial_group_message_create.py
Normal 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)] == [
|
||||
"第一段",
|
||||
"第二段",
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user