mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-03 11:10:14 +08:00
Compare commits
15 Commits
fix/prerel
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89b80a6ca7 | ||
|
|
9f50c900b0 | ||
|
|
e8f00960fe | ||
|
|
413340fca5 | ||
|
|
152fb3be8f | ||
|
|
1e3b12acc9 | ||
|
|
ea19be1d06 | ||
|
|
70a52ea6d0 | ||
|
|
029e9c84af | ||
|
|
3b41a870ff | ||
|
|
b673cb375f | ||
|
|
4cf210e503 | ||
|
|
372b9f5bfc | ||
|
|
41f8960302 | ||
|
|
7831c68660 |
@@ -1,4 +1,4 @@
|
||||
import logging
|
||||
|
||||
__version__ = "4.26.2"
|
||||
__version__ = "4.26.4"
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
@@ -543,11 +543,12 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
message_type=session.message_type,
|
||||
)
|
||||
cron_event.role = event.role
|
||||
cfg = ctx.get_config(umo=event.unified_msg_origin) or {}
|
||||
provider_settings = cfg.get("provider_settings") or {}
|
||||
config = MainAgentBuildConfig(
|
||||
tool_call_timeout=run_context.tool_call_timeout,
|
||||
streaming_response=ctx.get_config()
|
||||
.get("provider_settings", {})
|
||||
.get("stream", False),
|
||||
streaming_response=provider_settings.get("stream", False),
|
||||
provider_settings=provider_settings,
|
||||
)
|
||||
|
||||
req = ProviderRequest()
|
||||
|
||||
10
astrbot/core/desktop_runtime.py
Normal file
10
astrbot/core/desktop_runtime.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import os
|
||||
|
||||
DESKTOP_MANAGED_RESTART_MESSAGE = (
|
||||
"AstrBot Desktop manages this backend process. Please restart or update from "
|
||||
"the desktop app instead of the core WebUI."
|
||||
)
|
||||
|
||||
|
||||
def is_desktop_managed_backend() -> bool:
|
||||
return os.environ.get("ASTRBOT_DESKTOP_MANAGED") == "1"
|
||||
@@ -565,7 +565,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
return None
|
||||
|
||||
# Discord 斜杠指令名称规范
|
||||
if not re.match(r"^[a-z0-9_-]{1,32}$", cmd_name):
|
||||
if cmd_name != cmd_name.lower() or not re.match(r"^[-_'\\w]{1,32}$", cmd_name):
|
||||
logger.debug(f"[Discord] Skipping invalid slash command format: {cmd_name}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -176,15 +176,6 @@ class QQOfficialWebhook:
|
||||
响应数据
|
||||
"""
|
||||
body = await request.get_data()
|
||||
if not _verify_qq_webhook_signature(
|
||||
self.secret,
|
||||
request.headers.get(_SIGNATURE_TIMESTAMP_HEADER),
|
||||
request.headers.get(_SIGNATURE_HEADER),
|
||||
body,
|
||||
):
|
||||
logger.warning("qq_official_webhook signature verification failed.")
|
||||
return {"error": "Invalid signature"}, 401
|
||||
|
||||
try:
|
||||
msg = json.loads(body.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
@@ -205,6 +196,15 @@ class QQOfficialWebhook:
|
||||
logger.debug(f"webhook validation response: {signed}")
|
||||
return signed
|
||||
|
||||
if not _verify_qq_webhook_signature(
|
||||
self.secret,
|
||||
request.headers.get(_SIGNATURE_TIMESTAMP_HEADER),
|
||||
request.headers.get(_SIGNATURE_HEADER),
|
||||
body,
|
||||
):
|
||||
logger.warning("qq_official_webhook signature verification failed.")
|
||||
return {"error": "Invalid signature"}, 401
|
||||
|
||||
event_id = msg.get("id")
|
||||
if event_id:
|
||||
now = time.monotonic()
|
||||
|
||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from urllib.parse import unquote
|
||||
|
||||
from fastapi.responses import Response as FastAPIResponse
|
||||
from requests import Response
|
||||
from wechatpy.enterprise import WeChatClient, parse_message
|
||||
from wechatpy.enterprise.crypto import WeChatCrypto
|
||||
@@ -97,7 +98,7 @@ class WecomServer:
|
||||
"""内部服务器的 GET 验证入口"""
|
||||
return await self.handle_verify(request)
|
||||
|
||||
async def handle_verify(self, request) -> str:
|
||||
async def handle_verify(self, request) -> FastAPIResponse:
|
||||
"""处理验证请求,可被统一 webhook 入口复用
|
||||
|
||||
Args:
|
||||
@@ -116,7 +117,7 @@ class WecomServer:
|
||||
args.get("echostr"),
|
||||
)
|
||||
logger.info("验证请求有效性成功。")
|
||||
return echo_str
|
||||
return FastAPIResponse(content=echo_str, media_type="text/plain")
|
||||
except InvalidSignatureException:
|
||||
logger.error("验证请求有效性失败,签名异常,请检查配置。")
|
||||
raise
|
||||
@@ -271,15 +272,13 @@ class WecomPlatformAdapter(Platform):
|
||||
) -> None:
|
||||
# 企业微信客服不支持主动发送
|
||||
if hasattr(self.client, "kf_message"):
|
||||
logger.warning("企业微信客服模式不支持 send_by_session 主动发送。")
|
||||
await super().send_by_session(session, message_chain)
|
||||
return
|
||||
raise Exception("企业微信客服模式不支持 send_by_session 主动发送。")
|
||||
if not self.agent_id:
|
||||
logger.warning(
|
||||
await super().send_by_session(session, message_chain)
|
||||
raise Exception(
|
||||
f"send_by_session 失败:无法为会话 {session.session_id} 推断 agent_id。",
|
||||
)
|
||||
await super().send_by_session(session, message_chain)
|
||||
return
|
||||
|
||||
message_obj = AstrBotMessage()
|
||||
message_obj.self_id = self.agent_id
|
||||
@@ -302,7 +301,7 @@ class WecomPlatformAdapter(Platform):
|
||||
"wecom 适配器",
|
||||
id=self.config.get("id", "wecom"),
|
||||
support_streaming_message=False,
|
||||
support_proactive_message=False,
|
||||
support_proactive_message=True,
|
||||
)
|
||||
|
||||
@override
|
||||
|
||||
@@ -121,7 +121,7 @@ class WecomAIBotAdapter(Platform):
|
||||
name="wecom_ai_bot",
|
||||
description="企业微信智能机器人适配器,支持 HTTP 回调和长连接模式",
|
||||
id=self.config.get("id", "wecom_ai_bot"),
|
||||
support_proactive_message=bool(self.msg_push_webhook_url),
|
||||
support_proactive_message=True,
|
||||
)
|
||||
|
||||
self.api_client: WecomAIBotAPIClient | None = None
|
||||
@@ -568,21 +568,18 @@ class WecomAIBotAdapter(Platform):
|
||||
) -> None:
|
||||
"""通过消息推送 webhook 发送消息。"""
|
||||
if not self.webhook_client:
|
||||
logger.warning(
|
||||
"主动消息发送失败: 未配置企业微信消息推送 Webhook URL,请前往配置添加。session_id=%s",
|
||||
session.session_id,
|
||||
raise RuntimeError(
|
||||
"主动消息发送失败: 未配置企业微信消息推送 Webhook URL,请前往配置添加。"
|
||||
"详见文档: https://docs.astrbot.app/platform/wecom_ai_bot.html#%E9%85%8D%E7%BD%AE-astrbot。"
|
||||
f"session_id={session.session_id}"
|
||||
)
|
||||
await super().send_by_session(session, message_chain)
|
||||
return
|
||||
|
||||
try:
|
||||
await self.webhook_client.send_message_chain(message_chain)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"企业微信消息推送失败(session=%s): %s",
|
||||
session.session_id,
|
||||
e,
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"企业微信消息推送失败: session_id={session.session_id}, error={e}"
|
||||
) from e
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
def run(self) -> Awaitable[Any]:
|
||||
|
||||
@@ -395,6 +395,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
await super().send_by_session(session, message_chain)
|
||||
raise Exception("微信公众号不支持发送主动消息")
|
||||
|
||||
@override
|
||||
def meta(self) -> PlatformMetadata:
|
||||
@@ -403,7 +404,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
"微信公众平台 适配器",
|
||||
id=self.config.get("id", "weixin_official_account"),
|
||||
support_streaming_message=False,
|
||||
support_proactive_message=False,
|
||||
support_proactive_message=True,
|
||||
)
|
||||
|
||||
@override
|
||||
|
||||
@@ -33,7 +33,15 @@ class WebhookRequest:
|
||||
raise
|
||||
|
||||
|
||||
def _response_from_result(result: Any):
|
||||
def webhook_response_from_result(result: Any):
|
||||
"""Convert adapter callback results into raw webhook HTTP responses.
|
||||
|
||||
Args:
|
||||
result: Adapter callback return value.
|
||||
|
||||
Returns:
|
||||
A FastAPI-compatible raw response value.
|
||||
"""
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
|
||||
@@ -55,6 +63,9 @@ def _response_from_result(result: Any):
|
||||
if isinstance(result, dict | list):
|
||||
return JSONResponse(result)
|
||||
|
||||
if isinstance(result, str | bytes):
|
||||
return Response(content=result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -77,7 +88,7 @@ class FastAPIWebhookServer:
|
||||
result = view_func()
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
return _response_from_result(result)
|
||||
return webhook_response_from_result(result)
|
||||
|
||||
self.app.add_api_route(
|
||||
path,
|
||||
|
||||
@@ -1057,11 +1057,14 @@ class FunctionToolManager:
|
||||
"mcp_server_list",
|
||||
[],
|
||||
)
|
||||
local_mcp_config = self.load_mcp_config()
|
||||
local_mcp_config = copy.deepcopy(self.load_mcp_config())
|
||||
|
||||
synced_count = 0
|
||||
mcp_servers = local_mcp_config.setdefault("mcpServers", {})
|
||||
synced_servers: list[tuple[str, dict]] = []
|
||||
for server in mcp_server_list:
|
||||
server_name = server["name"]
|
||||
server_name = server.get("name")
|
||||
if not server_name:
|
||||
continue
|
||||
operational_urls = server.get("operational_urls", [])
|
||||
if not operational_urls:
|
||||
continue
|
||||
@@ -1070,28 +1073,28 @@ class FunctionToolManager:
|
||||
if not server_url:
|
||||
continue
|
||||
# 添加到配置中(同名会覆盖)
|
||||
local_mcp_config["mcpServers"][server_name] = {
|
||||
server_config = {
|
||||
"url": server_url,
|
||||
"transport": "sse",
|
||||
"active": True,
|
||||
"provider": "modelscope",
|
||||
}
|
||||
synced_count += 1
|
||||
mcp_servers[server_name] = server_config
|
||||
synced_servers.append((server_name, server_config))
|
||||
|
||||
if synced_count > 0:
|
||||
if synced_servers:
|
||||
self.save_mcp_config(local_mcp_config)
|
||||
tasks = []
|
||||
for server in mcp_server_list:
|
||||
name = server["name"]
|
||||
for name, config in synced_servers:
|
||||
tasks.append(
|
||||
self.enable_mcp_server(
|
||||
name=name,
|
||||
config=local_mcp_config["mcpServers"][name],
|
||||
config=config,
|
||||
),
|
||||
)
|
||||
await asyncio.gather(*tasks)
|
||||
logger.info(
|
||||
f"从 ModelScope 同步了 {synced_count} 个 MCP 服务器",
|
||||
f"从 ModelScope 同步了 {len(synced_servers)} 个 MCP 服务器",
|
||||
)
|
||||
else:
|
||||
logger.warning("没有找到可用的 ModelScope MCP 服务器")
|
||||
|
||||
@@ -1013,7 +1013,7 @@ class PluginManager:
|
||||
logger.warning(
|
||||
f"插件 {smd.name} 未被正常终止: {e!s}, 可能会导致该插件运行不正常。",
|
||||
)
|
||||
if smd.name:
|
||||
if smd.name and smd.activated:
|
||||
await self._unbind_plugin(smd.name, specified_module_path)
|
||||
|
||||
result = await self.load(specified_module_path)
|
||||
|
||||
@@ -304,6 +304,11 @@ class FileReadTool(FunctionTool):
|
||||
)
|
||||
if not normalized_path:
|
||||
raise ValueError("`path` must be a non-empty string.")
|
||||
if local_env and os.path.isdir(normalized_path):
|
||||
return (
|
||||
f"Error: '{normalized_path}' is a directory, not a file. "
|
||||
"Use a file path instead, or use 'astrbot_execute_shell' to list directory contents."
|
||||
)
|
||||
offset, limit = self._validate_read_window(offset, limit)
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
@@ -8,6 +9,10 @@ import psutil
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.desktop_runtime import (
|
||||
DESKTOP_MANAGED_RESTART_MESSAGE,
|
||||
is_desktop_managed_backend,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_path
|
||||
from astrbot.core.utils.io import ensure_dir
|
||||
|
||||
@@ -135,6 +140,11 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
quoted_args = [f'"{arg}"' if " " in arg else arg for arg in argv[1:]]
|
||||
os.execl(executable, quoted_executable, *quoted_args)
|
||||
return
|
||||
elif os.name == "nt":
|
||||
subprocess.Popen(
|
||||
[executable] + argv[1:], creationflags=subprocess.CREATE_NEW_CONSOLE
|
||||
)
|
||||
os._exit(0)
|
||||
os.execv(executable, argv)
|
||||
|
||||
def _reboot(self, delay: int = 3) -> None:
|
||||
@@ -142,6 +152,10 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
在指定的延迟后,终止所有子进程并重新启动程序
|
||||
这里只能使用 os.exec* 来重启程序
|
||||
"""
|
||||
if is_desktop_managed_backend():
|
||||
logger.error(DESKTOP_MANAGED_RESTART_MESSAGE)
|
||||
raise RuntimeError(DESKTOP_MANAGED_RESTART_MESSAGE)
|
||||
|
||||
time.sleep(delay)
|
||||
self.terminate_child_processes()
|
||||
executable = sys.executable
|
||||
|
||||
@@ -178,6 +178,101 @@ async def _emit_download_progress(progress_callback, payload: dict) -> None:
|
||||
await result
|
||||
|
||||
|
||||
class DownloadFileHTTPError(RuntimeError):
|
||||
"""Raised when a file download returns an unsuccessful HTTP status."""
|
||||
|
||||
|
||||
def _raise_for_download_status(resp, url: str) -> None:
|
||||
if resp.status == 200:
|
||||
return
|
||||
logger.error(
|
||||
"Failed to download file from %s. HTTP status code: %s",
|
||||
_safe_url_for_log(url),
|
||||
resp.status,
|
||||
)
|
||||
raise DownloadFileHTTPError(
|
||||
"Failed to download file from "
|
||||
f"{_safe_url_for_log(url)}. HTTP status code: {resp.status}"
|
||||
)
|
||||
|
||||
|
||||
async def _download_response_to_file(
|
||||
resp,
|
||||
file_obj,
|
||||
url: str,
|
||||
show_progress: bool,
|
||||
progress_callback,
|
||||
show_downloading_label: bool = True,
|
||||
) -> None:
|
||||
"""Write a successful download response to a local file.
|
||||
|
||||
Args:
|
||||
resp: aiohttp response object to read from.
|
||||
file_obj: Open writable binary file object.
|
||||
url: Source URL used for progress events and sanitized errors.
|
||||
show_progress: Whether to print progress to stdout.
|
||||
progress_callback: Optional callback for progress payloads.
|
||||
show_downloading_label: Whether to use the standard download heading.
|
||||
|
||||
"""
|
||||
|
||||
total_size = int(resp.headers.get("content-length", 0))
|
||||
downloaded_size = 0
|
||||
start_time = time.time()
|
||||
if show_progress:
|
||||
if show_downloading_label:
|
||||
print(
|
||||
f"Downloading: {_safe_url_for_log(url)} | "
|
||||
f"Size: {total_size / 1024:.2f} KB"
|
||||
)
|
||||
else:
|
||||
print(f"Size: {total_size / 1024:.2f} KB | URL: {_safe_url_for_log(url)}")
|
||||
await _emit_download_progress(
|
||||
progress_callback,
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": 0,
|
||||
"total": total_size,
|
||||
"percent": 0,
|
||||
"speed": 0,
|
||||
},
|
||||
)
|
||||
while True:
|
||||
chunk = await resp.content.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
file_obj.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
elapsed_time = time.time() - start_time if time.time() - start_time > 0 else 1
|
||||
speed = downloaded_size / 1024 / elapsed_time # KB/s
|
||||
percent = downloaded_size / total_size if total_size > 0 else 0
|
||||
await _emit_download_progress(
|
||||
progress_callback,
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": downloaded_size,
|
||||
"total": total_size,
|
||||
"percent": percent,
|
||||
"speed": speed,
|
||||
},
|
||||
)
|
||||
if show_progress:
|
||||
print(
|
||||
f"\rProgress: {percent:.2%} Speed: {speed:.2f} KB/s",
|
||||
end="",
|
||||
)
|
||||
await _emit_download_progress(
|
||||
progress_callback,
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": downloaded_size,
|
||||
"total": total_size,
|
||||
"percent": 1,
|
||||
"speed": 0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def download_file(
|
||||
url: str,
|
||||
path: str,
|
||||
@@ -209,69 +304,15 @@ async def download_file(
|
||||
connector=connector,
|
||||
) as session:
|
||||
async with session.get(url, timeout=1800) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(
|
||||
"Failed to download file from %s. HTTP status code: %s",
|
||||
_safe_url_for_log(url),
|
||||
resp.status,
|
||||
)
|
||||
total_size = int(resp.headers.get("content-length", 0))
|
||||
downloaded_size = 0
|
||||
start_time = time.time()
|
||||
if show_progress:
|
||||
print(
|
||||
f"Downloading: {_safe_url_for_log(url)} | "
|
||||
f"Size: {total_size / 1024:.2f} KB"
|
||||
)
|
||||
await _emit_download_progress(
|
||||
progress_callback,
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": 0,
|
||||
"total": total_size,
|
||||
"percent": 0,
|
||||
"speed": 0,
|
||||
},
|
||||
)
|
||||
_raise_for_download_status(resp, url)
|
||||
with open(path, "wb") as f:
|
||||
while True:
|
||||
chunk = await resp.content.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
elapsed_time = (
|
||||
time.time() - start_time
|
||||
if time.time() - start_time > 0
|
||||
else 1
|
||||
)
|
||||
speed = downloaded_size / 1024 / elapsed_time # KB/s
|
||||
percent = downloaded_size / total_size if total_size > 0 else 0
|
||||
await _emit_download_progress(
|
||||
progress_callback,
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": downloaded_size,
|
||||
"total": total_size,
|
||||
"percent": percent,
|
||||
"speed": speed,
|
||||
},
|
||||
)
|
||||
if show_progress:
|
||||
print(
|
||||
f"\rProgress: {percent:.2%} Speed: {speed:.2f} KB/s",
|
||||
end="",
|
||||
)
|
||||
await _emit_download_progress(
|
||||
progress_callback,
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": downloaded_size,
|
||||
"total": total_size,
|
||||
"percent": 1,
|
||||
"speed": 0,
|
||||
},
|
||||
)
|
||||
await _download_response_to_file(
|
||||
resp,
|
||||
f,
|
||||
url,
|
||||
show_progress,
|
||||
progress_callback,
|
||||
)
|
||||
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
|
||||
if not allow_insecure_ssl_fallback:
|
||||
raise
|
||||
@@ -291,63 +332,16 @@ async def download_file(
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, ssl=ssl_context, timeout=120) as resp:
|
||||
total_size = int(resp.headers.get("content-length", 0))
|
||||
downloaded_size = 0
|
||||
start_time = time.time()
|
||||
if show_progress:
|
||||
print(
|
||||
f"Size: {total_size / 1024:.2f} KB | "
|
||||
f"URL: {_safe_url_for_log(url)}"
|
||||
)
|
||||
await _emit_download_progress(
|
||||
progress_callback,
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": 0,
|
||||
"total": total_size,
|
||||
"percent": 0,
|
||||
"speed": 0,
|
||||
},
|
||||
)
|
||||
_raise_for_download_status(resp, url)
|
||||
with open(path, "wb") as f:
|
||||
while True:
|
||||
chunk = await resp.content.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
elapsed_time = (
|
||||
time.time() - start_time
|
||||
if time.time() - start_time > 0
|
||||
else 1
|
||||
)
|
||||
speed = downloaded_size / 1024 / elapsed_time # KB/s
|
||||
percent = downloaded_size / total_size if total_size > 0 else 0
|
||||
await _emit_download_progress(
|
||||
progress_callback,
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": downloaded_size,
|
||||
"total": total_size,
|
||||
"percent": percent,
|
||||
"speed": speed,
|
||||
},
|
||||
)
|
||||
if show_progress:
|
||||
print(
|
||||
f"\rProgress: {percent:.2%} Speed: {speed:.2f} KB/s",
|
||||
end="",
|
||||
)
|
||||
await _emit_download_progress(
|
||||
progress_callback,
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": downloaded_size,
|
||||
"total": total_size,
|
||||
"percent": 1,
|
||||
"speed": 0,
|
||||
},
|
||||
)
|
||||
await _download_response_to_file(
|
||||
resp,
|
||||
f,
|
||||
url,
|
||||
show_progress,
|
||||
progress_callback,
|
||||
show_downloading_label=False,
|
||||
)
|
||||
if show_progress:
|
||||
print()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tencent Silk audio conversion helpers."""
|
||||
|
||||
import asyncio
|
||||
import audioop
|
||||
import os
|
||||
import subprocess
|
||||
import wave
|
||||
@@ -8,6 +9,9 @@ from io import BytesIO
|
||||
|
||||
from astrbot.core import logger
|
||||
|
||||
# The SILK SDK only supports these rates
|
||||
_PYSILK_SUPPORTED_RATES = frozenset({8000, 12000, 16000, 24000, 32000, 48000})
|
||||
|
||||
|
||||
async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str:
|
||||
"""Decode a Tencent Silk file to 24 kHz mono PCM WAV.
|
||||
@@ -69,8 +73,19 @@ async def wav_to_tencent_silk(wav_path: str, output_path: str) -> float:
|
||||
|
||||
with wave.open(wav_path, "rb") as wav:
|
||||
rate = wav.getframerate()
|
||||
frames = wav.getnframes()
|
||||
pcm_data = wav.readframes(frames)
|
||||
channels = wav.getnchannels()
|
||||
sampwidth = wav.getsampwidth()
|
||||
pcm_data = wav.readframes(wav.getnframes())
|
||||
|
||||
# Downmix to mono, resample to 24 kHz if needed, and convert to 16-bit PCM
|
||||
# (pysilk only accepts 16-bit linear PCM)
|
||||
if channels == 2:
|
||||
pcm_data = audioop.tomono(pcm_data, sampwidth, 0.5, 0.5)
|
||||
if rate not in _PYSILK_SUPPORTED_RATES:
|
||||
pcm_data, _ = audioop.ratecv(pcm_data, sampwidth, 1, rate, 24000, None)
|
||||
rate = 24000
|
||||
if sampwidth != 2:
|
||||
pcm_data = audioop.lin2lin(pcm_data, sampwidth, 2)
|
||||
|
||||
input_io = BytesIO(pcm_data)
|
||||
output_io = BytesIO()
|
||||
@@ -78,7 +93,7 @@ async def wav_to_tencent_silk(wav_path: str, output_path: str) -> float:
|
||||
pysilk.encode(input_io, output_io, rate, rate, tencent=True)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(output_io.getvalue())
|
||||
return frames / rate if rate else 0
|
||||
return len(pcm_data) / (2 * rate) if rate else 0
|
||||
|
||||
|
||||
async def convert_to_pcm_wav(input_path: str, output_path: str) -> str:
|
||||
|
||||
@@ -234,45 +234,6 @@ class RepoZipUpdator:
|
||||
"""Semver 版本比较"""
|
||||
return VersionComparator.compare_version(v1, v2)
|
||||
|
||||
def _is_prerelease_version(self, version: str) -> bool:
|
||||
"""Check if a version string is a prerelease version."""
|
||||
return bool(
|
||||
re.search(
|
||||
r"[\-_.]?(alpha|beta|rc|dev)[\-_.]?\d*$",
|
||||
version,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
)
|
||||
|
||||
def _matches_prerelease_policy(
|
||||
self,
|
||||
release: dict,
|
||||
consider_prerelease: bool,
|
||||
) -> bool:
|
||||
"""Return whether a release is allowed by the prerelease policy."""
|
||||
return consider_prerelease or not self._is_prerelease_version(
|
||||
release["tag_name"],
|
||||
)
|
||||
|
||||
def _select_release_data(
|
||||
self,
|
||||
releases: list,
|
||||
consider_prerelease: bool,
|
||||
) -> dict | None:
|
||||
"""Select the first release allowed by the prerelease policy."""
|
||||
return next(
|
||||
(
|
||||
release
|
||||
for release in releases
|
||||
if self._matches_prerelease_policy(release, consider_prerelease)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _has_newer_version(self, current_version: str, release_version: str) -> bool:
|
||||
"""Return whether the selected release is newer than current version."""
|
||||
return self.compare_version(current_version, release_version) < 0
|
||||
|
||||
async def check_update(
|
||||
self,
|
||||
url: str,
|
||||
@@ -280,17 +241,29 @@ class RepoZipUpdator:
|
||||
consider_prerelease: bool = True,
|
||||
) -> ReleaseInfo | None:
|
||||
update_data = await self.fetch_release_info(url)
|
||||
if not update_data:
|
||||
|
||||
sel_release_data = None
|
||||
if consider_prerelease:
|
||||
tag_name = update_data[0]["tag_name"]
|
||||
sel_release_data = update_data[0]
|
||||
else:
|
||||
for data in update_data:
|
||||
# 跳过带有 alpha、beta 等预发布标签的版本
|
||||
if re.search(
|
||||
r"[\-_.]?(alpha|beta|rc|dev)[\-_.]?\d*$",
|
||||
data["tag_name"],
|
||||
re.IGNORECASE,
|
||||
):
|
||||
continue
|
||||
tag_name = data["tag_name"]
|
||||
sel_release_data = data
|
||||
break
|
||||
|
||||
if not sel_release_data or not tag_name:
|
||||
logger.error("未找到合适的发布版本")
|
||||
return None
|
||||
|
||||
sel_release_data = self._select_release_data(update_data, consider_prerelease)
|
||||
if not sel_release_data:
|
||||
logger.error("未找到合适的发布版本")
|
||||
return None
|
||||
|
||||
tag_name = sel_release_data["tag_name"]
|
||||
if not self._has_newer_version(current_version, tag_name):
|
||||
if self.compare_version(current_version, tag_name) >= 0:
|
||||
return None
|
||||
return ReleaseInfo(
|
||||
version=tag_name,
|
||||
|
||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from astrbot.core.platform.webhook_server import webhook_response_from_result
|
||||
from astrbot.dashboard.asgi_runtime import DashboardRequest
|
||||
from astrbot.dashboard.async_utils import run_maybe_async
|
||||
from astrbot.dashboard.responses import ApiError, ok
|
||||
@@ -50,11 +52,30 @@ def _model_dict(payload) -> dict[str, Any]:
|
||||
async def _run(operation):
|
||||
try:
|
||||
result = await run_maybe_async(operation)
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
return ok(result)
|
||||
except PlatformServiceError as exc:
|
||||
_raise_platform_error(exc)
|
||||
|
||||
|
||||
async def _run_webhook(operation):
|
||||
"""Run a platform webhook callback and preserve the platform response.
|
||||
|
||||
Args:
|
||||
operation: Callback operation returning a platform-specific response.
|
||||
|
||||
Returns:
|
||||
Raw FastAPI response compatible with third-party webhook protocols.
|
||||
"""
|
||||
try:
|
||||
result = await run_maybe_async(operation)
|
||||
except PlatformServiceError as exc:
|
||||
return webhook_response_from_result(({"error": str(exc)}, exc.status_code))
|
||||
|
||||
return webhook_response_from_result(result)
|
||||
|
||||
|
||||
@router.post("/bot-types/{bot_type}/registration")
|
||||
async def register_bot_type(
|
||||
bot_type: str,
|
||||
@@ -73,7 +94,7 @@ async def verify_platform_webhook(
|
||||
request: Request,
|
||||
service: PlatformService = Depends(get_service),
|
||||
):
|
||||
return await _run(
|
||||
return await _run_webhook(
|
||||
lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request))
|
||||
)
|
||||
|
||||
@@ -84,7 +105,7 @@ async def receive_platform_webhook(
|
||||
request: Request,
|
||||
service: PlatformService = Depends(get_service),
|
||||
):
|
||||
return await _run(
|
||||
return await _run_webhook(
|
||||
lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request))
|
||||
)
|
||||
|
||||
@@ -95,7 +116,7 @@ async def dashboard_platform_webhook(
|
||||
request: Request,
|
||||
service: PlatformService = Depends(get_service),
|
||||
):
|
||||
return await _run(
|
||||
return await _run_webhook(
|
||||
lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request))
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.desktop_runtime import DESKTOP_MANAGED_RESTART_MESSAGE
|
||||
from astrbot.dashboard.async_utils import run_maybe_async
|
||||
from astrbot.dashboard.schemas import PipInstallRequest, UpdateRequest
|
||||
from astrbot.dashboard.services.update_service import (
|
||||
@@ -58,6 +59,15 @@ def _service_response(result: UpdateServiceResult) -> JSONResponse:
|
||||
|
||||
def _service_error(exc: UpdateServiceError) -> JSONResponse:
|
||||
logger.error(f"Dashboard update operation failed: {exc}", exc_info=True)
|
||||
if exc.code == "desktop_managed":
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"message": DESKTOP_MANAGED_RESTART_MESSAGE,
|
||||
"data": None,
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"status": "error", "message": "An internal error has occurred.", "data": None},
|
||||
status_code=200,
|
||||
|
||||
@@ -21,6 +21,10 @@ from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import ProviderStat
|
||||
from astrbot.core.desktop_runtime import (
|
||||
DESKTOP_MANAGED_RESTART_MESSAGE,
|
||||
is_desktop_managed_backend,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_path
|
||||
from astrbot.core.utils.auth_password import (
|
||||
is_default_dashboard_password,
|
||||
@@ -57,6 +61,9 @@ class StatService:
|
||||
raise StatServiceError(
|
||||
"You are not permitted to do this operation in demo mode"
|
||||
)
|
||||
if is_desktop_managed_backend():
|
||||
raise StatServiceError(DESKTOP_MANAGED_RESTART_MESSAGE)
|
||||
|
||||
await self.core_lifecycle.restart()
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -15,6 +15,10 @@ from astrbot.core import logger
|
||||
from astrbot.core import pip_installer as _pip_installer
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.desktop_runtime import (
|
||||
DESKTOP_MANAGED_RESTART_MESSAGE,
|
||||
is_desktop_managed_backend,
|
||||
)
|
||||
from astrbot.core.updator import AstrBotUpdator
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_data_path,
|
||||
@@ -67,7 +71,9 @@ class UpdateServiceResult:
|
||||
|
||||
|
||||
class UpdateServiceError(Exception):
|
||||
pass
|
||||
def __init__(self, message: str, *, code: str | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
class UpdateService:
|
||||
@@ -143,6 +149,12 @@ class UpdateService:
|
||||
raise UpdateServiceError(exc.__str__()) from exc
|
||||
|
||||
async def update_project(self, data: object) -> UpdateServiceResult:
|
||||
if is_desktop_managed_backend():
|
||||
raise UpdateServiceError(
|
||||
DESKTOP_MANAGED_RESTART_MESSAGE,
|
||||
code="desktop_managed",
|
||||
)
|
||||
|
||||
payload = data if isinstance(data, dict) else {}
|
||||
version = payload.get("version", "")
|
||||
reboot = payload.get("reboot", True)
|
||||
|
||||
31
changelogs/v4.26.3.md
Normal file
31
changelogs/v4.26.3.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## What's Changed
|
||||
|
||||
### Features
|
||||
|
||||
- Support installing local plugins (#8448)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Preserve fallback models for future tasks (#9054)
|
||||
- Validate plugin install sources (#9061)
|
||||
- Paginate knowledge base dashboard lists (#9055)
|
||||
|
||||
### Styles
|
||||
|
||||
- Standardize dashboard dialog styling (#9062)
|
||||
|
||||
## 中文翻译
|
||||
|
||||
### 功能
|
||||
|
||||
- 支持安装本地插件 (#8448)
|
||||
|
||||
### 修复
|
||||
|
||||
- 保留未来任务可用的 fallback models (#9054)
|
||||
- 校验插件安装来源 (#9061)
|
||||
- 为知识库仪表盘列表增加分页 (#9055)
|
||||
|
||||
### 样式
|
||||
|
||||
- 统一仪表盘对话框样式 (#9062)
|
||||
13
changelogs/v4.26.4.md
Normal file
13
changelogs/v4.26.4.md
Normal file
@@ -0,0 +1,13 @@
|
||||
## What's Changed
|
||||
|
||||
- fix: astrbot_file_read_tool returns clear error for directory path instead of misleading Permission denied (#9088) (41f896030)
|
||||
- fix: reduce markdown streaming lag (#9097) (372b9f5bf)
|
||||
- fix: resample and downmix WAV files for Tencent Silk encoding (#9100) (4cf210e50)
|
||||
- fix: guard desktop-managed core restart (#9098) (b673cb375)
|
||||
- fix: updated reboot logic (#9073) (3b41a870f)
|
||||
- 修复了DISCORD适配器注册命令正则过于严格的问题 (#9102) (029e9c84a)
|
||||
- fix: reject non-200 download responses (#9085) (70a52ea6d)
|
||||
- fix: wecom adapter returning json instead of plain text (#9107) (ea19be1d0)
|
||||
- fix: preserve webhook callback responses (1e3b12acc)
|
||||
- fix: skip _unbind_plugin for inactivated plugins in reload() (#9096) (152fb3be8)
|
||||
- fix: apply fallback chat models to background wakeups (#9094) (413340fca)
|
||||
@@ -31,14 +31,14 @@
|
||||
"katex": "^0.16.27",
|
||||
"lodash": "4.17.23",
|
||||
"markdown-it": "^14.1.1",
|
||||
"markstream-vue": "1.0.1-beta.1",
|
||||
"markstream-vue": "1.0.5-beta.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"pinia": "2.1.6",
|
||||
"pinyin-pro": "^3.26.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"shiki": "^3.20.0",
|
||||
"stream-markdown": "^0.0.15",
|
||||
"shiki": "^3.23.0",
|
||||
"stream-markdown": "^0.0.16",
|
||||
"vee-validate": "4.11.3",
|
||||
"vite-plugin-vuetify": "2.1.3",
|
||||
"vue": "3.3.4",
|
||||
|
||||
135
dashboard/pnpm-lock.yaml
generated
135
dashboard/pnpm-lock.yaml
generated
@@ -58,8 +58,8 @@ importers:
|
||||
specifier: ^14.1.1
|
||||
version: 14.1.1
|
||||
markstream-vue:
|
||||
specifier: 1.0.1-beta.1
|
||||
version: 1.0.1-beta.1(katex@0.16.28)(mermaid@11.12.2)(stream-markdown@0.0.15(shiki@3.22.0)(vue@3.3.4))(vue-i18n@11.2.8(vue@3.3.4))(vue@3.3.4)
|
||||
specifier: 1.0.5-beta.0
|
||||
version: 1.0.5-beta.0(katex@0.16.28)(mermaid@11.12.2)(stream-markdown@0.0.16(shiki@3.23.0)(vue@3.3.4))(vue-i18n@11.2.8(vue@3.3.4))(vue@3.3.4)
|
||||
mermaid:
|
||||
specifier: ^11.12.2
|
||||
version: 11.12.2
|
||||
@@ -76,11 +76,11 @@ importers:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
shiki:
|
||||
specifier: ^3.20.0
|
||||
version: 3.22.0
|
||||
specifier: ^3.23.0
|
||||
version: 3.23.0
|
||||
stream-markdown:
|
||||
specifier: ^0.0.15
|
||||
version: 0.0.15(shiki@3.22.0)(vue@3.3.4)
|
||||
specifier: ^0.0.16
|
||||
version: 0.0.16(shiki@3.23.0)(vue@3.3.4)
|
||||
vee-validate:
|
||||
specifier: 4.11.3
|
||||
version: 4.11.3(vue@3.3.4)
|
||||
@@ -676,21 +676,27 @@ packages:
|
||||
'@shikijs/core@3.22.0':
|
||||
resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==}
|
||||
|
||||
'@shikijs/engine-javascript@3.22.0':
|
||||
resolution: {integrity: sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==}
|
||||
'@shikijs/core@3.23.0':
|
||||
resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
|
||||
|
||||
'@shikijs/engine-oniguruma@3.22.0':
|
||||
resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==}
|
||||
'@shikijs/engine-javascript@3.23.0':
|
||||
resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
|
||||
|
||||
'@shikijs/langs@3.22.0':
|
||||
resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==}
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
|
||||
|
||||
'@shikijs/themes@3.22.0':
|
||||
resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==}
|
||||
'@shikijs/langs@3.23.0':
|
||||
resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
|
||||
|
||||
'@shikijs/themes@3.23.0':
|
||||
resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
|
||||
|
||||
'@shikijs/types@3.22.0':
|
||||
resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==}
|
||||
|
||||
'@shikijs/types@3.23.0':
|
||||
resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==}
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||
|
||||
@@ -2134,6 +2140,9 @@ packages:
|
||||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
linkify-it@5.0.1:
|
||||
resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==}
|
||||
|
||||
loader-runner@4.3.1:
|
||||
resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
|
||||
engines: {node: '>=6.11.5'}
|
||||
@@ -2187,8 +2196,8 @@ packages:
|
||||
markdown-it-task-checkbox@1.0.6:
|
||||
resolution: {integrity: sha512-7pxkHuvqTOu3iwVGmDPeYjQg+AIS9VQxzyLP9JCg9lBjgPAJXGEkChK6A2iFuj3tS0GV3HG2u5AMNhcQqwxpJw==}
|
||||
|
||||
markdown-it-ts@1.0.0:
|
||||
resolution: {integrity: sha512-hQT/yCYryC3jNs2wJ35R4m1zKcBxNuFaKCGzwpmq2OuMXMNbUK1oTwCxONIjy5lXWWG1UCNbGXe1nbTiWbH/iA==}
|
||||
markdown-it-ts@1.0.2:
|
||||
resolution: {integrity: sha512-zba9mN313K2HmKk+BOHqkO/nuZtj9M1TTnUlSbItGrCMpYzc8OHGCm+IaqxWCi2pGcgpiFC8ltxkasYWYpp/YQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
markdown-it@14.1.1:
|
||||
@@ -2200,18 +2209,18 @@ packages:
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
markstream-core@1.0.0:
|
||||
resolution: {integrity: sha512-V39W0rPgJ5Yj/XEl11LaKQxX9dYOI34RL729Zoi9oyy/Y6z6H+PJOsBFktu7gU9ZZxCsevWMb/Re2LgSKbWfRg==}
|
||||
markstream-core@1.0.3:
|
||||
resolution: {integrity: sha512-QXn+yERo1q+RD6YlGDW61zc/af65uhkQEH3K8YvEKkprHbgRJ/JIeBeEQjELCTyBnPoxqtKQ7I565rylY+PePg==}
|
||||
|
||||
markstream-vue@1.0.1-beta.1:
|
||||
resolution: {integrity: sha512-45br3sbOQirIg0tmPMWRmdWk/vLE5aJtVx7cvLaa3e+klyYpILzQ7c4eOMq2EZkQTVewJhZHUVc2FTj+ujUJPg==}
|
||||
markstream-vue@1.0.5-beta.0:
|
||||
resolution: {integrity: sha512-/gmcNKa7v6qqmwQ/JAn3sWUoiM+EaMkvS0X5YRgqD74d+G22dzua4X8ZViYpbDGR/bl7BTw3juzsRfC0dm15pw==}
|
||||
peerDependencies:
|
||||
'@antv/infographic': ^0.2.3
|
||||
'@terrastruct/d2': '>=0.1.33'
|
||||
katex: '>=0.16.22'
|
||||
mermaid: '>=11'
|
||||
stream-markdown: '>=0.0.15'
|
||||
stream-monaco: '>=0.0.41'
|
||||
stream-markdown: '>=0.0.16'
|
||||
stream-monaco: '>=0.0.45'
|
||||
vue: '>=3.0.0'
|
||||
vue-i18n: '>=9'
|
||||
peerDependenciesMeta:
|
||||
@@ -2740,8 +2749,8 @@ packages:
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
shiki@3.22.0:
|
||||
resolution: {integrity: sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==}
|
||||
shiki@3.23.0:
|
||||
resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
|
||||
|
||||
slash@3.0.0:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
@@ -2764,13 +2773,13 @@ packages:
|
||||
state-local@1.0.7:
|
||||
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
|
||||
|
||||
stream-markdown-parser@1.0.0:
|
||||
resolution: {integrity: sha512-uOYQ8G9YFFtGM726fK77O/mJPYrwYHPy0DKBtk7bCPmkf06XwDVSOAFRTo2FsXnZ3vAaPBtxOgNGJ0CGieBcSQ==}
|
||||
stream-markdown-parser@1.0.8:
|
||||
resolution: {integrity: sha512-+Oo9ik7BtMBxf+Krh19YA0jmk+TIUxfULU5qQpWHo3Z4SBX74/Qwt6lVHcUvEcUQcd1kV35VNmw1SYdhBY4aOA==}
|
||||
|
||||
stream-markdown@0.0.15:
|
||||
resolution: {integrity: sha512-1WlzjZUb9W5BWZYMKCr2/exPVh5P7HIhHzkcYZczkXm0upiuN4zEddwjdckL+WSQWGGlv9bboXCqcTYCEgqexw==}
|
||||
stream-markdown@0.0.16:
|
||||
resolution: {integrity: sha512-2WoOxlpc3N5RLc3zGuW+g/w76z6ketWBY0N1YzDYbXds1qw7zrUBv5PTQ3DOtpqgCjLuF3P2iCfjJTEqWGv0NQ==}
|
||||
peerDependencies:
|
||||
shiki: '>=3.13.0'
|
||||
shiki: '>=3.23.0'
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
@@ -3599,30 +3608,42 @@ snapshots:
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@3.22.0':
|
||||
'@shikijs/core@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.22.0
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 4.3.4
|
||||
|
||||
'@shikijs/engine-oniguruma@3.22.0':
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.22.0
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/langs@3.22.0':
|
||||
'@shikijs/langs@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.22.0
|
||||
'@shikijs/types': 3.23.0
|
||||
|
||||
'@shikijs/themes@3.22.0':
|
||||
'@shikijs/themes@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.22.0
|
||||
'@shikijs/types': 3.23.0
|
||||
|
||||
'@shikijs/types@3.22.0':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/types@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2': {}
|
||||
|
||||
'@tiptap/core@2.27.2(@tiptap/pm@2.27.2)':
|
||||
@@ -5268,6 +5289,10 @@ snapshots:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
linkify-it@5.0.1:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
loader-runner@4.3.1: {}
|
||||
|
||||
loader-utils@2.0.4:
|
||||
@@ -5312,12 +5337,12 @@ snapshots:
|
||||
|
||||
markdown-it-task-checkbox@1.0.6: {}
|
||||
|
||||
markdown-it-ts@1.0.0:
|
||||
markdown-it-ts@1.0.2:
|
||||
dependencies:
|
||||
'@types/linkify-it': 5.0.0
|
||||
'@types/mdurl': 2.0.0
|
||||
entities: 4.5.0
|
||||
linkify-it: 5.0.0
|
||||
linkify-it: 5.0.1
|
||||
mdurl: 2.0.0
|
||||
punycode.js: 2.3.1
|
||||
uc.micro: 2.1.0
|
||||
@@ -5333,19 +5358,19 @@ snapshots:
|
||||
|
||||
marked@16.4.2: {}
|
||||
|
||||
markstream-core@1.0.0: {}
|
||||
markstream-core@1.0.3: {}
|
||||
|
||||
markstream-vue@1.0.1-beta.1(katex@0.16.28)(mermaid@11.12.2)(stream-markdown@0.0.15(shiki@3.22.0)(vue@3.3.4))(vue-i18n@11.2.8(vue@3.3.4))(vue@3.3.4):
|
||||
markstream-vue@1.0.5-beta.0(katex@0.16.28)(mermaid@11.12.2)(stream-markdown@0.0.16(shiki@3.23.0)(vue@3.3.4))(vue-i18n@11.2.8(vue@3.3.4))(vue@3.3.4):
|
||||
dependencies:
|
||||
'@chenglou/pretext': 0.0.5
|
||||
'@floating-ui/dom': 1.7.6
|
||||
markstream-core: 1.0.0
|
||||
stream-markdown-parser: 1.0.0
|
||||
markstream-core: 1.0.3
|
||||
stream-markdown-parser: 1.0.8
|
||||
vue: 3.3.4
|
||||
optionalDependencies:
|
||||
katex: 0.16.28
|
||||
mermaid: 11.12.2
|
||||
stream-markdown: 0.0.15(shiki@3.22.0)(vue@3.3.4)
|
||||
stream-markdown: 0.0.16(shiki@3.23.0)(vue@3.3.4)
|
||||
vue-i18n: 11.2.8(vue@3.3.4)
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
@@ -5885,14 +5910,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
vue: 3.3.4
|
||||
|
||||
shiki@3.22.0:
|
||||
shiki@3.23.0:
|
||||
dependencies:
|
||||
'@shikijs/core': 3.22.0
|
||||
'@shikijs/engine-javascript': 3.22.0
|
||||
'@shikijs/engine-oniguruma': 3.22.0
|
||||
'@shikijs/langs': 3.22.0
|
||||
'@shikijs/themes': 3.22.0
|
||||
'@shikijs/types': 3.22.0
|
||||
'@shikijs/core': 3.23.0
|
||||
'@shikijs/engine-javascript': 3.23.0
|
||||
'@shikijs/engine-oniguruma': 3.23.0
|
||||
'@shikijs/langs': 3.23.0
|
||||
'@shikijs/themes': 3.23.0
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
@@ -5911,7 +5936,7 @@ snapshots:
|
||||
|
||||
state-local@1.0.7: {}
|
||||
|
||||
stream-markdown-parser@1.0.0:
|
||||
stream-markdown-parser@1.0.8:
|
||||
dependencies:
|
||||
markdown-it-container: 4.0.0
|
||||
markdown-it-footnote: 4.0.0
|
||||
@@ -5920,11 +5945,11 @@ snapshots:
|
||||
markdown-it-sub: 2.0.0
|
||||
markdown-it-sup: 2.0.0
|
||||
markdown-it-task-checkbox: 1.0.6
|
||||
markdown-it-ts: 1.0.0
|
||||
markdown-it-ts: 1.0.2
|
||||
|
||||
stream-markdown@0.0.15(shiki@3.22.0)(vue@3.3.4):
|
||||
stream-markdown@0.0.16(shiki@3.23.0)(vue@3.3.4):
|
||||
dependencies:
|
||||
shiki: 3.22.0
|
||||
shiki: 3.23.0
|
||||
shiki-stream: 0.1.4(vue@3.3.4)
|
||||
transitivePeerDependencies:
|
||||
- react
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
:smooth-streaming="isStreaming ? 'auto' : false"
|
||||
:fade="false"
|
||||
:typewriter="false"
|
||||
:max-live-nodes="0"
|
||||
:max-live-nodes="MARKDOWN_RENDER_MAX_LIVE_NODES"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from "vue";
|
||||
import { MarkdownRender } from "markstream-vue";
|
||||
import { MARKDOWN_RENDER_MAX_LIVE_NODES } from "@/components/chat/markdownRenderConfig";
|
||||
import type { ChatThread } from "@/composables/useMessages";
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
1
dashboard/src/components/chat/markdownRenderConfig.ts
Normal file
1
dashboard/src/components/chat/markdownRenderConfig.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const MARKDOWN_RENDER_MAX_LIVE_NODES = 320;
|
||||
@@ -9,7 +9,7 @@
|
||||
:smooth-streaming="isStreaming ? 'auto' : false"
|
||||
:fade="false"
|
||||
:typewriter="false"
|
||||
:max-live-nodes="0"
|
||||
:max-live-nodes="MARKDOWN_RENDER_MAX_LIVE_NODES"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,6 +17,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from "vue";
|
||||
import { MarkdownRender } from "markstream-vue";
|
||||
import { MARKDOWN_RENDER_MAX_LIVE_NODES } from "@/components/chat/markdownRenderConfig";
|
||||
|
||||
const props = defineProps<{
|
||||
content: string;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
:fade="false"
|
||||
:typewriter="false"
|
||||
:is-dark="isDark"
|
||||
:max-live-nodes="0"
|
||||
:max-live-nodes="MARKDOWN_RENDER_MAX_LIVE_NODES"
|
||||
/>
|
||||
|
||||
<div v-else-if="entry.tool" class="reasoning-tool-call-block">
|
||||
@@ -62,6 +62,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { MarkdownRender } from "markstream-vue";
|
||||
import { MARKDOWN_RENDER_MAX_LIVE_NODES } from "@/components/chat/markdownRenderConfig";
|
||||
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
|
||||
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
|
||||
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
|
||||
|
||||
@@ -88,6 +88,17 @@
|
||||
v-if="larkCreationMode === 'scan'"
|
||||
class="registration-inline mt-3"
|
||||
>
|
||||
<v-text-field
|
||||
:model-value="selectedPlatformConfig.id || ''"
|
||||
:label="tm('registrationAction.platformIdLabel')"
|
||||
:error="Boolean(scanPlatformIdError)"
|
||||
:error-messages="scanPlatformIdError"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
class="registration-platform-id-field"
|
||||
@update:model-value="setScanPlatformId"
|
||||
/>
|
||||
<PlatformRegistrationAction
|
||||
:platform-config="selectedPlatformConfig"
|
||||
:active="larkCreationMode === 'scan'"
|
||||
@@ -140,6 +151,17 @@
|
||||
v-if="dingtalkCreationMode === 'scan'"
|
||||
class="registration-inline mt-3"
|
||||
>
|
||||
<v-text-field
|
||||
:model-value="selectedPlatformConfig.id || ''"
|
||||
:label="tm('registrationAction.platformIdLabel')"
|
||||
:error="Boolean(scanPlatformIdError)"
|
||||
:error-messages="scanPlatformIdError"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
class="registration-platform-id-field"
|
||||
@update:model-value="setScanPlatformId"
|
||||
/>
|
||||
<PlatformRegistrationAction
|
||||
:platform-config="selectedPlatformConfig"
|
||||
:active="dingtalkCreationMode === 'scan'"
|
||||
@@ -195,6 +217,17 @@
|
||||
v-if="qqOfficialCreationMode === 'scan'"
|
||||
class="registration-inline mt-3"
|
||||
>
|
||||
<v-text-field
|
||||
:model-value="selectedPlatformConfig.id || ''"
|
||||
:label="tm('registrationAction.platformIdLabel')"
|
||||
:error="Boolean(scanPlatformIdError)"
|
||||
:error-messages="scanPlatformIdError"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
class="registration-platform-id-field"
|
||||
@update:model-value="setScanPlatformId"
|
||||
/>
|
||||
<PlatformRegistrationAction
|
||||
:platform-config="selectedPlatformConfig"
|
||||
:active="qqOfficialCreationMode === 'scan'"
|
||||
@@ -229,8 +262,19 @@
|
||||
|
||||
<div
|
||||
v-else-if="isWeixinOcPlatform"
|
||||
class="weixin-oc-registration-inline mt-4"
|
||||
class="registration-inline mt-4"
|
||||
>
|
||||
<v-text-field
|
||||
:model-value="selectedPlatformConfig.id || ''"
|
||||
:label="tm('registrationAction.platformIdLabel')"
|
||||
:error="Boolean(scanPlatformIdError)"
|
||||
:error-messages="scanPlatformIdError"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
class="registration-platform-id-field"
|
||||
@update:model-value="setScanPlatformId"
|
||||
/>
|
||||
<PlatformRegistrationAction
|
||||
:platform-config="selectedPlatformConfig"
|
||||
:active="isWeixinOcPlatform"
|
||||
@@ -839,6 +883,7 @@ export default {
|
||||
larkCreationMode: "",
|
||||
dingtalkCreationMode: "",
|
||||
qqOfficialCreationMode: "",
|
||||
scanPlatformIdCustomized: false,
|
||||
|
||||
aBConfigRadioVal: "0",
|
||||
selectedAbConfId: "default",
|
||||
@@ -1054,6 +1099,16 @@ export default {
|
||||
this.selectedPlatformConfig?.type,
|
||||
);
|
||||
},
|
||||
scanPlatformIdError() {
|
||||
const platformId = String(this.selectedPlatformConfig?.id || "");
|
||||
if (!platformId) {
|
||||
return this.tm("registrationAction.platformIdRequired");
|
||||
}
|
||||
if (!this.isPlatformIdValid(platformId)) {
|
||||
return this.tm("registrationAction.platformIdInvalid");
|
||||
}
|
||||
return "";
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedPlatformType(newType) {
|
||||
@@ -1064,11 +1119,13 @@ export default {
|
||||
this.larkCreationMode = "";
|
||||
this.dingtalkCreationMode = "";
|
||||
this.qqOfficialCreationMode = "";
|
||||
this.scanPlatformIdCustomized = false;
|
||||
} else {
|
||||
this.selectedPlatformConfig = null;
|
||||
this.larkCreationMode = "";
|
||||
this.dingtalkCreationMode = "";
|
||||
this.qqOfficialCreationMode = "";
|
||||
this.scanPlatformIdCustomized = false;
|
||||
}
|
||||
},
|
||||
selectedAbConfId(newConfigId) {
|
||||
@@ -1156,6 +1213,7 @@ export default {
|
||||
this.larkCreationMode = "";
|
||||
this.dingtalkCreationMode = "";
|
||||
this.qqOfficialCreationMode = "";
|
||||
this.scanPlatformIdCustomized = false;
|
||||
|
||||
this.aBConfigRadioVal = "0";
|
||||
this.selectedAbConfId = "default";
|
||||
@@ -1472,6 +1530,14 @@ export default {
|
||||
this.$emit("show-toast", { message: message, type: "error" });
|
||||
},
|
||||
|
||||
setScanPlatformId(value) {
|
||||
if (!this.selectedPlatformConfig) {
|
||||
return;
|
||||
}
|
||||
this.scanPlatformIdCustomized = true;
|
||||
this.selectedPlatformConfig.id = String(value || "");
|
||||
},
|
||||
|
||||
buildRandomPlatformIdSuffix() {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyz";
|
||||
let suffix = "_";
|
||||
@@ -1492,6 +1558,9 @@ export default {
|
||||
if (!this.selectedPlatformConfig || !data) {
|
||||
return;
|
||||
}
|
||||
if (this.scanPlatformIdCustomized) {
|
||||
return;
|
||||
}
|
||||
const currentId = String(this.selectedPlatformConfig.id || "").trim();
|
||||
const platformType = this.selectedPlatformConfig.type;
|
||||
if (!currentId) {
|
||||
@@ -1968,8 +2037,14 @@ export default {
|
||||
|
||||
.registration-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
width: 320px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.registration-platform-id-field {
|
||||
width: 300px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -181,6 +181,9 @@
|
||||
"created": "Setup Complete",
|
||||
"startFailed": "Failed to start QR setup",
|
||||
"pollFailed": "Failed to poll QR setup status",
|
||||
"platformIdLabel": "Bot ID",
|
||||
"platformIdRequired": "Bot ID is required",
|
||||
"platformIdInvalid": "Bot ID cannot contain spaces, ':' or '!'",
|
||||
"mode": {
|
||||
"title": "Choose setup method",
|
||||
"scan": "One-click QR setup",
|
||||
|
||||
@@ -181,6 +181,9 @@
|
||||
"created": "Настройка завершена",
|
||||
"startFailed": "Не удалось начать QR настройку",
|
||||
"pollFailed": "Не удалось проверить статус QR настройки",
|
||||
"platformIdLabel": "ID бота",
|
||||
"platformIdRequired": "ID бота обязателен",
|
||||
"platformIdInvalid": "ID бота не может содержать пробелы, ':' или '!'",
|
||||
"mode": {
|
||||
"title": "Выберите способ создания",
|
||||
"scan": "Создать через QR",
|
||||
|
||||
@@ -181,6 +181,9 @@
|
||||
"created": "创建成功",
|
||||
"startFailed": "发起扫码创建失败",
|
||||
"pollFailed": "获取扫码创建状态失败",
|
||||
"platformIdLabel": "机器人 ID",
|
||||
"platformIdRequired": "机器人 ID 不能为空",
|
||||
"platformIdInvalid": "机器人 ID 不能包含空格、':' 或 '!'",
|
||||
"mode": {
|
||||
"title": "选择创建方式",
|
||||
"scan": "扫码一键创建",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.26.2"
|
||||
version = "4.26.4"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
|
||||
@@ -620,3 +620,23 @@ async def test_grep_tool_applies_result_limit(
|
||||
assert "match-2" in result
|
||||
assert "match-3" not in result
|
||||
assert "[Truncated to first 2 result groups.]" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_read_tool_rejects_directory_with_clear_message(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
):
|
||||
"""FileReadTool should return a helpful message when given a directory path."""
|
||||
workspace = _setup_local_fs_tools(monkeypatch, tmp_path)
|
||||
subdir = workspace / "my-directory"
|
||||
subdir.mkdir()
|
||||
|
||||
result = await fs_tools.FileReadTool().call(
|
||||
_make_context(),
|
||||
path="my-directory",
|
||||
)
|
||||
|
||||
assert "is a directory, not a file" in result
|
||||
assert "my-directory" in result
|
||||
assert "'astrbot_execute_shell'" in result
|
||||
|
||||
@@ -20,6 +20,7 @@ from werkzeug.datastructures import FileStorage
|
||||
from astrbot.core import LogBroker
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.core.desktop_runtime import DESKTOP_MANAGED_RESTART_MESSAGE
|
||||
from astrbot.core.star.star import StarMetadata, star_registry
|
||||
from astrbot.core.star.star_handler import star_handlers_registry
|
||||
from astrbot.core.utils.auth_password import (
|
||||
@@ -2662,6 +2663,35 @@ async def test_check_update(
|
||||
assert data["data"]["has_new_version"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_core_rejects_desktop_managed_backend(
|
||||
app: FastAPIAppAdapter,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
monkeypatch,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
restart_called = False
|
||||
|
||||
async def mock_restart():
|
||||
nonlocal restart_called
|
||||
restart_called = True
|
||||
|
||||
monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
|
||||
monkeypatch.setattr(core_lifecycle_td, "restart", mock_restart)
|
||||
|
||||
response = await test_client.post(
|
||||
"/api/stat/restart-core",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "error"
|
||||
assert data["message"] == DESKTOP_MANAGED_RESTART_MESSAGE
|
||||
assert restart_called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_do_update(
|
||||
app: FastAPIAppAdapter,
|
||||
@@ -2826,6 +2856,44 @@ async def test_do_update_does_not_apply_files_when_core_download_fails(
|
||||
assert calls == ["download-dashboard", "download-core"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_do_update_rejects_desktop_managed_backend(
|
||||
app: FastAPIAppAdapter,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
monkeypatch,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
calls = []
|
||||
|
||||
async def mock_download_core(*args, **kwargs):
|
||||
del args, kwargs
|
||||
calls.append("download-core")
|
||||
|
||||
async def mock_restart():
|
||||
calls.append("restart")
|
||||
|
||||
monkeypatch.setenv("ASTRBOT_DESKTOP_MANAGED", "1")
|
||||
monkeypatch.setattr(
|
||||
core_lifecycle_td.astrbot_updator,
|
||||
"download_update_package",
|
||||
mock_download_core,
|
||||
)
|
||||
monkeypatch.setattr(core_lifecycle_td, "restart", mock_restart)
|
||||
|
||||
response = await test_client.post(
|
||||
"/api/update/do",
|
||||
headers=authenticated_header,
|
||||
json={"version": "v3.4.0", "progress_id": "desktop-progress"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "error"
|
||||
assert data["message"] == DESKTOP_MANAGED_RESTART_MESSAGE
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_do_update_does_not_apply_files_when_package_verification_fails(
|
||||
app: FastAPIAppAdapter,
|
||||
|
||||
@@ -389,10 +389,15 @@ class FakePlatform:
|
||||
return True
|
||||
|
||||
async def webhook_callback(self, request_obj):
|
||||
payload = await request_obj.get_json(silent=True)
|
||||
if payload.get("response_mode") == "plain":
|
||||
return "success"
|
||||
if payload.get("response_mode") == "tuple":
|
||||
return "accepted", 202, {"Content-Type": "text/plain"}
|
||||
return {
|
||||
"webhook_uuid": self.config["webhook_uuid"],
|
||||
"method": request_obj.method,
|
||||
"payload": await request_obj.get_json(silent=True),
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
async def send_by_session(self, session, message_chain) -> None:
|
||||
@@ -3294,10 +3299,35 @@ async def test_v1_platform_webhook_is_public_route(
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["data"] == {
|
||||
assert response.json() == {
|
||||
"webhook_uuid": "demo-hook",
|
||||
"method": "POST",
|
||||
"payload": {"challenge": "ping"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v1_platform_webhook_preserves_plain_response(
|
||||
asgi_client: httpx.AsyncClient,
|
||||
):
|
||||
response = await asgi_client.post(
|
||||
"/api/v1/webhooks/platforms/demo-hook",
|
||||
json={"response_mode": "plain"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "success"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v1_platform_webhook_preserves_tuple_response(
|
||||
asgi_client: httpx.AsyncClient,
|
||||
):
|
||||
response = await asgi_client.post(
|
||||
"/api/v1/webhooks/platforms/demo-hook",
|
||||
json={"response_mode": "tuple"},
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert response.headers["content-type"] == "text/plain"
|
||||
assert response.text == "accepted"
|
||||
|
||||
@@ -2,6 +2,7 @@ import base64
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import wave
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
@@ -653,19 +654,39 @@ def test_path_mapping_accepts_standard_and_legacy_file_uri(tmp_path):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tencent_silk_encoding_uses_pysilk_tencent_format(tmp_path, monkeypatch):
|
||||
@pytest.mark.parametrize(
|
||||
"rate, channels",
|
||||
[
|
||||
(24000, 1), # supported, no resample
|
||||
(44100, 1), # unsupported rate, triggers resample
|
||||
(22050, 1), # unsupported rate, triggers resample
|
||||
(48000, 2), # stereo at supported rate, triggers downmix
|
||||
(44100, 2), # stereo + unsupported rate, triggers both
|
||||
],
|
||||
ids=["24k-mono", "44.1k-mono", "22.05k-mono", "48k-stereo", "44.1k-stereo"],
|
||||
)
|
||||
async def test_tencent_silk_encoding_uses_pysilk_tencent_format(
|
||||
rate, channels, tmp_path, monkeypatch
|
||||
):
|
||||
"""Real pysilk end-to-end across sample rates that previously failed.
|
||||
|
||||
44100 Hz was the regression trigger: pysilk rejects it with
|
||||
ENC_INPUT_INVALID_NO_OF_SAMPLES. The fix resamples to 24 kHz mono via
|
||||
audioop.ratecv before encoding.
|
||||
"""
|
||||
monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(tmp_path))
|
||||
wav_path = tmp_path / "tone.wav"
|
||||
silk_path = tmp_path / "tone.silk"
|
||||
rate = 24000
|
||||
frames = int(rate * 0.2)
|
||||
secs = 0.2
|
||||
frames = int(rate * secs)
|
||||
with wave.open(str(wav_path), "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setnchannels(channels)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(rate)
|
||||
for i in range(frames):
|
||||
sample = int(0.2 * 32767 * math.sin(2 * math.pi * 440 * i / rate))
|
||||
wav.writeframesraw(struct.pack("<h", sample))
|
||||
for _ in range(channels):
|
||||
wav.writeframesraw(struct.pack("<h", sample))
|
||||
|
||||
duration = await wav_to_tencent_silk(str(wav_path), str(silk_path))
|
||||
silk_bytes = silk_path.read_bytes()
|
||||
@@ -679,7 +700,82 @@ async def test_tencent_silk_encoding_uses_pysilk_tencent_format(tmp_path, monkey
|
||||
assert resolved.format == "tencent_silk"
|
||||
assert resolved.mime_type == "audio/silk"
|
||||
|
||||
assert duration == pytest.approx(0.2)
|
||||
assert duration == pytest.approx(secs, abs=0.05)
|
||||
assert silk_bytes.startswith(b"\x02#!SILK_V3")
|
||||
assert resolved_silk_bytes.startswith(b"\x02#!SILK_V3")
|
||||
assert not resolved_silk_path.exists()
|
||||
|
||||
|
||||
def _make_wav(path, rate, channels=1, secs=0.2, freq=440):
|
||||
"""Write a short sine-tone WAV at the given rate/channels."""
|
||||
nframes = int(rate * secs)
|
||||
with wave.open(str(path), "wb") as wav:
|
||||
wav.setnchannels(channels)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(rate)
|
||||
for i in range(nframes):
|
||||
sample = int(0.2 * 32767 * math.sin(2 * math.pi * freq * i / rate))
|
||||
for _ in range(channels):
|
||||
wav.writeframesraw(struct.pack("<h", sample))
|
||||
|
||||
|
||||
class _FakePysilk:
|
||||
"""Stand-in for the ``pysilk`` module that records encode() calls."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def encode(self, input_io, output_io, sample_rate, bit_rate, tencent=True):
|
||||
self.calls.append({"sample_rate": sample_rate, "tencent": tencent})
|
||||
output_io.write(b"\x02#!SILK_V3")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wav_to_tencent_silk_resamples_unsupported_rate(tmp_path, monkeypatch):
|
||||
"""44100 Hz input must be resampled to 24 kHz before pysilk.encode."""
|
||||
fake = _FakePysilk()
|
||||
monkeypatch.setitem(sys.modules, "pysilk", fake)
|
||||
|
||||
wav_path = tmp_path / "tts_44100.wav"
|
||||
_make_wav(wav_path, 44100)
|
||||
|
||||
silk_path = tmp_path / "out.silk"
|
||||
await wav_to_tencent_silk(str(wav_path), str(silk_path))
|
||||
|
||||
assert len(fake.calls) == 1
|
||||
assert fake.calls[0]["sample_rate"] == 24000
|
||||
assert fake.calls[0]["tencent"] is True
|
||||
assert silk_path.read_bytes().startswith(b"\x02#!SILK_V3")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wav_to_tencent_silk_resamples_stereo(tmp_path, monkeypatch):
|
||||
"""Stereo input at a supported rate must still be downmixed to mono."""
|
||||
fake = _FakePysilk()
|
||||
monkeypatch.setitem(sys.modules, "pysilk", fake)
|
||||
|
||||
wav_path = tmp_path / "stereo_48k.wav"
|
||||
_make_wav(wav_path, 48000, channels=2)
|
||||
|
||||
await wav_to_tencent_silk(str(wav_path), str(tmp_path / "out.silk"))
|
||||
|
||||
assert len(fake.calls) == 1
|
||||
# 48000 Hz is supported, so only downmix happens -- rate stays unchanged.
|
||||
assert fake.calls[0]["sample_rate"] == 48000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wav_to_tencent_silk_skips_resample_for_supported_rate(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""24000 Hz mono must go straight to pysilk without resampling."""
|
||||
fake = _FakePysilk()
|
||||
monkeypatch.setitem(sys.modules, "pysilk", fake)
|
||||
|
||||
wav_path = tmp_path / "tone_24k.wav"
|
||||
_make_wav(wav_path, 24000)
|
||||
|
||||
await wav_to_tencent_silk(str(wav_path), str(tmp_path / "out.silk"))
|
||||
|
||||
assert len(fake.calls) == 1
|
||||
assert fake.calls[0]["sample_rate"] == 24000
|
||||
|
||||
@@ -1976,3 +1976,202 @@ async def test_uninstall_failed_plugin_without_plugin_id_in_record(
|
||||
|
||||
assert len(cleanup_calls) == 1
|
||||
assert cleanup_calls[0]["plugin_id"] is None
|
||||
|
||||
|
||||
# --- reload + deactivated plugin regression tests ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_deactivated_plugin_preserves_tools(
|
||||
plugin_manager_pm: PluginManager, monkeypatch
|
||||
):
|
||||
"""Specified reload of a deactivated plugin keeps its tools in func_list."""
|
||||
_clear_star_runtime_state()
|
||||
plugin_name = "demo_plugin"
|
||||
module_path = f"data.plugins.{plugin_name}.main"
|
||||
metadata = star_manager_module.StarMetadata(
|
||||
name=plugin_name,
|
||||
root_dir_name=plugin_name,
|
||||
module_path=module_path,
|
||||
activated=False,
|
||||
)
|
||||
star_manager_module.star_map[module_path] = metadata
|
||||
star_manager_module.star_registry.append(metadata)
|
||||
|
||||
plugin_tool = star_manager_module.FunctionTool(
|
||||
name="plugin_search",
|
||||
description="plugin search",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
handler_module_path=f"data.plugins.{plugin_name}.main.tools.search",
|
||||
)
|
||||
llm_tools = cast(Any, star_manager_module.llm_tools)
|
||||
original_func_list = llm_tools.func_list
|
||||
llm_tools.func_list = [plugin_tool]
|
||||
|
||||
async def mock_terminate(smd):
|
||||
pass # deactivated → no-op
|
||||
|
||||
async def mock_load(specified_module_path=None, **kwargs):
|
||||
return True, None
|
||||
|
||||
monkeypatch.setattr(plugin_manager_pm, "_terminate_plugin", mock_terminate)
|
||||
monkeypatch.setattr(plugin_manager_pm, "load", mock_load)
|
||||
|
||||
try:
|
||||
await plugin_manager_pm.reload(plugin_name)
|
||||
assert plugin_tool in llm_tools.func_list
|
||||
finally:
|
||||
llm_tools.func_list = original_func_list
|
||||
_clear_star_runtime_state()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_activated_plugin_still_unbinds(
|
||||
plugin_manager_pm: PluginManager, monkeypatch
|
||||
):
|
||||
"""Specified reload of an activated plugin still calls _unbind_plugin."""
|
||||
_clear_star_runtime_state()
|
||||
plugin_name = "demo_plugin"
|
||||
module_path = f"data.plugins.{plugin_name}.main"
|
||||
metadata = star_manager_module.StarMetadata(
|
||||
name=plugin_name,
|
||||
root_dir_name=plugin_name,
|
||||
module_path=module_path,
|
||||
activated=True,
|
||||
)
|
||||
star_manager_module.star_map[module_path] = metadata
|
||||
star_manager_module.star_registry.append(metadata)
|
||||
|
||||
unbound = []
|
||||
|
||||
async def mock_terminate(smd):
|
||||
pass
|
||||
|
||||
async def mock_unbind(name, path):
|
||||
unbound.append(name)
|
||||
|
||||
async def mock_load(specified_module_path=None, **kwargs):
|
||||
return True, None
|
||||
|
||||
monkeypatch.setattr(plugin_manager_pm, "_terminate_plugin", mock_terminate)
|
||||
monkeypatch.setattr(plugin_manager_pm, "_unbind_plugin", mock_unbind)
|
||||
monkeypatch.setattr(plugin_manager_pm, "load", mock_load)
|
||||
|
||||
try:
|
||||
await plugin_manager_pm.reload(plugin_name)
|
||||
assert unbound == [plugin_name]
|
||||
finally:
|
||||
_clear_star_runtime_state()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_reload_deactivated_plugin_stays_registered(
|
||||
plugin_manager_pm: PluginManager, monkeypatch
|
||||
):
|
||||
"""Full reload keeps deactivated plugin in star_map with activated=False."""
|
||||
_clear_star_runtime_state()
|
||||
plugin_name = "demo_plugin"
|
||||
module_path = f"data.plugins.{plugin_name}.main"
|
||||
metadata = star_manager_module.StarMetadata(
|
||||
name=plugin_name,
|
||||
root_dir_name=plugin_name,
|
||||
module_path=module_path,
|
||||
activated=False,
|
||||
)
|
||||
star_manager_module.star_map[module_path] = metadata
|
||||
star_manager_module.star_registry.append(metadata)
|
||||
|
||||
async def mock_terminate(smd):
|
||||
pass
|
||||
|
||||
async def mock_unbind_full(name, path):
|
||||
pass
|
||||
|
||||
async def mock_load(specified_module_path=None, **kwargs):
|
||||
# In full reload, load() re-registers all plugins.
|
||||
# Deactivated plugins get registered with activated=False.
|
||||
re_registered = star_manager_module.StarMetadata(
|
||||
name=plugin_name,
|
||||
root_dir_name=plugin_name,
|
||||
module_path=module_path,
|
||||
activated=False,
|
||||
)
|
||||
star_manager_module.star_map[module_path] = re_registered
|
||||
star_manager_module.star_registry.append(re_registered)
|
||||
return True, None
|
||||
|
||||
monkeypatch.setattr(plugin_manager_pm, "_terminate_plugin", mock_terminate)
|
||||
monkeypatch.setattr(plugin_manager_pm, "_unbind_plugin", mock_unbind_full)
|
||||
monkeypatch.setattr(plugin_manager_pm, "load", mock_load)
|
||||
|
||||
try:
|
||||
await plugin_manager_pm.reload()
|
||||
assert module_path in star_manager_module.star_map
|
||||
assert star_manager_module.star_map[module_path].activated is False
|
||||
finally:
|
||||
_clear_star_runtime_state()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_on_plugin_after_deactivated_reload_reactivates_tools(
|
||||
plugin_manager_pm: PluginManager, monkeypatch
|
||||
):
|
||||
"""turn_on_plugin reactivates tools after a deactivated plugin is reloaded."""
|
||||
_clear_star_runtime_state()
|
||||
plugin_name = "demo_plugin"
|
||||
module_path = f"data.plugins.{plugin_name}.main"
|
||||
plugin = star_manager_module.StarMetadata(
|
||||
name=plugin_name,
|
||||
root_dir_name=plugin_name,
|
||||
module_path=module_path,
|
||||
activated=False,
|
||||
)
|
||||
cast(Any, plugin_manager_pm.context).stars.append(plugin)
|
||||
star_manager_module.star_map[module_path] = plugin
|
||||
star_manager_module.star_registry.append(plugin)
|
||||
|
||||
plugin_tool = star_manager_module.FunctionTool(
|
||||
name="plugin_search",
|
||||
description="plugin search",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
handler_module_path=f"data.plugins.{plugin_name}.main.tools.search",
|
||||
)
|
||||
plugin_tool.active = False # simulate deactivated state
|
||||
llm_tools = cast(Any, star_manager_module.llm_tools)
|
||||
original_func_list = llm_tools.func_list
|
||||
llm_tools.func_list = [plugin_tool]
|
||||
preferences = {
|
||||
"inactivated_plugins": [module_path],
|
||||
"inactivated_llm_tools": [],
|
||||
}
|
||||
|
||||
async def mock_global_get(key, default=None):
|
||||
return preferences.get(key, default)
|
||||
|
||||
async def mock_global_put(key, value):
|
||||
preferences[key] = value
|
||||
|
||||
async def mock_terminate(smd):
|
||||
pass
|
||||
|
||||
async def mock_reload(plugin_name_arg):
|
||||
assert plugin_name_arg == plugin_name
|
||||
# Simulate what load() does: re-register with activated=True
|
||||
# since it's no longer in inactivated_plugins
|
||||
plugin.activated = True
|
||||
return True, None
|
||||
|
||||
monkeypatch.setattr(star_manager_module.sp, "global_get", mock_global_get)
|
||||
monkeypatch.setattr(star_manager_module.sp, "global_put", mock_global_put)
|
||||
monkeypatch.setattr(plugin_manager_pm, "_terminate_plugin", mock_terminate)
|
||||
monkeypatch.setattr(plugin_manager_pm, "reload", mock_reload)
|
||||
|
||||
try:
|
||||
await plugin_manager_pm.turn_on_plugin(plugin_name)
|
||||
assert plugin_tool.active is True
|
||||
assert module_path not in preferences["inactivated_plugins"]
|
||||
assert plugin.activated is True
|
||||
finally:
|
||||
llm_tools.func_list = original_func_list
|
||||
cast(Any, plugin_manager_pm.context).stars.remove(plugin)
|
||||
_clear_star_runtime_state()
|
||||
|
||||
@@ -63,7 +63,7 @@ async def test_qq_webhook_callback_rejects_missing_signature():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qq_webhook_callback_accepts_signed_validation():
|
||||
async def test_qq_webhook_callback_accepts_unsigned_validation():
|
||||
secret = "test-secret"
|
||||
event_ts = "1710000000"
|
||||
plain_token = "plain-token"
|
||||
@@ -71,19 +71,10 @@ async def test_qq_webhook_callback_accepts_signed_validation():
|
||||
{"op": 13, "d": {"event_ts": event_ts, "plain_token": plain_token}},
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
signature = _sign_qq_webhook_payload(secret, event_ts, body)
|
||||
webhook = object.__new__(QQOfficialWebhook)
|
||||
webhook.secret = secret
|
||||
|
||||
result = await webhook.handle_callback(
|
||||
FakeRequest(
|
||||
body,
|
||||
{
|
||||
_SIGNATURE_TIMESTAMP_HEADER: event_ts,
|
||||
_SIGNATURE_HEADER: signature,
|
||||
},
|
||||
)
|
||||
)
|
||||
result = await webhook.handle_callback(FakeRequest(body))
|
||||
|
||||
assert result == {
|
||||
"plain_token": plain_token,
|
||||
|
||||
@@ -10,6 +10,7 @@ import certifi
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from astrbot.core import updator as core_updator
|
||||
from astrbot.core.star.updator import PluginUpdator
|
||||
from astrbot.core.updator import AstrBotUpdator
|
||||
from astrbot.core.utils import io as io_utils
|
||||
@@ -80,6 +81,60 @@ class _FakeStatusErrorResponse:
|
||||
)
|
||||
|
||||
|
||||
def test_astrbot_updator_exec_reboot_spawns_new_console_on_windows(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
popen_calls = []
|
||||
exit_codes = []
|
||||
execv_calls = []
|
||||
|
||||
def fake_popen(args, creationflags=0):
|
||||
popen_calls.append((args, creationflags))
|
||||
return SimpleNamespace(pid=1234)
|
||||
|
||||
def fake_exit(code):
|
||||
exit_codes.append(code)
|
||||
raise SystemExit(code)
|
||||
|
||||
def fake_execv(*args):
|
||||
execv_calls.append(args)
|
||||
|
||||
monkeypatch.setattr(core_updator.os, "name", "nt")
|
||||
monkeypatch.setattr(core_updator.sys, "frozen", False, raising=False)
|
||||
monkeypatch.setattr(
|
||||
core_updator.subprocess, "CREATE_NEW_CONSOLE", 0x00000010, raising=False
|
||||
)
|
||||
monkeypatch.setattr(core_updator.subprocess, "Popen", fake_popen)
|
||||
monkeypatch.setattr(core_updator.os, "_exit", fake_exit)
|
||||
monkeypatch.setattr(core_updator.os, "execv", fake_execv)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
AstrBotUpdator._exec_reboot(
|
||||
r"C:\Python312\python.exe",
|
||||
[
|
||||
r"C:\Python312\python.exe",
|
||||
"main.py",
|
||||
"--webui-dir",
|
||||
r"C:\AstrBot WebUI\dist",
|
||||
],
|
||||
)
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert popen_calls == [
|
||||
(
|
||||
[
|
||||
r"C:\Python312\python.exe",
|
||||
"main.py",
|
||||
"--webui-dir",
|
||||
r"C:\AstrBot WebUI\dist",
|
||||
],
|
||||
core_updator.subprocess.CREATE_NEW_CONSOLE,
|
||||
)
|
||||
]
|
||||
assert exit_codes == [0]
|
||||
assert execv_calls == []
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeAsyncClientState:
|
||||
json_payload: object = field(default_factory=list)
|
||||
@@ -418,41 +473,6 @@ async def test_plugin_update_validates_archive_before_removing_existing_plugin(
|
||||
assert marker_path.read_text(encoding="utf-8") == "VALUE = 'old'\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_update_skips_stable_when_current_prerelease_is_newer(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
updator = RepoZipUpdator()
|
||||
|
||||
async def fake_fetch_release_info(url: str, latest: bool = True): # noqa: ARG001
|
||||
return [
|
||||
{
|
||||
"version": "AstrBot v4.26.0-beta.1",
|
||||
"published_at": "2026-06-20T00:00:00Z",
|
||||
"body": "beta",
|
||||
"tag_name": "v4.26.0-beta.1",
|
||||
"zipball_url": "https://github.example/beta.zip",
|
||||
},
|
||||
{
|
||||
"version": "AstrBot v4.25.6",
|
||||
"published_at": "2026-06-19T00:00:00Z",
|
||||
"body": "stable",
|
||||
"tag_name": "v4.25.6",
|
||||
"zipball_url": "https://github.example/stable.zip",
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(updator, "fetch_release_info", fake_fetch_release_info)
|
||||
|
||||
result = await updator.check_update(
|
||||
"https://example.invalid/releases",
|
||||
current_version="v4.26.0-dev",
|
||||
consider_prerelease=False,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_astrbot_updator_prefers_hosted_core_package(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
27
tests/test_webhook_server_response.py
Normal file
27
tests/test_webhook_server_response.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from astrbot.core.platform.webhook_server import webhook_response_from_result
|
||||
|
||||
|
||||
def test_webhook_response_preserves_plain_string():
|
||||
response = webhook_response_from_result("success")
|
||||
|
||||
assert isinstance(response, Response)
|
||||
assert response.body == b"success"
|
||||
|
||||
|
||||
def test_webhook_response_preserves_tuple_headers():
|
||||
response = webhook_response_from_result(
|
||||
("accepted", 202, {"Content-Type": "text/plain"})
|
||||
)
|
||||
|
||||
assert isinstance(response, Response)
|
||||
assert response.status_code == 202
|
||||
assert response.media_type == "text/plain"
|
||||
assert response.body == b"accepted"
|
||||
|
||||
|
||||
def test_webhook_response_keeps_json_for_dict():
|
||||
response = webhook_response_from_result({"ok": True})
|
||||
|
||||
assert isinstance(response, JSONResponse)
|
||||
@@ -1,4 +1,5 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import mcp
|
||||
import pytest
|
||||
@@ -19,6 +20,7 @@ class _DummyEvent:
|
||||
def __init__(self, message_components: list[object] | None = None) -> None:
|
||||
self.unified_msg_origin = "webchat:FriendMessage:webchat!user!session"
|
||||
self.message_obj = SimpleNamespace(message=message_components or [])
|
||||
self.role = "member"
|
||||
|
||||
def get_extra(self, _key: str):
|
||||
return None
|
||||
@@ -36,6 +38,15 @@ def _build_run_context(message_components: list[object] | None = None):
|
||||
return ContextWrapper(context=ctx)
|
||||
|
||||
|
||||
class _DoneRunner:
|
||||
async def step_until_done(self, _max_step):
|
||||
for item in ():
|
||||
yield item
|
||||
|
||||
def get_final_llm_resp(self):
|
||||
return SimpleNamespace(role="assistant", completion_text="done")
|
||||
|
||||
|
||||
def test_build_handoff_toolset_keeps_permission_guards_for_default_tools():
|
||||
mgr = FunctionToolManager()
|
||||
plugin_tool = FunctionTool(
|
||||
@@ -354,6 +365,71 @@ async def test_execute_handoff_passes_tool_call_timeout_to_tool_loop_agent(
|
||||
assert captured["tool_call_timeout"] == 120
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_wakeup_passes_provider_settings_to_main_agent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
provider_settings = {
|
||||
"fallback_chat_models": ["fallback-provider"],
|
||||
"request_max_retries": 3,
|
||||
"stream": True,
|
||||
}
|
||||
captured: dict = {}
|
||||
|
||||
async def _fake_get_session_conv(**_kwargs):
|
||||
return SimpleNamespace(history="[]")
|
||||
|
||||
async def _fake_build_main_agent(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(agent_runner=_DoneRunner())
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.astr_main_agent._get_session_conv",
|
||||
_fake_get_session_conv,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.astr_main_agent.build_main_agent",
|
||||
_fake_build_main_agent,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.astr_agent_tool_exec.persist_agent_history",
|
||||
AsyncMock(),
|
||||
)
|
||||
|
||||
send_tool = FunctionTool(
|
||||
name="send_message_to_user",
|
||||
description="send",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
get_config=lambda **_kwargs: {"provider_settings": provider_settings},
|
||||
get_llm_tool_manager=lambda: SimpleNamespace(
|
||||
get_builtin_tool=lambda _tool_cls: send_tool
|
||||
),
|
||||
conversation_manager=SimpleNamespace(),
|
||||
)
|
||||
run_context = ContextWrapper(
|
||||
context=SimpleNamespace(event=_DummyEvent([]), context=context),
|
||||
tool_call_timeout=456,
|
||||
)
|
||||
|
||||
await FunctionToolExecutor._wake_main_agent_for_background_result(
|
||||
run_context,
|
||||
task_id="task-id",
|
||||
tool_name="long_tool",
|
||||
result_text="ok",
|
||||
tool_args={},
|
||||
note="task finished",
|
||||
summary_name="BackgroundTask",
|
||||
)
|
||||
|
||||
config = captured["config"]
|
||||
assert config.tool_call_timeout == 456
|
||||
assert config.streaming_response == provider_settings["stream"]
|
||||
assert config.provider_settings == provider_settings
|
||||
assert config.provider_settings["fallback_chat_models"] == ["fallback-provider"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_handoff_image_urls_filters_extensionless_file_outside_temp_root(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
import pytest
|
||||
|
||||
from astrbot.core import sp
|
||||
from astrbot.core.provider import func_tool_manager as ftm
|
||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||
from astrbot.core.tools.computer_tools.shell import ExecuteShellTool
|
||||
from astrbot.core.tools.message_tools import SendMessageToUserTool
|
||||
@@ -345,3 +346,80 @@ def test_firecrawl_tools_are_registered_as_builtin_tools():
|
||||
assert extract_tool.name == "firecrawl_extract_web_page"
|
||||
assert manager.is_builtin_tool("web_search_firecrawl") is True
|
||||
assert manager.is_builtin_tool("firecrawl_extract_web_page") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modelscope_sync_enables_only_synced_servers(monkeypatch):
|
||||
class FakeResponse:
|
||||
status = 200
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def json(self):
|
||||
return {
|
||||
"data": {
|
||||
"mcp_server_list": [
|
||||
{
|
||||
"name": "valid",
|
||||
"operational_urls": [{"url": "https://example.com/mcp"}],
|
||||
},
|
||||
{"name": "missing-url", "operational_urls": []},
|
||||
{"name": "empty-url", "operational_urls": [{}]},
|
||||
{"operational_urls": [{"url": "https://example.com/no-name"}]},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def get(self, *_args, **_kwargs):
|
||||
return FakeResponse()
|
||||
|
||||
saved_configs = []
|
||||
enabled_servers = []
|
||||
default_config = {"mcpServers": {}}
|
||||
manager = FunctionToolManager()
|
||||
|
||||
async def fake_enable_mcp_server(name, config):
|
||||
enabled_servers.append((name, config))
|
||||
|
||||
monkeypatch.setattr(ftm.aiohttp, "ClientSession", lambda: FakeSession())
|
||||
monkeypatch.setattr(manager, "load_mcp_config", lambda: default_config)
|
||||
monkeypatch.setattr(manager, "save_mcp_config", saved_configs.append)
|
||||
monkeypatch.setattr(manager, "enable_mcp_server", fake_enable_mcp_server)
|
||||
|
||||
await manager.sync_modelscope_mcp_servers("token")
|
||||
|
||||
assert default_config == {"mcpServers": {}}
|
||||
assert saved_configs == [
|
||||
{
|
||||
"mcpServers": {
|
||||
"valid": {
|
||||
"url": "https://example.com/mcp",
|
||||
"transport": "sse",
|
||||
"active": True,
|
||||
"provider": "modelscope",
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
assert enabled_servers == [
|
||||
(
|
||||
"valid",
|
||||
{
|
||||
"url": "https://example.com/mcp",
|
||||
"transport": "sse",
|
||||
"active": True,
|
||||
"provider": "modelscope",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
107
tests/unit/test_io_download_file.py
Normal file
107
tests/unit/test_io_download_file.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import pytest
|
||||
|
||||
from astrbot.core.utils import io
|
||||
|
||||
|
||||
class _FakeContent:
|
||||
def __init__(self, chunks: list[bytes]):
|
||||
self._chunks = chunks
|
||||
|
||||
async def read(self, _size: int) -> bytes:
|
||||
if self._chunks:
|
||||
return self._chunks.pop(0)
|
||||
return b""
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, *, status: int, chunks: list[bytes]):
|
||||
self.status = status
|
||||
self.headers = {"content-length": str(sum(len(chunk) for chunk in chunks))}
|
||||
self.content = _FakeContent(chunks)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, response: _FakeResponse | Exception):
|
||||
self._response = response
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def get(self, *_args, **_kwargs):
|
||||
if isinstance(self._response, Exception):
|
||||
raise self._response
|
||||
return self._response
|
||||
|
||||
|
||||
def _patch_download_session(monkeypatch, response: _FakeResponse):
|
||||
_patch_download_sessions(monkeypatch, [response])
|
||||
|
||||
|
||||
def _patch_download_sessions(monkeypatch, responses: list[_FakeResponse | Exception]):
|
||||
monkeypatch.setattr(io.aiohttp, "TCPConnector", lambda **_kwargs: object())
|
||||
monkeypatch.setattr(
|
||||
io.aiohttp,
|
||||
"ClientSession",
|
||||
lambda **_kwargs: _FakeSession(responses.pop(0)),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_rejects_non_200_response(monkeypatch, tmp_path):
|
||||
target_path = tmp_path / "missing.bin"
|
||||
_patch_download_session(
|
||||
monkeypatch,
|
||||
_FakeResponse(status=404, chunks=[b"not found"]),
|
||||
)
|
||||
|
||||
with pytest.raises(io.DownloadFileHTTPError, match="HTTP status code: 404"):
|
||||
await io.download_file("https://example.test/missing", str(target_path))
|
||||
|
||||
assert not target_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_rejects_non_200_response_after_ssl_fallback(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
class FakeSSLError(Exception):
|
||||
pass
|
||||
|
||||
target_path = tmp_path / "missing.bin"
|
||||
_patch_download_sessions(
|
||||
monkeypatch,
|
||||
[
|
||||
FakeSSLError(),
|
||||
_FakeResponse(status=404, chunks=[b"not found"]),
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(io.aiohttp, "ClientConnectorSSLError", FakeSSLError)
|
||||
monkeypatch.setattr(io.aiohttp, "ClientConnectorCertificateError", FakeSSLError)
|
||||
|
||||
with pytest.raises(io.DownloadFileHTTPError, match="HTTP status code: 404"):
|
||||
await io.download_file("https://example.test/missing", str(target_path))
|
||||
|
||||
assert not target_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_writes_successful_response(monkeypatch, tmp_path):
|
||||
target_path = tmp_path / "ok.bin"
|
||||
_patch_download_session(
|
||||
monkeypatch,
|
||||
_FakeResponse(status=200, chunks=[b"hello", b" world"]),
|
||||
)
|
||||
|
||||
await io.download_file("https://example.test/ok.bin", str(target_path))
|
||||
|
||||
assert target_path.read_bytes() == b"hello world"
|
||||
Reference in New Issue
Block a user