Compare commits

...

13 Commits

Author SHA1 Message Date
Soulter 349ca05e26 fix(dingtalk): text is ignored; cannot send file actively 2026-03-08 23:30:07 +08:00
Soulter 7f3c0fdeb2 fix: cannot receive image, file in dingtalk (#5920)
fixes: #5916 #5786
2026-03-08 23:18:56 +08:00
Windy_cold 8e431e2076 correct openrouter api_base (#5911) 2026-03-08 21:53:56 +09:00
ChuwuYo 89c11fd683 fix(extension): support searching installed plugins by display name (#5806) (#5811)
* fix(extension): support searching installed plugins by display name

* fix: unify plugin search matching across installed and market tabs

* refactor(extension): optimize plugin search matcher and remove redundant checks

* refactor(extension-page): centralize search query normalization and text matching logic

- Extract `buildSearchQuery` to create normalized query objects from raw input
- Extract `matchesText` as a reusable text matching helper for normalized/loose/pinyin/initials matching
- Remove unused `marketCustomFilter` to eliminate dead code
- Simplify `matchesPluginSearch` to accept query object instead of pre-normalized string
- Replace Set with Array for candidates to simplify control flow
- Avoid redundant normalization by having callers pass raw strings to `buildSearchQuery`

* refactor: remove unused marketCustomFilter from extension page components

- Remove marketCustomFilter from destructuring in ExtensionPage.vue, InstalledPluginsTab.vue, and MarketPluginsTab.vue

* refactor(extension): extract plugin search utilities into shared module

- Create pluginSearch.js to centralize plugin search helpers
- Move `normalizeStr`, `normalizeLoose`, `toPinyinText`, and `toInitials` into the shared module
- Add `buildSearchQuery`, `matchesText`, and `matchesPluginSearch` for reusable search matching
- Refactor useExtensionPage.js to consume the shared utilities
- Simplify plugin search logic by consolidating normalization and matching in one place

* refactor(extension): add caching to pinyin utilities and extract search fields helper

- Add Map-based caching for `toPinyinText` and `toInitials` to avoid redundant pinyin computation
- Extract `getPluginSearchFields` function to retrieve plugin fields for searching
- Improve plugin search performance with caching and better code organization

* perf(extension): add bounded caching for plugin search

- cap normalization and pinyin caches with `MAX_SEARCH_CACHE_SIZE`
- add `setCacheValue()` for oldest-entry eviction
- cache normalized and loose text values to avoid repeated string processing
- skip pinyin matching for non-CJK text using Unicode `\p{Unified_Ideograph}` property
- improve search performance while keeping memory usage bounded

* refactor(extension): extract memoizeLRU helper for cache management

- Create `memoizeLRU` higher-order function to generate LRU-cached functions
- Replace manual cache implementation with `memoizeLRU` for cleaner code
- Optimize `matchesText` to lazily compute looseValue only when needed
- Simplify caching logic while maintaining bounded cache size

* refactor(extension): simplify memoization and remove LRU logic

- Rename `memoizeLRU` to `memoizeStringFn` and remove bounded cache size
- Simplify cache hit logic for cleaner code
- Remove `MAX_SEARCH_CACHE_SIZE` constant as it's no longer needed
2026-03-08 17:41:45 +09:00
時壹 7cfe2aca99 fix: apply reply_with_quote and reply_with_mention to image-only response (#5219)
* fix: apply reply_with_quote and reply_with_mention to image-only responses

* fix: restrict reply_with_quote and reply_with_mention to plain-text/image chains
2026-03-08 17:41:12 +09:00
時壹 3a938d2a13 fix: use re.search instead of re.match in RegexFilter (#5368) 2026-03-08 17:40:40 +09:00
whatevertogo 812834bc9f feat(skills): add batch upload functionality for multiple skill ZIP files (#5804)
* feat(skills): add batch upload functionality for multiple skill ZIP files

- Implemented a new endpoint for batch uploading skills.
- Enhanced the SkillsSection component to support multiple file selection and drag-and-drop functionality.
- Updated localization files for new upload features and messages.
- Added tests to validate batch upload behavior and error handling.

* feat(skills): improve batch upload handling and enhance accessibility for dropzone

* feat(skills): enhance batch upload process and improve UI for better user experience

* feat(skills): enhance skills upload dialog layout and styling for improved usability

* feat(skills): update upload dialog description styling for better visibility and usability

* feat(skills): improve upload dialog button styling and layout for enhanced usability

* feat(skills): refine upload dialog text for clarity and consistency

* feat(skills): enhance batch upload functionality by ignoring __MACOSX entries and improving upload dialog styling

* feat(skills): refactor upload dialog and button styles for improved consistency and usability

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
2026-03-07 23:18:01 +08:00
エイカク 51ff4f6e46 fix: detect desktop runtime without frozen python (#5859)
* fix: detect desktop runtime without frozen python

* chore: drop planning docs from runtime fix pr
2026-03-07 21:42:56 +09:00
Soulter 7ac169c5e8 docs: add macOS usage note and update instructions for astrbot in multiple README files 2026-03-06 14:34:29 +08:00
Soulter 61648ebe3e docs: add new QQ group entries to README files 2026-03-06 11:11:12 +08:00
Soulter 0610f0db0a fix: pipeline scheduler not found after creating platform bot via using 'create new config' (#5776) 2026-03-05 23:53:53 +08:00
whatevertogo 8c935981bb fix: align aiocqhttp poke payload with onebot v11 (#5773)
Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
2026-03-05 23:02:26 +08:00
Ruochen Pan 3f3b4e4924 test(skill_manager): update sandbox cache path expectations (#5706)
* test(skill_manager): update sandbox cache path expectations

adjust sandbox cache tests to match absolute path resolution in
list_skills for sandbox runtime.

verify sandbox-cached skills cannot be deactivated via set_skill_active
by asserting a PermissionError, and keep active-only listing behavior
intact.

add coverage for show_sandbox_path=false to ensure local skills still
override cached metadata while sandbox-only skills retain cached paths.

* test(skill_manager): tighten local skill path assertions
2026-03-05 22:47:20 +08:00
30 changed files with 1943 additions and 203 deletions
+12
View File
@@ -83,6 +83,15 @@ astrbot
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
> [!NOTE]
> For macOS user: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s).
Update `astrbot`:
```bash
uv tool upgrade astrbot
```
### Docker Deployment
For users familiar with containers and looking for a more stable, production-ready deployment method, we recommend deploying AstrBot with Docker / Docker Compose.
@@ -216,12 +225,15 @@ pre-commit install
### QQ Groups
- Group 9: 1076659624 (New)
- Group 10: 1078079676 (New)
- Group 1: 322154837
- Group 3: 630166526
- Group 5: 822130018
- Group 6: 753075035
- Group 7: 743746109
- Group 8: 1030353265
- Developer Group: 975206796
### Discord Server
+9
View File
@@ -83,6 +83,15 @@ astrbot
> [uv](https://docs.astral.sh/uv/) doit être installé.
> [!NOTE]
> Pour les utilisateurs macOS : en raison des vérifications de sécurité de macOS, la première exécution de la commande `astrbot` peut prendre plus de temps (environ 10-20s).
Mettre à jour `astrbot` :
```bash
uv tool upgrade astrbot
```
### Déploiement Docker
Pour les utilisateurs familiers avec les conteneurs et qui souhaitent une méthode plus stable et adaptée à la production, nous recommandons de déployer AstrBot avec Docker / Docker Compose.
+9
View File
@@ -83,6 +83,15 @@ astrbot
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
> [!NOTE]
> macOS ユーザーの場合:macOS のセキュリティチェックにより、`astrbot` コマンドの初回実行に時間がかかる場合があります(約 10〜20 秒)。
`astrbot` の更新:
```bash
uv tool upgrade astrbot
```
### Docker デプロイ
コンテナ運用に慣れており、より安定した本番向けのデプロイ方法を求めるユーザーには、Docker / Docker Compose での AstrBot デプロイをおすすめします。
+9
View File
@@ -83,6 +83,15 @@ astrbot
> Требуется установленный [uv](https://docs.astral.sh/uv/).
> [!NOTE]
> Для пользователей macOS: из-за проверок безопасности macOS первый запуск команды `astrbot` может занять больше времени (около 10-20 секунд).
Обновить `astrbot`:
```bash
uv tool upgrade astrbot
```
### Развёртывание Docker
Для пользователей, знакомых с контейнерами и которым нужен более стабильный и подходящий для production способ, мы рекомендуем разворачивать AstrBot через Docker / Docker Compose.
+13
View File
@@ -83,6 +83,15 @@ astrbot
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
> [!NOTE]
> 對於 macOS 使用者:由於 macOS 安全性檢查,首次執行 `astrbot` 指令可能需要較長時間(約 10-20 秒)。
更新 `astrbot`
```bash
uv tool upgrade astrbot
```
### Docker 部署
對於熟悉容器、希望獲得更穩定且更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。
@@ -208,10 +217,14 @@ pre-commit install
### QQ 群組
- 9 群: 1076659624 (新)
- 10 群: 1078079676 (新)
- 1 群:322154837
- 3 群:630166526
- 5 群:822130018
- 6 群:753075035
- 7 群:743746109
- 8 群:1030353265
- 開發者群:975206796
### Discord 群組
+11
View File
@@ -83,6 +83,15 @@ astrbot
> 需要安装 [uv](https://docs.astral.sh/uv/)。
> [!NOTE]
> 对于 macOS 用户:由于 macOS 安全检查,首次运行 `astrbot` 命令可能需要较长时间(约 10-20 秒)。
更新 `astrbot`
```bash
uv tool upgrade astrbot
```
### Docker 部署
对于熟悉容器、希望获得更稳定且更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。
@@ -209,6 +218,8 @@ pre-commit install
### QQ 群组
- 9 群: 1076659624 (新)
- 10 群: 1078079676 (新)
- 1 群:322154837
- 3 群:630166526
- 5 群:822130018
+1 -1
View File
@@ -1123,7 +1123,7 @@ CONFIG_METADATA_2 = {
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://openrouter.ai/v1",
"api_base": "https://openrouter.ai/api/v1",
"proxy": "",
"custom_headers": {},
},
+29 -6
View File
@@ -539,13 +539,36 @@ class Reply(BaseMessageComponent):
class Poke(BaseMessageComponent):
type: str = ComponentType.Poke
id: int | None = 0
qq: int | None = 0
type: ComponentType = ComponentType.Poke
_type: str | int = "126"
id: int | str | None = 0
qq: int | str | None = 0 # deprecated: legacy field, kept for compatibility
def __init__(self, type: str, **_) -> None:
type = f"Poke:{type}"
super().__init__(type=type, **_)
def __init__(self, poke_type: str | int | None = None, **_) -> None:
# Backward compatible with old signature: Poke(type="poke", ...)
legacy_type = _.pop("type", None)
if poke_type is None:
poke_type = legacy_type
if poke_type in (None, "", "poke", "Poke"):
poke_type = "126"
super().__init__(_type=str(poke_type), **_)
def target_id(self) -> str | None:
"""Return normalized target id, compatible with old `qq` field."""
for value in (self.id, self.qq):
if value is None:
continue
text = str(value).strip()
if text and text != "0":
return text
return None
def toDict(self):
target_id = self.target_id()
data = {"type": str(self._type or "126")}
if target_id:
data["id"] = target_id
return {"type": "poke", "data": data}
class Forward(BaseMessageComponent):
+1 -1
View File
@@ -28,7 +28,7 @@ class RespondStage(Stage):
Comp.At: lambda comp: bool(comp.qq) or bool(comp.name), # @
Comp.Image: lambda comp: bool(comp.file), # 图片
Comp.Reply: lambda comp: bool(comp.id) and comp.sender_id is not None, # 回复
Comp.Poke: lambda comp: comp.id != 0 and comp.qq != 0, # 戳一戳
Comp.Poke: lambda comp: comp.target_id() is not None, # 戳一戳
Comp.Node: lambda comp: bool(comp.content), # 转发节点
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
Comp.File: lambda comp: bool(comp.file_ or comp.url),
@@ -5,7 +5,7 @@ import traceback
from collections.abc import AsyncGenerator
from astrbot.core import file_token_service, html_renderer, logger
from astrbot.core.message.components import At, File, Image, Node, Plain, Record, Reply
from astrbot.core.message.components import At, Image, Node, Plain, Record, Reply
from astrbot.core.message.message_event_result import ResultContentType
from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
from astrbot.core.platform.astr_message_event import AstrMessageEvent
@@ -383,8 +383,11 @@ class ResultDecorateStage(Stage):
)
result.chain = [node]
has_plain = any(isinstance(item, Plain) for item in result.chain)
if has_plain:
# at 回复 / 引用回复仅适用于纯文本或图文消息
can_decorate = all(
isinstance(item, (Plain, Image)) for item in result.chain
)
if can_decorate:
# at 回复
if (
self.reply_with_mention
@@ -399,5 +402,4 @@ class ResultDecorateStage(Stage):
# 引用回复
if self.reply_with_quote:
if not any(isinstance(item, File) for item in result.chain):
result.chain.insert(0, Reply(id=event.message_obj.message_id))
result.chain.insert(0, Reply(id=event.message_obj.message_id))
@@ -191,7 +191,7 @@ class AiocqhttpAdapter(Platform):
if "sub_type" in event:
if event["sub_type"] == "poke" and "target_id" in event:
abm.message.append(Poke(qq=str(event["target_id"]), type="poke"))
abm.message.append(Poke(id=str(event["target_id"])))
return abm
@@ -11,7 +11,7 @@ from dingtalk_stream import AckMessage
from astrbot import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, Image, Plain, Record, Video
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
@@ -178,29 +178,110 @@ class DingtalkPlatformAdapter(Platform):
abm.session_id = abm.sender.user_id
message_type: str = cast(str, message.message_type)
robot_code = cast(str, message.robot_code or "")
raw_content = cast(dict, message.extensions.get("content") or {})
if not isinstance(raw_content, dict):
raw_content = {}
match message_type:
case "text":
abm.message_str = message.text.content.strip()
abm.message.append(Plain(abm.message_str))
case "picture":
if not robot_code:
logger.error("钉钉图片消息解析失败: 回调中缺少 robotCode")
await self._remember_sender_binding(message, abm)
return abm
image_content = cast(
dingtalk_stream.ImageContent | None,
message.image_content,
)
download_code = cast(
str, (image_content.download_code if image_content else "") or ""
)
if not download_code:
logger.warning("钉钉图片消息缺少 downloadCode,已跳过")
else:
f_path = await self.download_ding_file(
download_code,
robot_code,
"jpg",
)
if f_path:
abm.message.append(Image.fromFileSystem(f_path))
else:
logger.warning("钉钉图片消息下载失败,无法解析为图片")
case "richText":
rtc: dingtalk_stream.RichTextContent = cast(
dingtalk_stream.RichTextContent, message.rich_text_content
)
contents: list[dict] = cast(list[dict], rtc.rich_text_list)
plain_parts: list[str] = []
for content in contents:
plains = ""
if "text" in content:
plains += content["text"]
abm.message.append(Plain(plains))
plain_text = cast(str, content.get("text") or "")
if plain_text:
plain_parts.append(plain_text)
abm.message.append(Plain(plain_text))
elif "type" in content and content["type"] == "picture":
download_code = cast(str, content.get("downloadCode") or "")
if not download_code:
logger.warning(
"钉钉富文本图片消息缺少 downloadCode,已跳过"
)
continue
if not robot_code:
logger.error(
"钉钉富文本图片消息解析失败: 回调中缺少 robotCode"
)
continue
f_path = await self.download_ding_file(
content["downloadCode"],
cast(str, message.robot_code),
download_code,
robot_code,
"jpg",
)
abm.message.append(Image.fromFileSystem(f_path))
case "audio":
pass
if f_path:
abm.message.append(Image.fromFileSystem(f_path))
abm.message_str = "".join(plain_parts).strip()
case "audio" | "voice":
download_code = cast(str, raw_content.get("downloadCode") or "")
if not download_code:
logger.warning("钉钉语音消息缺少 downloadCode,已跳过")
elif not robot_code:
logger.error("钉钉语音消息解析失败: 回调中缺少 robotCode")
else:
voice_ext = cast(str, raw_content.get("fileExtension") or "")
if not voice_ext:
voice_ext = "amr"
voice_ext = voice_ext.lstrip(".")
f_path = await self.download_ding_file(
download_code,
robot_code,
voice_ext,
)
if f_path:
abm.message.append(Record.fromFileSystem(f_path))
case "file":
download_code = cast(str, raw_content.get("downloadCode") or "")
if not download_code:
logger.warning("钉钉文件消息缺少 downloadCode,已跳过")
elif not robot_code:
logger.error("钉钉文件消息解析失败: 回调中缺少 robotCode")
else:
file_name = cast(str, raw_content.get("fileName") or "")
file_ext = Path(file_name).suffix.lstrip(".") if file_name else ""
if not file_ext:
file_ext = cast(str, raw_content.get("fileExtension") or "")
if not file_ext:
file_ext = "file"
f_path = await self.download_ding_file(
download_code,
robot_code,
file_ext,
)
if f_path:
if not file_name:
file_name = Path(f_path).name
abm.message.append(File(name=file_name, file=f_path))
await self._remember_sender_binding(message, abm)
return abm # 别忘了返回转换后的消息对象
@@ -270,7 +351,17 @@ class DingtalkPlatformAdapter(Platform):
)
return ""
resp_data = await resp.json()
download_url = resp_data["data"]["downloadUrl"]
download_url = cast(
str,
(
resp_data.get("downloadUrl")
or resp_data.get("data", {}).get("downloadUrl")
or ""
),
)
if not download_url:
logger.error(f"下载钉钉文件失败: 未找到 downloadUrl, 响应: {resp_data}")
return ""
await download_file(download_url, str(f_path))
return str(f_path)
@@ -541,6 +632,28 @@ class DingtalkPlatformAdapter(Platform):
self._safe_remove_file(cover_path)
if converted_video:
self._safe_remove_file(video_path)
elif isinstance(segment, File):
try:
file_path = await segment.get_file()
if not file_path:
logger.warning("钉钉文件发送失败: 无法解析文件路径")
continue
media_id = await self.upload_media(file_path, "file")
if not media_id:
continue
file_name = segment.name or Path(file_path).name
file_type = Path(file_name).suffix.lstrip(".")
await send_message(
msg_key="sampleFile",
msg_param={
"mediaId": media_id,
"fileName": file_name,
"fileType": file_type,
},
)
except Exception as e:
logger.warning(f"钉钉文件发送失败: {e}")
continue
async def send_message_chain_to_group(
self,
+17 -2
View File
@@ -26,6 +26,13 @@ _SANDBOX_SKILLS_CACHE_VERSION = 1
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
def _is_ignored_zip_entry(name: str) -> bool:
parts = PurePosixPath(name).parts
if not parts:
return True
return parts[0] == "__MACOSX"
@dataclass
class SkillInfo:
name: str
@@ -401,7 +408,11 @@ class SkillManager:
raise ValueError("Uploaded file is not a valid zip archive.")
with zipfile.ZipFile(zip_path) as zf:
names = [name.replace("\\", "/") for name in zf.namelist()]
names = [
name
for name in (entry.replace("\\", "/") for entry in zf.namelist())
if name and not _is_ignored_zip_entry(name)
]
file_names = [name for name in names if name and not name.endswith("/")]
if not file_names:
raise ValueError("Zip archive is empty.")
@@ -436,7 +447,11 @@ class SkillManager:
raise ValueError("SKILL.md not found in the skill folder.")
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
zf.extractall(tmp_dir)
for member in zf.infolist():
member_name = member.filename.replace("\\", "/")
if not member_name or _is_ignored_zip_entry(member_name):
continue
zf.extract(member, tmp_dir)
src_dir = Path(tmp_dir) / skill_name
if not src_dir.exists():
raise ValueError("Skill folder not found after extraction.")
+1 -1
View File
@@ -15,4 +15,4 @@ class RegexFilter(HandlerFilter):
self.regex = re.compile(regex)
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
return bool(self.regex.match(event.get_message_str().strip()))
return bool(self.regex.search(event.get_message_str().strip()))
+1 -1
View File
@@ -7,4 +7,4 @@ def is_frozen_runtime() -> bool:
def is_packaged_desktop_runtime() -> bool:
return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
return os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
+2
View File
@@ -610,6 +610,7 @@ class ConfigRoute(Route):
try:
conf_id = self.acm.create_conf(name=name, config=config)
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
return Response().ok(message="创建成功", data={"conf_id": conf_id}).__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
@@ -649,6 +650,7 @@ class ConfigRoute(Route):
try:
success = self.acm.delete_conf(conf_id)
if success:
self.core_lifecycle.pipeline_scheduler_mapping.pop(conf_id, None)
return Response().ok(message="删除成功").__dict__
return Response().error("删除失败").__dict__
except ValueError as e:
+110
View File
@@ -2,6 +2,7 @@ import os
import re
import shutil
import traceback
import uuid
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
@@ -50,6 +51,7 @@ class SkillsRoute(Route):
self.routes = {
"/skills": ("GET", self.get_skills),
"/skills/upload": ("POST", self.upload_skill),
"/skills/batch-upload": ("POST", self.batch_upload_skills),
"/skills/download": ("GET", self.download_skill),
"/skills/update": ("POST", self.update_skill),
"/skills/delete": ("POST", self.delete_skill),
@@ -188,6 +190,114 @@ class SkillsRoute(Route):
except Exception:
logger.warning(f"Failed to remove temp skill file: {temp_path}")
async def batch_upload_skills(self):
"""批量上传多个 skill ZIP 文件"""
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
try:
files = await request.files
file_list = files.getlist("files")
if not file_list:
return Response().error("No files provided").__dict__
succeeded = []
failed = []
skill_mgr = SkillManager()
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
for file in file_list:
filename = os.path.basename(file.filename or "unknown.zip")
temp_path = None
try:
if not filename.lower().endswith(".zip"):
failed.append(
{
"filename": filename,
"error": "Only .zip files are supported",
}
)
continue
temp_path = os.path.join(
temp_dir, f"batch_{uuid.uuid4().hex}_{filename}"
)
await file.save(temp_path)
skill_name = skill_mgr.install_skill_from_zip(
temp_path, overwrite=True
)
succeeded.append({"filename": filename, "name": skill_name})
except Exception as e:
failed.append({"filename": filename, "error": str(e)})
finally:
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except Exception:
pass
if succeeded:
try:
await sync_skills_to_active_sandboxes()
except Exception:
logger.warning(
"Failed to sync uploaded skills to active sandboxes."
)
total = len(file_list)
success_count = len(succeeded)
if success_count == total:
message = f"All {total} skill(s) uploaded successfully."
return (
Response()
.ok(
{
"total": total,
"succeeded": succeeded,
"failed": failed,
},
message,
)
.__dict__
)
if success_count == 0:
message = f"Upload failed for all {total} file(s)."
resp = Response().error(message)
resp.data = {
"total": total,
"succeeded": succeeded,
"failed": failed,
}
return resp.__dict__
message = f"Partial success: {success_count}/{total} skill(s) uploaded."
return (
Response()
.ok(
{
"total": total,
"succeeded": succeeded,
"failed": failed,
},
message,
)
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def download_skill(self):
try:
name = str(request.args.get("name") or "").strip()
File diff suppressed because it is too large Load Diff
@@ -224,10 +224,43 @@
"empty": "No Skills found",
"emptyHint": "Upload a Skills zip to get started",
"uploadDialogTitle": "Upload Skills",
"uploadHint": "Upload a zip file that contains skill_name/ and a SKILL.md inside.",
"uploadHint": "Upload multiple zip skill packages or drag them in. The system validates the structure automatically and shows a result for each file.",
"structureRequirement": "The most common failure is an invalid archive structure. Each zip must contain exactly one top-level folder such as `skillname/`, and that folder must include `SKILL.md`.",
"abilityMultiple": "Upload multiple zip files at once",
"abilityValidate": "Validate `SKILL.md` automatically",
"abilitySkip": "Automatically skip duplicate files.",
"selectFile": "Select file",
"confirmUpload": "Upload",
"selectFiles": "Select files (multiple allowed)",
"dropzoneTitle": "Drag multiple zip files here",
"dropzoneAction": "or click to pick multiple files from a folder",
"dropzoneHint": "Batch upload is supported and the structure will be validated automatically",
"fileListTitle": "Files in queue",
"fileListEmpty": "Selected files will appear here with validation feedback and upload status",
"uploading": "Uploading...",
"batchResultTitle": "Batch Upload Results",
"batchResultSummary": "{success} of {total} files uploaded successfully",
"batchSuccessList": "Successfully uploaded",
"batchFailedList": "Failed to upload",
"confirm": "OK",
"confirmUpload": "Start Upload",
"cancel": "Cancel",
"statusWaiting": "Waiting",
"statusUploading": "Uploading",
"statusSuccess": "Uploaded",
"statusError": "Failed",
"statusSkipped": "Skipped",
"summaryTotal": "{count} file(s)",
"summaryReady": "Pending {count}",
"summarySuccess": "Success {count}",
"summaryFailed": "Failed {count}",
"summarySkipped": "Skipped {count}",
"validationReady": "Ready to upload. The archive structure will be checked during upload.",
"validationZipOnly": "Only zip skill packages are supported",
"validationDuplicate": "A file with the same name is already in the queue and has been skipped",
"validationUploading": "Validating and uploading...",
"validationUploadFailed": "Upload failed. Please try again.",
"validationUploadedAs": "Installed as {name}",
"validationNoResult": "No validation result was returned. Check the platform logs.",
"noDescription": "No description",
"path": "Path",
"uploadSuccess": "Upload succeeded",
@@ -224,10 +224,43 @@
"empty": "暂无 Skills",
"emptyHint": "请上传 Skills 压缩包",
"uploadDialogTitle": "上传 Skills",
"uploadHint": "上传 zip 压缩包,解压后为 skill_name/ 目录,且包含 SKILL.md",
"uploadHint": "支持批量上传 zip 技能包,也支持拖拽批量上传 zip 技能包。系统会自动校验目录结构,并给出逐个文件的结果。",
"structureRequirement": "常见失败原因是压缩包结构不正确。每个 zip 必须只包含一个顶层目录,例如 `skillname/`,且该目录下必须存在 `SKILL.md`。",
"abilityMultiple": "支持一次上传多个zip文件",
"abilityValidate": "自动校验 `SKILL.md`",
"abilitySkip": "自动跳过重复文件",
"selectFile": "选择文件",
"confirmUpload": "上传",
"selectFiles": "选择文件(可多选)",
"dropzoneTitle": "拖拽多个 zip 文件到这里",
"dropzoneAction": "或者点击之后在文件夹中选择多个文件",
"dropzoneHint": "支持批量上传,系统会自动校验目录结构",
"fileListTitle": "待处理文件",
"fileListEmpty": "选择文件后会在这里显示校验结果与上传状态",
"uploading": "正在上传...",
"batchResultTitle": "批量上传结果",
"batchResultSummary": "共 {total} 个文件,成功 {success} 个",
"batchSuccessList": "上传成功",
"batchFailedList": "上传失败",
"confirm": "确定",
"confirmUpload": "开始上传",
"cancel": "取消",
"statusWaiting": "待上传",
"statusUploading": "上传中",
"statusSuccess": "已上传",
"statusError": "校验失败",
"statusSkipped": "已跳过",
"summaryTotal": "共 {count} 个文件",
"summaryReady": "待处理 {count}",
"summarySuccess": "成功 {count}",
"summaryFailed": "失败 {count}",
"summarySkipped": "跳过 {count}",
"validationReady": "等待上传,上传时会自动校验目录结构",
"validationZipOnly": "仅支持 zip 技能包",
"validationDuplicate": "同名文件已在列表中,已跳过",
"validationUploading": "正在校验并上传...",
"validationUploadFailed": "上传失败,请重试",
"validationUploadedAs": "已安装为 {name}",
"validationNoResult": "未收到校验结果,请检查平台日志",
"noDescription": "无描述",
"path": "路径",
"uploadSuccess": "上传成功",
+102
View File
@@ -0,0 +1,102 @@
import { pinyin } from "pinyin-pro";
const HAN_IDEOGRAPH_RE = /\p{Unified_Ideograph}/u;
export const normalizeStr = (s) => (s ?? "").toString().toLowerCase().trim();
const normalizeLooseFromNormalized = (normalized) =>
normalized.replace(/[\s_-]+/g, "").replace(/[()()【】\[\]{}·•]+/g, "");
export const normalizeLoose = (s) =>
normalizeLooseFromNormalized(normalizeStr(s));
const memoizeStringFn = (fn) => {
const cache = new Map();
return (raw) => {
const key = (raw ?? "").toString();
if (cache.has(key)) {
return cache.get(key);
}
const value = fn(key);
cache.set(key, value);
return value;
};
};
const getNormalizedText = memoizeStringFn(normalizeStr);
const getLooseText = memoizeStringFn((text) =>
normalizeLooseFromNormalized(getNormalizedText(text)),
);
export const toPinyinText = memoizeStringFn((text) =>
pinyin(text, { toneType: "none" })
.toLowerCase()
.replace(/\s+/g, ""),
);
export const toInitials = memoizeStringFn((text) =>
pinyin(text, { pattern: "first", toneType: "none" })
.toLowerCase()
.replace(/\s+/g, ""),
);
export const buildSearchQuery = (raw) => {
const norm = getNormalizedText(raw);
if (!norm) return null;
return {
norm,
loose: getLooseText(raw),
};
};
export const matchesText = (value, query) => {
if (value == null || !query?.norm) return false;
const text = String(value);
const normalizedValue = getNormalizedText(text);
const looseValue = query.loose ? getLooseText(text) : null;
if (normalizedValue.includes(query.norm)) return true;
if (query.loose && looseValue?.includes(query.loose)) return true;
if (!HAN_IDEOGRAPH_RE.test(text)) return false;
const pinyinValue = toPinyinText(text);
if (pinyinValue.includes(query.norm)) return true;
const initialsValue = toInitials(text);
if (initialsValue.includes(query.norm)) return true;
return false;
};
export const getPluginSearchFields = (plugin) => {
const supportPlatforms = Array.isArray(plugin?.support_platforms)
? plugin.support_platforms.join(" ")
: "";
const tags = Array.isArray(plugin?.tags) ? plugin.tags.join(" ") : "";
return [
plugin?.name,
plugin?.trimmedName,
plugin?.display_name,
plugin?.desc,
plugin?.author,
plugin?.repo,
plugin?.version,
plugin?.astrbot_version,
supportPlatforms,
tags,
];
};
export const matchesPluginSearch = (plugin, query) => {
if (!query) return true;
return getPluginSearchFields(plugin).some((candidate) =>
matchesText(candidate, query),
);
};
-1
View File
@@ -84,7 +84,6 @@ const {
normalizeStr,
toPinyinText,
toInitials,
marketCustomFilter,
plugin_handler_info_headers,
pluginHeaders,
filteredExtensions,
@@ -81,7 +81,6 @@ const {
normalizeStr,
toPinyinText,
toInitials,
marketCustomFilter,
plugin_handler_info_headers,
pluginHeaders,
filteredExtensions,
@@ -82,7 +82,6 @@ const {
normalizeStr,
toPinyinText,
toInitials,
marketCustomFilter,
plugin_handler_info_headers,
pluginHeaders,
filteredExtensions,
@@ -1,9 +1,15 @@
import axios from "axios";
import { pinyin } from "pinyin-pro";
import { useCommonStore } from "@/stores/common";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { getPlatformDisplayName } from "@/utils/platformUtils";
import { resolveErrorMessage } from "@/utils/errorUtils";
import {
buildSearchQuery,
matchesPluginSearch,
normalizeStr,
toInitials,
toPinyinText,
} from "@/utils/pluginSearch";
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
@@ -240,37 +246,6 @@ export const useExtensionPage = () => {
});
// 插件市场拼音搜索
const normalizeStr = (s) => (s ?? "").toString().toLowerCase().trim();
const toPinyinText = (s) =>
pinyin(s ?? "", { toneType: "none" })
.toLowerCase()
.replace(/\s+/g, "");
const toInitials = (s) =>
pinyin(s ?? "", { pattern: "first", toneType: "none" })
.toLowerCase()
.replace(/\s+/g, "");
const marketCustomFilter = (value, query, item) => {
const q = normalizeStr(query);
if (!q) return true;
const candidates = new Set();
if (value != null) candidates.add(String(value));
if (item?.name) candidates.add(String(item.name));
if (item?.trimmedName) candidates.add(String(item.trimmedName));
if (item?.display_name) candidates.add(String(item.display_name));
if (item?.desc) candidates.add(String(item.desc));
if (item?.author) candidates.add(String(item.author));
for (const v of candidates) {
const nv = normalizeStr(v);
if (nv.includes(q)) return true;
const pv = toPinyinText(v);
if (pv.includes(q)) return true;
const iv = toInitials(v);
if (iv.includes(q)) return true;
}
return false;
};
const plugin_handler_info_headers = computed(() => [
{ title: tm("table.headers.eventType"), key: "event_type_h" },
@@ -347,47 +322,24 @@ export const useExtensionPage = () => {
// 通过搜索过滤插件
const filteredPlugins = computed(() => {
const plugins = filteredExtensions.value;
let filtered = plugins;
if (pluginSearch.value) {
const search = pluginSearch.value.toLowerCase();
filtered = plugins.filter((plugin) => {
const pluginName = (plugin.name ?? "").toLowerCase();
const pluginDesc = (plugin.desc ?? "").toLowerCase();
const pluginAuthor = (plugin.author ?? "").toLowerCase();
const supportPlatforms = Array.isArray(plugin.support_platforms)
? plugin.support_platforms.join(" ").toLowerCase()
: "";
const astrbotVersion = (plugin.astrbot_version ?? "").toLowerCase();
return (
pluginName.includes(search) ||
pluginDesc.includes(search) ||
pluginAuthor.includes(search) ||
supportPlatforms.includes(search) ||
astrbotVersion.includes(search)
);
});
}
const query = buildSearchQuery(pluginSearch.value);
const filtered = query
? plugins.filter((plugin) => matchesPluginSearch(plugin, query))
: plugins;
return sortPluginsByName([...filtered]);
});
// 过滤后的插件市场数据(带搜索)
const filteredMarketPlugins = computed(() => {
if (!debouncedMarketSearch.value) {
const query = buildSearchQuery(debouncedMarketSearch.value);
if (!query) {
return pluginMarketData.value;
}
const search = debouncedMarketSearch.value.toLowerCase();
return pluginMarketData.value.filter((plugin) => {
// 使用自定义过滤器
return (
marketCustomFilter(plugin.name, search, plugin) ||
marketCustomFilter(plugin.desc, search, plugin) ||
marketCustomFilter(plugin.author, search, plugin)
);
});
return pluginMarketData.value.filter((plugin) =>
matchesPluginSearch(plugin, query),
);
});
// 所有插件列表,推荐插件排在前面
@@ -1563,7 +1515,6 @@ export const useExtensionPage = () => {
normalizeStr,
toPinyinText,
toInitials,
marketCustomFilter,
plugin_handler_info_headers,
pluginHeaders,
filteredExtensions,
+227 -5
View File
@@ -1,11 +1,14 @@
import asyncio
import io
import os
import sys
import zipfile
from types import SimpleNamespace
import pytest
import pytest_asyncio
from quart import Quart
from werkzeug.datastructures import FileStorage
from astrbot.core import LogBroker
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -15,7 +18,6 @@ from astrbot.core.star.star_handler import star_handlers_registry
from astrbot.dashboard.server import AstrBotDashboard
from tests.fixtures.helpers import (
MockPluginBuilder,
MockPluginConfig,
create_mock_updater_install,
create_mock_updater_update,
)
@@ -145,9 +147,7 @@ async def test_plugins(
monkeypatch.setattr(
core_lifecycle_td.plugin_manager.updator, "install", mock_install
)
monkeypatch.setattr(
core_lifecycle_td.plugin_manager.updator, "update", mock_update
)
monkeypatch.setattr(core_lifecycle_td.plugin_manager.updator, "update", mock_update)
try:
# 插件安装
@@ -158,7 +158,9 @@ async def test_plugins(
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok", f"安装失败: {data.get('message', 'unknown error')}"
assert data["status"] == "ok", (
f"安装失败: {data.get('message', 'unknown error')}"
)
# 验证插件已注册
exists = any(md.name == test_plugin_name for md in star_registry)
@@ -493,3 +495,223 @@ async def test_neo_skills_routes(
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["skill_key"] == "neo.demo"
@pytest.mark.asyncio
async def test_batch_upload_skills_returns_error_when_all_files_invalid(
app: Quart,
authenticated_header: dict,
):
test_client = app.test_client()
response = await test_client.post(
"/api/skills/batch-upload",
headers=authenticated_header,
files={
"files": FileStorage(
stream=io.BytesIO(b"not-a-zip"),
filename="invalid.txt",
content_type="text/plain",
),
},
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "error"
assert data["message"] == "Upload failed for all 1 file(s)."
@pytest.mark.asyncio
async def test_batch_upload_skills_accepts_zip_files(
app: Quart,
authenticated_header: dict,
monkeypatch,
):
async def _fake_sync_skills_to_active_sandboxes():
return
def _fake_install_skill_from_zip(
self,
zip_path: str,
*,
overwrite: bool = True,
):
_ = self, overwrite
assert zip_path.endswith(".zip")
return "demo_skill"
monkeypatch.setattr(
"astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes",
_fake_sync_skills_to_active_sandboxes,
)
monkeypatch.setattr(
"astrbot.dashboard.routes.skills.SkillManager.install_skill_from_zip",
_fake_install_skill_from_zip,
)
test_client = app.test_client()
response = await test_client.post(
"/api/skills/batch-upload",
headers=authenticated_header,
files={
"files": FileStorage(
stream=io.BytesIO(b"fake-zip"),
filename="demo_skill.zip",
content_type="application/zip",
),
},
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["message"] == "All 1 skill(s) uploaded successfully."
assert data["data"]["total"] == 1
assert data["data"]["succeeded"] == [
{"filename": "demo_skill.zip", "name": "demo_skill"}
]
assert data["data"]["failed"] == []
@pytest.mark.asyncio
async def test_batch_upload_skills_accepts_valid_skill_archive(
app: Quart,
authenticated_header: dict,
monkeypatch,
tmp_path,
):
data_dir = tmp_path / "data"
skills_dir = tmp_path / "skills"
temp_dir = tmp_path / "temp"
data_dir.mkdir()
skills_dir.mkdir()
temp_dir.mkdir()
async def _fake_sync_skills_to_active_sandboxes():
return
monkeypatch.setattr(
"astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes",
_fake_sync_skills_to_active_sandboxes,
)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_data_path",
lambda: str(data_dir),
)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_skills_path",
lambda: str(skills_dir),
)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_temp_path",
lambda: str(temp_dir),
)
monkeypatch.setattr(
"astrbot.dashboard.routes.skills.get_astrbot_temp_path",
lambda: str(temp_dir),
)
archive = io.BytesIO()
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(
"demo_skill/SKILL.md",
"---\nname: demo-skill\ndescription: Demo skill\n---\n",
)
zf.writestr("demo_skill/notes.txt", "hello")
zf.writestr("__MACOSX/demo_skill/._SKILL.md", "")
zf.writestr("__MACOSX/._demo_skill", "")
archive.seek(0)
test_client = app.test_client()
response = await test_client.post(
"/api/skills/batch-upload",
headers=authenticated_header,
files={
"files": FileStorage(
stream=archive,
filename="demo_skill.zip",
content_type="application/zip",
),
},
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["succeeded"] == [
{"filename": "demo_skill.zip", "name": "demo_skill"}
]
assert data["data"]["failed"] == []
assert (skills_dir / "demo_skill" / "SKILL.md").exists()
@pytest.mark.asyncio
async def test_batch_upload_skills_partial_success(
app: Quart,
authenticated_header: dict,
monkeypatch,
):
async def _fake_sync_skills_to_active_sandboxes():
return
def _fake_install_skill_from_zip(
self,
zip_path: str,
*,
overwrite: bool = True,
):
_ = self, overwrite
if "ok_skill" in zip_path:
return "ok_skill"
raise RuntimeError("install failed")
monkeypatch.setattr(
"astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes",
_fake_sync_skills_to_active_sandboxes,
)
monkeypatch.setattr(
"astrbot.dashboard.routes.skills.SkillManager.install_skill_from_zip",
_fake_install_skill_from_zip,
)
test_client = app.test_client()
boundary = "----AstrBotBatchBoundary"
body = (
(
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="files"; filename="ok_skill.zip"\r\n'
"Content-Type: application/zip\r\n\r\n"
).encode()
+ b"fake-zip-1\r\n"
+ (
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="files"; filename="bad_skill.zip"\r\n'
"Content-Type: application/zip\r\n\r\n"
).encode()
+ b"fake-zip-2\r\n"
+ f"--{boundary}--\r\n".encode()
)
headers = dict(authenticated_header)
headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
response = await test_client.post(
"/api/skills/batch-upload",
headers=headers,
data=body,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["message"] == "Partial success: 1/2 skill(s) uploaded."
assert data["data"]["total"] == 2
assert data["data"]["succeeded"] == [
{"filename": "ok_skill.zip", "name": "ok_skill"}
]
assert data["data"]["failed"] == [
{"filename": "bad_skill.zip", "error": "install failed"}
]
+41
View File
@@ -0,0 +1,41 @@
from unittest.mock import AsyncMock
import pytest
from astrbot.core.utils.pip_installer import PipInstaller
@pytest.mark.asyncio
async def test_install_targets_site_packages_for_desktop_client(monkeypatch, tmp_path):
monkeypatch.setenv("ASTRBOT_DESKTOP_CLIENT", "1")
monkeypatch.delattr("sys.frozen", raising=False)
site_packages_path = tmp_path / "site-packages"
run_pip = AsyncMock(return_value=0)
prepend_sys_path_calls = []
ensure_preferred_calls = []
monkeypatch.setattr(PipInstaller, "_run_pip_in_process", run_pip)
monkeypatch.setattr(
"astrbot.core.utils.pip_installer.get_astrbot_site_packages_path",
lambda: str(site_packages_path),
)
monkeypatch.setattr(
"astrbot.core.utils.pip_installer._prepend_sys_path",
lambda path: prepend_sys_path_calls.append(path),
)
monkeypatch.setattr(
"astrbot.core.utils.pip_installer._ensure_plugin_dependencies_preferred",
lambda path, requirements: ensure_preferred_calls.append((path, requirements)),
)
installer = PipInstaller("")
await installer.install(package_name="demo-package")
run_pip.assert_awaited_once()
recorded_args = run_pip.await_args_list[0].args[0]
assert "--target" in recorded_args
assert str(site_packages_path) in recorded_args
assert prepend_sys_path_calls == [str(site_packages_path), str(site_packages_path)]
assert ensure_preferred_calls == [(str(site_packages_path), {"demo-package"})]
+26
View File
@@ -0,0 +1,26 @@
from astrbot.core.utils.astrbot_path import get_astrbot_root
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
def test_desktop_client_env_marks_desktop_runtime_without_frozen(monkeypatch):
monkeypatch.setenv("ASTRBOT_DESKTOP_CLIENT", "1")
monkeypatch.delattr("sys.frozen", raising=False)
assert is_packaged_desktop_runtime() is True
def test_desktop_client_uses_home_root_without_explicit_astrbot_root(monkeypatch):
monkeypatch.setenv("ASTRBOT_DESKTOP_CLIENT", "1")
monkeypatch.delenv("ASTRBOT_ROOT", raising=False)
monkeypatch.delattr("sys.frozen", raising=False)
assert get_astrbot_root().endswith(".astrbot")
def test_explicit_astrbot_root_overrides_desktop_default(monkeypatch, tmp_path):
explicit_root = tmp_path / "astrbot-root"
monkeypatch.setenv("ASTRBOT_DESKTOP_CLIENT", "1")
monkeypatch.setenv("ASTRBOT_ROOT", str(explicit_root))
monkeypatch.delattr("sys.frozen", raising=False)
assert get_astrbot_root() == str(explicit_root.resolve())
+57 -4
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
from pathlib import Path
import pytest
from astrbot.core.skills.skill_manager import SkillManager
@@ -56,7 +58,7 @@ def test_list_skills_merges_local_and_sandbox_cache(monkeypatch, tmp_path: Path)
assert by_name["custom-local"].description == "local description"
assert by_name["custom-local"].path == "skills/custom-local/SKILL.md"
assert by_name["python-sandbox"].description == "ship built-in"
assert by_name["python-sandbox"].path == "skills/python-sandbox/SKILL.md"
assert by_name["python-sandbox"].path == "/workspace/skills/python-sandbox/SKILL.md"
def test_sandbox_cached_skill_respects_active_and_display_path(
@@ -98,7 +100,58 @@ def test_sandbox_cached_skill_respects_active_and_display_path(
assert len(all_skills) == 1
assert all_skills[0].path == "/app/skills/browser-automation/SKILL.md"
mgr.set_skill_active("browser-automation", False)
active_skills = mgr.list_skills(runtime="sandbox", active_only=True)
assert active_skills == []
with pytest.raises(PermissionError):
mgr.set_skill_active("browser-automation", False)
active_skills = mgr.list_skills(runtime="sandbox", active_only=True)
assert len(active_skills) == 1
assert active_skills[0].name == "browser-automation"
def test_sandbox_and_local_path_resolution_with_show_sandbox_path_false(
monkeypatch,
tmp_path: Path,
):
data_dir = tmp_path / "data"
temp_dir = tmp_path / "temp"
skills_root = tmp_path / "skills"
data_dir.mkdir(parents=True, exist_ok=True)
temp_dir.mkdir(parents=True, exist_ok=True)
skills_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_data_path",
lambda: str(data_dir),
)
monkeypatch.setattr(
"astrbot.core.skills.skill_manager.get_astrbot_temp_path",
lambda: str(temp_dir),
)
mgr = SkillManager(skills_root=str(skills_root))
_write_skill(skills_root, "custom-local", "local description")
mgr.set_sandbox_skills_cache(
[
{
"name": "custom-local",
"description": "cached description should be overridden",
"path": "/app/skills/custom-local/SKILL.md",
},
{
"name": "python-sandbox",
"description": "ship built-in",
"path": "/app/skills/python-sandbox/SKILL.md",
},
]
)
skills = mgr.list_skills(runtime="sandbox", show_sandbox_path=False)
by_name = {item.name: item for item in skills}
assert sorted(by_name) == ["custom-local", "python-sandbox"]
assert by_name["custom-local"].description == "local description"
local_skill_path = Path(by_name["custom-local"].path)
assert local_skill_path.is_relative_to(skills_root)
assert local_skill_path == skills_root / "custom-local" / "SKILL.md"
assert by_name["python-sandbox"].path == "/app/skills/python-sandbox/SKILL.md"
+59
View File
@@ -0,0 +1,59 @@
from unittest.mock import AsyncMock
import pytest
import astrbot.core.message.components as Comp
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.pipeline.respond.stage import RespondStage
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import (
AiocqhttpMessageEvent,
)
def test_poke_to_dict_matches_onebot_v11_segment_format():
poke = Comp.Poke(type="126", id=2003)
assert poke.toDict() == {
"type": "poke",
"data": {"type": "126", "id": "2003"},
}
def test_poke_to_dict_keeps_legacy_qq_compatible():
poke = Comp.Poke(type="poke", qq=2916963017)
assert poke.toDict() == {
"type": "poke",
"data": {"type": "126", "id": "2916963017"},
}
@pytest.mark.asyncio
async def test_respond_stage_treats_poke_with_target_as_non_empty():
stage = RespondStage()
chain = [Comp.Poke(type="126", id=2003)]
assert await stage._is_empty_message_chain(chain) is False
@pytest.mark.asyncio
async def test_aiocqhttp_parse_json_outputs_standard_poke_data():
chain = MessageChain([Comp.Poke(type="126", id=2003)])
data = await AiocqhttpMessageEvent._parse_onebot_json(chain)
assert data == [{"type": "poke", "data": {"type": "126", "id": "2003"}}]
@pytest.mark.asyncio
async def test_aiocqhttp_send_message_dispatches_onebot_v11_poke_payload():
bot = AsyncMock()
chain = MessageChain([Comp.Poke(type="126", id=2003)])
await AiocqhttpMessageEvent.send_message(
bot=bot,
message_chain=chain,
event=None,
is_group=True,
session_id="123456",
)
bot.send_group_msg.assert_awaited_once_with(
group_id=123456,
message=[{"type": "poke", "data": {"type": "126", "id": "2003"}}],
)