Compare commits

..

2 Commits

Author SHA1 Message Date
Lightjunction Assistant
e912488bbd refactor: simplify prerelease update selection 2026-07-01 03:44:09 +08:00
Lightjunction Assistant
0a8fb37ca3 fix: skip stable update prompt for newer prereleases 2026-06-29 03:18:20 +08:00
44 changed files with 338 additions and 1280 deletions

View File

@@ -1,4 +1,4 @@
import logging
__version__ = "4.26.4"
__version__ = "4.26.2"
logger = logging.getLogger("astrbot")

View File

@@ -543,12 +543,11 @@ 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=provider_settings.get("stream", False),
provider_settings=provider_settings,
streaming_response=ctx.get_config()
.get("provider_settings", {})
.get("stream", False),
)
req = ProviderRequest()

View File

@@ -1,10 +0,0 @@
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"

View File

@@ -565,7 +565,7 @@ class DiscordPlatformAdapter(Platform):
return None
# Discord 斜杠指令名称规范
if cmd_name != cmd_name.lower() or not re.match(r"^[-_'\\w]{1,32}$", cmd_name):
if not re.match(r"^[a-z0-9_-]{1,32}$", cmd_name):
logger.debug(f"[Discord] Skipping invalid slash command format: {cmd_name}")
return None

View File

@@ -176,6 +176,15 @@ 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:
@@ -196,15 +205,6 @@ 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()

View File

@@ -8,7 +8,6 @@ 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
@@ -98,7 +97,7 @@ class WecomServer:
"""内部服务器的 GET 验证入口"""
return await self.handle_verify(request)
async def handle_verify(self, request) -> FastAPIResponse:
async def handle_verify(self, request) -> str:
"""处理验证请求,可被统一 webhook 入口复用
Args:
@@ -117,7 +116,7 @@ class WecomServer:
args.get("echostr"),
)
logger.info("验证请求有效性成功。")
return FastAPIResponse(content=echo_str, media_type="text/plain")
return echo_str
except InvalidSignatureException:
logger.error("验证请求有效性失败,签名异常,请检查配置。")
raise
@@ -272,13 +271,15 @@ class WecomPlatformAdapter(Platform):
) -> None:
# 企业微信客服不支持主动发送
if hasattr(self.client, "kf_message"):
logger.warning("企业微信客服模式不支持 send_by_session 主动发送。")
await super().send_by_session(session, message_chain)
raise Exception("企业微信客服模式不支持 send_by_session 主动发送。")
return
if not self.agent_id:
await super().send_by_session(session, message_chain)
raise Exception(
logger.warning(
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
@@ -301,7 +302,7 @@ class WecomPlatformAdapter(Platform):
"wecom 适配器",
id=self.config.get("id", "wecom"),
support_streaming_message=False,
support_proactive_message=True,
support_proactive_message=False,
)
@override

View File

@@ -121,7 +121,7 @@ class WecomAIBotAdapter(Platform):
name="wecom_ai_bot",
description="企业微信智能机器人适配器,支持 HTTP 回调和长连接模式",
id=self.config.get("id", "wecom_ai_bot"),
support_proactive_message=True,
support_proactive_message=bool(self.msg_push_webhook_url),
)
self.api_client: WecomAIBotAPIClient | None = None
@@ -568,18 +568,21 @@ class WecomAIBotAdapter(Platform):
) -> None:
"""通过消息推送 webhook 发送消息。"""
if not self.webhook_client:
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}"
logger.warning(
"主动消息发送失败: 未配置企业微信消息推送 Webhook URL请前往配置添加。session_id=%s",
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:
raise RuntimeError(
f"企业微信消息推送失败: session_id={session.session_id}, error={e}"
) from e
logger.error(
"企业微信消息推送失败(session=%s): %s",
session.session_id,
e,
)
await super().send_by_session(session, message_chain)
def run(self) -> Awaitable[Any]:

View File

@@ -395,7 +395,6 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
message_chain: MessageChain,
) -> None:
await super().send_by_session(session, message_chain)
raise Exception("微信公众号不支持发送主动消息")
@override
def meta(self) -> PlatformMetadata:
@@ -404,7 +403,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
"微信公众平台 适配器",
id=self.config.get("id", "weixin_official_account"),
support_streaming_message=False,
support_proactive_message=True,
support_proactive_message=False,
)
@override

View File

@@ -33,15 +33,7 @@ class WebhookRequest:
raise
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.
"""
def _response_from_result(result: Any):
if isinstance(result, Response):
return result
@@ -63,9 +55,6 @@ def webhook_response_from_result(result: Any):
if isinstance(result, dict | list):
return JSONResponse(result)
if isinstance(result, str | bytes):
return Response(content=result)
return result
@@ -88,7 +77,7 @@ class FastAPIWebhookServer:
result = view_func()
if inspect.isawaitable(result):
result = await result
return webhook_response_from_result(result)
return _response_from_result(result)
self.app.add_api_route(
path,

View File

@@ -1057,14 +1057,11 @@ class FunctionToolManager:
"mcp_server_list",
[],
)
local_mcp_config = copy.deepcopy(self.load_mcp_config())
local_mcp_config = self.load_mcp_config()
mcp_servers = local_mcp_config.setdefault("mcpServers", {})
synced_servers: list[tuple[str, dict]] = []
synced_count = 0
for server in mcp_server_list:
server_name = server.get("name")
if not server_name:
continue
server_name = server["name"]
operational_urls = server.get("operational_urls", [])
if not operational_urls:
continue
@@ -1073,28 +1070,28 @@ class FunctionToolManager:
if not server_url:
continue
# 添加到配置中(同名会覆盖)
server_config = {
local_mcp_config["mcpServers"][server_name] = {
"url": server_url,
"transport": "sse",
"active": True,
"provider": "modelscope",
}
mcp_servers[server_name] = server_config
synced_servers.append((server_name, server_config))
synced_count += 1
if synced_servers:
if synced_count > 0:
self.save_mcp_config(local_mcp_config)
tasks = []
for name, config in synced_servers:
for server in mcp_server_list:
name = server["name"]
tasks.append(
self.enable_mcp_server(
name=name,
config=config,
config=local_mcp_config["mcpServers"][name],
),
)
await asyncio.gather(*tasks)
logger.info(
f"从 ModelScope 同步了 {len(synced_servers)} 个 MCP 服务器",
f"从 ModelScope 同步了 {synced_count} 个 MCP 服务器",
)
else:
logger.warning("没有找到可用的 ModelScope MCP 服务器")

View File

@@ -1013,7 +1013,7 @@ class PluginManager:
logger.warning(
f"插件 {smd.name} 未被正常终止: {e!s}, 可能会导致该插件运行不正常。",
)
if smd.name and smd.activated:
if smd.name:
await self._unbind_plugin(smd.name, specified_module_path)
result = await self.load(specified_module_path)

View File

@@ -304,11 +304,6 @@ 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,

View File

@@ -1,5 +1,4 @@
import os
import subprocess
import sys
import time
import zipfile
@@ -9,10 +8,6 @@ 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
@@ -140,11 +135,6 @@ 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:
@@ -152,10 +142,6 @@ 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

View File

@@ -178,101 +178,6 @@ 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,
@@ -304,15 +209,69 @@ async def download_file(
connector=connector,
) as session:
async with session.get(url, timeout=1800) as resp:
_raise_for_download_status(resp, url)
with open(path, "wb") as f:
await _download_response_to_file(
resp,
f,
url,
show_progress,
progress_callback,
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,
},
)
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,
},
)
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
if not allow_insecure_ssl_fallback:
raise
@@ -332,16 +291,63 @@ 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:
_raise_for_download_status(resp, url)
with open(path, "wb") as f:
await _download_response_to_file(
resp,
f,
url,
show_progress,
progress_callback,
show_downloading_label=False,
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,
},
)
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,
},
)
if show_progress:
print()

View File

@@ -1,7 +1,6 @@
"""Tencent Silk audio conversion helpers."""
import asyncio
import audioop
import os
import subprocess
import wave
@@ -9,9 +8,6 @@ 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.
@@ -73,19 +69,8 @@ async def wav_to_tencent_silk(wav_path: str, output_path: str) -> float:
with wave.open(wav_path, "rb") as wav:
rate = wav.getframerate()
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)
frames = wav.getnframes()
pcm_data = wav.readframes(frames)
input_io = BytesIO(pcm_data)
output_io = BytesIO()
@@ -93,7 +78,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 len(pcm_data) / (2 * rate) if rate else 0
return frames / rate if rate else 0
async def convert_to_pcm_wav(input_path: str, output_path: str) -> str:

View File

@@ -234,6 +234,45 @@ 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,
@@ -241,29 +280,17 @@ class RepoZipUpdator:
consider_prerelease: bool = True,
) -> ReleaseInfo | None:
update_data = await self.fetch_release_info(url)
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:
if not update_data:
logger.error("未找到合适的发布版本")
return None
if self.compare_version(current_version, tag_name) >= 0:
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):
return None
return ReleaseInfo(
version=tag_name,

View File

@@ -3,9 +3,7 @@ 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
@@ -52,30 +50,11 @@ 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,
@@ -94,7 +73,7 @@ async def verify_platform_webhook(
request: Request,
service: PlatformService = Depends(get_service),
):
return await _run_webhook(
return await _run(
lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request))
)
@@ -105,7 +84,7 @@ async def receive_platform_webhook(
request: Request,
service: PlatformService = Depends(get_service),
):
return await _run_webhook(
return await _run(
lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request))
)
@@ -116,7 +95,7 @@ async def dashboard_platform_webhook(
request: Request,
service: PlatformService = Depends(get_service),
):
return await _run_webhook(
return await _run(
lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request))
)

View File

@@ -4,7 +4,6 @@ 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 (
@@ -59,15 +58,6 @@ 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,

View File

@@ -21,10 +21,6 @@ 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,
@@ -61,9 +57,6 @@ 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

View File

@@ -15,10 +15,6 @@ 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,
@@ -71,9 +67,7 @@ class UpdateServiceResult:
class UpdateServiceError(Exception):
def __init__(self, message: str, *, code: str | None = None) -> None:
super().__init__(message)
self.code = code
pass
class UpdateService:
@@ -149,12 +143,6 @@ 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)

View File

@@ -1,31 +0,0 @@
## 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)

View File

@@ -1,13 +0,0 @@
## 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)

View File

@@ -31,14 +31,14 @@
"katex": "^0.16.27",
"lodash": "4.17.23",
"markdown-it": "^14.1.1",
"markstream-vue": "1.0.5-beta.0",
"markstream-vue": "1.0.1-beta.1",
"mermaid": "^11.12.2",
"monaco-editor": "^0.52.2",
"pinia": "2.1.6",
"pinyin-pro": "^3.26.0",
"qrcode": "^1.5.4",
"shiki": "^3.23.0",
"stream-markdown": "^0.0.16",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.15",
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "2.1.3",
"vue": "3.3.4",

135
dashboard/pnpm-lock.yaml generated
View File

@@ -58,8 +58,8 @@ importers:
specifier: ^14.1.1
version: 14.1.1
markstream-vue:
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)
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)
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.23.0
version: 3.23.0
specifier: ^3.20.0
version: 3.22.0
stream-markdown:
specifier: ^0.0.16
version: 0.0.16(shiki@3.23.0)(vue@3.3.4)
specifier: ^0.0.15
version: 0.0.15(shiki@3.22.0)(vue@3.3.4)
vee-validate:
specifier: 4.11.3
version: 4.11.3(vue@3.3.4)
@@ -676,27 +676,21 @@ packages:
'@shikijs/core@3.22.0':
resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==}
'@shikijs/core@3.23.0':
resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
'@shikijs/engine-javascript@3.22.0':
resolution: {integrity: sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==}
'@shikijs/engine-javascript@3.23.0':
resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
'@shikijs/engine-oniguruma@3.22.0':
resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==}
'@shikijs/engine-oniguruma@3.23.0':
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
'@shikijs/langs@3.22.0':
resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==}
'@shikijs/langs@3.23.0':
resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
'@shikijs/themes@3.23.0':
resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
'@shikijs/themes@3.22.0':
resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==}
'@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==}
@@ -2140,9 +2134,6 @@ 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'}
@@ -2196,8 +2187,8 @@ packages:
markdown-it-task-checkbox@1.0.6:
resolution: {integrity: sha512-7pxkHuvqTOu3iwVGmDPeYjQg+AIS9VQxzyLP9JCg9lBjgPAJXGEkChK6A2iFuj3tS0GV3HG2u5AMNhcQqwxpJw==}
markdown-it-ts@1.0.2:
resolution: {integrity: sha512-zba9mN313K2HmKk+BOHqkO/nuZtj9M1TTnUlSbItGrCMpYzc8OHGCm+IaqxWCi2pGcgpiFC8ltxkasYWYpp/YQ==}
markdown-it-ts@1.0.0:
resolution: {integrity: sha512-hQT/yCYryC3jNs2wJ35R4m1zKcBxNuFaKCGzwpmq2OuMXMNbUK1oTwCxONIjy5lXWWG1UCNbGXe1nbTiWbH/iA==}
engines: {node: '>=18'}
markdown-it@14.1.1:
@@ -2209,18 +2200,18 @@ packages:
engines: {node: '>= 20'}
hasBin: true
markstream-core@1.0.3:
resolution: {integrity: sha512-QXn+yERo1q+RD6YlGDW61zc/af65uhkQEH3K8YvEKkprHbgRJ/JIeBeEQjELCTyBnPoxqtKQ7I565rylY+PePg==}
markstream-core@1.0.0:
resolution: {integrity: sha512-V39W0rPgJ5Yj/XEl11LaKQxX9dYOI34RL729Zoi9oyy/Y6z6H+PJOsBFktu7gU9ZZxCsevWMb/Re2LgSKbWfRg==}
markstream-vue@1.0.5-beta.0:
resolution: {integrity: sha512-/gmcNKa7v6qqmwQ/JAn3sWUoiM+EaMkvS0X5YRgqD74d+G22dzua4X8ZViYpbDGR/bl7BTw3juzsRfC0dm15pw==}
markstream-vue@1.0.1-beta.1:
resolution: {integrity: sha512-45br3sbOQirIg0tmPMWRmdWk/vLE5aJtVx7cvLaa3e+klyYpILzQ7c4eOMq2EZkQTVewJhZHUVc2FTj+ujUJPg==}
peerDependencies:
'@antv/infographic': ^0.2.3
'@terrastruct/d2': '>=0.1.33'
katex: '>=0.16.22'
mermaid: '>=11'
stream-markdown: '>=0.0.16'
stream-monaco: '>=0.0.45'
stream-markdown: '>=0.0.15'
stream-monaco: '>=0.0.41'
vue: '>=3.0.0'
vue-i18n: '>=9'
peerDependenciesMeta:
@@ -2749,8 +2740,8 @@ packages:
vue:
optional: true
shiki@3.23.0:
resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
shiki@3.22.0:
resolution: {integrity: sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==}
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
@@ -2773,13 +2764,13 @@ packages:
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
stream-markdown-parser@1.0.8:
resolution: {integrity: sha512-+Oo9ik7BtMBxf+Krh19YA0jmk+TIUxfULU5qQpWHo3Z4SBX74/Qwt6lVHcUvEcUQcd1kV35VNmw1SYdhBY4aOA==}
stream-markdown-parser@1.0.0:
resolution: {integrity: sha512-uOYQ8G9YFFtGM726fK77O/mJPYrwYHPy0DKBtk7bCPmkf06XwDVSOAFRTo2FsXnZ3vAaPBtxOgNGJ0CGieBcSQ==}
stream-markdown@0.0.16:
resolution: {integrity: sha512-2WoOxlpc3N5RLc3zGuW+g/w76z6ketWBY0N1YzDYbXds1qw7zrUBv5PTQ3DOtpqgCjLuF3P2iCfjJTEqWGv0NQ==}
stream-markdown@0.0.15:
resolution: {integrity: sha512-1WlzjZUb9W5BWZYMKCr2/exPVh5P7HIhHzkcYZczkXm0upiuN4zEddwjdckL+WSQWGGlv9bboXCqcTYCEgqexw==}
peerDependencies:
shiki: '>=3.23.0'
shiki: '>=3.13.0'
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
@@ -3608,42 +3599,30 @@ snapshots:
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/core@3.23.0':
'@shikijs/engine-javascript@3.22.0':
dependencies:
'@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/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.4
'@shikijs/engine-oniguruma@3.23.0':
'@shikijs/engine-oniguruma@3.22.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@3.23.0':
'@shikijs/langs@3.22.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/types': 3.22.0
'@shikijs/themes@3.23.0':
'@shikijs/themes@3.22.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/types': 3.22.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)':
@@ -5289,10 +5268,6 @@ 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:
@@ -5337,12 +5312,12 @@ snapshots:
markdown-it-task-checkbox@1.0.6: {}
markdown-it-ts@1.0.2:
markdown-it-ts@1.0.0:
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
entities: 4.5.0
linkify-it: 5.0.1
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
@@ -5358,19 +5333,19 @@ snapshots:
marked@16.4.2: {}
markstream-core@1.0.3: {}
markstream-core@1.0.0: {}
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):
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):
dependencies:
'@chenglou/pretext': 0.0.5
'@floating-ui/dom': 1.7.6
markstream-core: 1.0.3
stream-markdown-parser: 1.0.8
markstream-core: 1.0.0
stream-markdown-parser: 1.0.0
vue: 3.3.4
optionalDependencies:
katex: 0.16.28
mermaid: 11.12.2
stream-markdown: 0.0.16(shiki@3.23.0)(vue@3.3.4)
stream-markdown: 0.0.15(shiki@3.22.0)(vue@3.3.4)
vue-i18n: 11.2.8(vue@3.3.4)
math-intrinsics@1.1.0: {}
@@ -5910,14 +5885,14 @@ snapshots:
optionalDependencies:
vue: 3.3.4
shiki@3.23.0:
shiki@3.22.0:
dependencies:
'@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/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/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
@@ -5936,7 +5911,7 @@ snapshots:
state-local@1.0.7: {}
stream-markdown-parser@1.0.8:
stream-markdown-parser@1.0.0:
dependencies:
markdown-it-container: 4.0.0
markdown-it-footnote: 4.0.0
@@ -5945,11 +5920,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.2
markdown-it-ts: 1.0.0
stream-markdown@0.0.16(shiki@3.23.0)(vue@3.3.4):
stream-markdown@0.0.15(shiki@3.22.0)(vue@3.3.4):
dependencies:
shiki: 3.23.0
shiki: 3.22.0
shiki-stream: 0.1.4(vue@3.3.4)
transitivePeerDependencies:
- react

View File

@@ -8,14 +8,13 @@
:smooth-streaming="isStreaming ? 'auto' : false"
:fade="false"
:typewriter="false"
:max-live-nodes="MARKDOWN_RENDER_MAX_LIVE_NODES"
:max-live-nodes="0"
/>
</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<{

View File

@@ -1 +0,0 @@
export const MARKDOWN_RENDER_MAX_LIVE_NODES = 320;

View File

@@ -9,7 +9,7 @@
:smooth-streaming="isStreaming ? 'auto' : false"
:fade="false"
:typewriter="false"
:max-live-nodes="MARKDOWN_RENDER_MAX_LIVE_NODES"
:max-live-nodes="0"
/>
</div>
</template>
@@ -17,7 +17,6 @@
<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;

View File

@@ -27,7 +27,7 @@
:fade="false"
:typewriter="false"
:is-dark="isDark"
:max-live-nodes="MARKDOWN_RENDER_MAX_LIVE_NODES"
:max-live-nodes="0"
/>
<div v-else-if="entry.tool" class="reasoning-tool-call-block">
@@ -62,7 +62,6 @@
<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";

View File

@@ -88,17 +88,6 @@
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'"
@@ -151,17 +140,6 @@
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'"
@@ -217,17 +195,6 @@
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'"
@@ -262,19 +229,8 @@
<div
v-else-if="isWeixinOcPlatform"
class="registration-inline mt-4"
class="weixin-oc-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"
@@ -883,7 +839,6 @@ export default {
larkCreationMode: "",
dingtalkCreationMode: "",
qqOfficialCreationMode: "",
scanPlatformIdCustomized: false,
aBConfigRadioVal: "0",
selectedAbConfId: "default",
@@ -1099,16 +1054,6 @@ 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) {
@@ -1119,13 +1064,11 @@ 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) {
@@ -1213,7 +1156,6 @@ export default {
this.larkCreationMode = "";
this.dingtalkCreationMode = "";
this.qqOfficialCreationMode = "";
this.scanPlatformIdCustomized = false;
this.aBConfigRadioVal = "0";
this.selectedAbConfId = "default";
@@ -1530,14 +1472,6 @@ 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 = "_";
@@ -1558,9 +1492,6 @@ 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) {
@@ -2037,14 +1968,8 @@ 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>

View File

@@ -181,9 +181,6 @@
"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",

View File

@@ -181,9 +181,6 @@
"created": "Настройка завершена",
"startFailed": "Не удалось начать QR настройку",
"pollFailed": "Не удалось проверить статус QR настройки",
"platformIdLabel": "ID бота",
"platformIdRequired": "ID бота обязателен",
"platformIdInvalid": "ID бота не может содержать пробелы, ':' или '!'",
"mode": {
"title": "Выберите способ создания",
"scan": "Создать через QR",

View File

@@ -181,9 +181,6 @@
"created": "创建成功",
"startFailed": "发起扫码创建失败",
"pollFailed": "获取扫码创建状态失败",
"platformIdLabel": "机器人 ID",
"platformIdRequired": "机器人 ID 不能为空",
"platformIdInvalid": "机器人 ID 不能包含空格、':' 或 '!'",
"mode": {
"title": "选择创建方式",
"scan": "扫码一键创建",

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.26.4"
version = "4.26.2"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
license = { text = "AGPL-3.0-or-later" }

View File

@@ -620,23 +620,3 @@ 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

View File

@@ -20,7 +20,6 @@ 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 (
@@ -2663,35 +2662,6 @@ 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,
@@ -2856,44 +2826,6 @@ 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,

View File

@@ -389,15 +389,10 @@ 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": payload,
"payload": await request_obj.get_json(silent=True),
}
async def send_by_session(self, session, message_chain) -> None:
@@ -3299,35 +3294,10 @@ async def test_v1_platform_webhook_is_public_route(
)
assert response.status_code == 200
assert response.json() == {
data = response.json()
assert data["status"] == "ok"
assert data["data"] == {
"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"

View File

@@ -2,7 +2,6 @@ import base64
import math
import os
import struct
import sys
import wave
from io import BytesIO
from pathlib import Path
@@ -654,39 +653,19 @@ def test_path_mapping_accepts_standard_and_legacy_file_uri(tmp_path):
@pytest.mark.asyncio
@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.
"""
async def test_tencent_silk_encoding_uses_pysilk_tencent_format(tmp_path, monkeypatch):
monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(tmp_path))
wav_path = tmp_path / "tone.wav"
silk_path = tmp_path / "tone.silk"
secs = 0.2
frames = int(rate * secs)
rate = 24000
frames = int(rate * 0.2)
with wave.open(str(wav_path), "wb") as wav:
wav.setnchannels(channels)
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(rate)
for i in range(frames):
sample = int(0.2 * 32767 * math.sin(2 * math.pi * 440 * i / rate))
for _ in range(channels):
wav.writeframesraw(struct.pack("<h", sample))
wav.writeframesraw(struct.pack("<h", sample))
duration = await wav_to_tencent_silk(str(wav_path), str(silk_path))
silk_bytes = silk_path.read_bytes()
@@ -700,82 +679,7 @@ async def test_tencent_silk_encoding_uses_pysilk_tencent_format(
assert resolved.format == "tencent_silk"
assert resolved.mime_type == "audio/silk"
assert duration == pytest.approx(secs, abs=0.05)
assert duration == pytest.approx(0.2)
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

View File

@@ -1976,202 +1976,3 @@ 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()

View File

@@ -63,7 +63,7 @@ async def test_qq_webhook_callback_rejects_missing_signature():
@pytest.mark.asyncio
async def test_qq_webhook_callback_accepts_unsigned_validation():
async def test_qq_webhook_callback_accepts_signed_validation():
secret = "test-secret"
event_ts = "1710000000"
plain_token = "plain-token"
@@ -71,10 +71,19 @@ async def test_qq_webhook_callback_accepts_unsigned_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))
result = await webhook.handle_callback(
FakeRequest(
body,
{
_SIGNATURE_TIMESTAMP_HEADER: event_ts,
_SIGNATURE_HEADER: signature,
},
)
)
assert result == {
"plain_token": plain_token,

View File

@@ -10,7 +10,6 @@ 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
@@ -81,60 +80,6 @@ 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)
@@ -473,6 +418,41 @@ 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,

View File

@@ -1,27 +0,0 @@
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)

View File

@@ -1,5 +1,4 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import mcp
import pytest
@@ -20,7 +19,6 @@ 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
@@ -38,15 +36,6 @@ 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(
@@ -365,71 +354,6 @@ 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,

View File

@@ -3,7 +3,6 @@ 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
@@ -346,80 +345,3 @@ 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",
},
)
]

View File

@@ -1,107 +0,0 @@
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"