Compare commits

...

16 Commits

Author SHA1 Message Date
Soulter
021ca8175b chore: bump version to 4.5.4 2025-11-07 14:28:51 +08:00
Soulter
39d6207fe1 chore: remove dynamic version 2025-11-07 14:26:56 +08:00
Soulter
23ce687229 chore: fix dockerfile 2025-11-07 14:23:49 +08:00
鸦羽
3715312fd2 fix: update project description to English (#3516) 2025-11-07 01:13:32 +08:00
Soulter
8196922cac docs: simplify README 2025-11-06 15:22:43 +08:00
Soulter
8089ad91da perf: improve extension market ui 2025-11-06 13:57:46 +08:00
Soulter
2930cc3fd8 chore: bump version to 4.5.3 2025-11-05 21:21:14 +08:00
Soulter
0e841a8b25 fix: correct tools dictionary comprehension in get_tool_list method 2025-11-05 21:19:10 +08:00
Soulter
67fa1611cc chore: bump version to 4.5.2 2025-11-05 19:02:51 +08:00
Soulter
91136bb9f7 fix: llm tool register error (#3493) 2025-11-05 14:27:37 +08:00
Copilot
7c050d1adc feat: add customizable sidebar module ordering (#3307)
* Initial plan

* Add sidebar customization feature with drag-and-drop support

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* Add dist/ to .gitignore to exclude build artifacts

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* Fix memory leak and improve code quality per code review

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* Fix i18n key format: use dot notation instead of colon notation

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* Fix drag-and-drop to empty list issue

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2025-11-04 23:59:45 +08:00
Misaka Mikoto
a0690a6afc feat: support options to delete plugins config and data (#3280)
* - 为插件管理页面中,删除插件提供一致的二次确认(原本只有卡片视图有二次确认)
- 二次确认时可选删除插件配置和持久化数据
- 添加对应的i18n支持

* ruff

* 移除未使用的
const $confirm = inject('$confirm');
2025-11-04 11:48:48 +08:00
Dt8333
c51609b261 fix: typing error (#3267)
* fix: 修复一些小错误。

修复aiocqhttp和slack中部分逻辑缺失的await。修复discord中错误的异常捕获类型。

* fix(core.platform): 修复discord适配器中错误的message_chain赋值

* fix(aiocqhttp): 更新convert_message方法的返回类型为AstrBotMessage | None

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-11-03 23:38:52 +08:00
Soulter
72148f66eb chore: nodejs in Dockerfile 2025-11-03 13:19:51 +08:00
Copilot
a04993a2bb Replace insecure random with secrets module in cryptographic contexts (#3248)
* Initial plan

* Security fixes: Replace insecure random with secrets module and improve SSL context

Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com>

* Address code review feedback: fix POST method and add named constants

Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com>

* Improve documentation for random number generation constants

Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com>

* Update astrbot/core/utils/io.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update astrbot/core/platform/sources/wecom_ai_bot/WXBizJsonMsgCrypt.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update tests/test_security_fixes.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update astrbot/core/utils/io.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update astrbot/core/utils/io.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix: Handle path parameter in SSL fallback for download_image_by_url

Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com>
Co-authored-by: LIghtJUNction <lightjunction.me@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-03 02:43:00 +08:00
LIghtJUNction
74f845b06d Chore: Dockerfile (#3266)
* fix: Dockerfile

python main.py 改为uv run main.py

* fix(dockerfile): 减少重复安装

* fix: 修复一些细节问题

* fix(.dockerignore): 需要git文件夹以获取astrbot版本(带git commit hash后缀)

* fix(.dockerignore): uv run之前会uv sync
2025-11-03 02:41:40 +08:00
38 changed files with 1260 additions and 248 deletions

View File

@@ -1,9 +1,8 @@
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# github acions # github actions
.github/ .github/
.*ignore .*ignore
.git/
# User-specific stuff # User-specific stuff
.idea/ .idea/
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
@@ -15,7 +14,6 @@ env/
venv*/ venv*/
ENV/ ENV/
.conda/ .conda/
README*.md
dashboard/ dashboard/
data/ data/
changelogs/ changelogs/

View File

@@ -12,19 +12,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
bash \ bash \
ffmpeg \ ffmpeg \
curl \
gnupg \
git \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN apt-get update && apt-get install -y curl gnupg && \ RUN apt-get update && apt-get install -y curl gnupg && \
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \ curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
apt-get install -y nodejs && \ apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*
RUN python -m pip install uv RUN python -m pip install uv
RUN uv pip install -r requirements.txt --no-cache-dir --system RUN uv pip install -r requirements.txt --no-cache-dir --system
RUN uv pip install socksio uv pilk --no-cache-dir --system RUN uv pip install socksio uv pilk --no-cache-dir --system
EXPOSE 6185 EXPOSE 6185
EXPOSE 6186
CMD [ "python", "main.py" ] CMD ["python", "main.py"]

View File

@@ -1,35 +0,0 @@
FROM python:3.10-slim
WORKDIR /AstrBot
COPY . /AstrBot/
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
build-essential \
python3-dev \
libffi-dev \
libssl-dev \
curl \
unzip \
ca-certificates \
bash \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Installation of Node.js
ENV NVM_DIR="/root/.nvm"
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash && \
. "$NVM_DIR/nvm.sh" && \
nvm install 22 && \
nvm use 22
RUN /bin/bash -c ". \"$NVM_DIR/nvm.sh\" && node -v && npm -v"
RUN python -m pip install uv
RUN uv pip install -r requirements.txt --no-cache-dir --system
RUN uv pip install socksio uv pyffmpeg --no-cache-dir --system
EXPOSE 6185
EXPOSE 6186
CMD ["python", "main.py"]

114
README.md
View File

@@ -119,83 +119,73 @@ uv run main.py
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a> <a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
## 消息平台支持情况 ## 支持的消息平台
**官方维护** **官方维护**
| 平台 | 支持性 | - QQ (官方平台 & OneBot)
| -------- | ------- | - Telegram
| QQ(官方平台) | ✔ | - 企微应用 & 企微智能机器人
| QQ(OneBot) | ✔ | - 微信客服 & 微信公众号
| Telegram | ✔ | - 飞书
| 企微应用 | ✔ | - 钉钉
| 企微智能机器人 | ✔ | - Slack
| 微信客服 | ✔ | - Discord
| 微信公众号 | ✔ | - Satori
| 飞书 | ✔ | - Misskey
| 钉钉 | ✔ | - Whatsapp (将支持)
| Slack | ✔ | - LINE (将支持)
| Discord | ✔ |
| Satori | ✔ |
| Misskey | ✔ |
| Whatsapp | 将支持 |
| LINE | 将支持 |
**社区维护** **社区维护**
| 平台 | 支持性 | - [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
| -------- | ------- | - [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | ✔ | - [Bilibili 私信](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter)
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | ✔ | - [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11)
| [Bilibili 私信](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter) | ✔ |
| [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11) | ✔ |
## ⚡ 提供商支持情况 ## 支持的模型服务
**大模型服务** **大模型服务**
| 名称 | 支持性 | 备注 | - OpenAI 及兼容服务
| -------- | ------- | ------- | - Anthropic
| OpenAI | ✔ | 支持任何兼容 OpenAI API 的服务 | - Google Gemini
| Anthropic | ✔ | | - Moonshot AI
| Google Gemini | ✔ | | - 智谱 AI
| Moonshot AI | ✔ | | - DeepSeek
| 智谱 AI | ✔ | | - Ollama (本地部署)
| DeepSeek | ✔ | | - LM Studio (本地部署)
| Ollama | ✔ | 本地部署 DeepSeek 等开源语言模型 | - [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
| LM Studio | ✔ | 本地部署 DeepSeek 等开源语言模型 | - [302.AI](https://share.302.ai/rr1M3l)
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | ✔ | | - [小马算力](https://www.tokenpony.cn/3YPyf)
| [302.AI](https://share.302.ai/rr1M3l) | ✔ | | - [硅基流动](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
| [小马算力](https://www.tokenpony.cn/3YPyf) | ✔ | | - [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE)
| 硅基流动 | ✔ | | - ModelScope
| PPIO 派欧云 | ✔ | | - OneAPI
| ModelScope | ✔ | |
| OneAPI | ✔ | | **LLMOps 平台**
| Dify | ✔ | |
| 阿里云百炼应用 | ✔ | | - Dify
| Coze | ✔ | | - 阿里云百炼应用
- Coze
**语音转文本服务** **语音转文本服务**
| 名称 | 支持性 | 备注 | - OpenAI Whisper
| -------- | ------- | ------- | - SenseVoice
| Whisper | ✔ | 支持 API、本地部署 |
| SenseVoice | ✔ | 本地部署 |
**文本转语音服务** **文本转语音服务**
| 名称 | 支持性 | 备注 | - OpenAI TTS
| -------- | ------- | ------- | - Gemini TTS
| OpenAI TTS | ✔ | | - GPT-Sovits-Inference
| Gemini TTS | ✔ | | - GPT-Sovits
| GSVI | ✔ | GPT-Sovits-Inference | - FishAudio
| GPT-SoVITs | ✔ | GPT-Sovits | - Edge TTS
| FishAudio | ✔ | | - 阿里云百炼 TTS
| Edge TTS | ✔ | Edge 浏览器的免费 TTS | - Azure TTS
| 阿里云百炼 TTS | ✔ | | - Minimax TTS
| Azure TTS | ✔ | | - 火山引擎 TTS
| Minimax TTS | ✔ | |
| 火山引擎 TTS | ✔ | |
## ❤️ 贡献 ## ❤️ 贡献
@@ -229,7 +219,7 @@ pre-commit install
## ⭐ Star History ## ⭐ Star History
> [!TIP] > [!TIP]
> 如果本项目对您的生活 / 工作产生了帮助,或者您关注本项目的未来发展,请给项目 Star这是我们维护这个开源项目的动力 <3 > 如果本项目对您的生活 / 工作产生了帮助,或者您关注本项目的未来发展,请给项目 Star这是我们维护这个开源项目的动力 <3
<div align="center"> <div align="center">

View File

@@ -55,14 +55,6 @@ class FunctionTool(ToolSchema, Generic[TContext]):
def __repr__(self): def __repr__(self):
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})" return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})"
def __dict__(self) -> dict[str, Any]:
return {
"name": self.name,
"parameters": self.parameters,
"description": self.description,
"active": self.active,
}
async def call( async def call(
self, context: ContextWrapper[TContext], **kwargs self, context: ContextWrapper[TContext], **kwargs
) -> str | mcp.types.CallToolResult: ) -> str | mcp.types.CallToolResult:

View File

@@ -2,9 +2,9 @@
import os import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.cdefaore.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.5.1" VERSION = "4.5.4"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
# 默认配置 # 默认配置

View File

@@ -107,7 +107,7 @@ class AiocqhttpAdapter(Platform):
) )
await super().send_by_session(session, message_chain) await super().send_by_session(session, message_chain)
async def convert_message(self, event: Event) -> AstrBotMessage: async def convert_message(self, event: Event) -> AstrBotMessage | None:
logger.debug(f"[aiocqhttp] RawMessage {event}") logger.debug(f"[aiocqhttp] RawMessage {event}")
if event["post_type"] == "message": if event["post_type"] == "message":
@@ -222,7 +222,7 @@ class AiocqhttpAdapter(Platform):
err = f"aiocqhttp: 无法识别的消息类型: {event.message!s},此条消息将被忽略。如果您在使用 go-cqhttp请将其配置文件中的 message.post-format 更改为 array。" err = f"aiocqhttp: 无法识别的消息类型: {event.message!s},此条消息将被忽略。如果您在使用 go-cqhttp请将其配置文件中的 message.post-format 更改为 array。"
logger.critical(err) logger.critical(err)
try: try:
self.bot.send(event, err) await self.bot.send(event, err)
except BaseException as e: except BaseException as e:
logger.error(f"回复消息失败: {e}") logger.error(f"回复消息失败: {e}")
return None return None

View File

@@ -90,7 +90,7 @@ class DiscordPlatformAdapter(Platform):
) )
message_obj.self_id = self.client_self_id message_obj.self_id = self.client_self_id
message_obj.session_id = session.session_id message_obj.session_id = session.session_id
message_obj.message = message_chain message_obj.message = message_chain.chain
# 创建临时事件对象来发送消息 # 创建临时事件对象来发送消息
temp_event = DiscordPlatformEvent( temp_event = DiscordPlatformEvent(

View File

@@ -1,5 +1,6 @@
import asyncio import asyncio
import base64 import base64
import binascii
import sys import sys
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
@@ -183,7 +184,7 @@ class DiscordPlatformEvent(AstrMessageEvent):
BytesIO(img_bytes), BytesIO(img_bytes),
filename=filename or "image.png", filename=filename or "image.png",
) )
except (ValueError, TypeError, base64.binascii.Error): except (ValueError, TypeError, binascii.Error):
logger.debug( logger.debug(
f"[Discord] 裸 Base64 解码失败,作为本地路径处理: {file_content}", f"[Discord] 裸 Base64 解码失败,作为本地路径处理: {file_content}",
) )

View File

@@ -82,7 +82,7 @@ class SlackAdapter(Platform):
session: MessageSesion, session: MessageSesion,
message_chain: MessageChain, message_chain: MessageChain,
): ):
blocks, text = SlackMessageEvent._parse_slack_blocks( blocks, text = await SlackMessageEvent._parse_slack_blocks(
message_chain=message_chain, message_chain=message_chain,
web_client=self.web_client, web_client=self.web_client,
) )

View File

@@ -10,7 +10,7 @@ import base64
import hashlib import hashlib
import json import json
import logging import logging
import random import secrets
import socket import socket
import struct import struct
import time import time
@@ -139,6 +139,12 @@ class PKCS7Encoder:
class Prpcrypt: class Prpcrypt:
"""提供接收和推送给企业微信消息的加解密接口""" """提供接收和推送给企业微信消息的加解密接口"""
# 16位随机字符串的范围常量
# randbelow(RANDOM_RANGE) 返回 [0, 8999999999999999]两端都包含即包含0和8999999999999999
# 加上 MIN_RANDOM_VALUE 后得到 [1000000000000000, 9999999999999999]两端都包含即16位数字
MIN_RANDOM_VALUE = 1000000000000000 # 最小值: 1000000000000000 (16位)
RANDOM_RANGE = 9000000000000000 # 范围大小: 确保最大值为 9999999999999999 (16位)
def __init__(self, key): def __init__(self, key):
# self.key = base64.b64decode(key+"=") # self.key = base64.b64decode(key+"=")
self.key = key self.key = key
@@ -207,7 +213,9 @@ class Prpcrypt:
"""随机生成16位字符串 """随机生成16位字符串
@return: 16位字符串 @return: 16位字符串
""" """
return str(random.randint(1000000000000000, 9999999999999999)).encode() return str(
secrets.randbelow(self.RANDOM_RANGE) + self.MIN_RANDOM_VALUE
).encode()
class WXBizJsonMsgCrypt: class WXBizJsonMsgCrypt:

View File

@@ -5,7 +5,7 @@
import asyncio import asyncio
import base64 import base64
import hashlib import hashlib
import random import secrets
import string import string
from typing import Any from typing import Any
@@ -53,7 +53,7 @@ def generate_random_string(length: int = 10) -> str:
""" """
letters = string.ascii_letters + string.digits letters = string.ascii_letters + string.digits
return "".join(random.choice(letters) for _ in range(length)) return "".join(secrets.choice(letters) for _ in range(length))
def calculate_image_md5(image_data: bytes) -> str: def calculate_image_md5(image_data: bytes) -> str:

View File

@@ -1,8 +1,8 @@
import asyncio import asyncio
import hashlib import hashlib
import json import json
import random
import re import re
import secrets
import time import time
import uuid import uuid
from pathlib import Path from pathlib import Path
@@ -54,7 +54,9 @@ class OTTSProvider:
async def _generate_signature(self) -> str: async def _generate_signature(self) -> str:
await self._sync_time() await self._sync_time()
timestamp = int(time.time()) + self.time_offset timestamp = int(time.time()) + self.time_offset
nonce = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=10)) nonce = "".join(
secrets.choice("abcdefghijklmnopqrstuvwxyz0123456789") for _ in range(10)
)
path = re.sub(r"^https?://[^/]+", "", self.api_url) or "/" path = re.sub(r"^https?://[^/]+", "", self.api_url) or "/"
return f"{timestamp}-{nonce}-0-{hashlib.md5(f'{path}-{timestamp}-{nonce}-0-{self.skey}'.encode()).hexdigest()}" return f"{timestamp}-{nonce}-0-{hashlib.md5(f'{path}-{timestamp}-{nonce}-0-{self.skey}'.encode()).hexdigest()}"

View File

@@ -680,11 +680,18 @@ class PluginManager:
return plugin_info return plugin_info
async def uninstall_plugin(self, plugin_name: str): async def uninstall_plugin(
self,
plugin_name: str,
delete_config: bool = False,
delete_data: bool = False,
):
"""卸载指定的插件。 """卸载指定的插件。
Args: Args:
plugin_name (str): 要卸载的插件名称 plugin_name (str): 要卸载的插件名称
delete_config (bool): 是否删除插件配置文件,默认为 False
delete_data (bool): 是否删除插件数据,默认为 False
Raises: Raises:
Exception: 当插件不存在、是保留插件时,或删除插件文件夹失败时抛出异常 Exception: 当插件不存在、是保留插件时,或删除插件文件夹失败时抛出异常
@@ -714,6 +721,7 @@ class PluginManager:
await self._unbind_plugin(plugin_name, plugin.module_path) await self._unbind_plugin(plugin_name, plugin.module_path)
# 删除插件文件夹
try: try:
remove_dir(os.path.join(ppath, root_dir_name)) remove_dir(os.path.join(ppath, root_dir_name))
except Exception as e: except Exception as e:
@@ -721,6 +729,51 @@ class PluginManager:
f"移除插件成功,但是删除插件文件夹失败: {e!s}。您可以手动删除该文件夹,位于 addons/plugins/ 下。", f"移除插件成功,但是删除插件文件夹失败: {e!s}。您可以手动删除该文件夹,位于 addons/plugins/ 下。",
) )
# 删除插件配置文件
if delete_config and root_dir_name:
config_file = os.path.join(
self.plugin_config_path,
f"{root_dir_name}_config.json",
)
if os.path.exists(config_file):
try:
os.remove(config_file)
logger.info(f"已删除插件 {plugin_name} 的配置文件")
except Exception as e:
logger.warning(f"删除插件配置文件失败: {e!s}")
# 删除插件持久化数据
# 注意需要检查两个可能的目录名plugin_data 和 plugins_data
# data/temp 目录可能被多个插件共享,不自动删除以防误删
if delete_data and root_dir_name:
data_base_dir = os.path.dirname(ppath) # data/
# 删除 data/plugin_data 下的插件持久化数据(单数形式,新版本)
plugin_data_dir = os.path.join(
data_base_dir, "plugin_data", root_dir_name
)
if os.path.exists(plugin_data_dir):
try:
remove_dir(plugin_data_dir)
logger.info(
f"已删除插件 {plugin_name} 的持久化数据 (plugin_data)"
)
except Exception as e:
logger.warning(f"删除插件持久化数据失败 (plugin_data): {e!s}")
# 删除 data/plugins_data 下的插件持久化数据(复数形式,旧版本兼容)
plugins_data_dir = os.path.join(
data_base_dir, "plugins_data", root_dir_name
)
if os.path.exists(plugins_data_dir):
try:
remove_dir(plugins_data_dir)
logger.info(
f"已删除插件 {plugin_name} 的持久化数据 (plugins_data)"
)
except Exception as e:
logger.warning(f"删除插件持久化数据失败 (plugins_data): {e!s}")
async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str): async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str):
"""解绑并移除一个插件。 """解绑并移除一个插件。

View File

@@ -105,16 +105,31 @@ async def download_image_by_url(
f.write(await resp.read()) f.write(await resp.read())
return path return path
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError): except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
# 关闭SSL验证 # 关闭SSL验证仅在证书验证失败时作为fallback
logger.warning(
f"SSL certificate verification failed for {url}. "
"Disabling SSL verification (CERT_NONE) as a fallback. "
"This is insecure and exposes the application to man-in-the-middle attacks. "
"Please investigate and resolve certificate issues."
)
ssl_context = ssl.create_default_context() ssl_context = ssl.create_default_context()
ssl_context.set_ciphers("DEFAULT") ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
if post: if post:
async with session.get(url, ssl=ssl_context) as resp: async with session.post(url, json=post_data, ssl=ssl_context) as resp:
return save_temp_img(await resp.read()) if not path:
return save_temp_img(await resp.read())
with open(path, "wb") as f:
f.write(await resp.read())
return path
else: else:
async with session.get(url, ssl=ssl_context) as resp: async with session.get(url, ssl=ssl_context) as resp:
return save_temp_img(await resp.read()) if not path:
return save_temp_img(await resp.read())
with open(path, "wb") as f:
f.write(await resp.read())
return path
except Exception as e: except Exception as e:
raise e raise e
@@ -157,9 +172,19 @@ async def download_file(url: str, path: str, show_progress: bool = False):
end="", end="",
) )
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError): except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
# 关闭SSL验证 # 关闭SSL验证仅在证书验证失败时作为fallback
logger.warning(
"SSL 证书验证失败,已关闭 SSL 验证(不安全,仅用于临时下载)。请检查目标服务器的证书配置。"
)
logger.warning(
f"SSL certificate verification failed for {url}. "
"Falling back to unverified connection (CERT_NONE). "
"This is insecure and exposes the application to man-in-the-middle attacks. "
"Please investigate certificate issues with the remote server."
)
ssl_context = ssl.create_default_context() ssl_context = ssl.create_default_context()
ssl_context.set_ciphers("DEFAULT") ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(url, ssl=ssl_context, timeout=120) as resp: async with session.get(url, ssl=ssl_context, timeout=120) as resp:
total_size = int(resp.headers.get("content-length", 0)) total_size = int(resp.headers.get("content-length", 0))

View File

@@ -395,9 +395,15 @@ class PluginRoute(Route):
post_data = await request.json post_data = await request.json
plugin_name = post_data["name"] plugin_name = post_data["name"]
delete_config = post_data.get("delete_config", False)
delete_data = post_data.get("delete_data", False)
try: try:
logger.info(f"正在卸载插件 {plugin_name}") logger.info(f"正在卸载插件 {plugin_name}")
await self.plugin_manager.uninstall_plugin(plugin_name) await self.plugin_manager.uninstall_plugin(
plugin_name,
delete_config=delete_config,
delete_data=delete_data,
)
logger.info(f"卸载插件 {plugin_name} 成功") logger.info(f"卸载插件 {plugin_name} 成功")
return Response().ok(None, "卸载成功").__dict__ return Response().ok(None, "卸载成功").__dict__
except Exception as e: except Exception as e:

View File

@@ -296,7 +296,15 @@ class ToolsRoute(Route):
"""获取所有注册的工具列表""" """获取所有注册的工具列表"""
try: try:
tools = self.tool_mgr.func_list tools = self.tool_mgr.func_list
tools_dict = [tool.__dict__() for tool in tools] tools_dict = [
{
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
"active": tool.active,
}
for tool in tools
]
return Response().ok(data=tools_dict).__dict__ return Response().ok(data=tools_dict).__dict__
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())

8
changelogs/v4.5.2.md Normal file
View File

@@ -0,0 +1,8 @@
## What's Changed
1. 修复:>= Python 3.12 版本下可能导致 LLM Tool 注册错误的问题。
2. 优化:更好地适配 Class 方式注册 LLM Tool 的场景。引入 `call` 方法。
3. 新增:`ConversationManager` 类支持 `add_message_pair` 方法,简化对话消息的添加操作。
4. 新增:增加对 Tool Parameters 的参数验证,确保工具参数符合 JSON Schema 标准。
5. 新增:增加 LLM Message Schema 定义,提升消息结构的规范性和一致性。
6. 新增:支持对 WebUI 的侧边栏模块进行自定义配置(入口在侧边栏下方的设置页中)。

5
changelogs/v4.5.3.md Normal file
View File

@@ -0,0 +1,5 @@
## What's Changed
> hotfix version of 4.5.2
1. 修复:修正 `get_tool_list` 方法中工具字典推导式的错误导致的 WebUI MCP 页面工具列表无法显示的问题。

5
changelogs/v4.5.4.md Normal file
View File

@@ -0,0 +1,5 @@
## What's Changed
1. 修复Docker 镜像部分依赖问题导致某些情况下无法启动容器的问题;
2. 优化:插件卡片样式
3. 修复:部分情况下 Windows 一键启动部署时,更新 / 部署失败的问题;

View File

@@ -1,2 +1,3 @@
node_modules/ node_modules/
.DS_Store .DS_Store
dist/

View File

@@ -2,6 +2,7 @@
import { ref, computed, inject } from 'vue'; import { ref, computed, inject } from 'vue';
import { useCustomizerStore } from "@/stores/customizer"; import { useCustomizerStore } from "@/stores/customizer";
import { useModuleI18n } from '@/i18n/composables'; import { useModuleI18n } from '@/i18n/composables';
import UninstallConfirmDialog from './UninstallConfirmDialog.vue';
const props = defineProps({ const props = defineProps({
extension: { extension: {
@@ -31,6 +32,7 @@ const emit = defineEmits([
]); ]);
const reveal = ref(false); const reveal = ref(false);
const showUninstallDialog = ref(false);
// 国际化 // 国际化
const { tm } = useModuleI18n('features/extension'); const { tm } = useModuleI18n('features/extension');
@@ -55,19 +57,11 @@ const installExtension = async () => {
}; };
const uninstallExtension = async () => { const uninstallExtension = async () => {
if (typeof $confirm !== "function") { showUninstallDialog.value = true;
console.error(tm("card.errors.confirmNotRegistered")); };
return;
}
const confirmed = await $confirm({ const handleUninstallConfirm = (options: { deleteConfig: boolean; deleteData: boolean }) => {
title: tm("dialogs.uninstall.title"), emit("uninstall", props.extension, options);
message: tm("dialogs.uninstall.message"),
});
if (confirmed) {
emit("uninstall", props.extension);
}
}; };
const toggleActivation = () => { const toggleActivation = () => {
@@ -220,6 +214,12 @@ const viewReadme = () => {
</v-card-actions> </v-card-actions>
</v-card> </v-card>
<!-- 卸载确认对话框 -->
<UninstallConfirmDialog
v-model="showUninstallDialog"
@confirm="handleUninstallConfirm"
/>
</template> </template>
<style scoped> <style scoped>

View File

@@ -0,0 +1,290 @@
<template>
<div style="margin-top: 16px;">
<v-btn
color="primary"
variant="outlined"
size="small"
@click="openDialog"
style="margin-bottom: 8px;"
>
{{ t('features.settings.sidebar.customize.title') }}
</v-btn>
<v-dialog v-model="dialog" max-width="700px">
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span>{{ t('features.settings.sidebar.customize.title') }}</span>
<v-btn
icon="mdi-close"
variant="text"
@click="dialog = false"
></v-btn>
</v-card-title>
<v-card-text>
<p class="text-body-2 mb-4">{{ t('features.settings.sidebar.customize.subtitle') }}</p>
<v-row>
<v-col cols="12" md="6">
<div class="mb-2 font-weight-medium">{{ t('features.settings.sidebar.customize.mainItems') }}</div>
<v-list
density="compact"
class="custom-list"
@dragover.prevent
@drop="handleDropToList($event, 'main')"
>
<v-list-item
v-for="(item, index) in mainItems"
:key="item.title"
class="mb-1 draggable-item"
draggable="true"
@dragstart="handleDragStart($event, 'main', index)"
@dragover.prevent
@drop.stop="handleDrop($event, 'main', index)"
>
<template v-slot:prepend>
<v-icon :icon="item.icon" size="small" class="mr-2"></v-icon>
</template>
<v-list-item-title>{{ t(item.title) }}</v-list-item-title>
<template v-slot:append>
<v-btn
icon="mdi-arrow-right"
variant="text"
size="x-small"
@click="moveToMore(index)"
></v-btn>
</template>
</v-list-item>
</v-list>
</v-col>
<v-col cols="12" md="6">
<div class="mb-2 font-weight-medium">{{ t('features.settings.sidebar.customize.moreItems') }}</div>
<v-list
density="compact"
class="custom-list"
@dragover.prevent
@drop="handleDropToList($event, 'more')"
>
<v-list-item
v-for="(item, index) in moreItems"
:key="item.title"
class="mb-1 draggable-item"
draggable="true"
@dragstart="handleDragStart($event, 'more', index)"
@dragover.prevent
@drop.stop="handleDrop($event, 'more', index)"
>
<template v-slot:prepend>
<v-icon :icon="item.icon" size="small" class="mr-2"></v-icon>
</template>
<v-list-item-title>{{ t(item.title) }}</v-list-item-title>
<template v-slot:append>
<v-btn
icon="mdi-arrow-left"
variant="text"
size="x-small"
@click="moveToMain(index)"
></v-btn>
</template>
</v-list-item>
</v-list>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-btn
color="error"
variant="text"
@click="resetToDefault"
>
{{ t('features.settings.sidebar.customize.reset') }}
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="saveCustomization"
>
{{ t('core.actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from '@/i18n/composables';
import sidebarItems from '@/layouts/full/vertical-sidebar/sidebarItem';
import {
getSidebarCustomization,
setSidebarCustomization,
clearSidebarCustomization
} from '@/utils/sidebarCustomization';
const { t } = useI18n();
const dialog = ref(false);
const mainItems = ref([]);
const moreItems = ref([]);
const draggedItem = ref(null);
function initializeItems() {
const customization = getSidebarCustomization();
if (customization) {
// Load from customization
const allItemsMap = new Map();
sidebarItems.forEach(item => {
if (item.children) {
item.children.forEach(child => {
allItemsMap.set(child.title, child);
});
} else {
allItemsMap.set(item.title, item);
}
});
mainItems.value = customization.mainItems
.map(title => allItemsMap.get(title))
.filter(item => item);
moreItems.value = customization.moreItems
.map(title => allItemsMap.get(title))
.filter(item => item);
} else {
// Load default structure
mainItems.value = sidebarItems.filter(item => !item.children);
const moreGroup = sidebarItems.find(item => item.title === 'core.navigation.groups.more');
moreItems.value = moreGroup ? [...moreGroup.children] : [];
}
}
function openDialog() {
initializeItems();
dialog.value = true;
}
function handleDragStart(event, listType, index) {
draggedItem.value = {
type: listType,
index: index,
item: listType === 'main' ? mainItems.value[index] : moreItems.value[index]
};
event.dataTransfer.effectAllowed = 'move';
}
function handleDrop(event, targetListType, targetIndex) {
event.preventDefault();
if (!draggedItem.value) return;
const sourceListType = draggedItem.value.type;
const sourceIndex = draggedItem.value.index;
const item = draggedItem.value.item;
// Remove from source
if (sourceListType === 'main') {
mainItems.value.splice(sourceIndex, 1);
} else {
moreItems.value.splice(sourceIndex, 1);
}
// Add to target
if (targetListType === 'main') {
mainItems.value.splice(targetIndex, 0, item);
} else {
moreItems.value.splice(targetIndex, 0, item);
}
draggedItem.value = null;
}
function handleDropToList(event, targetListType) {
event.preventDefault();
if (!draggedItem.value) return;
const sourceListType = draggedItem.value.type;
const sourceIndex = draggedItem.value.index;
const item = draggedItem.value.item;
// Remove from source
if (sourceListType === 'main') {
mainItems.value.splice(sourceIndex, 1);
} else {
moreItems.value.splice(sourceIndex, 1);
}
// Add to target list at the end
if (targetListType === 'main') {
mainItems.value.push(item);
} else {
moreItems.value.push(item);
}
draggedItem.value = null;
}
function moveToMore(index) {
const item = mainItems.value.splice(index, 1)[0];
moreItems.value.push(item);
}
function moveToMain(index) {
const item = moreItems.value.splice(index, 1)[0];
mainItems.value.push(item);
}
function saveCustomization() {
const config = {
mainItems: mainItems.value.map(item => item.title),
moreItems: moreItems.value.map(item => item.title)
};
setSidebarCustomization(config);
// Notify the sidebar to reload
window.dispatchEvent(new CustomEvent('sidebar-customization-changed'));
dialog.value = false;
}
function resetToDefault() {
clearSidebarCustomization();
initializeItems();
// Notify the sidebar to reload
window.dispatchEvent(new CustomEvent('sidebar-customization-changed'));
}
onMounted(() => {
initializeItems();
});
</script>
<style scoped>
.draggable-item {
cursor: move;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
background-color: rgba(var(--v-theme-surface));
transition: all 0.2s;
}
.draggable-item:hover {
background-color: rgba(var(--v-theme-primary), 0.1);
border-color: rgba(var(--v-theme-primary), 0.3);
}
.custom-list {
min-height: 200px;
border: 1px dashed rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
padding: 8px;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<v-dialog
v-model="show"
max-width="500"
@click:outside="handleCancel"
@keydown.esc="handleCancel"
>
<v-card>
<v-card-title class="text-h5">
{{ tm('dialogs.uninstall.title') }}
</v-card-title>
<v-card-text>
<div class="mb-4">
{{ tm('dialogs.uninstall.message') }}
</div>
<v-divider class="my-4"></v-divider>
<div class="text-subtitle-2 mb-3">{{ t('core.common.actions') }}:</div>
<v-checkbox
v-model="deleteConfig"
:label="tm('dialogs.uninstall.deleteConfig')"
color="warning"
hide-details
class="mb-2"
>
<template v-slot:append>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" size="small" color="grey">mdi-information-outline</v-icon>
</template>
<span>{{ tm('dialogs.uninstall.configHint') }}</span>
</v-tooltip>
</template>
</v-checkbox>
<v-checkbox
v-model="deleteData"
:label="tm('dialogs.uninstall.deleteData')"
color="error"
hide-details
>
<template v-slot:append>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" size="small" color="grey">mdi-information-outline</v-icon>
</template>
<span>{{ tm('dialogs.uninstall.dataHint') }}</span>
</v-tooltip>
</template>
</v-checkbox>
<v-alert
v-if="deleteConfig || deleteData"
type="warning"
variant="tonal"
density="compact"
class="mt-4"
>
<template v-slot:prepend>
<v-icon>mdi-alert</v-icon>
</template>
{{ t('messages.validation.operation_cannot_be_undone') }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="grey"
variant="text"
@click="handleCancel"
>
{{ t('core.common.cancel') }}
</v-btn>
<v-btn
color="error"
variant="elevated"
@click="handleConfirm"
>
{{ t('core.common.confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']);
const { t } = useI18n();
const { tm } = useModuleI18n('features/extension');
const show = ref(props.modelValue);
const deleteConfig = ref(false);
const deleteData = ref(false);
watch(() => props.modelValue, (val) => {
show.value = val;
if (val) {
// 重置选项
deleteConfig.value = false;
deleteData.value = false;
}
});
watch(show, (val) => {
emit('update:modelValue', val);
});
const handleConfirm = () => {
emit('confirm', {
deleteConfig: deleteConfig.value,
deleteData: deleteData.value,
});
show.value = false;
};
const handleCancel = () => {
emit('cancel');
show.value = false;
};
</script>

View File

@@ -18,5 +18,6 @@
"refresh": "Refresh", "refresh": "Refresh",
"submit": "Submit", "submit": "Submit",
"reset": "Reset", "reset": "Reset",
"clear": "Clear" "clear": "Clear",
"save": "Save"
} }

View File

@@ -79,6 +79,14 @@
"devDocs": "Extension Development Docs", "devDocs": "Extension Development Docs",
"submitRepo": "Submit Extension Repository" "submitRepo": "Submit Extension Repository"
}, },
"sort": {
"default": "Default",
"stars": "Stars",
"author": "Author",
"updated": "Last Updated",
"ascending": "Ascending",
"descending": "Descending"
},
"tags": { "tags": {
"danger": "Danger" "danger": "Danger"
}, },
@@ -97,7 +105,11 @@
}, },
"uninstall": { "uninstall": {
"title": "Confirm Deletion", "title": "Confirm Deletion",
"message": "Are you sure you want to delete this extension?" "message": "Are you sure you want to delete this extension?",
"deleteConfig": "Also delete plugin configuration file",
"deleteData": "Also delete plugin persistent data",
"configHint": "Configuration file located in data/config directory",
"dataHint": "Deletes data in data/plugin_data and data/plugins_data"
}, },
"install": { "install": {
"title": "Install Extension", "title": "Install Extension",

View File

@@ -19,5 +19,15 @@
"subtitle": "If you encounter data compatibility issues, you can manually start the database migration assistant", "subtitle": "If you encounter data compatibility issues, you can manually start the database migration assistant",
"button": "Start Migration Assistant" "button": "Start Migration Assistant"
} }
},
"sidebar": {
"title": "Sidebar",
"customize": {
"title": "Customize Sidebar",
"subtitle": "Drag to reorder modules, or move modules in/out of the \"More Features\" group. Settings are saved locally in your browser.",
"reset": "Reset to Default",
"mainItems": "Main Modules",
"moreItems": "More Features"
}
} }
} }

View File

@@ -20,5 +20,6 @@
"invalid_date": "Please enter a valid date", "invalid_date": "Please enter a valid date",
"date_range": "Invalid date range", "date_range": "Invalid date range",
"upload_failed": "File upload failed", "upload_failed": "File upload failed",
"network_error": "Network connection error, please try again" "network_error": "Network connection error, please try again",
"operation_cannot_be_undone": "⚠️ This operation cannot be undone, please choose carefully!"
} }

View File

@@ -18,5 +18,6 @@
"refresh": "刷新", "refresh": "刷新",
"submit": "提交", "submit": "提交",
"reset": "重置", "reset": "重置",
"clear": "清空" "clear": "清空",
"save": "保存"
} }

View File

@@ -79,6 +79,14 @@
"devDocs": "插件开发文档", "devDocs": "插件开发文档",
"submitRepo": "提交插件仓库" "submitRepo": "提交插件仓库"
}, },
"sort": {
"default": "默认排序",
"stars": "Star数",
"author": "作者名",
"updated": "更新时间",
"ascending": "升序",
"descending": "降序"
},
"tags": { "tags": {
"danger": "危险" "danger": "危险"
}, },
@@ -97,7 +105,11 @@
}, },
"uninstall": { "uninstall": {
"title": "删除确认", "title": "删除确认",
"message": "你确定要删除当前插件吗?" "message": "你确定要删除当前插件吗?",
"deleteConfig": "同时删除插件配置文件",
"deleteData": "同时删除插件持久化数据",
"configHint": "配置文件位于 data/config 目录",
"dataHint": "删除 data/plugin_data 和 data/plugins_data 目录下的数据"
}, },
"install": { "install": {
"title": "安装插件", "title": "安装插件",

View File

@@ -19,5 +19,15 @@
"subtitle": "如果您遇到数据兼容性问题,可以手动启动数据库迁移助手", "subtitle": "如果您遇到数据兼容性问题,可以手动启动数据库迁移助手",
"button": "启动迁移助手" "button": "启动迁移助手"
} }
},
"sidebar": {
"title": "侧边栏",
"customize": {
"title": "自定义侧边栏",
"subtitle": "拖拽以调整模块顺序,或者将模块移入/移出\"更多功能\"分组。设置仅保存在浏览器本地。",
"reset": "恢复默认",
"mainItems": "主要模块",
"moreItems": "更多功能"
}
} }
} }

View File

@@ -20,5 +20,6 @@
"invalid_date": "请输入有效的日期", "invalid_date": "请输入有效的日期",
"date_range": "日期范围无效", "date_range": "日期范围无效",
"upload_failed": "文件上传失败", "upload_failed": "文件上传失败",
"network_error": "网络连接错误,请重试" "network_error": "网络连接错误,请重试",
"operation_cannot_be_undone": "⚠️ 此操作无法撤销,请谨慎选择!"
} }

View File

@@ -1,15 +1,39 @@
<script setup> <script setup>
import { ref, shallowRef } from 'vue'; import { ref, shallowRef, onMounted, onUnmounted } from 'vue';
import { useCustomizerStore } from '../../../stores/customizer'; import { useCustomizerStore } from '../../../stores/customizer';
import { useI18n } from '@/i18n/composables'; import { useI18n } from '@/i18n/composables';
import sidebarItems from './sidebarItem'; import sidebarItems from './sidebarItem';
import NavItem from './NavItem.vue'; import NavItem from './NavItem.vue';
import { applySidebarCustomization } from '@/utils/sidebarCustomization';
const { t } = useI18n(); const { t } = useI18n();
const customizer = useCustomizerStore(); const customizer = useCustomizerStore();
const sidebarMenu = shallowRef(sidebarItems); const sidebarMenu = shallowRef(sidebarItems);
// Apply customization on mount and listen for storage changes
const handleStorageChange = (e) => {
if (e.key === 'astrbot_sidebar_customization') {
sidebarMenu.value = applySidebarCustomization(sidebarItems);
}
};
const handleCustomEvent = () => {
sidebarMenu.value = applySidebarCustomization(sidebarItems);
};
onMounted(() => {
sidebarMenu.value = applySidebarCustomization(sidebarItems);
window.addEventListener('storage', handleStorageChange);
window.addEventListener('sidebar-customization-changed', handleCustomEvent);
});
onUnmounted(() => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('sidebar-customization-changed', handleCustomEvent);
});
const showIframe = ref(false); const showIframe = ref(false);
const starCount = ref(null); const starCount = ref(null);

View File

@@ -0,0 +1,99 @@
// Utility for managing sidebar customization in localStorage
const STORAGE_KEY = 'astrbot_sidebar_customization';
/**
* Get the customized sidebar configuration from localStorage
* @returns {Object|null} The customization config or null if not set
*/
export function getSidebarCustomization() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch (error) {
console.error('Error reading sidebar customization:', error);
return null;
}
}
/**
* Save the sidebar customization to localStorage
* @param {Object} config - The customization configuration
* @param {Array} config.mainItems - Array of item titles for main sidebar
* @param {Array} config.moreItems - Array of item titles for "More Features" group
*/
export function setSidebarCustomization(config) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch (error) {
console.error('Error saving sidebar customization:', error);
}
}
/**
* Clear the sidebar customization (reset to default)
*/
export function clearSidebarCustomization() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.error('Error clearing sidebar customization:', error);
}
}
/**
* Apply customization to sidebar items
* @param {Array} defaultItems - Default sidebar items array
* @returns {Array} Customized sidebar items array (new array, doesn't mutate input)
*/
export function applySidebarCustomization(defaultItems) {
const customization = getSidebarCustomization();
if (!customization) {
return defaultItems;
}
const { mainItems, moreItems } = customization;
// Create a map of all items by title for quick lookup
// Deep clone items to avoid mutating originals
const allItemsMap = new Map();
defaultItems.forEach(item => {
if (item.children) {
// If it's the "More" group, add children to map
item.children.forEach(child => {
allItemsMap.set(child.title, { ...child });
});
} else {
allItemsMap.set(item.title, { ...item });
}
});
const customizedItems = [];
// Add main items in custom order
mainItems.forEach(title => {
const item = allItemsMap.get(title);
if (item) {
customizedItems.push(item);
}
});
// If there are items in moreItems, create the "More Features" group
if (moreItems && moreItems.length > 0) {
const moreGroup = {
title: 'core.navigation.groups.more',
icon: 'mdi-dots-horizontal',
children: []
};
moreItems.forEach(title => {
const item = allItemsMap.get(title);
if (item) {
moreGroup.children.push(item);
}
});
customizedItems.push(moreGroup);
}
return customizedItems;
}

View File

@@ -4,12 +4,13 @@ import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue'; import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ReadmeDialog from '@/components/shared/ReadmeDialog.vue'; import ReadmeDialog from '@/components/shared/ReadmeDialog.vue';
import ProxySelector from '@/components/shared/ProxySelector.vue'; import ProxySelector from '@/components/shared/ProxySelector.vue';
import UninstallConfirmDialog from '@/components/shared/UninstallConfirmDialog.vue';
import axios from 'axios'; import axios from 'axios';
import { pinyin } from 'pinyin-pro'; import { pinyin } from 'pinyin-pro';
import { useCommonStore } from '@/stores/common'; import { useCommonStore } from '@/stores/common';
import { useI18n, useModuleI18n } from '@/i18n/composables'; import { useI18n, useModuleI18n } from '@/i18n/composables';
import { ref, computed, onMounted, reactive } from 'vue'; import { ref, computed, onMounted, reactive, inject, watch } from 'vue';
const commonStore = useCommonStore(); const commonStore = useCommonStore();
@@ -52,10 +53,18 @@ const isListView = ref(false);
const pluginSearch = ref(""); const pluginSearch = ref("");
const loading_ = ref(false); const loading_ = ref(false);
// 分页相关
const currentPage = ref(1);
const itemsPerPage = ref(6); // 每页显示6个卡片 (2行 x 3列避免滚动)
// 危险插件确认对话框 // 危险插件确认对话框
const dangerConfirmDialog = ref(false); const dangerConfirmDialog = ref(false);
const selectedDangerPlugin = ref(null); const selectedDangerPlugin = ref(null);
// 卸载插件确认对话框(列表模式用)
const showUninstallDialog = ref(false);
const pluginToUninstall = ref(null);
// 插件市场相关 // 插件市场相关
const extension_url = ref(""); const extension_url = ref("");
const dialog = ref(false); const dialog = ref(false);
@@ -63,8 +72,11 @@ const upload_file = ref(null);
const uploadTab = ref('file'); const uploadTab = ref('file');
const showPluginFullName = ref(false); const showPluginFullName = ref(false);
const marketSearch = ref(""); const marketSearch = ref("");
const debouncedMarketSearch = ref("");
const filterKeys = ['name', 'desc', 'author']; const filterKeys = ['name', 'desc', 'author'];
const refreshingMarket = ref(false); const refreshingMarket = ref(false);
const sortBy = ref('default'); // default, stars, author, updated
const sortOrder = ref('desc'); // desc (降序) or asc (升序)
// 插件市场拼音搜索 // 插件市场拼音搜索
const normalizeStr = (s) => (s ?? '').toString().toLowerCase().trim(); const normalizeStr = (s) => (s ?? '').toString().toLowerCase().trim();
@@ -148,6 +160,71 @@ const pinnedPlugins = computed(() => {
return pluginMarketData.value.filter(plugin => plugin?.pinned); return pluginMarketData.value.filter(plugin => plugin?.pinned);
}); });
// 过滤后的插件市场数据(带搜索)
const filteredMarketPlugins = computed(() => {
if (!debouncedMarketSearch.value) {
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);
});
});
// 所有插件列表,推荐插件排在前面
const sortedPlugins = computed(() => {
let plugins = [...filteredMarketPlugins.value];
// 根据排序选项排序
if (sortBy.value === 'stars') {
// 按 star 数排序
plugins.sort((a, b) => {
const starsA = a.stars ?? 0;
const starsB = b.stars ?? 0;
return sortOrder.value === 'desc' ? starsB - starsA : starsA - starsB;
});
} else if (sortBy.value === 'author') {
// 按作者名字典序排序
plugins.sort((a, b) => {
const authorA = (a.author ?? '').toLowerCase();
const authorB = (b.author ?? '').toLowerCase();
const result = authorA.localeCompare(authorB);
return sortOrder.value === 'desc' ? -result : result;
});
} else if (sortBy.value === 'updated') {
// 按更新时间排序
plugins.sort((a, b) => {
const dateA = a.updated_at ? new Date(a.updated_at).getTime() : 0;
const dateB = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return sortOrder.value === 'desc' ? dateB - dateA : dateA - dateB;
});
} else {
// default: 推荐插件排在前面
const pinned = plugins.filter(plugin => plugin?.pinned);
const notPinned = plugins.filter(plugin => !plugin?.pinned);
return [...pinned, ...notPinned];
}
return plugins;
});
// 分页计算属性
const displayItemsPerPage = 9; // 固定每页显示6个卡片2行
const totalPages = computed(() => {
return Math.ceil(sortedPlugins.value.length / displayItemsPerPage);
});
const paginatedPlugins = computed(() => {
const start = (currentPage.value - 1) * displayItemsPerPage;
const end = start + displayItemsPerPage;
return sortedPlugins.value.slice(start, end);
});
// 方法 // 方法
const toggleShowReserved = () => { const toggleShowReserved = () => {
showReserved.value = !showReserved.value; showReserved.value = !showReserved.value;
@@ -213,10 +290,35 @@ const checkUpdate = () => {
}); });
}; };
const uninstallExtension = async (extension_name) => { const uninstallExtension = async (extension_name, optionsOrSkipConfirm = false) => {
let deleteConfig = false;
let deleteData = false;
let skipConfirm = false;
// 处理参数:可能是布尔值(旧的 skipConfirm或对象新的选项
if (typeof optionsOrSkipConfirm === 'boolean') {
skipConfirm = optionsOrSkipConfirm;
} else if (typeof optionsOrSkipConfirm === 'object' && optionsOrSkipConfirm !== null) {
deleteConfig = optionsOrSkipConfirm.deleteConfig || false;
deleteData = optionsOrSkipConfirm.deleteData || false;
skipConfirm = true; // 如果传递了选项对象,说明已经确认过了
}
// 如果没有跳过确认且没有传递选项对象,显示自定义卸载对话框
if (!skipConfirm) {
pluginToUninstall.value = extension_name;
showUninstallDialog.value = true;
return; // 等待对话框回调
}
// 执行卸载
toast(tm('messages.uninstalling') + " " + extension_name, "primary"); toast(tm('messages.uninstalling') + " " + extension_name, "primary");
try { try {
const res = await axios.post('/api/plugin/uninstall', { name: extension_name }); const res = await axios.post('/api/plugin/uninstall', {
name: extension_name,
delete_config: deleteConfig,
delete_data: deleteData,
});
if (res.data.status === "error") { if (res.data.status === "error") {
toast(res.data.message, "error"); toast(res.data.message, "error");
return; return;
@@ -229,6 +331,14 @@ const uninstallExtension = async (extension_name) => {
} }
}; };
// 处理卸载确认对话框的确认事件
const handleUninstallConfirm = (options) => {
if (pluginToUninstall.value) {
uninstallExtension(pluginToUninstall.value, options);
pluginToUninstall.value = null;
}
};
const updateExtension = async (extension_name) => { const updateExtension = async (extension_name) => {
loadingDialog.title = tm('status.loading'); loadingDialog.title = tm('status.loading');
loadingDialog.show = true; loadingDialog.show = true;
@@ -496,6 +606,7 @@ const refreshPluginMarket = async () => {
trimExtensionName(); trimExtensionName();
checkAlreadyInstalled(); checkAlreadyInstalled();
checkUpdate(); checkUpdate();
currentPage.value = 1; // 重置到第一页
toast(tm('messages.refreshSuccess'), "success"); toast(tm('messages.refreshSuccess'), "success");
} catch (err) { } catch (err) {
@@ -537,6 +648,20 @@ onMounted(async () => {
} }
}); });
// 搜索防抖处理
let searchDebounceTimer = null;
watch(marketSearch, (newVal) => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
}
searchDebounceTimer = setTimeout(() => {
debouncedMarketSearch.value = newVal;
// 搜索时重置到第一页
currentPage.value = 1;
}, 300); // 300ms 防抖延迟
});
</script> </script>
@@ -750,8 +875,10 @@ onMounted(async () => {
<v-row> <v-row>
<v-col cols="12" md="6" lg="4" v-for="extension in filteredPlugins" :key="extension.name" <v-col cols="12" md="6" lg="4" v-for="extension in filteredPlugins" :key="extension.name"
class="pb-2"> class="pb-2">
<ExtensionCard :extension="extension" class="rounded-lg" style="background-color: rgb(var(--v-theme-mcpCardBg));" <ExtensionCard :extension="extension" class="rounded-lg"
@configure="openExtensionConfig(extension.name)" @uninstall="uninstallExtension(extension.name)" style="background-color: rgb(var(--v-theme-mcpCardBg));"
@configure="openExtensionConfig(extension.name)"
@uninstall="(ext, options) => uninstallExtension(ext.name, options)"
@update="updateExtension(extension.name)" @reload="reloadPlugin(extension.name)" @update="updateExtension(extension.name)" @reload="reloadPlugin(extension.name)"
@toggle-activation="extension.activated ? pluginOff(extension) : pluginOn(extension)" @toggle-activation="extension.activated ? pluginOff(extension) : pluginOn(extension)"
@view-handlers="showPluginInfo(extension)" @view-readme="viewReadme(extension)"> @view-handlers="showPluginInfo(extension)" @view-readme="viewReadme(extension)">
@@ -771,85 +898,158 @@ onMounted(async () => {
@click="dialog = true" color="darkprimary"> @click="dialog = true" color="darkprimary">
</v-btn> </v-btn>
<div v-if="pinnedPlugins.length > 0" class="mt-4">
<h2>{{ tm('market.recommended') }}</h2>
<v-row style="margin-top: 8px;">
<v-col cols="12" md="6" lg="6" v-for="plugin in pinnedPlugins" :key="plugin.name">
<ExtensionCard :extension="plugin" class="h-120 rounded-lg" market-mode="true" :highlight="true"
@install="handleInstallPlugin(plugin)" @view-readme="open(plugin.repo)">
</ExtensionCard>
</v-col>
</v-row>
</div>
<div class="mt-4"> <div class="mt-4">
<div class="d-flex align-center mb-2" style="justify-content: space-between;"> <div class="d-flex align-center mb-2" style="justify-content: space-between; flex-wrap: wrap; gap: 8px;">
<h2>{{ tm('market.allPlugins') }}</h2> <div class="d-flex align-center" style="gap: 6px;">
<div class="d-flex align-center"> <h2>{{ tm('market.allPlugins') }}({{ filteredMarketPlugins.length }})</h2>
<v-btn variant="tonal" size="small" @click="refreshPluginMarket" :loading="refreshingMarket" <v-btn icon variant="text" @click="refreshPluginMarket" :loading="refreshingMarket">
class="mr-2">
<v-icon>mdi-refresh</v-icon> <v-icon>mdi-refresh</v-icon>
{{ tm('buttons.refresh') }}
</v-btn> </v-btn>
<v-switch v-model="showPluginFullName" :label="tm('market.showFullName')" hide-details </div>
density="compact" style="margin-left: 12px" />
<div class="d-flex align-center" style="gap: 8px; flex-wrap: wrap;">
<v-pagination v-model="currentPage" :length="totalPages" :total-visible="5" size="small"
density="comfortable"></v-pagination>
<!-- 排序选择器 -->
<v-select v-model="sortBy" :items="[
{ title: tm('sort.default'), value: 'default' },
{ title: tm('sort.stars'), value: 'stars' },
{ title: tm('sort.author'), value: 'author' },
{ title: tm('sort.updated'), value: 'updated' }
]" density="compact" variant="outlined" hide-details style="max-width: 150px;">
<template v-slot:prepend-inner>
<v-icon size="small">mdi-sort</v-icon>
</template>
</v-select>
<!-- 排序方向切换按钮 -->
<v-btn icon v-if="sortBy !== 'default'" @click="sortOrder = sortOrder === 'desc' ? 'asc' : 'desc'"
variant="text" density="compact">
<v-icon>{{ sortOrder === 'desc' ? 'mdi-sort-descending' : 'mdi-sort-ascending'
}}</v-icon>
<v-tooltip activator="parent" location="top">
{{ sortOrder === 'desc' ? tm('sort.descending') : tm('sort.ascending') }}
</v-tooltip>
</v-btn>
<!-- <v-switch v-model="showPluginFullName" :label="tm('market.showFullName')" hide-details
density="compact" style="margin-left: 12px" /> -->
</div> </div>
</div> </div>
<v-col cols="12" md="12" style="padding: 0px;"> <v-row style="min-height: 26rem;">
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name" style="border-radius: 10px;" <v-col v-for="plugin in paginatedPlugins" :key="plugin.name" cols="12" md="6" lg="4">
:loading="loading_" v-model:search="marketSearch" :filter-keys="filterKeys" <v-card class="rounded-lg d-flex flex-column" elevation="0"
:custom-filter="marketCustomFilter"> style=" height: 12rem; position: relative;">
<template v-slot:item.name="{ item }">
<div class="d-flex align-center" <!-- 推荐标记 -->
style="overflow-x: auto; scrollbar-width: thin; scrollbar-track-color: transparent;"> <v-chip v-if="plugin?.pinned" color="warning" size="x-small" label
<img v-if="item.logo" :src="item.logo" style="position: absolute; right: 8px; top: 8px; z-index: 10; height: 20px; font-weight: bold;">
style="height: 80px; width: 80px; margin-right: 8px; border-radius: 8px; margin-top: 8px; margin-bottom: 8px;" 🥳 推荐
alt="logo"> </v-chip>
<a :href="item?.repo" style="color: var(--v-theme-primaryText, #000);
text-decoration:none"> <v-card-text
<div v-if="item.display_name"> style="padding: 12px; padding-bottom: 8px; display: flex; gap: 12px; width: 100%; flex: 1; overflow: hidden;">
<span class="d-block">{{ item.display_name }}</span> <div v-if="plugin?.logo" style="flex-shrink: 0;">
<small style="color: grey; font-size: 60%;">({{ item.name }})</small> <img :src="plugin.logo" :alt="plugin.name"
style="height: 75px; width: 75px; border-radius: 8px; object-fit: cover;" />
</div>
<div style="flex: 1; overflow: hidden; display: flex; flex-direction: column;">
<!-- Display Name -->
<div class="font-weight-bold"
style="margin-bottom: 4px; line-height: 1.3; font-size: 1.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<span style="overflow: hidden; text-overflow: ellipsis;">
{{ plugin.display_name?.length ? plugin.display_name :
(showPluginFullName ? plugin.name : plugin.trimmedName) }}
</span>
</div>
<!-- Author with link -->
<div class="d-flex align-center" style="gap: 4px; margin-bottom: 6px;">
<v-icon icon="mdi-account" size="x-small"
style="color: rgba(var(--v-theme-on-surface), 0.5);"></v-icon>
<a v-if="plugin?.social_link" :href="plugin.social_link" target="_blank"
class="text-subtitle-2 font-weight-medium"
style="text-decoration: none; color: rgb(var(--v-theme-primary)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
{{ plugin.author }}
</a>
<span v-else class="text-subtitle-2 font-weight-medium"
style="color: rgb(var(--v-theme-primary)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
{{ plugin.author }}
</span>
<div class="d-flex align-center text-subtitle-2 ml-2"
style="color: rgba(var(--v-theme-on-surface), 0.7);">
<v-icon icon="mdi-source-branch" size="x-small" style="margin-right: 2px;"></v-icon>
<span>{{ plugin.version }}</span>
</div> </div>
<span v-else>{{ showPluginFullName ? item.name : item.trimmedName }}</span> </div>
</a>
</div> <!-- Description -->
</template> <div class="text-caption"
<template v-slot:item.desc="{ item }"> style="overflow: scroll; color: rgba(var(--v-theme-on-surface), 0.6); line-height: 1.3; margin-bottom: 6px; flex: 1;">
<small> {{ plugin.desc }}
{{ item.desc }} </div>
</small>
</template> <!-- Stats: Stars & Updated & Version -->
<template v-slot:item.author="{ item }"> <div class="d-flex align-center" style="gap: 8px; margin-top: auto;">
<div style="font-size: 12px;"> <div v-if="plugin.stars !== undefined" class="d-flex align-center text-subtitle-2"
<span v-if="item?.social_link"><a :href="item?.social_link">{{ item.author }}</a></span> style="color: rgba(var(--v-theme-on-surface), 0.7);">
<span v-else>{{ item.author }}</span> <v-icon icon="mdi-star" size="x-small" style="margin-right: 2px;"></v-icon>
</div> <span>{{ plugin.stars }}</span>
</template> </div>
<template v-slot:item.stars="{ item }"> <div v-if="plugin.updated_at" class="d-flex align-center text-subtitle-2"
<span>{{ item.stars }}</span> style="color: rgba(var(--v-theme-on-surface), 0.7);">
</template> <v-icon icon="mdi-clock-outline" size="x-small" style="margin-right: 2px;"></v-icon>
<template v-slot:item.updated_at="{ item }"> <span>{{ new Date(plugin.updated_at).toLocaleString() }}</span>
<small>{{ new Date(item.updated_at).toLocaleString() }}</small> </div>
</template> </div>
<template v-slot:item.tags="{ item }"> </div>
<span v-if="item.tags.length === 0">-</span> </v-card-text>
<v-chip v-for="tag in item.tags" :key="tag" :color="tag === 'danger' ? 'error' : 'primary'"
size="x-small" v-show="tag !== 'danger'" class="ma-1"> <!-- Actions -->
{{ tag }}</v-chip> <v-card-actions style="gap: 6px; padding: 8px 12px; padding-top: 0;">
</template> <v-chip v-for="tag in plugin.tags?.slice(0, 2)" :key="tag"
<template v-slot:item.actions="{ item }"> :color="tag === 'danger' ? 'error' : 'primary'" label size="x-small" style="height: 20px;">
<v-btn class="text-none mr-2" size="x-small" icon variant="text" {{ tag === 'danger' ? tm('tags.danger') : tag }}
@click="open(item.repo)"><v-icon>mdi-github</v-icon></v-btn> </v-chip>
<v-btn v-if="!item.installed" class="text-none mr-2" size="x-small" icon variant="text" <v-menu v-if="plugin.tags && plugin.tags.length > 2" open-on-hover offset-y>
@click="handleInstallPlugin(item)"> <template v-slot:activator="{ props: menuProps }">
<v-icon>mdi-download</v-icon></v-btn> <v-chip v-bind="menuProps" color="grey" label size="x-small"
<v-btn v-else class="text-none mr-2" size="x-small" icon variant="text" style="height: 20px; cursor: pointer;">
disabled><v-icon>mdi-check</v-icon></v-btn> +{{ plugin.tags.length - 2 }}
</template> </v-chip>
</v-data-table> </template>
</v-col> <v-list density="compact">
<v-list-item v-for="tag in plugin.tags.slice(2)" :key="tag">
<v-chip :color="tag === 'danger' ? 'error' : 'primary'" label size="small">
{{ tag === 'danger' ? tm('tags.danger') : tag }}
</v-chip>
</v-list-item>
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-btn v-if="plugin?.repo" color="secondary" size="x-small" variant="tonal" :href="plugin.repo"
target="_blank" style="height: 24px;">
<v-icon icon="mdi-github" start size="x-small"></v-icon>
仓库
</v-btn>
<v-btn v-if="!plugin?.installed" color="primary" size="x-small"
@click="handleInstallPlugin(plugin)" variant="flat" style="height: 24px;">
{{ tm('buttons.install') }}
</v-btn>
<v-chip v-else color="success" size="x-small" label style="height: 20px;">
{{ tm('status.installed') }}
</v-chip>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<!-- 底部分页控件 -->
<div class="d-flex justify-center mt-4" v-if="totalPages > 1">
<v-pagination v-model="currentPage" :length="totalPages" :total-visible="7" size="small"></v-pagination>
</div>
</div> </div>
</v-tab-item> </v-tab-item>
@@ -862,7 +1062,7 @@ onMounted(async () => {
</v-card> </v-card>
</v-col> </v-col>
<v-col v-if="activeTab === 'market'" style="margin-bottom: 16px;" cols="12" md="12"> <v-col v-if="activeTab === 'market'" cols="12" md="12">
<small><a href="https://astrbot.app/dev/plugin.html">{{ tm('market.devDocs') }}</a></small> | <small><a href="https://astrbot.app/dev/plugin.html">{{ tm('market.devDocs') }}</a></small> |
<small> <a href="https://github.com/AstrBotDevs/AstrBot_Plugins_Collection">{{ tm('market.submitRepo') <small> <a href="https://github.com/AstrBotDevs/AstrBot_Plugins_Collection">{{ tm('market.submitRepo')
}}</a></small> }}</a></small>
@@ -958,6 +1158,9 @@ onMounted(async () => {
<ReadmeDialog v-model:show="readmeDialog.show" :plugin-name="readmeDialog.pluginName" <ReadmeDialog v-model:show="readmeDialog.show" :plugin-name="readmeDialog.pluginName"
:repo-url="readmeDialog.repoUrl" /> :repo-url="readmeDialog.repoUrl" />
<!-- 卸载插件确认对话框列表模式用 -->
<UninstallConfirmDialog v-model="showUninstallDialog" @confirm="handleUninstallConfirm" />
<!-- 危险插件确认对话框 --> <!-- 危险插件确认对话框 -->
<v-dialog v-model="dangerConfirmDialog" width="500" persistent> <v-dialog v-model="dangerConfirmDialog" width="500" persistent>
<v-card> <v-card>

View File

@@ -9,6 +9,12 @@
<ProxySelector></ProxySelector> <ProxySelector></ProxySelector>
</v-list-item> </v-list-item>
<v-list-subheader>{{ tm('sidebar.title') }}</v-list-subheader>
<v-list-item :subtitle="tm('sidebar.customize.subtitle')" :title="tm('sidebar.customize.title')">
<SidebarCustomizer></SidebarCustomizer>
</v-list-item>
<v-list-subheader>{{ tm('system.title') }}</v-list-subheader> <v-list-subheader>{{ tm('system.title') }}</v-list-subheader>
<v-list-item :subtitle="tm('system.restart.subtitle')" :title="tm('system.restart.title')"> <v-list-item :subtitle="tm('system.restart.subtitle')" :title="tm('system.restart.title')">
@@ -33,6 +39,7 @@ import axios from 'axios';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue'; import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import ProxySelector from '@/components/shared/ProxySelector.vue'; import ProxySelector from '@/components/shared/ProxySelector.vue';
import MigrationDialog from '@/components/shared/MigrationDialog.vue'; import MigrationDialog from '@/components/shared/MigrationDialog.vue';
import SidebarCustomizer from '@/components/shared/SidebarCustomizer.vue';
import { useModuleI18n } from '@/i18n/composables'; import { useModuleI18n } from '@/i18n/composables';
const { tm } = useModuleI18n('features/settings'); const { tm } = useModuleI18n('features/settings');

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "AstrBot" name = "AstrBot"
dynamic = ["version"] version = "4.5.4"
description = "易上手的多平台 LLM 聊天机器人及开发框架" description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
@@ -111,16 +111,3 @@ reportMissingTypeStubs = false
reportMissingImports = false reportMissingImports = false
include = ["astrbot","packages"] include = ["astrbot","packages"]
exclude = ["dashboard", "node_modules", "dist", "data", "tests"] exclude = ["dashboard", "node_modules", "dist", "data", "tests"]
[tool.hatch.version]
source = "uv-dynamic-versioning"
[tool.uv-dynamic-versioning]
vcs = "git"
style = "pep440"
bump = true
[build-system]
requires = ["hatchling", "uv-dynamic-versioning"]
build-backend = "hatchling.build"

View File

@@ -0,0 +1,151 @@
"""Tests for security fixes - cryptographic random number generation and SSL context."""
import os
import ssl
import sys
# Add project root to sys.path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import pytest
def test_wecom_crypto_uses_secrets():
"""Test that WXBizJsonMsgCrypt uses secrets module instead of random."""
from astrbot.core.platform.sources.wecom_ai_bot.WXBizJsonMsgCrypt import Prpcrypt
# Create an instance and test that random string generation works
prpcrypt = Prpcrypt(b"test_key_32_bytes_long_value!")
# Generate multiple random strings and verify they are different and valid
random_strings = [prpcrypt.get_random_str() for _ in range(10)]
# All strings should be 16 bytes long
assert all(len(s) == 16 for s in random_strings)
# All strings should be different (extremely high probability with cryptographic random)
assert len(set(random_strings)) == 10
# All strings should be numeric when decoded
for s in random_strings:
decoded = s.decode()
assert decoded.isdigit()
assert 1000000000000000 <= int(decoded) <= 9999999999999999
def test_wecomai_utils_uses_secrets():
"""Test that wecomai_utils uses secrets module for random string generation."""
from astrbot.core.platform.sources.wecom_ai_bot.wecomai_utils import (
generate_random_string,
)
# Generate multiple random strings and verify they are different
random_strings = [generate_random_string(10) for _ in range(20)]
# All strings should be 10 characters long
assert all(len(s) == 10 for s in random_strings)
# All strings should be alphanumeric
for s in random_strings:
assert s.isalnum()
# All strings should be different (extremely high probability with cryptographic random)
assert len(set(random_strings)) >= 19 # Allow for 1 collision in 20 (very unlikely)
def test_azure_tts_signature_uses_secrets():
"""Test that Azure TTS signature generation uses secrets module."""
import asyncio
from astrbot.core.provider.sources.azure_tts_source import OTTSProvider
# Create a provider with test config
config = {
"OTTS_SKEY": "test_secret_key",
"OTTS_URL": "https://example.com/api/tts",
"OTTS_AUTH_TIME": "https://example.com/api/time",
}
async def test_nonce_generation():
async with OTTSProvider(config) as provider:
# Mock time sync to avoid actual API calls
provider.time_offset = 0
provider.last_sync_time = 9999999999
# Generate multiple signatures and extract nonces
signatures = []
for _ in range(10):
sig = await provider._generate_signature()
signatures.append(sig)
# Extract nonces (second field in signature format: timestamp-nonce-0-hash)
nonces = [sig.split("-")[1] for sig in signatures]
# All nonces should be 10 characters long
assert all(len(n) == 10 for n in nonces)
# All nonces should be alphanumeric (lowercase letters and digits)
for n in nonces:
assert all(c in "abcdefghijklmnopqrstuvwxyz0123456789" for c in n)
# All nonces should be different (cryptographic random ensures uniqueness)
assert len(set(nonces)) == 10
asyncio.run(test_nonce_generation())
def test_ssl_context_fallback_explicit():
"""Test that SSL context fallback is properly configured."""
# This test verifies the SSL context configuration
# We can't easily test the full io.py functions without network calls,
# but we can verify that ssl.CERT_NONE and check_hostname=False are valid settings
# Create a context similar to what's used in io.py
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
# Verify the settings are applied correctly
assert ssl_context.check_hostname is False
assert ssl_context.verify_mode == ssl.CERT_NONE
# This configuration should work but is intentionally insecure for fallback
# The actual code only uses this when certificate validation fails
def test_io_module_has_ssl_imports():
"""Verify that io.py properly imports ssl module."""
from astrbot.core.utils import io
# Check that ssl is available in the module
assert hasattr(io, "ssl")
# Check that CERT_NONE constant is accessible
assert hasattr(io.ssl, "CERT_NONE")
def test_secrets_module_randomness_quality():
"""Test that secrets module provides high-quality randomness."""
import secrets
# Generate a large set of random numbers
random_numbers = [secrets.randbelow(100) for _ in range(1000)]
# Basic statistical test: should have good distribution
unique_values = len(set(random_numbers))
# With 1000 random numbers from 0-99, we should see most values at least once
# This is a very basic test - real cryptographic random should pass this easily
assert unique_values >= 60 # Should see at least 60 different values out of 100
# Test secrets.choice for string generation
chars = "abcdefghijklmnopqrstuvwxyz0123456789"
random_chars = [secrets.choice(chars) for _ in range(1000)]
# Should have good character distribution
unique_chars = len(set(random_chars))
assert unique_chars >= 20 # Should see at least 20 different characters
if __name__ == "__main__":
pytest.main([__file__, "-v"])