Compare commits
15 Commits
v4.5.5
...
feat/vpo-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c56d2aeced | ||
|
|
fb56ac9f47 | ||
|
|
5719dbd8b2 | ||
|
|
5217450c8a | ||
|
|
15aafa2043 | ||
|
|
36a3b4d318 | ||
|
|
7518c4a057 | ||
|
|
8e3bd92c09 | ||
|
|
9e4fec8488 | ||
|
|
983264dc1a | ||
|
|
b3f23b23da | ||
|
|
212d8cc101 | ||
|
|
36de3c541a | ||
|
|
1151677f13 | ||
|
|
2ccb85d802 |
@@ -1,9 +1,9 @@
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
# github actions
|
||||
.git
|
||||
# github acions
|
||||
.github/
|
||||
.*ignore
|
||||
.git/
|
||||
# User-specific stuff
|
||||
.idea/
|
||||
# Byte-compiled / optimized / DLL files
|
||||
@@ -15,10 +15,10 @@ env/
|
||||
venv*/
|
||||
ENV/
|
||||
.conda/
|
||||
README*.md
|
||||
dashboard/
|
||||
data/
|
||||
changelogs/
|
||||
tests/
|
||||
.ruff_cache/
|
||||
.astrbot
|
||||
astrbot.lock
|
||||
.astrbot
|
||||
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# ASTRBOT 数据目录
|
||||
# ASTRBOT_ROOT = ./data
|
||||
98
.github/copilot-instructions.md
vendored
98
.github/copilot-instructions.md
vendored
@@ -1,63 +1,63 @@
|
||||
# AstrBot Development Instructions
|
||||
# AstrBot 开发指南
|
||||
|
||||
AstrBot is a multi-platform LLM chatbot and development framework written in Python with a Vue.js dashboard. It supports multiple messaging platforms (QQ, Telegram, Discord, etc.) and various LLM providers (OpenAI, Anthropic, Google Gemini, etc.).
|
||||
AstrBot 是一个使用 Python 编写、配备 Vue.js 仪表盘的多平台 LLM 聊天机器人开发框架。它支持多个消息平台(QQ、Telegram、Discord 等)和多种 LLM 提供商(OpenAI、Anthropic、Google Gemini 等)。
|
||||
|
||||
Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.
|
||||
始终优先参考这些指南,仅在遇到与此处信息不符的意外情况时才回退到搜索或 bash 命令。
|
||||
|
||||
## Working Effectively
|
||||
## 高效工作
|
||||
|
||||
### Bootstrap and Install Dependencies
|
||||
- **Python 3.10+ required** - Check `.python-version` file
|
||||
- Install UV package manager: `pip install uv`
|
||||
- Install project dependencies: `uv sync` -- takes 6-7 minutes. NEVER CANCEL. Set timeout to 10+ minutes.
|
||||
- Create required directories: `mkdir -p data/plugins data/config data/temp`
|
||||
### 引导和安装依赖
|
||||
- **需要 Python 3.10+** - 检查 `.python-version` 文件
|
||||
- 安装 UV 包管理器:`pip install uv`
|
||||
- 安装项目依赖:`uv sync` -- 很快几分钟。绝不要取消。设置超时时间为 10+ 分钟。
|
||||
- 创建必需的目录:`mkdir -p data/plugins data/config data/temp`
|
||||
|
||||
### Running the Application
|
||||
- Run main application: `uv run main.py` -- starts in ~3 seconds
|
||||
- Application creates WebUI on http://localhost:6185 (default credentials: `astrbot`/`astrbot`)
|
||||
- Application loads plugins automatically from `packages/` and `data/plugins/` directories
|
||||
### 运行应用程序
|
||||
- 运行主应用程序:`uv run main.py` -- 约 3 秒启动
|
||||
- 应用程序在 http://localhost:6185 创建 WebUI(默认凭据:`astrbot`/`astrbot`)
|
||||
- 应用程序自动从 `packages/` 和 `data/plugins/` 目录加载插件
|
||||
|
||||
### Dashboard Build (Vue.js/Node.js)
|
||||
- **Prerequisites**: Node.js 20+ and npm 10+ required
|
||||
- Navigate to dashboard: `cd dashboard`
|
||||
- Install dashboard dependencies: `npm install` -- takes 2-3 minutes. NEVER CANCEL. Set timeout to 5+ minutes.
|
||||
- Build dashboard: `npm run build` -- takes 25-30 seconds. NEVER CANCEL.
|
||||
- Dashboard creates optimized production build in `dashboard/dist/`
|
||||
### 仪表盘构建(Vue.js/Node.js)
|
||||
- **前置要求**:需要 Node.js 20+ 和 npm 10+
|
||||
- 导航到仪表盘:`cd dashboard`
|
||||
- 安装仪表盘依赖:`npm install` -- 需要 2-3 分钟。绝不要取消。设置超时时间为 5+ 分钟。
|
||||
- 构建仪表盘:`npm run build` -- 需要 25-30 秒。绝不要取消。
|
||||
- 仪表盘在 `dashboard/dist/` 创建优化的生产构建
|
||||
|
||||
### Testing
|
||||
- Do not generate test files for now.
|
||||
### 测试
|
||||
- 暂时不要生成测试文件。
|
||||
|
||||
### Code Quality and Linting
|
||||
- Install ruff linter: `uv add --dev ruff`
|
||||
- Check code style: `uv run ruff check .` -- takes <1 second
|
||||
- Check formatting: `uv run ruff format --check .` -- takes <1 second
|
||||
- Fix formatting: `uv run ruff format .`
|
||||
- **ALWAYS** run `uv run ruff check .` and `uv run ruff format .` before committing changes
|
||||
### 代码质量和检查
|
||||
- 安装 ruff 检查器:`uv add --dev ruff`
|
||||
- 检查代码风格:`uv run ruff check .` -- 耗时 <1 秒
|
||||
- 检查格式:`uv run ruff format --check .` -- 耗时 <1 秒
|
||||
- 修复格式:`uv run ruff format .`
|
||||
- **始终**在提交更改前运行 `uv run ruff check .` 和 `uv run ruff format .`
|
||||
|
||||
### Plugin Development
|
||||
- Plugins load from `packages/` (built-in) and `data/plugins/` (user-installed)
|
||||
- Plugin system supports function tools and message handlers
|
||||
- Key plugins: python_interpreter, web_searcher, astrbot, reminder, session_controller
|
||||
### 插件开发
|
||||
- 插件从 `packages/`(内置)和 `data/plugins/`(用户安装)加载
|
||||
- 插件系统支持函数工具和消息处理器
|
||||
- 关键插件:python_interpreter、web_searcher、astrbot、reminder、session_controller
|
||||
|
||||
### Common Issues and Workarounds
|
||||
- **Dashboard download fails**: Known issue with "division by zero" error - application still works
|
||||
- **Import errors in tests**: Ensure `uv run` is used to run tests in proper environment
|
||||
=- **Build timeouts**: Always set appropriate timeouts (10+ minutes for uv sync, 5+ minutes for npm install)
|
||||
### 常见问题和解决方法
|
||||
- **仪表盘下载失败**:已知的"除以零"错误问题 - 应用程序仍可正常工作
|
||||
- **测试中的导入错误**:确保使用 `uv run` 在适当的环境中运行测试
|
||||
- **构建超时**:始终设置适当的超时时间(uv sync 为 10+ 分钟,npm install 为 5+ 分钟)
|
||||
|
||||
## CI/CD Integration
|
||||
- GitHub Actions workflows in `.github/workflows/`
|
||||
- Docker builds supported via `Dockerfile`
|
||||
- Pre-commit hooks enforce ruff formatting and linting
|
||||
## CI/CD 集成
|
||||
- GitHub Actions 工作流在 `.github/workflows/` 中
|
||||
- 通过 `Dockerfile` 支持 Docker 构建
|
||||
- Pre-commit 钩子强制执行 ruff 格式化和检查
|
||||
|
||||
## Docker Support
|
||||
- Primary deployment method: `docker run soulter/astrbot:latest`
|
||||
- Compose file available: `compose.yml`
|
||||
- Exposes ports: 6185 (WebUI), 6195 (WeChat), 6199 (QQ), etc.
|
||||
- Volume mount required: `./data:/AstrBot/data`
|
||||
## Docker 支持
|
||||
- 主要部署方法:`docker run soulter/astrbot:latest`
|
||||
- 可用的 Compose 文件:`compose.yml`
|
||||
- 暴露端口:6185(WebUI)、6195(WeChat)、6199(QQ)等
|
||||
- 需要挂载卷:`./data:/AstrBot/data`
|
||||
|
||||
## Multi-language Support
|
||||
- Documentation in Chinese (README.md), English (README_en.md), Japanese (README_ja.md)
|
||||
- UI supports internationalization
|
||||
- Default language is Chinese
|
||||
## 多语言支持
|
||||
- 文档包括中文(README.md)、英文(README_en.md)、日文(README_ja.md)
|
||||
- UI 支持国际化
|
||||
- 默认语言为中文
|
||||
|
||||
Remember: This is a production chatbot framework with real users. Always test thoroughly and ensure changes don't break existing functionality.
|
||||
请记住:这是一个有真实用户的生产聊天机器人框架。始终进行彻底测试,确保更改不会破坏现有功能。
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -12,21 +12,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
bash \
|
||||
ffmpeg \
|
||||
curl \
|
||||
gnupg \
|
||||
git \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN apt-get update && apt-get install -y curl gnupg \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
RUN apt-get update && apt-get install -y curl gnupg && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN python -m pip install uv \
|
||||
&& echo "3.11" > .python-version
|
||||
RUN python -m pip install uv
|
||||
RUN uv pip install -r requirements.txt --no-cache-dir --system
|
||||
RUN uv pip install socksio uv pilk --no-cache-dir --system
|
||||
|
||||
EXPOSE 6185
|
||||
EXPOSE 6186
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
CMD [ "python", "main.py" ]
|
||||
|
||||
35
Dockerfile_with_node
Normal file
35
Dockerfile_with_node
Normal file
@@ -0,0 +1,35 @@
|
||||
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
114
README.md
@@ -119,73 +119,83 @@ 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>
|
||||
|
||||
## 支持的消息平台
|
||||
## ⚡ 消息平台支持情况
|
||||
|
||||
**官方维护**
|
||||
|
||||
- QQ (官方平台 & OneBot)
|
||||
- Telegram
|
||||
- 企微应用 & 企微智能机器人
|
||||
- 微信客服 & 微信公众号
|
||||
- 飞书
|
||||
- 钉钉
|
||||
- Slack
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- Whatsapp (将支持)
|
||||
- LINE (将支持)
|
||||
| 平台 | 支持性 |
|
||||
| -------- | ------- |
|
||||
| QQ(官方平台) | ✔ |
|
||||
| QQ(OneBot) | ✔ |
|
||||
| Telegram | ✔ |
|
||||
| 企微应用 | ✔ |
|
||||
| 企微智能机器人 | ✔ |
|
||||
| 微信客服 | ✔ |
|
||||
| 微信公众号 | ✔ |
|
||||
| 飞书 | ✔ |
|
||||
| 钉钉 | ✔ |
|
||||
| Slack | ✔ |
|
||||
| Discord | ✔ |
|
||||
| Satori | ✔ |
|
||||
| Misskey | ✔ |
|
||||
| Whatsapp | 将支持 |
|
||||
| LINE | 将支持 |
|
||||
|
||||
**社区维护**
|
||||
|
||||
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
|
||||
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
|
||||
- [Bilibili 私信](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter)
|
||||
- [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11)
|
||||
| 平台 | 支持性 |
|
||||
| -------- | ------- |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | ✔ |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | ✔ |
|
||||
| [Bilibili 私信](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter) | ✔ |
|
||||
| [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11) | ✔ |
|
||||
|
||||
## 支持的模型服务
|
||||
## ⚡ 提供商支持情况
|
||||
|
||||
**大模型服务**
|
||||
|
||||
- OpenAI 及兼容服务
|
||||
- Anthropic
|
||||
- Google Gemini
|
||||
- Moonshot AI
|
||||
- 智谱 AI
|
||||
- DeepSeek
|
||||
- Ollama (本地部署)
|
||||
- LM Studio (本地部署)
|
||||
- [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [小马算力](https://www.tokenpony.cn/3YPyf)
|
||||
- [硅基流动](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
||||
- [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE)
|
||||
- ModelScope
|
||||
- OneAPI
|
||||
|
||||
**LLMOps 平台**
|
||||
|
||||
- Dify
|
||||
- 阿里云百炼应用
|
||||
- Coze
|
||||
| 名称 | 支持性 | 备注 |
|
||||
| -------- | ------- | ------- |
|
||||
| OpenAI | ✔ | 支持任何兼容 OpenAI API 的服务 |
|
||||
| Anthropic | ✔ | |
|
||||
| Google Gemini | ✔ | |
|
||||
| Moonshot AI | ✔ | |
|
||||
| 智谱 AI | ✔ | |
|
||||
| DeepSeek | ✔ | |
|
||||
| Ollama | ✔ | 本地部署 DeepSeek 等开源语言模型 |
|
||||
| LM Studio | ✔ | 本地部署 DeepSeek 等开源语言模型 |
|
||||
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | ✔ | |
|
||||
| [302.AI](https://share.302.ai/rr1M3l) | ✔ | |
|
||||
| [小马算力](https://www.tokenpony.cn/3YPyf) | ✔ | |
|
||||
| 硅基流动 | ✔ | |
|
||||
| PPIO 派欧云 | ✔ | |
|
||||
| ModelScope | ✔ | |
|
||||
| OneAPI | ✔ | |
|
||||
| Dify | ✔ | |
|
||||
| 阿里云百炼应用 | ✔ | |
|
||||
| Coze | ✔ | |
|
||||
|
||||
**语音转文本服务**
|
||||
|
||||
- OpenAI Whisper
|
||||
- SenseVoice
|
||||
| 名称 | 支持性 | 备注 |
|
||||
| -------- | ------- | ------- |
|
||||
| Whisper | ✔ | 支持 API、本地部署 |
|
||||
| SenseVoice | ✔ | 本地部署 |
|
||||
|
||||
**文本转语音服务**
|
||||
|
||||
- OpenAI TTS
|
||||
- Gemini TTS
|
||||
- GPT-Sovits-Inference
|
||||
- GPT-Sovits
|
||||
- FishAudio
|
||||
- Edge TTS
|
||||
- 阿里云百炼 TTS
|
||||
- Azure TTS
|
||||
- Minimax TTS
|
||||
- 火山引擎 TTS
|
||||
| 名称 | 支持性 | 备注 |
|
||||
| -------- | ------- | ------- |
|
||||
| OpenAI TTS | ✔ | |
|
||||
| Gemini TTS | ✔ | |
|
||||
| GSVI | ✔ | GPT-Sovits-Inference |
|
||||
| GPT-SoVITs | ✔ | GPT-Sovits |
|
||||
| FishAudio | ✔ | |
|
||||
| Edge TTS | ✔ | Edge 浏览器的免费 TTS |
|
||||
| 阿里云百炼 TTS | ✔ | |
|
||||
| Azure TTS | ✔ | |
|
||||
| Minimax TTS | ✔ | |
|
||||
| 火山引擎 TTS | ✔ | |
|
||||
|
||||
## ❤️ 贡献
|
||||
|
||||
@@ -219,7 +229,7 @@ pre-commit install
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
> [!TIP]
|
||||
> [!TIP]
|
||||
> 如果本项目对您的生活 / 工作产生了帮助,或者您关注本项目的未来发展,请给项目 Star,这是我们维护这个开源项目的动力 <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
97
astrbot/__main__.py
Normal file
97
astrbot/__main__.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import os
|
||||
import sys
|
||||
|
||||
from astrbot.core import LogBroker, LogManager, db_helper, logger
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.initial_loader import InitialLoader
|
||||
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
||||
from astrbot_api import LOGO, IAstrbotPaths
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
|
||||
def check_env() -> None:
|
||||
if not (sys.version_info.major == 3 and sys.version_info.minor >= 10):
|
||||
logger.error("请使用 Python3.10+ 运行本项目。")
|
||||
exit()
|
||||
|
||||
# os.makedirs("data/config", exist_ok=True)
|
||||
# os.makedirs("data/plugins", exist_ok=True)
|
||||
# os.makedirs("data/temp", exist_ok=True)
|
||||
|
||||
# 针对问题 #181 的临时解决方案
|
||||
mimetypes.add_type("text/javascript", ".js")
|
||||
mimetypes.add_type("text/javascript", ".mjs")
|
||||
mimetypes.add_type("application/json", ".json")
|
||||
|
||||
|
||||
async def check_dashboard_files(webui_dir: str | None = None):
|
||||
"""下载管理面板文件"""
|
||||
# 指定webui目录
|
||||
if webui_dir:
|
||||
if os.path.exists(webui_dir):
|
||||
logger.info(f"使用指定的 WebUI 目录: {webui_dir}")
|
||||
return webui_dir
|
||||
logger.warning(f"指定的 WebUI 目录 {webui_dir} 不存在,将使用默认逻辑。")
|
||||
|
||||
data_dist_path = str(AstrbotPaths.astrbot_root / "dist")
|
||||
if os.path.exists(data_dist_path):
|
||||
v = await get_dashboard_version()
|
||||
if v is not None:
|
||||
# 存在文件
|
||||
if v == f"v{VERSION}":
|
||||
logger.info("WebUI 版本已是最新。")
|
||||
else:
|
||||
logger.warning(
|
||||
f"检测到 WebUI 版本 ({v}) 与当前 AstrBot 版本 (v{VERSION}) 不符。",
|
||||
)
|
||||
return data_dist_path
|
||||
|
||||
logger.info(
|
||||
"开始下载管理面板文件...高峰期(晚上)可能导致较慢的速度。如多次下载失败,请前往 https://github.com/AstrBotDevs/AstrBot/releases/latest 下载 dist.zip,并将其中的 dist 文件夹解压至 data 目录下。",
|
||||
)
|
||||
|
||||
try:
|
||||
await download_dashboard(version=f"v{VERSION}", latest=False)
|
||||
except Exception as e:
|
||||
logger.critical(f"下载管理面板文件失败: {e}。")
|
||||
return None
|
||||
|
||||
logger.info("管理面板下载完成。")
|
||||
return data_dist_path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="AstrBot")
|
||||
parser.add_argument(
|
||||
"--webui-dir",
|
||||
type=str,
|
||||
help="指定 WebUI 静态文件目录路径",
|
||||
default=None,
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
check_env()
|
||||
|
||||
# 启动日志代理
|
||||
log_broker = LogBroker()
|
||||
LogManager.set_queue_handler(logger, log_broker)
|
||||
|
||||
# 检查仪表板文件
|
||||
webui_dir = asyncio.run(check_dashboard_files(args.webui_dir))
|
||||
|
||||
db = db_helper
|
||||
|
||||
# 打印 logo
|
||||
logger.info(LOGO)
|
||||
|
||||
core_lifecycle = InitialLoader(db, log_broker)
|
||||
core_lifecycle.webui_dir = webui_dir
|
||||
asyncio.run(core_lifecycle.start())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.5.23"
|
||||
"""AstrBot CLI入口"""
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
"""AstrBot CLI入口"""
|
||||
|
||||
import sys
|
||||
from importlib.metadata import version
|
||||
|
||||
import click
|
||||
|
||||
from . import __version__
|
||||
from astrbot_api import LOGO
|
||||
|
||||
from .commands import conf, init, plug, run
|
||||
|
||||
logo_tmpl = r"""
|
||||
___ _______.___________..______ .______ ______ .___________.
|
||||
/ \ / | || _ \ | _ \ / __ \ | |
|
||||
/ ^ \ | (----`---| |----`| |_) | | |_) | | | | | `---| |----`
|
||||
/ /_\ \ \ \ | | | / | _ < | | | | | |
|
||||
/ _____ \ .----) | | | | |\ \----.| |_) | | `--' | | |
|
||||
/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__|
|
||||
"""
|
||||
__version__ = version("astrbot")
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(__version__, prog_name="AstrBot")
|
||||
def cli() -> None:
|
||||
"""The AstrBot CLI"""
|
||||
click.echo(logo_tmpl)
|
||||
click.echo(LOGO)
|
||||
click.echo("Welcome to AstrBot CLI!")
|
||||
click.echo(f"AstrBot CLI version: {__version__}")
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
|
||||
from ..utils import (
|
||||
PluginStatus,
|
||||
@@ -47,8 +52,7 @@ def display_plugins(plugins, title=None, color=None):
|
||||
@click.argument("name")
|
||||
def new(name: str):
|
||||
"""创建新插件"""
|
||||
base_path = _get_data_path()
|
||||
plug_path = base_path / "plugins" / name
|
||||
plug_path = AstrbotPaths.getPaths(name).plugins
|
||||
|
||||
if plug_path.exists():
|
||||
raise click.ClickException(f"插件 {name} 已存在")
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
AstrbotPaths = sync_base_container.get(type[IAstrbotPaths])
|
||||
|
||||
def check_astrbot_root(path: str | Path) -> bool:
|
||||
"""检查路径是否为 AstrBot 根目录"""
|
||||
if not isinstance(path, Path):
|
||||
path = Path(path)
|
||||
if not path.exists() or not path.is_dir():
|
||||
return False
|
||||
if not (path / ".astrbot").exists():
|
||||
return False
|
||||
return True
|
||||
warnings.warn(
|
||||
"请使用 AstrbotPaths 类代替本模块中的函数",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return AstrbotPaths.is_root(Path(path))
|
||||
|
||||
|
||||
def get_astrbot_root() -> Path:
|
||||
"""获取Astrbot根目录路径"""
|
||||
return Path.cwd()
|
||||
warnings.warn(
|
||||
"请使用 AstrbotPaths 类代替本模块中的函数",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return AstrbotPaths.astrbot_root
|
||||
|
||||
|
||||
async def check_dashboard(astrbot_root: Path) -> None:
|
||||
|
||||
@@ -9,10 +9,6 @@ from astrbot.core.utils.shared_preferences import SharedPreferences
|
||||
from astrbot.core.utils.t2i.renderer import HtmlRenderer
|
||||
|
||||
from .log import LogBroker, LogManager # noqa
|
||||
from .utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
# 初始化数据存储文件夹
|
||||
os.makedirs(get_astrbot_data_path(), exist_ok=True)
|
||||
|
||||
DEMO_MODE = os.getenv("DEMO_MODE", False)
|
||||
|
||||
|
||||
@@ -55,6 +55,14 @@ class FunctionTool(ToolSchema, Generic[TContext]):
|
||||
def __repr__(self):
|
||||
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(
|
||||
self, context: ContextWrapper[TContext], **kwargs
|
||||
) -> str | mcp.types.CallToolResult:
|
||||
|
||||
@@ -3,11 +3,15 @@ import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
from .default import DEFAULT_CONFIG, DEFAULT_VALUE_MAP
|
||||
|
||||
ASTRBOT_CONFIG_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
|
||||
ASTRBOT_CONFIG_PATH = str(AstrbotPaths.astrbot_root / "cmd_config.json")
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
"""如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。"""
|
||||
|
||||
import os
|
||||
from importlib.metadata import version
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
|
||||
VERSION = "4.5.5"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
|
||||
# 警告,请使用version函数获取版本,此变量兼容保留
|
||||
VERSION = version("astrbot")
|
||||
|
||||
DB_PATH = str(AstrbotPaths.astrbot_root / "data_v4.db")
|
||||
|
||||
# 默认配置
|
||||
DEFAULT_CONFIG = {
|
||||
|
||||
@@ -21,20 +21,20 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from enum import Enum
|
||||
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from astrbot.core import astrbot_config, file_token_service, logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.io import download_file, download_image_by_url, file_to_base64
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
class ComponentType(str, Enum):
|
||||
# Basic Segment Types
|
||||
Plain = "Plain" # plain text message
|
||||
@@ -153,8 +153,7 @@ class Record(BaseMessageComponent):
|
||||
if self.file.startswith("base64://"):
|
||||
bs64_data = self.file.removeprefix("base64://")
|
||||
image_bytes = base64.b64decode(bs64_data)
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.jpg")
|
||||
file_path = str(AstrbotPaths.astrbot_root / "temp" / f"{uuid.uuid4()}.jpg")
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
return os.path.abspath(file_path)
|
||||
@@ -242,8 +241,9 @@ class Video(BaseMessageComponent):
|
||||
if url and url.startswith("file:///"):
|
||||
return url[8:]
|
||||
if url and url.startswith("http"):
|
||||
download_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
video_file_path = os.path.join(download_dir, f"{uuid.uuid4().hex}")
|
||||
video_file_path = str(
|
||||
AstrbotPaths.astrbot_root / "temp" / f"{uuid.uuid4().hex}"
|
||||
)
|
||||
await download_file(url, video_file_path)
|
||||
if os.path.exists(video_file_path):
|
||||
return os.path.abspath(video_file_path)
|
||||
@@ -442,8 +442,9 @@ class Image(BaseMessageComponent):
|
||||
if url.startswith("base64://"):
|
||||
bs64_data = url.removeprefix("base64://")
|
||||
image_bytes = base64.b64decode(bs64_data)
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
image_file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.jpg")
|
||||
image_file_path = str(
|
||||
AstrbotPaths.astrbot_root / "temp" / f"{uuid.uuid4()}.jpg"
|
||||
)
|
||||
with open(image_file_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
return os.path.abspath(image_file_path)
|
||||
@@ -527,7 +528,7 @@ class Reply(BaseMessageComponent):
|
||||
|
||||
|
||||
class Poke(BaseMessageComponent):
|
||||
type: str = ComponentType.Poke
|
||||
type = ComponentType.Poke
|
||||
id: int | None = 0
|
||||
qq: int | None = 0
|
||||
|
||||
@@ -654,33 +655,19 @@ class File(BaseMessageComponent):
|
||||
|
||||
@property
|
||||
def file(self) -> str:
|
||||
"""获取文件路径,如果文件不存在但有URL,则同步下载文件
|
||||
"""获取本地文件路径(仅返回已存在的文件)
|
||||
|
||||
⚠️ 警告:此属性不会自动下载文件!
|
||||
- 如果文件已存在,返回绝对路径
|
||||
- 如果只有 URL 没有本地文件,返回空字符串
|
||||
- 需要下载文件请使用 `await get_file()` 方法
|
||||
|
||||
Returns:
|
||||
str: 文件路径
|
||||
str: 文件的绝对路径,如果文件不存在则返回空字符串
|
||||
|
||||
"""
|
||||
if self.file_ and os.path.exists(self.file_):
|
||||
return os.path.abspath(self.file_)
|
||||
|
||||
if self.url:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
logger.warning(
|
||||
"不可以在异步上下文中同步等待下载! "
|
||||
"这个警告通常发生于某些逻辑试图通过 <File>.file 获取文件消息段的文件内容。"
|
||||
"请使用 await get_file() 代替直接获取 <File>.file 字段",
|
||||
)
|
||||
return ""
|
||||
# 等待下载完成
|
||||
loop.run_until_complete(self._download_file())
|
||||
|
||||
if self.file_ and os.path.exists(self.file_):
|
||||
return os.path.abspath(self.file_)
|
||||
except Exception as e:
|
||||
logger.error(f"文件下载失败: {e}")
|
||||
|
||||
return ""
|
||||
|
||||
@file.setter
|
||||
@@ -714,15 +701,18 @@ class File(BaseMessageComponent):
|
||||
|
||||
if self.url:
|
||||
await self._download_file()
|
||||
return os.path.abspath(self.file_)
|
||||
if self.file_:
|
||||
return os.path.abspath(self.file_)
|
||||
|
||||
return ""
|
||||
|
||||
async def _download_file(self):
|
||||
"""下载文件"""
|
||||
download_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
if not self.url:
|
||||
raise ValueError("No URL provided for download")
|
||||
download_dir = str(AstrbotPaths.astrbot_root / "temp")
|
||||
os.makedirs(download_dir, exist_ok=True)
|
||||
file_path = os.path.join(download_dir, f"{uuid.uuid4().hex}")
|
||||
file_path = str(AstrbotPaths.astrbot_root / "temp" / f"{uuid.uuid4().hex}")
|
||||
await download_file(self.url, file_path)
|
||||
self.file_ = os.path.abspath(file_path)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import math
|
||||
import random
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot.core import logger
|
||||
@@ -153,7 +152,7 @@ class RespondStage(Stage):
|
||||
async def process(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
) -> None | AsyncGenerator[None, None]:
|
||||
) -> None:
|
||||
result = event.get_result()
|
||||
if result is None:
|
||||
return
|
||||
|
||||
@@ -107,7 +107,7 @@ class AiocqhttpAdapter(Platform):
|
||||
)
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
async def convert_message(self, event: Event) -> AstrBotMessage | None:
|
||||
async def convert_message(self, event: Event) -> AstrBotMessage:
|
||||
logger.debug(f"[aiocqhttp] RawMessage {event}")
|
||||
|
||||
if event["post_type"] == "message":
|
||||
@@ -222,7 +222,7 @@ class AiocqhttpAdapter(Platform):
|
||||
err = f"aiocqhttp: 无法识别的消息类型: {event.message!s},此条消息将被忽略。如果您在使用 go-cqhttp,请将其配置文件中的 message.post-format 更改为 array。"
|
||||
logger.critical(err)
|
||||
try:
|
||||
await self.bot.send(event, err)
|
||||
self.bot.send(event, err)
|
||||
except BaseException as e:
|
||||
logger.error(f"回复消息失败: {e}")
|
||||
return None
|
||||
|
||||
@@ -90,7 +90,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
)
|
||||
message_obj.self_id = self.client_self_id
|
||||
message_obj.session_id = session.session_id
|
||||
message_obj.message = message_chain.chain
|
||||
message_obj.message = message_chain
|
||||
|
||||
# 创建临时事件对象来发送消息
|
||||
temp_event = DiscordPlatformEvent(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import sys
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
@@ -184,7 +183,7 @@ class DiscordPlatformEvent(AstrMessageEvent):
|
||||
BytesIO(img_bytes),
|
||||
filename=filename or "image.png",
|
||||
)
|
||||
except (ValueError, TypeError, binascii.Error):
|
||||
except (ValueError, TypeError, base64.binascii.Error):
|
||||
logger.debug(
|
||||
f"[Discord] 裸 Base64 解码失败,作为本地路径处理: {file_content}",
|
||||
)
|
||||
|
||||
@@ -82,7 +82,7 @@ class SlackAdapter(Platform):
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
):
|
||||
blocks, text = await SlackMessageEvent._parse_slack_blocks(
|
||||
blocks, text = SlackMessageEvent._parse_slack_blocks(
|
||||
message_chain=message_chain,
|
||||
web_client=self.web_client,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,8 @@ import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.message.components import Image, Plain, Record
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
@@ -16,12 +18,13 @@ from astrbot.core.platform import (
|
||||
PlatformMetadata,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
from ...register import register_platform_adapter
|
||||
from .webchat_event import WebChatMessageEvent
|
||||
from .webchat_queue_mgr import WebChatQueueMgr, webchat_queue_mgr
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
|
||||
class QueueListener:
|
||||
def __init__(self, webchat_queue_mgr: WebChatQueueMgr, callback: Callable) -> None:
|
||||
@@ -79,7 +82,7 @@ class WebChatAdapter(Platform):
|
||||
self.config = platform_config
|
||||
self.settings = platform_settings
|
||||
self.unique_session = platform_settings["unique_session"]
|
||||
self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
||||
self.imgs_dir = str(AstrbotPaths.astrbot_root / "webchat" / "imgs")
|
||||
os.makedirs(self.imgs_dir, exist_ok=True)
|
||||
|
||||
self.metadata = PlatformMetadata(
|
||||
|
||||
@@ -8,10 +8,14 @@ import traceback
|
||||
import aiohttp
|
||||
import anyio
|
||||
import websockets
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.message_components import At, Image, Plain, Record
|
||||
from astrbot.api.platform import Platform, PlatformMetadata
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.platform.astrbot_message import (
|
||||
@@ -19,7 +23,6 @@ from astrbot.core.platform.astrbot_message import (
|
||||
MessageMember,
|
||||
MessageType,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
from ...register import register_platform_adapter
|
||||
from .wechatpadpro_message_event import WeChatPadProMessageEvent
|
||||
@@ -68,9 +71,8 @@ class WeChatPadProAdapter(Platform):
|
||||
self.base_url = f"http://{self.host}:{self.port}"
|
||||
self.auth_key = None # 用于保存生成的授权码
|
||||
self.wxid = None # 用于保存登录成功后的 wxid
|
||||
self.credentials_file = os.path.join(
|
||||
get_astrbot_data_path(),
|
||||
"wechatpadpro_credentials.json",
|
||||
self.credentials_file = str(
|
||||
AstrbotPaths.astrbot_root / "wechatpadpro_credentials.json"
|
||||
) # 持久化文件路径
|
||||
self.ws_handle_task = None
|
||||
|
||||
@@ -155,8 +157,8 @@ class WeChatPadProAdapter(Platform):
|
||||
}
|
||||
try:
|
||||
# 确保数据目录存在
|
||||
data_dir = os.path.dirname(self.credentials_file)
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
config_dir = AstrbotPaths.astrbot_root / "config"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.credentials_file, "w") as f:
|
||||
json.dump(credentials, f)
|
||||
except Exception as e:
|
||||
@@ -787,10 +789,10 @@ class WeChatPadProAdapter(Platform):
|
||||
voice_bs64_data = voice_resp.get("Data", {}).get("Base64", None)
|
||||
if voice_bs64_data:
|
||||
voice_bs64_data = base64.b64decode(voice_bs64_data)
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
file_path = os.path.join(
|
||||
temp_dir,
|
||||
f"wechatpadpro_voice_{abm.message_id}.silk",
|
||||
file_path = str(
|
||||
AstrbotPaths.astrbot_root
|
||||
/ "temp"
|
||||
/ f"wechatpadpro_voice_{abm.message_id}.silk"
|
||||
)
|
||||
|
||||
async with await anyio.open_file(file_path, "wb") as f:
|
||||
|
||||
@@ -10,7 +10,7 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import random
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
@@ -139,12 +139,6 @@ class PKCS7Encoder:
|
||||
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):
|
||||
# self.key = base64.b64decode(key+"=")
|
||||
self.key = key
|
||||
@@ -213,9 +207,7 @@ class Prpcrypt:
|
||||
"""随机生成16位字符串
|
||||
@return: 16位字符串
|
||||
"""
|
||||
return str(
|
||||
secrets.randbelow(self.RANDOM_RANGE) + self.MIN_RANDOM_VALUE
|
||||
).encode()
|
||||
return str(random.randint(1000000000000000, 9999999999999999)).encode()
|
||||
|
||||
|
||||
class WXBizJsonMsgCrypt:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
import random
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
@@ -53,7 +53,7 @@ def generate_random_string(length: int = 10) -> str:
|
||||
|
||||
"""
|
||||
letters = string.ascii_letters + string.digits
|
||||
return "".join(secrets.choice(letters) for _ in range(length))
|
||||
return "".join(random.choice(letters) for _ in range(length))
|
||||
|
||||
|
||||
def calculate_image_md5(image_data: bytes) -> str:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -54,9 +54,7 @@ class OTTSProvider:
|
||||
async def _generate_signature(self) -> str:
|
||||
await self._sync_time()
|
||||
timestamp = int(time.time()) + self.time_offset
|
||||
nonce = "".join(
|
||||
secrets.choice("abcdefghijklmnopqrstuvwxyz0123456789") for _ in range(10)
|
||||
)
|
||||
nonce = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=10))
|
||||
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()}"
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ except (
|
||||
): # pragma: no cover - older dashscope versions without Qwen TTS support
|
||||
MultiModalConversation = None
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
|
||||
from ..entities import ProviderType
|
||||
from ..provider import TTSProvider
|
||||
@@ -45,7 +49,7 @@ class ProviderDashscopeTTSAPI(TTSProvider):
|
||||
if not model:
|
||||
raise RuntimeError("Dashscope TTS model is not configured.")
|
||||
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = str(AstrbotPaths.astrbot_root / "temp")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
if self._is_qwen_tts_model(model):
|
||||
|
||||
@@ -4,9 +4,12 @@ import subprocess
|
||||
import uuid
|
||||
|
||||
import edge_tts
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
from ..entities import ProviderType
|
||||
from ..provider import TTSProvider
|
||||
@@ -46,8 +49,10 @@ class ProviderEdgeTTS(TTSProvider):
|
||||
self.set_model("edge_tts")
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
mp3_path = os.path.join(temp_dir, f"edge_tts_temp_{uuid.uuid4()}.mp3")
|
||||
temp_dir = str(AstrbotPaths.astrbot_root / "temp")
|
||||
mp3_path = str(
|
||||
AstrbotPaths.astrbot_root / "temp" / f"edge_tts_temp_{uuid.uuid4()}.mp3"
|
||||
)
|
||||
wav_path = os.path.join(temp_dir, f"edge_tts_{uuid.uuid4()}.wav")
|
||||
|
||||
# 构建 Edge TTS 参数
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
import uuid
|
||||
|
||||
import aiohttp
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
|
||||
from ..entities import ProviderType
|
||||
from ..provider import TTSProvider
|
||||
@@ -92,9 +95,10 @@ class ProviderVolcengineTTS(TTSProvider):
|
||||
if "data" in resp_data:
|
||||
audio_data = base64.b64decode(resp_data["data"])
|
||||
|
||||
os.makedirs("data/temp", exist_ok=True)
|
||||
temp_dir = AstrbotPaths.astrbot_root / "temp"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_path = f"data/temp/volcengine_tts_{uuid.uuid4()}.mp3"
|
||||
file_path = str(temp_dir / f"volcengine_tts_{uuid.uuid4()}.mp3")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
from openai import NOT_GIVEN, AsyncOpenAI
|
||||
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
|
||||
|
||||
@@ -53,8 +56,7 @@ class ProviderOpenAIWhisperAPI(STTProvider):
|
||||
is_tencent = True
|
||||
|
||||
name = str(uuid.uuid4())
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
path = os.path.join(temp_dir, name)
|
||||
path = str(AstrbotPaths.astrbot_root / "temp" / name)
|
||||
await download_file(audio_url, path)
|
||||
audio_url = path
|
||||
|
||||
@@ -65,8 +67,9 @@ class ProviderOpenAIWhisperAPI(STTProvider):
|
||||
is_silk = await self._is_silk_file(audio_url)
|
||||
if is_silk:
|
||||
logger.info("Converting silk file to wav ...")
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav")
|
||||
output_path = str(
|
||||
AstrbotPaths.astrbot_root / "temp" / f"{uuid.uuid4()}.wav"
|
||||
)
|
||||
await tencent_silk_to_wav(audio_url, output_path)
|
||||
audio_url = output_path
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
|
||||
|
||||
def load_config(namespace: str) -> dict | bool:
|
||||
@@ -11,7 +15,7 @@ def load_config(namespace: str) -> dict | bool:
|
||||
namespace: str, 配置的唯一识别符,也就是配置文件的名字。
|
||||
返回值: 当配置文件存在时,返回 namespace 对应配置文件的内容dict,否则返回 False。
|
||||
"""
|
||||
path = os.path.join(get_astrbot_data_path(), "config", f"{namespace}.json")
|
||||
path = str(AstrbotPaths.astrbot_root / "config" / f"{namespace}.json")
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
with open(path, encoding="utf-8-sig") as f:
|
||||
@@ -41,8 +45,7 @@ def put_config(namespace: str, name: str, key: str, value, description: str):
|
||||
if not isinstance(value, (str, int, float, bool, list)):
|
||||
raise ValueError("value 只支持 str, int, float, bool, list 类型。")
|
||||
|
||||
config_dir = os.path.join(get_astrbot_data_path(), "config")
|
||||
path = os.path.join(config_dir, f"{namespace}.json")
|
||||
path = str(AstrbotPaths.astrbot_root / "config" / f"{namespace}.json")
|
||||
|
||||
if not os.path.exists(path):
|
||||
with open(path, "w", encoding="utf-8-sig") as f:
|
||||
@@ -70,7 +73,7 @@ def update_config(namespace: str, key: str, value):
|
||||
key: str, 配置项的键。
|
||||
value: str, int, float, bool, list, 配置项的值。
|
||||
"""
|
||||
path = os.path.join(get_astrbot_data_path(), "config", f"{namespace}.json")
|
||||
path = str(AstrbotPaths.astrbot_root / "config" / f"{namespace}.json")
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"配置文件 {namespace}.json 不存在。")
|
||||
with open(path, encoding="utf-8-sig") as f:
|
||||
|
||||
@@ -680,18 +680,11 @@ class PluginManager:
|
||||
|
||||
return plugin_info
|
||||
|
||||
async def uninstall_plugin(
|
||||
self,
|
||||
plugin_name: str,
|
||||
delete_config: bool = False,
|
||||
delete_data: bool = False,
|
||||
):
|
||||
async def uninstall_plugin(self, plugin_name: str):
|
||||
"""卸载指定的插件。
|
||||
|
||||
Args:
|
||||
plugin_name (str): 要卸载的插件名称
|
||||
delete_config (bool): 是否删除插件配置文件,默认为 False
|
||||
delete_data (bool): 是否删除插件数据,默认为 False
|
||||
|
||||
Raises:
|
||||
Exception: 当插件不存在、是保留插件时,或删除插件文件夹失败时抛出异常
|
||||
@@ -721,7 +714,6 @@ class PluginManager:
|
||||
|
||||
await self._unbind_plugin(plugin_name, plugin.module_path)
|
||||
|
||||
# 删除插件文件夹
|
||||
try:
|
||||
remove_dir(os.path.join(ppath, root_dir_name))
|
||||
except Exception as e:
|
||||
@@ -729,51 +721,6 @@ class PluginManager:
|
||||
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):
|
||||
"""解绑并移除一个插件。
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import psutil
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
|
||||
from .zip_updator import ReleaseInfo, RepoZipUpdator
|
||||
@@ -20,7 +20,7 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
|
||||
def __init__(self, repo_mirror: str = "") -> None:
|
||||
super().__init__(repo_mirror)
|
||||
self.MAIN_PATH = get_astrbot_path()
|
||||
self.astrbot_root = get_astrbot_data_path()
|
||||
self.ASTRBOT_RELEASE_API = "https://api.soulter.top/releases"
|
||||
|
||||
def terminate_child_processes(self):
|
||||
@@ -117,7 +117,7 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
try:
|
||||
await download_file(file_url, "temp.zip")
|
||||
logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...")
|
||||
self.unzip_file("temp.zip", self.MAIN_PATH)
|
||||
self.unzip_file("temp.zip", self.astrbot_root)
|
||||
except BaseException as e:
|
||||
raise e
|
||||
|
||||
|
||||
@@ -1,39 +1,70 @@
|
||||
"""Astrbot统一路径获取
|
||||
|
||||
项目路径:固定为源码所在路径
|
||||
根目录路径:默认为当前工作目录,可通过环境变量 ASTRBOT_ROOT 指定
|
||||
数据目录路径:固定为根目录下的 data 目录
|
||||
配置文件路径:固定为数据目录下的 config 目录
|
||||
插件目录路径:固定为数据目录下的 plugins 目录
|
||||
|
||||
"""
|
||||
|
||||
import os
|
||||
import warnings
|
||||
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
AstrbotPaths = sync_base_container.get(type[IAstrbotPaths])
|
||||
|
||||
def get_astrbot_path() -> str:
|
||||
"""获取Astrbot项目路径"""
|
||||
"""获取Astrbot项目路径 -- 请勿使用本函数!!! -- 仅供兼容旧代码使用"""
|
||||
warnings.warn(
|
||||
"get_astrbot_path is deprecated. Use AstrbotPaths class instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return os.path.realpath(
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../../"),
|
||||
)
|
||||
|
||||
|
||||
def get_astrbot_root() -> str:
|
||||
"""获取Astrbot根目录路径"""
|
||||
if path := os.environ.get("ASTRBOT_ROOT"):
|
||||
return os.path.realpath(path)
|
||||
return os.path.realpath(os.getcwd())
|
||||
"""获取Astrbot根目录路径 --> get_astrbot_data_path"""
|
||||
warnings.warn(
|
||||
"不要再使用本函数!等效于: AstrbotPaths.astrbot_root",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return str(AstrbotPaths.astrbot_root)
|
||||
|
||||
|
||||
def get_astrbot_data_path() -> str:
|
||||
"""获取Astrbot数据目录路径"""
|
||||
return os.path.realpath(os.path.join(get_astrbot_root(), "data"))
|
||||
"""获取Astrbot数据目录路径
|
||||
特别注意!
|
||||
这里的data目录指的就是.astrbot根目录!
|
||||
两者是等价的!
|
||||
不要和AstrbotPaths.data混淆!
|
||||
"""
|
||||
warnings.warn(
|
||||
"等效于: AstrbotPaths.astrbot_root",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return str(AstrbotPaths.astrbot_root)
|
||||
|
||||
|
||||
def get_astrbot_config_path() -> str:
|
||||
"""获取Astrbot配置文件路径"""
|
||||
return os.path.realpath(os.path.join(get_astrbot_data_path(), "config"))
|
||||
warnings.warn(
|
||||
"get_astrbot_config_path is deprecated. Use AstrbotPaths class instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return str(AstrbotPaths.astrbot_root / "config")
|
||||
|
||||
|
||||
def get_astrbot_plugin_path() -> str:
|
||||
"""获取Astrbot插件目录路径"""
|
||||
return os.path.realpath(os.path.join(get_astrbot_data_path(), "plugins"))
|
||||
warnings.warn(
|
||||
"get_astrbot_plugin_path is deprecated. Use AstrbotPaths class instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return str(AstrbotPaths.astrbot_root / "plugins")
|
||||
|
||||
@@ -12,12 +12,16 @@ from pathlib import Path
|
||||
import aiohttp
|
||||
import certifi
|
||||
import psutil
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
from PIL import Image
|
||||
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
from .astrbot_path import get_astrbot_data_path
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
|
||||
def on_error(func, path, exc_info):
|
||||
"""A callback of the rmtree function."""
|
||||
@@ -50,11 +54,11 @@ def port_checker(port: int, host: str = "localhost"):
|
||||
|
||||
|
||||
def save_temp_img(img: Image.Image | str) -> str:
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = str(AstrbotPaths.astrbot_root / "temp")
|
||||
# 获得文件创建时间,清除超过 12 小时的
|
||||
try:
|
||||
for f in os.listdir(temp_dir):
|
||||
path = os.path.join(temp_dir, f)
|
||||
path = str(AstrbotPaths.astrbot_root / "temp" / f)
|
||||
if os.path.isfile(path):
|
||||
ctime = os.path.getctime(path)
|
||||
if time.time() - ctime > 3600 * 12:
|
||||
@@ -64,7 +68,7 @@ def save_temp_img(img: Image.Image | str) -> str:
|
||||
|
||||
# 获得时间戳
|
||||
timestamp = f"{int(time.time())}_{uuid.uuid4().hex[:8]}"
|
||||
p = os.path.join(temp_dir, f"{timestamp}.jpg")
|
||||
p = str(AstrbotPaths.astrbot_root / "temp" / f"{timestamp}.jpg")
|
||||
|
||||
if isinstance(img, Image.Image):
|
||||
img.save(p)
|
||||
@@ -105,31 +109,16 @@ async def download_image_by_url(
|
||||
f.write(await resp.read())
|
||||
return path
|
||||
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
|
||||
# 关闭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验证
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
ssl_context.set_ciphers("DEFAULT")
|
||||
async with aiohttp.ClientSession() as session:
|
||||
if post:
|
||||
async with session.post(url, json=post_data, ssl=ssl_context) as resp:
|
||||
if not path:
|
||||
return save_temp_img(await resp.read())
|
||||
with open(path, "wb") as f:
|
||||
f.write(await resp.read())
|
||||
return path
|
||||
async with session.get(url, ssl=ssl_context) as resp:
|
||||
return save_temp_img(await resp.read())
|
||||
else:
|
||||
async with session.get(url, ssl=ssl_context) as resp:
|
||||
if not path:
|
||||
return save_temp_img(await resp.read())
|
||||
with open(path, "wb") as f:
|
||||
f.write(await resp.read())
|
||||
return path
|
||||
return save_temp_img(await resp.read())
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
@@ -172,19 +161,9 @@ async def download_file(url: str, path: str, show_progress: bool = False):
|
||||
end="",
|
||||
)
|
||||
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
|
||||
# 关闭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验证
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
ssl_context.set_ciphers("DEFAULT")
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, ssl=ssl_context, timeout=120) as resp:
|
||||
total_size = int(resp.headers.get("content-length", 0))
|
||||
@@ -230,9 +209,9 @@ def get_local_ip_addresses():
|
||||
|
||||
|
||||
async def get_dashboard_version():
|
||||
dist_dir = os.path.join(get_astrbot_data_path(), "dist")
|
||||
dist_dir = str(AstrbotPaths.astrbot_root / "dist")
|
||||
if os.path.exists(dist_dir):
|
||||
version_file = os.path.join(dist_dir, "assets", "version")
|
||||
version_file = str(AstrbotPaths.astrbot_root / "dist" / "assets" / "version")
|
||||
if os.path.exists(version_file):
|
||||
with open(version_file, encoding="utf-8") as f:
|
||||
v = f.read().strip()
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from importlib import resources
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
|
||||
class TemplateManager:
|
||||
@@ -15,14 +16,10 @@ class TemplateManager:
|
||||
CORE_TEMPLATES = ["base.html", "astrbot_powershell.html"]
|
||||
|
||||
def __init__(self):
|
||||
self.builtin_template_dir = os.path.join(
|
||||
get_astrbot_path(),
|
||||
"astrbot",
|
||||
"core",
|
||||
"utils",
|
||||
"t2i",
|
||||
"template",
|
||||
self.builtin_template_dir = str(
|
||||
resources.files("astrbot.core.utils.t2i.template")
|
||||
)
|
||||
|
||||
self.user_template_dir = os.path.join(get_astrbot_data_path(), "t2i_templates")
|
||||
|
||||
os.makedirs(self.user_template_dir, exist_ok=True)
|
||||
|
||||
@@ -4,15 +4,18 @@ import os
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
from quart import Response as QuartResponse
|
||||
from quart import g, make_response, request
|
||||
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.platform.astr_message_event import MessageSession
|
||||
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
@@ -47,7 +50,7 @@ class ChatRoute(Route):
|
||||
}
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.register_routes()
|
||||
self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
||||
self.imgs_dir = str(AstrbotPaths.astrbot_root / "webchat" / "imgs")
|
||||
os.makedirs(self.imgs_dir, exist_ok=True)
|
||||
|
||||
self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import importlib.resources
|
||||
import inspect
|
||||
import os
|
||||
import traceback
|
||||
@@ -21,7 +22,6 @@ from astrbot.core.provider.entities import ProviderType
|
||||
from astrbot.core.provider.provider import RerankProvider
|
||||
from astrbot.core.provider.register import provider_registry
|
||||
from astrbot.core.star.star import star_registry
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_path
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
@@ -461,11 +461,12 @@ class ConfigRoute(Route):
|
||||
logger.debug(
|
||||
f"Sending health check audio to provider: {status_info['name']}",
|
||||
)
|
||||
sample_audio_path = os.path.join(
|
||||
get_astrbot_path(),
|
||||
"samples",
|
||||
"stt_health_check.wav",
|
||||
sample_audio_path = str(
|
||||
importlib.resources.files("astrbot")
|
||||
/ "samples"
|
||||
/ "stt_health_check.wav"
|
||||
)
|
||||
|
||||
if not os.path.exists(sample_audio_path):
|
||||
status_info["status"] = "unavailable"
|
||||
status_info["error"] = (
|
||||
|
||||
@@ -395,15 +395,9 @@ class PluginRoute(Route):
|
||||
|
||||
post_data = await request.json
|
||||
plugin_name = post_data["name"]
|
||||
delete_config = post_data.get("delete_config", False)
|
||||
delete_data = post_data.get("delete_data", False)
|
||||
try:
|
||||
logger.info(f"正在卸载插件 {plugin_name}")
|
||||
await self.plugin_manager.uninstall_plugin(
|
||||
plugin_name,
|
||||
delete_config=delete_config,
|
||||
delete_data=delete_data,
|
||||
)
|
||||
await self.plugin_manager.uninstall_plugin(plugin_name)
|
||||
logger.info(f"卸载插件 {plugin_name} 成功")
|
||||
return Response().ok(None, "卸载成功").__dict__
|
||||
except Exception as e:
|
||||
|
||||
@@ -296,15 +296,7 @@ class ToolsRoute(Route):
|
||||
"""获取所有注册的工具列表"""
|
||||
try:
|
||||
tools = self.tool_mgr.func_list
|
||||
tools_dict = [
|
||||
{
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters,
|
||||
"active": tool.active,
|
||||
}
|
||||
for tool in tools
|
||||
]
|
||||
tools_dict = [tool.__dict__() for tool in tools]
|
||||
return Response().ok(data=tools_dict).__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
@@ -5,6 +5,7 @@ import socket
|
||||
|
||||
import jwt
|
||||
import psutil
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
from quart import Quart, g, jsonify, request
|
||||
from quart.logging import default_handler
|
||||
|
||||
@@ -12,8 +13,8 @@ from astrbot.core import logger
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.io import get_local_ip_addresses
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
from .routes import *
|
||||
from .routes.route import Response, RouteContext
|
||||
@@ -22,7 +23,7 @@ from .routes.t2i import T2iRoute
|
||||
|
||||
APP: Quart = None
|
||||
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
class AstrBotDashboard:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -38,9 +39,7 @@ class AstrBotDashboard:
|
||||
if webui_dir and os.path.exists(webui_dir):
|
||||
self.data_path = os.path.abspath(webui_dir)
|
||||
else:
|
||||
self.data_path = os.path.abspath(
|
||||
os.path.join(get_astrbot_data_path(), "dist"),
|
||||
)
|
||||
self.data_path = os.path.abspath(str(AstrbotPaths.astrbot_root / "dist"))
|
||||
|
||||
self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
|
||||
APP = self.app # noqa
|
||||
|
||||
0
astrbot_api/README.md
Normal file
0
astrbot_api/README.md
Normal file
25
astrbot_api/pyproject.toml
Normal file
25
astrbot_api/pyproject.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "astrbot-api"
|
||||
dynamic = ["version"]
|
||||
description = "Astrbot Python API"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
{ name = "AstrBotDevs", email = "community@astrbot.app" }
|
||||
]
|
||||
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"anyio>=4.11.0",
|
||||
"mcp>=1.12.4",
|
||||
"pydantic>=2.10.6",
|
||||
"pyyaml>=6.0.3",
|
||||
"types-pyyaml>=6.0.12.20250915",
|
||||
"yarl>=1.22.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling", "uv-dynamic-versioning"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "uv-dynamic-versioning"
|
||||
7
astrbot_api/src/astrbot_api/__init__.py
Normal file
7
astrbot_api/src/astrbot_api/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .abc import IAstrbotPaths
|
||||
from .const import LOGO
|
||||
|
||||
__all__ = [
|
||||
"IAstrbotPaths",
|
||||
"LOGO",
|
||||
]
|
||||
379
astrbot_api/src/astrbot_api/abc.py
Normal file
379
astrbot_api/src/astrbot_api/abc.py
Normal file
@@ -0,0 +1,379 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncGenerator, Callable, Generator
|
||||
from contextlib import (
|
||||
AbstractAsyncContextManager,
|
||||
AbstractContextManager,
|
||||
asynccontextmanager,
|
||||
contextmanager,
|
||||
)
|
||||
from importlib.abc import Traversable
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
import anyio
|
||||
import tomli
|
||||
import yaml
|
||||
from yarl import URL
|
||||
|
||||
from .models import AstrbotPluginMetadata
|
||||
|
||||
# region 核心运行时协议
|
||||
|
||||
|
||||
class IAstrbotContainerMgr(ABC):
|
||||
"""AstrBot 容器管理器的抽象基类."""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class IAstrbotPaths(ABC):
|
||||
"""路径管理的抽象基类."""
|
||||
astrbot_root: ClassVar[Path]
|
||||
"""Astrbot 根目录路径."""
|
||||
|
||||
@abstractmethod
|
||||
def __init__(self, name: str) -> None:
|
||||
"""初始化路径管理器."""
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def getPaths(cls, name: str) -> IAstrbotPaths:
|
||||
"""返回Paths实例,用于访问模块的各类目录."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def root(self) -> Path:
|
||||
"""获取根目录."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def home(self) -> Path:
|
||||
"""获取模块/插件主目录."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def config(self) -> Path:
|
||||
"""获取模块配置目录."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def data(self) -> Path:
|
||||
"""获取模块数据目录."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def log(self) -> Path:
|
||||
"""获取模块日志目录."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def temp(self) -> Path:
|
||||
"""获取模块临时目录."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def plugins(self) -> Path:
|
||||
"""获取插件目录."""
|
||||
|
||||
@abstractmethod
|
||||
def reload(self) -> None:
|
||||
"""重新加载环境变量."""
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def is_root(cls, path: Path) -> bool:
|
||||
"""判断路径是否为根目录."""
|
||||
|
||||
@abstractmethod
|
||||
def chdir(self, cwd: str = "home") -> AbstractContextManager[Path]:
|
||||
"""临时切换到指定目录, 子进程将继承此 CWD。"""
|
||||
|
||||
@abstractmethod
|
||||
async def achdir(self, cwd: str = "home") -> AbstractAsyncContextManager[Path]:
|
||||
"""异步临时切换到指定目录, 子进程将继承此 CWD。"""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class AstrbotPluginBaseSession(ABC):
|
||||
"""插件会话的基类."""
|
||||
|
||||
url: URL
|
||||
"""插件的astrbot专有协议URL地址.
|
||||
|
||||
协议: astrbot://{stdio/web/legacy}/plugin_id
|
||||
"""
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def connect(self) -> AstrbotPluginBaseSession:
|
||||
"""连接到插件."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def disconnect(self) -> None:
|
||||
"""断开与插件的连接."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def async_connect(self) -> AstrbotPluginBaseSession:
|
||||
"""异步连接到插件."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def async_disconnect(self) -> None:
|
||||
"""异步断开与插件的连接."""
|
||||
...
|
||||
|
||||
@contextmanager
|
||||
def session_scope(self) -> Generator[AstrbotPluginBaseSession]:
|
||||
"""插件会话的上下文管理器."""
|
||||
try:
|
||||
yield self.connect()
|
||||
finally:
|
||||
self.disconnect()
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_session_scope(self) -> AsyncGenerator[AstrbotPluginBaseSession]:
|
||||
"""异步插件会话的上下文管理器."""
|
||||
try:
|
||||
yield await self.async_connect()
|
||||
finally:
|
||||
await self.async_disconnect()
|
||||
|
||||
# region 数据传输方法
|
||||
|
||||
# 主动发送数据
|
||||
@abstractmethod
|
||||
def send(self, data: bytes) -> bytes:
|
||||
"""发送数据到插件并接收响应."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def async_send(self, data: bytes) -> bytes:
|
||||
"""异步发送数据到插件并接收响应."""
|
||||
...
|
||||
|
||||
# 被动接收数据
|
||||
@abstractmethod
|
||||
def listen(self, callback: Callable[[bytes], bytes | None]) -> None:
|
||||
"""监听插件发送的数据.
|
||||
|
||||
callback: 一个接受 bytes 类型参数并返回 bytes 或 None 的函数.
|
||||
如果返回 None, 则不给插件发送响应.
|
||||
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def async_listen(self, callback: Callable[[bytes], bytes | None]) -> None:
|
||||
"""异步监听插件发送的数据.
|
||||
|
||||
callback: 一个接受 bytes 类型参数并返回 bytes 或 None 的函数.
|
||||
如果返回 None, 则不给插件发送响应.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class IVirtualAstrbotPlugin(ABC):
|
||||
"""AstrBot 虚拟插件的基类协议."""
|
||||
|
||||
vpo_map: ClassVar[dict[URL, IVirtualAstrbotPlugin]] = {}
|
||||
"""虚拟插件对象映射表, key 是插件的 astrbot 协议 URL 地址."""
|
||||
|
||||
url: URL
|
||||
"""AstrBot 插件的 astrbot 专有协议 URL 地址.
|
||||
协议:
|
||||
astrbot://{stdio/web/legacy}/plugin_id
|
||||
"""
|
||||
|
||||
metadata: AstrbotPluginMetadata
|
||||
"""插件元数据."""
|
||||
|
||||
session: AstrbotPluginBaseSession
|
||||
"""插件会话对象."""
|
||||
|
||||
# region 公共方法
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def handshake(cls) -> None:
|
||||
"""通用插件握手方法.
|
||||
|
||||
1. 发送握手请求
|
||||
2. 接受插件元数据响应,并设置 metadata 属性
|
||||
3. 返回确认消息,表示握手成功
|
||||
"""
|
||||
...
|
||||
|
||||
# part1 :工厂方法
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def fromFile(cls, path: Path, * , stdio: bool = False) -> IVirtualAstrbotPlugin:
|
||||
"""从文件加载插件/插件包的公共方法.
|
||||
|
||||
通过此方法加载经典插件: stdio=False
|
||||
或者子进程插件: stdio=True
|
||||
|
||||
任务:
|
||||
1. 从 path 加载插件,调用私有方法 _load_metadata 加载插件元数据.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def fromURL(cls, url: URL) -> IVirtualAstrbotPlugin:
|
||||
"""从URL加载插件/插件包的公共方法."""
|
||||
...
|
||||
|
||||
|
||||
# part2 :实例方法
|
||||
|
||||
@abstractmethod
|
||||
def get_logo(self) -> Traversable | None:
|
||||
"""获取插件的logo文件路径(Optional).
|
||||
|
||||
请使用importlib.resources.files来访问并返回Traversable对象.
|
||||
示例:
|
||||
from importlib.resources import files
|
||||
logo_path = files("plugin_a/assets/logo.png")
|
||||
# 如果插件安装在虚拟环境,可以直接这样获取
|
||||
|
||||
# 如果插件不可以安装到虚拟环境,适用于子进程插件/网络插件,可以这样获取
|
||||
|
||||
|
||||
返回None表示没有logo文件.
|
||||
Returns:
|
||||
Traversable | None: 插件logo文件的路径或None.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_metadata(self) -> AstrbotPluginMetadata:
|
||||
"""获取插件元数据的公共方法."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_url(self) -> URL:
|
||||
"""获取插件URL(astrbot协议)的公共方法."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_session(self) -> AstrbotPluginBaseSession:
|
||||
"""获取插件会话对象的公共方法."""
|
||||
...
|
||||
|
||||
|
||||
|
||||
# region 魔术方法
|
||||
|
||||
@abstractmethod
|
||||
def __str__(self) -> str:
|
||||
"""返回插件元数据的字符串表示."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def __repr__(self) -> str:
|
||||
"""返回插件元数据的正式字符串表示."""
|
||||
...
|
||||
|
||||
# region 私有方法
|
||||
@classmethod
|
||||
def _load_metadata(cls, path: Path) -> AstrbotPluginMetadata:
|
||||
"""加载插件元数据的私有方法,自动按下列优先级加载: pyproject -> yaml -> toml -> json.
|
||||
|
||||
其中yaml: plugin.yaml > metadata.yaml
|
||||
|
||||
此函数是上述工厂方法的辅助函数,用于加载插件元数据.
|
||||
"""
|
||||
match path.suffix.lower():
|
||||
case ".json":
|
||||
return cls._load_metadata_json(path)
|
||||
case ".toml":
|
||||
return cls._load_metadata_toml(path)
|
||||
case ".yaml" | ".yml":
|
||||
return cls._load_metadata_yaml(path)
|
||||
case _:
|
||||
raise ValueError(f"不支持的插件元数据文件格式: {path.suffix}")
|
||||
|
||||
@classmethod
|
||||
async def _load_metadata_async(cls, path: Path) -> AstrbotPluginMetadata:
|
||||
"""异步加载插件元数据的私有方法,自动按下列优先级加载: pyproject -> yaml -> toml -> json.
|
||||
|
||||
其中yaml: plugin.yaml > metadata.yaml
|
||||
|
||||
此函数是上述工厂方法的辅助函数,用于加载插件元数据.
|
||||
"""
|
||||
return await anyio.to_thread.run_sync(cls._load_metadata, path) # type: ignore[attr-defined]
|
||||
|
||||
@classmethod
|
||||
def _load_metadata_json(cls, path: Path) -> AstrbotPluginMetadata:
|
||||
"""从json文件加载插件元数据的私有方法."""
|
||||
with path.open(encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return AstrbotPluginMetadata.model_validate(**data)
|
||||
|
||||
@classmethod
|
||||
async def _load_metadata_json_async(cls, path: Path) -> AstrbotPluginMetadata:
|
||||
"""异步从json文件加载插件元数据的私有方法."""
|
||||
async with await anyio.open_file(path, "r", encoding="utf-8") as f:
|
||||
content = await f.read()
|
||||
data = json.loads(content)
|
||||
return AstrbotPluginMetadata.model_validate(**data)
|
||||
|
||||
@classmethod
|
||||
def _load_metadata_toml(cls, path: Path) -> AstrbotPluginMetadata:
|
||||
"""从toml文件加载插件元数据的私有方法."""
|
||||
with path.open("rb") as f:
|
||||
data = tomli.load(f)
|
||||
return AstrbotPluginMetadata.model_validate(**data)
|
||||
|
||||
@classmethod
|
||||
async def _load_metadata_toml_async(cls, path: Path) -> AstrbotPluginMetadata:
|
||||
"""异步从toml文件加载插件元数据的私有方法."""
|
||||
async with await anyio.open_file(path, "rb") as f:
|
||||
content = await f.read()
|
||||
content_str = content.decode("utf-8")
|
||||
data = tomli.loads(content_str)
|
||||
return AstrbotPluginMetadata.model_validate(**data)
|
||||
|
||||
@classmethod
|
||||
def _load_metadata_yaml(cls, path: Path) -> AstrbotPluginMetadata:
|
||||
"""从yaml文件加载插件元数据的私有方法."""
|
||||
with path.open(encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return AstrbotPluginMetadata.model_validate(**data)
|
||||
|
||||
@classmethod
|
||||
async def _load_metadata_yaml_async(cls, path: Path) -> AstrbotPluginMetadata:
|
||||
"""异步从yaml文件加载插件元数据的私有方法."""
|
||||
async with await anyio.open_file(path, "r", encoding="utf-8") as f:
|
||||
content = await f.read()
|
||||
data = yaml.safe_load(content)
|
||||
return AstrbotPluginMetadata.model_validate(**data)
|
||||
|
||||
|
||||
# region 插件运行时协议
|
||||
|
||||
class IAstrbotPluginRuntime(ABC):
|
||||
"""AstrBot 插件运行时的基类协议."""
|
||||
|
||||
@abstractmethod
|
||||
def start(self) -> None:
|
||||
"""启动插件运行时."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def stop(self) -> None:
|
||||
"""停止插件运行时."""
|
||||
...
|
||||
|
||||
9
astrbot_api/src/astrbot_api/const.py
Normal file
9
astrbot_api/src/astrbot_api/const.py
Normal file
@@ -0,0 +1,9 @@
|
||||
LOGO = r"""
|
||||
___ _______.___________..______ .______ ______ .___________.
|
||||
/ \ / | || _ \ | _ \ / __ \ | |
|
||||
/ ^ \ | (----`---| |----`| |_) | | |_) | | | | | `---| |----`
|
||||
/ /_\ \ \ \ | | | / | _ < | | | | | |
|
||||
/ _____ \ .----) | | | | |\ \----.| |_) | | `--' | | |
|
||||
/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__|
|
||||
|
||||
"""
|
||||
11
astrbot_api/src/astrbot_api/enums.py
Normal file
11
astrbot_api/src/astrbot_api/enums.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class PluginType(Enum):
|
||||
"""插件大类."""
|
||||
# 兼容保留
|
||||
LEGACY = "legacy" # 经典插件,直接在主进程中运行 -- 逐渐淘汰
|
||||
|
||||
# 后两者为新插件机制
|
||||
STDIO = "stdio" # 子进程 -- 进程间通信
|
||||
WEB = "web" # 通过 HTTP/HTTPS 协议调用
|
||||
3
astrbot_api/src/astrbot_api/exceptions.py
Normal file
3
astrbot_api/src/astrbot_api/exceptions.py
Normal file
@@ -0,0 +1,3 @@
|
||||
|
||||
class AstrbotBaseError(Exception):
|
||||
"""Base exception for Astrbot API errors."""
|
||||
28
astrbot_api/src/astrbot_api/models.py
Normal file
28
astrbot_api/src/astrbot_api/models.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AstrbotPluginMetadata(BaseModel):
|
||||
"""AstrBot 插件元数据模型."""
|
||||
name: str | None = None
|
||||
"""插件名"""
|
||||
author: str | None = None
|
||||
"""插件作者"""
|
||||
desc: str | None = None
|
||||
"""插件简介"""
|
||||
version: str | None = None
|
||||
"""插件版本"""
|
||||
repo: str | None = None
|
||||
"""插件仓库地址"""
|
||||
|
||||
reserved: bool = False
|
||||
"""是否是 AstrBot 的保留插件"""
|
||||
|
||||
activated: bool = True
|
||||
"""是否被激活"""
|
||||
|
||||
display_name: str | None = None
|
||||
"""用于展示的插件名称"""
|
||||
|
||||
|
||||
1
astrbot_api/src/astrbot_api/protocols.py
Normal file
1
astrbot_api/src/astrbot_api/protocols.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
0
astrbot_api/src/astrbot_api/py.typed
Normal file
0
astrbot_api/src/astrbot_api/py.typed
Normal file
0
astrbot_api/src/astrbot_api/types.py
Normal file
0
astrbot_api/src/astrbot_api/types.py
Normal file
0
astrbot_sdk/README.md
Normal file
0
astrbot_sdk/README.md
Normal file
25
astrbot_sdk/pyproject.toml
Normal file
25
astrbot_sdk/pyproject.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "astrbot-sdk"
|
||||
dynamic = ["version"]
|
||||
description = "Astrbot Python SDK"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
{ name = "AstrBotDevs", email = "community@astrbot.app" }
|
||||
]
|
||||
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"astrbot-api",
|
||||
"dishka>=1.7.2",
|
||||
"dotenv>=0.9.9",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling", "uv-dynamic-versioning"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "uv-dynamic-versioning"
|
||||
|
||||
[tool.uv.sources]
|
||||
astrbot-api = { workspace = true }
|
||||
23
astrbot_sdk/src/astrbot_sdk/__init__.py
Normal file
23
astrbot_sdk/src/astrbot_sdk/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from dishka import (
|
||||
AsyncContainer,
|
||||
Container,
|
||||
Provider,
|
||||
make_async_container,
|
||||
make_container,
|
||||
)
|
||||
|
||||
from .base import AstrbotBaseProvider
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
base_provider: Provider = AstrbotBaseProvider()
|
||||
async_base_container: AsyncContainer = make_async_container(base_provider)
|
||||
sync_base_container: Container = make_container(base_provider)
|
||||
|
||||
__all__ = [
|
||||
"async_base_container",
|
||||
"sync_base_container",
|
||||
]
|
||||
3
astrbot_sdk/src/astrbot_sdk/base/README.md
Normal file
3
astrbot_sdk/src/astrbot_sdk/base/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# 基础类的实现
|
||||
|
||||
- 单向导出
|
||||
3
astrbot_sdk/src/astrbot_sdk/base/__init__.py
Normal file
3
astrbot_sdk/src/astrbot_sdk/base/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .provider import AstrbotBaseProvider
|
||||
|
||||
__all__ = ["AstrbotBaseProvider"]
|
||||
131
astrbot_sdk/src/astrbot_sdk/base/paths.py
Normal file
131
astrbot_sdk/src/astrbot_sdk/base/paths.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from os import chdir, getenv
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from packaging.utils import NormalizedName, canonicalize_name
|
||||
|
||||
from astrbot_api import IAstrbotPaths
|
||||
|
||||
|
||||
class AstrbotPaths(IAstrbotPaths):
|
||||
"""统一化路径获取."""
|
||||
|
||||
load_dotenv()
|
||||
astrbot_root: ClassVar[Path] = Path(
|
||||
getenv("ASTRBOT_ROOT", Path.home() / ".astrbot")
|
||||
).absolute()
|
||||
|
||||
_instances: ClassVar[dict[str, AstrbotPaths]] = {}
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name: str = name
|
||||
# 确保根目录存在
|
||||
self.astrbot_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@classmethod
|
||||
def getPaths(cls, name: str) -> AstrbotPaths:
|
||||
"""返回Paths实例,用于访问模块的各类目录."""
|
||||
normalized_name: NormalizedName = canonicalize_name(name)
|
||||
if normalized_name in cls._instances:
|
||||
return cls._instances[normalized_name]
|
||||
instance: AstrbotPaths = cls(normalized_name)
|
||||
instance.name = normalized_name
|
||||
cls._instances[normalized_name] = instance
|
||||
return instance
|
||||
|
||||
@property
|
||||
def root(self) -> Path:
|
||||
"""返回根目录."""
|
||||
return (
|
||||
self.astrbot_root if self.astrbot_root.exists() else Path.cwd() / ".astrbot"
|
||||
)
|
||||
|
||||
@property
|
||||
def home(self) -> Path:
|
||||
"""模块/插件主目录.
|
||||
|
||||
通过此属性获取模块/插件主目录.
|
||||
"""
|
||||
my_home = self.astrbot_root / "home" / self.name
|
||||
my_home.mkdir(parents=True, exist_ok=True)
|
||||
return my_home
|
||||
|
||||
@property
|
||||
def config(self) -> Path:
|
||||
"""返回模块/插件配置目录.
|
||||
|
||||
搭配 astrbot_config 使用.
|
||||
"""
|
||||
config_path = self.astrbot_root / "config" / self.name
|
||||
config_path.mkdir(parents=True, exist_ok=True)
|
||||
return config_path
|
||||
|
||||
@property
|
||||
def data(self) -> Path:
|
||||
"""返回模块/插件数据目录."""
|
||||
data_path = self.astrbot_root / "data" / self.name
|
||||
data_path.mkdir(parents=True, exist_ok=True)
|
||||
return data_path
|
||||
|
||||
@property
|
||||
def log(self) -> Path:
|
||||
"""返回模块日志目录."""
|
||||
log_path = self.astrbot_root / "logs" / self.name
|
||||
log_path.mkdir(parents=True, exist_ok=True)
|
||||
return log_path
|
||||
|
||||
@property
|
||||
def temp(self) -> Path:
|
||||
"""返回模块临时文件目录."""
|
||||
temp_path = self.astrbot_root / "temp" / self.name
|
||||
temp_path.mkdir(parents=True, exist_ok=True)
|
||||
return temp_path
|
||||
|
||||
@property
|
||||
def plugins(self) -> Path:
|
||||
"""返回插件目录."""
|
||||
plugin_path = self.astrbot_root / "plugins" / self.name
|
||||
plugin_path.mkdir(parents=True, exist_ok=True)
|
||||
return plugin_path
|
||||
|
||||
@classmethod
|
||||
def is_root(cls, path: Path) -> bool:
|
||||
"""检查路径是否为 Astrbot 根目录."""
|
||||
if not path.exists() or not path.is_dir():
|
||||
return False
|
||||
# 检查此目录内是是否包含.astrbot标记文件
|
||||
return bool((path / ".astrbot").exists())
|
||||
|
||||
def reload(self) -> None:
|
||||
"""重新加载环境变量."""
|
||||
load_dotenv()
|
||||
self.__class__.astrbot_root = Path(
|
||||
getenv("ASTRBOT_ROOT", Path.home() / ".astrbot")
|
||||
).absolute()
|
||||
|
||||
@contextmanager
|
||||
def chdir(self, cwd: str = "home") -> Generator[Path]:
|
||||
"""临时切换到指定目录, 子进程将继承此 CWD。"""
|
||||
original_cwd = Path.cwd()
|
||||
target_dir = self.root / cwd
|
||||
try:
|
||||
chdir(target_dir)
|
||||
yield target_dir
|
||||
finally:
|
||||
chdir(original_cwd)
|
||||
|
||||
@asynccontextmanager
|
||||
async def achdir(self, cwd: str = "home") -> AsyncGenerator[Path]:
|
||||
"""异步上下文管理器: 临时切换到指定目录, 子进程将继承此 CWD。"""
|
||||
original_cwd = Path.cwd()
|
||||
target_dir = self.root / cwd
|
||||
try:
|
||||
chdir(target_dir)
|
||||
yield target_dir
|
||||
finally:
|
||||
chdir(original_cwd)
|
||||
19
astrbot_sdk/src/astrbot_sdk/base/provider.py
Normal file
19
astrbot_sdk/src/astrbot_sdk/base/provider.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from dishka import Provider, Scope, provide
|
||||
|
||||
from astrbot_api import IAstrbotPaths
|
||||
|
||||
from .paths import AstrbotPaths
|
||||
|
||||
|
||||
class AstrbotBaseProvider(Provider):
|
||||
scope = Scope.APP # 基础Provider的作用域设为APP
|
||||
|
||||
@provide
|
||||
def get_astrbot_paths_cls(self) -> type[IAstrbotPaths]:
|
||||
return AstrbotPaths
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
0
astrbot_sdk/src/astrbot_sdk/py.typed
Normal file
0
astrbot_sdk/src/astrbot_sdk/py.typed
Normal file
@@ -1,8 +0,0 @@
|
||||
## 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 的侧边栏模块进行自定义配置(入口在侧边栏下方的设置页中)。
|
||||
@@ -1,5 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
> hotfix version of 4.5.2
|
||||
|
||||
1. 修复:修正 `get_tool_list` 方法中工具字典推导式的错误导致的 WebUI MCP 页面工具列表无法显示的问题。
|
||||
@@ -1,5 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
1. 修复:Docker 镜像部分依赖问题导致某些情况下无法启动容器的问题;
|
||||
2. 优化:插件卡片样式
|
||||
3. 修复:部分情况下 Windows 一键启动部署时,更新 / 部署失败的问题;
|
||||
@@ -1,3 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
1. 修复:部署失败
|
||||
3
dashboard/.gitignore
vendored
3
dashboard/.gitignore
vendored
@@ -1,3 +1,2 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
dist/
|
||||
.DS_Store
|
||||
@@ -2,7 +2,6 @@
|
||||
import { ref, computed, inject } from 'vue';
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import UninstallConfirmDialog from './UninstallConfirmDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
extension: {
|
||||
@@ -32,7 +31,6 @@ const emit = defineEmits([
|
||||
]);
|
||||
|
||||
const reveal = ref(false);
|
||||
const showUninstallDialog = ref(false);
|
||||
|
||||
// 国际化
|
||||
const { tm } = useModuleI18n('features/extension');
|
||||
@@ -57,11 +55,19 @@ const installExtension = async () => {
|
||||
};
|
||||
|
||||
const uninstallExtension = async () => {
|
||||
showUninstallDialog.value = true;
|
||||
};
|
||||
if (typeof $confirm !== "function") {
|
||||
console.error(tm("card.errors.confirmNotRegistered"));
|
||||
return;
|
||||
}
|
||||
|
||||
const handleUninstallConfirm = (options: { deleteConfig: boolean; deleteData: boolean }) => {
|
||||
emit("uninstall", props.extension, options);
|
||||
const confirmed = await $confirm({
|
||||
title: tm("dialogs.uninstall.title"),
|
||||
message: tm("dialogs.uninstall.message"),
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
emit("uninstall", props.extension);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActivation = () => {
|
||||
@@ -214,12 +220,6 @@ const viewReadme = () => {
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<!-- 卸载确认对话框 -->
|
||||
<UninstallConfirmDialog
|
||||
v-model="showUninstallDialog"
|
||||
@confirm="handleUninstallConfirm"
|
||||
/>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
<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>
|
||||
@@ -1,135 +0,0 @@
|
||||
<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>
|
||||
@@ -18,6 +18,5 @@
|
||||
"refresh": "Refresh",
|
||||
"submit": "Submit",
|
||||
"reset": "Reset",
|
||||
"clear": "Clear",
|
||||
"save": "Save"
|
||||
"clear": "Clear"
|
||||
}
|
||||
@@ -79,14 +79,6 @@
|
||||
"devDocs": "Extension Development Docs",
|
||||
"submitRepo": "Submit Extension Repository"
|
||||
},
|
||||
"sort": {
|
||||
"default": "Default",
|
||||
"stars": "Stars",
|
||||
"author": "Author",
|
||||
"updated": "Last Updated",
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending"
|
||||
},
|
||||
"tags": {
|
||||
"danger": "Danger"
|
||||
},
|
||||
@@ -105,11 +97,7 @@
|
||||
},
|
||||
"uninstall": {
|
||||
"title": "Confirm Deletion",
|
||||
"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"
|
||||
"message": "Are you sure you want to delete this extension?"
|
||||
},
|
||||
"install": {
|
||||
"title": "Install Extension",
|
||||
|
||||
@@ -19,15 +19,5 @@
|
||||
"subtitle": "If you encounter data compatibility issues, you can manually start the database 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,5 @@
|
||||
"invalid_date": "Please enter a valid date",
|
||||
"date_range": "Invalid date range",
|
||||
"upload_failed": "File upload failed",
|
||||
"network_error": "Network connection error, please try again",
|
||||
"operation_cannot_be_undone": "⚠️ This operation cannot be undone, please choose carefully!"
|
||||
"network_error": "Network connection error, please try again"
|
||||
}
|
||||
@@ -18,6 +18,5 @@
|
||||
"refresh": "刷新",
|
||||
"submit": "提交",
|
||||
"reset": "重置",
|
||||
"clear": "清空",
|
||||
"save": "保存"
|
||||
"clear": "清空"
|
||||
}
|
||||
@@ -79,14 +79,6 @@
|
||||
"devDocs": "插件开发文档",
|
||||
"submitRepo": "提交插件仓库"
|
||||
},
|
||||
"sort": {
|
||||
"default": "默认排序",
|
||||
"stars": "Star数",
|
||||
"author": "作者名",
|
||||
"updated": "更新时间",
|
||||
"ascending": "升序",
|
||||
"descending": "降序"
|
||||
},
|
||||
"tags": {
|
||||
"danger": "危险"
|
||||
},
|
||||
@@ -105,11 +97,7 @@
|
||||
},
|
||||
"uninstall": {
|
||||
"title": "删除确认",
|
||||
"message": "你确定要删除当前插件吗?",
|
||||
"deleteConfig": "同时删除插件配置文件",
|
||||
"deleteData": "同时删除插件持久化数据",
|
||||
"configHint": "配置文件位于 data/config 目录",
|
||||
"dataHint": "删除 data/plugin_data 和 data/plugins_data 目录下的数据"
|
||||
"message": "你确定要删除当前插件吗?"
|
||||
},
|
||||
"install": {
|
||||
"title": "安装插件",
|
||||
|
||||
@@ -19,15 +19,5 @@
|
||||
"subtitle": "如果您遇到数据兼容性问题,可以手动启动数据库迁移助手",
|
||||
"button": "启动迁移助手"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"title": "侧边栏",
|
||||
"customize": {
|
||||
"title": "自定义侧边栏",
|
||||
"subtitle": "拖拽以调整模块顺序,或者将模块移入/移出\"更多功能\"分组。设置仅保存在浏览器本地。",
|
||||
"reset": "恢复默认",
|
||||
"mainItems": "主要模块",
|
||||
"moreItems": "更多功能"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,5 @@
|
||||
"invalid_date": "请输入有效的日期",
|
||||
"date_range": "日期范围无效",
|
||||
"upload_failed": "文件上传失败",
|
||||
"network_error": "网络连接错误,请重试",
|
||||
"operation_cannot_be_undone": "⚠️ 此操作无法撤销,请谨慎选择!"
|
||||
"network_error": "网络连接错误,请重试"
|
||||
}
|
||||
@@ -1,39 +1,15 @@
|
||||
<script setup>
|
||||
import { ref, shallowRef, onMounted, onUnmounted } from 'vue';
|
||||
import { ref, shallowRef } from 'vue';
|
||||
import { useCustomizerStore } from '../../../stores/customizer';
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
import sidebarItems from './sidebarItem';
|
||||
import NavItem from './NavItem.vue';
|
||||
import { applySidebarCustomization } from '@/utils/sidebarCustomization';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const customizer = useCustomizerStore();
|
||||
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 starCount = ref(null);
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -4,13 +4,12 @@ import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
|
||||
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
|
||||
import ReadmeDialog from '@/components/shared/ReadmeDialog.vue';
|
||||
import ProxySelector from '@/components/shared/ProxySelector.vue';
|
||||
import UninstallConfirmDialog from '@/components/shared/UninstallConfirmDialog.vue';
|
||||
import axios from 'axios';
|
||||
import { pinyin } from 'pinyin-pro';
|
||||
import { useCommonStore } from '@/stores/common';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
import { ref, computed, onMounted, reactive, inject, watch } from 'vue';
|
||||
import { ref, computed, onMounted, reactive } from 'vue';
|
||||
|
||||
|
||||
const commonStore = useCommonStore();
|
||||
@@ -53,18 +52,10 @@ const isListView = ref(false);
|
||||
const pluginSearch = ref("");
|
||||
const loading_ = ref(false);
|
||||
|
||||
// 分页相关
|
||||
const currentPage = ref(1);
|
||||
const itemsPerPage = ref(6); // 每页显示6个卡片 (2行 x 3列,避免滚动)
|
||||
|
||||
// 危险插件确认对话框
|
||||
const dangerConfirmDialog = ref(false);
|
||||
const selectedDangerPlugin = ref(null);
|
||||
|
||||
// 卸载插件确认对话框(列表模式用)
|
||||
const showUninstallDialog = ref(false);
|
||||
const pluginToUninstall = ref(null);
|
||||
|
||||
// 插件市场相关
|
||||
const extension_url = ref("");
|
||||
const dialog = ref(false);
|
||||
@@ -72,11 +63,8 @@ const upload_file = ref(null);
|
||||
const uploadTab = ref('file');
|
||||
const showPluginFullName = ref(false);
|
||||
const marketSearch = ref("");
|
||||
const debouncedMarketSearch = ref("");
|
||||
const filterKeys = ['name', 'desc', 'author'];
|
||||
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();
|
||||
@@ -160,71 +148,6 @@ const pinnedPlugins = computed(() => {
|
||||
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 = () => {
|
||||
showReserved.value = !showReserved.value;
|
||||
@@ -290,35 +213,10 @@ const checkUpdate = () => {
|
||||
});
|
||||
};
|
||||
|
||||
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; // 等待对话框回调
|
||||
}
|
||||
|
||||
// 执行卸载
|
||||
const uninstallExtension = async (extension_name) => {
|
||||
toast(tm('messages.uninstalling') + " " + extension_name, "primary");
|
||||
try {
|
||||
const res = await axios.post('/api/plugin/uninstall', {
|
||||
name: extension_name,
|
||||
delete_config: deleteConfig,
|
||||
delete_data: deleteData,
|
||||
});
|
||||
const res = await axios.post('/api/plugin/uninstall', { name: extension_name });
|
||||
if (res.data.status === "error") {
|
||||
toast(res.data.message, "error");
|
||||
return;
|
||||
@@ -331,14 +229,6 @@ const uninstallExtension = async (extension_name, optionsOrSkipConfirm = false)
|
||||
}
|
||||
};
|
||||
|
||||
// 处理卸载确认对话框的确认事件
|
||||
const handleUninstallConfirm = (options) => {
|
||||
if (pluginToUninstall.value) {
|
||||
uninstallExtension(pluginToUninstall.value, options);
|
||||
pluginToUninstall.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const updateExtension = async (extension_name) => {
|
||||
loadingDialog.title = tm('status.loading');
|
||||
loadingDialog.show = true;
|
||||
@@ -606,7 +496,6 @@ const refreshPluginMarket = async () => {
|
||||
trimExtensionName();
|
||||
checkAlreadyInstalled();
|
||||
checkUpdate();
|
||||
currentPage.value = 1; // 重置到第一页
|
||||
|
||||
toast(tm('messages.refreshSuccess'), "success");
|
||||
} catch (err) {
|
||||
@@ -648,20 +537,6 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// 搜索防抖处理
|
||||
let searchDebounceTimer = null;
|
||||
watch(marketSearch, (newVal) => {
|
||||
if (searchDebounceTimer) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
}
|
||||
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
debouncedMarketSearch.value = newVal;
|
||||
// 搜索时重置到第一页
|
||||
currentPage.value = 1;
|
||||
}, 300); // 300ms 防抖延迟
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -875,10 +750,8 @@ watch(marketSearch, (newVal) => {
|
||||
<v-row>
|
||||
<v-col cols="12" md="6" lg="4" v-for="extension in filteredPlugins" :key="extension.name"
|
||||
class="pb-2">
|
||||
<ExtensionCard :extension="extension" class="rounded-lg"
|
||||
style="background-color: rgb(var(--v-theme-mcpCardBg));"
|
||||
@configure="openExtensionConfig(extension.name)"
|
||||
@uninstall="(ext, options) => uninstallExtension(ext.name, options)"
|
||||
<ExtensionCard :extension="extension" class="rounded-lg" style="background-color: rgb(var(--v-theme-mcpCardBg));"
|
||||
@configure="openExtensionConfig(extension.name)" @uninstall="uninstallExtension(extension.name)"
|
||||
@update="updateExtension(extension.name)" @reload="reloadPlugin(extension.name)"
|
||||
@toggle-activation="extension.activated ? pluginOff(extension) : pluginOn(extension)"
|
||||
@view-handlers="showPluginInfo(extension)" @view-readme="viewReadme(extension)">
|
||||
@@ -898,158 +771,85 @@ watch(marketSearch, (newVal) => {
|
||||
@click="dialog = true" color="darkprimary">
|
||||
</v-btn>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="d-flex align-center mb-2" style="justify-content: space-between; flex-wrap: wrap; gap: 8px;">
|
||||
<div class="d-flex align-center" style="gap: 6px;">
|
||||
<h2>{{ tm('market.allPlugins') }}({{ filteredMarketPlugins.length }})</h2>
|
||||
<v-btn icon variant="text" @click="refreshPluginMarket" :loading="refreshingMarket">
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<v-row style="min-height: 26rem;">
|
||||
<v-col v-for="plugin in paginatedPlugins" :key="plugin.name" cols="12" md="6" lg="4">
|
||||
<v-card class="rounded-lg d-flex flex-column" elevation="0"
|
||||
style=" height: 12rem; position: relative;">
|
||||
|
||||
<!-- 推荐标记 -->
|
||||
<v-chip v-if="plugin?.pinned" color="warning" size="x-small" label
|
||||
style="position: absolute; right: 8px; top: 8px; z-index: 10; height: 20px; font-weight: bold;">
|
||||
🥳 推荐
|
||||
</v-chip>
|
||||
|
||||
<v-card-text
|
||||
style="padding: 12px; padding-bottom: 8px; display: flex; gap: 12px; width: 100%; flex: 1; overflow: hidden;">
|
||||
<div v-if="plugin?.logo" style="flex-shrink: 0;">
|
||||
<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>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="text-caption"
|
||||
style="overflow: scroll; color: rgba(var(--v-theme-on-surface), 0.6); line-height: 1.3; margin-bottom: 6px; flex: 1;">
|
||||
{{ plugin.desc }}
|
||||
</div>
|
||||
|
||||
<!-- Stats: Stars & Updated & Version -->
|
||||
<div class="d-flex align-center" style="gap: 8px; margin-top: auto;">
|
||||
<div v-if="plugin.stars !== undefined" class="d-flex align-center text-subtitle-2"
|
||||
style="color: rgba(var(--v-theme-on-surface), 0.7);">
|
||||
<v-icon icon="mdi-star" size="x-small" style="margin-right: 2px;"></v-icon>
|
||||
<span>{{ plugin.stars }}</span>
|
||||
</div>
|
||||
<div v-if="plugin.updated_at" class="d-flex align-center text-subtitle-2"
|
||||
style="color: rgba(var(--v-theme-on-surface), 0.7);">
|
||||
<v-icon icon="mdi-clock-outline" size="x-small" style="margin-right: 2px;"></v-icon>
|
||||
<span>{{ new Date(plugin.updated_at).toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<!-- Actions -->
|
||||
<v-card-actions style="gap: 6px; padding: 8px 12px; padding-top: 0;">
|
||||
<v-chip v-for="tag in plugin.tags?.slice(0, 2)" :key="tag"
|
||||
:color="tag === 'danger' ? 'error' : 'primary'" label size="x-small" style="height: 20px;">
|
||||
{{ tag === 'danger' ? tm('tags.danger') : tag }}
|
||||
</v-chip>
|
||||
<v-menu v-if="plugin.tags && plugin.tags.length > 2" open-on-hover offset-y>
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<v-chip v-bind="menuProps" color="grey" label size="x-small"
|
||||
style="height: 20px; cursor: pointer;">
|
||||
+{{ plugin.tags.length - 2 }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<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>
|
||||
<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="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 class="mt-4">
|
||||
<div class="d-flex align-center mb-2" style="justify-content: space-between;">
|
||||
<h2>{{ tm('market.allPlugins') }}</h2>
|
||||
<div class="d-flex align-center">
|
||||
<v-btn variant="tonal" size="small" @click="refreshPluginMarket" :loading="refreshingMarket"
|
||||
class="mr-2">
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
{{ tm('buttons.refresh') }}
|
||||
</v-btn>
|
||||
<v-switch v-model="showPluginFullName" :label="tm('market.showFullName')" hide-details
|
||||
density="compact" style="margin-left: 12px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-col cols="12" md="12" style="padding: 0px;">
|
||||
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name" style="border-radius: 10px;"
|
||||
:loading="loading_" v-model:search="marketSearch" :filter-keys="filterKeys"
|
||||
:custom-filter="marketCustomFilter">
|
||||
<template v-slot:item.name="{ item }">
|
||||
<div class="d-flex align-center"
|
||||
style="overflow-x: auto; scrollbar-width: thin; scrollbar-track-color: transparent;">
|
||||
<img v-if="item.logo" :src="item.logo"
|
||||
style="height: 80px; width: 80px; margin-right: 8px; border-radius: 8px; margin-top: 8px; margin-bottom: 8px;"
|
||||
alt="logo">
|
||||
<a :href="item?.repo" style="color: var(--v-theme-primaryText, #000);
|
||||
text-decoration:none">
|
||||
<div v-if="item.display_name">
|
||||
<span class="d-block">{{ item.display_name }}</span>
|
||||
<small style="color: grey; font-size: 60%;">({{ item.name }})</small>
|
||||
</div>
|
||||
<span v-else>{{ showPluginFullName ? item.name : item.trimmedName }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:item.desc="{ item }">
|
||||
<small>
|
||||
{{ item.desc }}
|
||||
</small>
|
||||
</template>
|
||||
<template v-slot:item.author="{ item }">
|
||||
<div style="font-size: 12px;">
|
||||
<span v-if="item?.social_link"><a :href="item?.social_link">{{ item.author }}</a></span>
|
||||
<span v-else>{{ item.author }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:item.stars="{ item }">
|
||||
<span>{{ item.stars }}</span>
|
||||
</template>
|
||||
<template v-slot:item.updated_at="{ item }">
|
||||
<small>{{ new Date(item.updated_at).toLocaleString() }}</small>
|
||||
</template>
|
||||
<template v-slot:item.tags="{ item }">
|
||||
<span v-if="item.tags.length === 0">-</span>
|
||||
<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">
|
||||
{{ tag }}</v-chip>
|
||||
</template>
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<v-btn class="text-none mr-2" size="x-small" icon variant="text"
|
||||
@click="open(item.repo)"><v-icon>mdi-github</v-icon></v-btn>
|
||||
<v-btn v-if="!item.installed" class="text-none mr-2" size="x-small" icon variant="text"
|
||||
@click="handleInstallPlugin(item)">
|
||||
<v-icon>mdi-download</v-icon></v-btn>
|
||||
<v-btn v-else class="text-none mr-2" size="x-small" icon variant="text"
|
||||
disabled><v-icon>mdi-check</v-icon></v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-col>
|
||||
</div>
|
||||
</v-tab-item>
|
||||
|
||||
@@ -1062,7 +862,7 @@ watch(marketSearch, (newVal) => {
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col v-if="activeTab === 'market'" cols="12" md="12">
|
||||
<v-col v-if="activeTab === 'market'" style="margin-bottom: 16px;" cols="12" md="12">
|
||||
<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')
|
||||
}}</a></small>
|
||||
@@ -1158,9 +958,6 @@ watch(marketSearch, (newVal) => {
|
||||
<ReadmeDialog v-model:show="readmeDialog.show" :plugin-name="readmeDialog.pluginName"
|
||||
:repo-url="readmeDialog.repoUrl" />
|
||||
|
||||
<!-- 卸载插件确认对话框(列表模式用) -->
|
||||
<UninstallConfirmDialog v-model="showUninstallDialog" @confirm="handleUninstallConfirm" />
|
||||
|
||||
<!-- 危险插件确认对话框 -->
|
||||
<v-dialog v-model="dangerConfirmDialog" width="500" persistent>
|
||||
<v-card>
|
||||
|
||||
@@ -9,12 +9,6 @@
|
||||
<ProxySelector></ProxySelector>
|
||||
</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-item :subtitle="tm('system.restart.subtitle')" :title="tm('system.restart.title')">
|
||||
@@ -39,7 +33,6 @@ import axios from 'axios';
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
||||
import ProxySelector from '@/components/shared/ProxySelector.vue';
|
||||
import MigrationDialog from '@/components/shared/MigrationDialog.vue';
|
||||
import SidebarCustomizer from '@/components/shared/SidebarCustomizer.vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
const { tm } = useModuleI18n('features/settings');
|
||||
|
||||
105
main.py
105
main.py
@@ -1,105 +1,4 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.core import LogBroker, LogManager, db_helper, logger
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.initial_loader import InitialLoader
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
||||
|
||||
# 将父目录添加到 sys.path
|
||||
sys.path.append(Path(__file__).parent.as_posix())
|
||||
|
||||
logo_tmpl = r"""
|
||||
___ _______.___________..______ .______ ______ .___________.
|
||||
/ \ / | || _ \ | _ \ / __ \ | |
|
||||
/ ^ \ | (----`---| |----`| |_) | | |_) | | | | | `---| |----`
|
||||
/ /_\ \ \ \ | | | / | _ < | | | | | |
|
||||
/ _____ \ .----) | | | | |\ \----.| |_) | | `--' | | |
|
||||
/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__|
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def check_env():
|
||||
if not (sys.version_info.major == 3 and sys.version_info.minor >= 10):
|
||||
logger.error("请使用 Python3.10+ 运行本项目。")
|
||||
exit()
|
||||
|
||||
os.makedirs("data/config", exist_ok=True)
|
||||
os.makedirs("data/plugins", exist_ok=True)
|
||||
os.makedirs("data/temp", exist_ok=True)
|
||||
|
||||
# 针对问题 #181 的临时解决方案
|
||||
mimetypes.add_type("text/javascript", ".js")
|
||||
mimetypes.add_type("text/javascript", ".mjs")
|
||||
mimetypes.add_type("application/json", ".json")
|
||||
|
||||
|
||||
async def check_dashboard_files(webui_dir: str | None = None):
|
||||
"""下载管理面板文件"""
|
||||
# 指定webui目录
|
||||
if webui_dir:
|
||||
if os.path.exists(webui_dir):
|
||||
logger.info(f"使用指定的 WebUI 目录: {webui_dir}")
|
||||
return webui_dir
|
||||
logger.warning(f"指定的 WebUI 目录 {webui_dir} 不存在,将使用默认逻辑。")
|
||||
|
||||
data_dist_path = os.path.join(get_astrbot_data_path(), "dist")
|
||||
if os.path.exists(data_dist_path):
|
||||
v = await get_dashboard_version()
|
||||
if v is not None:
|
||||
# 存在文件
|
||||
if v == f"v{VERSION}":
|
||||
logger.info("WebUI 版本已是最新。")
|
||||
else:
|
||||
logger.warning(
|
||||
f"检测到 WebUI 版本 ({v}) 与当前 AstrBot 版本 (v{VERSION}) 不符。",
|
||||
)
|
||||
return data_dist_path
|
||||
|
||||
logger.info(
|
||||
"开始下载管理面板文件...高峰期(晚上)可能导致较慢的速度。如多次下载失败,请前往 https://github.com/AstrBotDevs/AstrBot/releases/latest 下载 dist.zip,并将其中的 dist 文件夹解压至 data 目录下。",
|
||||
)
|
||||
|
||||
try:
|
||||
await download_dashboard(version=f"v{VERSION}", latest=False)
|
||||
except Exception as e:
|
||||
logger.critical(f"下载管理面板文件失败: {e}。")
|
||||
return None
|
||||
|
||||
logger.info("管理面板下载完成。")
|
||||
return data_dist_path
|
||||
|
||||
from astrbot.__main__ import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="AstrBot")
|
||||
parser.add_argument(
|
||||
"--webui-dir",
|
||||
type=str,
|
||||
help="指定 WebUI 静态文件目录路径",
|
||||
default=None,
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
check_env()
|
||||
|
||||
# 启动日志代理
|
||||
log_broker = LogBroker()
|
||||
LogManager.set_queue_handler(logger, log_broker)
|
||||
|
||||
# 检查仪表板文件
|
||||
webui_dir = asyncio.run(check_dashboard_files(args.webui_dir))
|
||||
|
||||
db = db_helper
|
||||
|
||||
# 打印 logo
|
||||
logger.info(logo_tmpl)
|
||||
|
||||
core_lifecycle = InitialLoader(db, log_broker)
|
||||
core_lifecycle.webui_dir = webui_dir
|
||||
asyncio.run(core_lifecycle.start())
|
||||
main()
|
||||
|
||||
@@ -9,12 +9,15 @@ from collections import defaultdict
|
||||
|
||||
import aiodocker
|
||||
import aiohttp
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
|
||||
from astrbot.api import llm_tool, logger, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
|
||||
from astrbot.api.message_components import File, Image
|
||||
from astrbot.api.provider import ProviderRequest
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
from astrbot.core.utils.io import download_file, download_image_by_url
|
||||
|
||||
PROMPT = """
|
||||
@@ -91,7 +94,7 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
"docker_host_astrbot_abs_path": "",
|
||||
}
|
||||
PATH = os.path.join(get_astrbot_data_path(), "config", "python_interpreter.json")
|
||||
PATH = str(AstrbotPaths.astrbot_root / "config" / "python_interpreter.json")
|
||||
|
||||
|
||||
class Main(star.Star):
|
||||
@@ -101,13 +104,15 @@ class Main(star.Star):
|
||||
self.context = context
|
||||
self.curr_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
self.shared_path = os.path.join("data", "py_interpreter_shared")
|
||||
self.shared_path = str(AstrbotPaths.astrbot_root / "py_interpreter_shared")
|
||||
if not os.path.exists(self.shared_path):
|
||||
# 复制 api.py 到 shared 目录
|
||||
os.makedirs(self.shared_path, exist_ok=True)
|
||||
shared_api_file = os.path.join(self.curr_dir, "shared", "api.py")
|
||||
shutil.copy(shared_api_file, self.shared_path)
|
||||
self.workplace_path = os.path.join("data", "py_interpreter_workplace")
|
||||
self.workplace_path = str(
|
||||
AstrbotPaths.astrbot_root / "py_interpreter_workplace"
|
||||
)
|
||||
os.makedirs(self.workplace_path, exist_ok=True)
|
||||
|
||||
self.user_file_msg_buffer = defaultdict(list)
|
||||
@@ -212,8 +217,7 @@ class Main(star.Star):
|
||||
file_path = await comp.get_file()
|
||||
if file_path.startswith("http"):
|
||||
name = comp.name if comp.name else uuid.uuid4().hex[:8]
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
path = os.path.join(temp_dir, name)
|
||||
path = str(AstrbotPaths.astrbot_root / "temp" / name)
|
||||
await download_file(file_path, path)
|
||||
else:
|
||||
path = file_path
|
||||
|
||||
@@ -5,9 +5,13 @@ import uuid
|
||||
import zoneinfo
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
|
||||
from astrbot.api import llm_tool, logger, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
|
||||
@@ -27,7 +31,7 @@ class Main(star.Star):
|
||||
self.scheduler = AsyncIOScheduler(timezone=self.timezone)
|
||||
|
||||
# set and load config
|
||||
reminder_file = os.path.join(get_astrbot_data_path(), "astrbot-reminder.json")
|
||||
reminder_file = str(AstrbotPaths.astrbot_root / "astrbot-reminder.json")
|
||||
if not os.path.exists(reminder_file):
|
||||
with open(reminder_file, "w", encoding="utf-8") as f:
|
||||
f.write("{}")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.5.5"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
dynamic = ["version"]
|
||||
description = "易上手的多平台 LLM 聊天机器人及开发框架"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -63,6 +63,10 @@ dependencies = [
|
||||
"jieba>=0.42.1",
|
||||
"markitdown-no-magika[docx,xls,xlsx]>=0.1.2",
|
||||
"xinference-client",
|
||||
"dotenv>=0.9.9",
|
||||
"astrbot-api",
|
||||
"astrbot-sdk",
|
||||
"dishka>=1.7.2",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -103,11 +107,33 @@ ignore = [
|
||||
"E501",
|
||||
"ASYNC230" # TODO: handle ASYNC230 in AstrBot
|
||||
]
|
||||
[tool.uv.workspace]
|
||||
members = [
|
||||
"astrbot_api",
|
||||
"astrbot_sdk",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
astrbot-api = { workspace = true }
|
||||
astrbot-sdk = { workspace = true }
|
||||
|
||||
[tool.pyright]
|
||||
typeCheckingMode = "basic"
|
||||
pythonVersion = "3.10"
|
||||
reportMissingTypeStubs = false
|
||||
reportMissingImports = false
|
||||
include = ["astrbot","packages"]
|
||||
include = ["astrbot","astrbot_api","astrbot_sdk","packages"]
|
||||
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"
|
||||
|
||||
|
||||
517
tests/test_paths.py
Normal file
517
tests/test_paths.py
Normal file
@@ -0,0 +1,517 @@
|
||||
"""测试 AstrbotPaths 路径类的综合测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from astrbot_api.abc import IAstrbotPaths
|
||||
|
||||
from astrbot_sdk import sync_base_container
|
||||
|
||||
AstrbotPaths: type[IAstrbotPaths] = sync_base_container.get(type[IAstrbotPaths])
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_root(monkeypatch: pytest.MonkeyPatch) -> Generator[Path]:
|
||||
"""创建一个临时根目录用于测试."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
temp_path = Path(tmpdir)
|
||||
monkeypatch.setenv("ASTRBOT_ROOT", str(temp_path))
|
||||
# 清除类变量和实例缓存
|
||||
AstrbotPaths._instances.clear()
|
||||
# 重新加载环境变量
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
AstrbotPaths.astrbot_root = temp_path
|
||||
yield temp_path
|
||||
# 清理
|
||||
AstrbotPaths._instances.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def paths_instance(temp_root: Path) -> AstrbotPaths:
|
||||
"""创建一个 AstrbotPaths 实例用于测试."""
|
||||
return AstrbotPaths.getPaths("test-module")
|
||||
|
||||
|
||||
class TestAstrbotPathsInit:
|
||||
"""测试 AstrbotPaths 初始化."""
|
||||
|
||||
def test_init_creates_root_directory(self, temp_root: Path) -> None:
|
||||
"""测试初始化时创建根目录."""
|
||||
# 删除根目录以测试自动创建
|
||||
if temp_root.exists():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(temp_root)
|
||||
|
||||
AstrbotPaths("test-init")
|
||||
assert temp_root.exists()
|
||||
assert temp_root.is_dir()
|
||||
|
||||
def test_init_with_name(self, temp_root: Path) -> None:
|
||||
"""测试使用名称初始化."""
|
||||
paths = AstrbotPaths("my-module")
|
||||
assert paths.name == "my-module"
|
||||
|
||||
def test_astrbot_root_from_env(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""测试从环境变量读取根目录."""
|
||||
custom_root = tmp_path / "custom_root"
|
||||
custom_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 清除实例缓存
|
||||
AstrbotPaths._instances.clear()
|
||||
|
||||
# 直接设置环境变量(在 load_dotenv 之前)
|
||||
monkeypatch.setenv("ASTRBOT_ROOT", str(custom_root))
|
||||
|
||||
# 直接更新 astrbot_root(模拟 load_dotenv 的效果但使用我们设置的环境变量)
|
||||
AstrbotPaths.astrbot_root = Path(
|
||||
os.getenv("ASTRBOT_ROOT", Path.home() / ".astrbot")
|
||||
).absolute()
|
||||
|
||||
assert AstrbotPaths.astrbot_root == custom_root.absolute()
|
||||
|
||||
def test_astrbot_root_default(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""测试默认根目录."""
|
||||
# 清除环境变量
|
||||
monkeypatch.delenv("ASTRBOT_ROOT", raising=False)
|
||||
# 清除任何可能存在的 .env 文件影响
|
||||
monkeypatch.setattr("os.environ", {**os.environ})
|
||||
|
||||
# 清除实例缓存
|
||||
AstrbotPaths._instances.clear()
|
||||
|
||||
# 重新计算根目录
|
||||
AstrbotPaths.astrbot_root = Path(
|
||||
os.getenv("ASTRBOT_ROOT", Path.home() / ".astrbot")
|
||||
).absolute()
|
||||
|
||||
expected = (Path.home() / ".astrbot").absolute()
|
||||
assert AstrbotPaths.astrbot_root == expected
|
||||
|
||||
|
||||
class TestGetPaths:
|
||||
"""测试 getPaths 单例模式."""
|
||||
|
||||
def test_get_paths_returns_same_instance(self, temp_root: Path) -> None:
|
||||
"""测试多次调用返回同一个实例."""
|
||||
paths1 = AstrbotPaths.getPaths("test-module")
|
||||
paths2 = AstrbotPaths.getPaths("test-module")
|
||||
assert paths1 is paths2
|
||||
|
||||
def test_get_paths_different_names(self, temp_root: Path) -> None:
|
||||
"""测试不同名称返回不同实例."""
|
||||
paths1 = AstrbotPaths.getPaths("module-a")
|
||||
paths2 = AstrbotPaths.getPaths("module-b")
|
||||
assert paths1 is not paths2
|
||||
assert paths1.name == "module-a"
|
||||
assert paths2.name == "module-b"
|
||||
|
||||
def test_get_paths_normalizes_name(self, temp_root: Path) -> None:
|
||||
"""测试名称规范化."""
|
||||
# PEP 503 规范化: 转小写, 替换 -, _, .
|
||||
paths1 = AstrbotPaths.getPaths("Test_Module")
|
||||
paths2 = AstrbotPaths.getPaths("test-module")
|
||||
paths3 = AstrbotPaths.getPaths("TEST.MODULE")
|
||||
|
||||
# 所有这些名称应该被规范化为相同的名称
|
||||
assert paths1 is paths2
|
||||
assert paths2 is paths3
|
||||
|
||||
|
||||
class TestProperties:
|
||||
"""测试所有属性访问器."""
|
||||
|
||||
def test_root_property(self, paths_instance: AstrbotPaths, temp_root: Path) -> None:
|
||||
"""测试 root 属性."""
|
||||
assert paths_instance.root == temp_root
|
||||
assert paths_instance.root.exists()
|
||||
|
||||
def test_root_property_when_not_exists(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""测试 root 属性当根目录不存在时."""
|
||||
non_existent = tmp_path / "non_existent_path"
|
||||
# 确保目录不存在
|
||||
if non_existent.exists():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(non_existent)
|
||||
|
||||
# 清除实例缓存
|
||||
AstrbotPaths._instances.clear()
|
||||
# 设置不存在的路径
|
||||
AstrbotPaths.astrbot_root = non_existent
|
||||
|
||||
# __init__ 会创建根目录,所以 getPaths 会使根目录存在
|
||||
# 我们测试的是在 __init__ 创建目录之前访问 root 属性的行为
|
||||
# 但由于 getPaths 总是调用 __init__,目录总是会被创建
|
||||
# 所以这个测试应该验证即使最初不存在,getPaths 之后也会存在
|
||||
paths = AstrbotPaths.getPaths("test")
|
||||
# getPaths 调用 __init__,__init__ 会创建根目录
|
||||
# 所以 root 应该返回 astrbot_root(现在已存在)
|
||||
assert paths.root == non_existent
|
||||
assert non_existent.exists()
|
||||
|
||||
def test_root_property_fallback_to_cwd(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""测试 root 属性在根目录被删除后回退到 cwd/.astrbot."""
|
||||
import shutil
|
||||
|
||||
# 创建并设置一个根目录
|
||||
temp_root = tmp_path / "test_root"
|
||||
temp_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 清除实例缓存
|
||||
AstrbotPaths._instances.clear()
|
||||
AstrbotPaths.astrbot_root = temp_root
|
||||
|
||||
# 创建实例
|
||||
paths = AstrbotPaths.getPaths("test-fallback")
|
||||
|
||||
# 删除根目录(模拟被外部删除的情况)
|
||||
shutil.rmtree(temp_root)
|
||||
|
||||
# 现在访问 root 应该回退到 cwd/.astrbot
|
||||
expected = Path.cwd() / ".astrbot"
|
||||
assert paths.root == expected
|
||||
|
||||
def test_home_property(self, paths_instance: AstrbotPaths, temp_root: Path) -> None:
|
||||
"""测试 home 属性."""
|
||||
home_path = paths_instance.home
|
||||
expected = temp_root / "home" / paths_instance.name
|
||||
assert home_path == expected
|
||||
assert home_path.exists()
|
||||
assert home_path.is_dir()
|
||||
|
||||
def test_config_property(
|
||||
self, paths_instance: AstrbotPaths, temp_root: Path
|
||||
) -> None:
|
||||
"""测试 config 属性."""
|
||||
config_path = paths_instance.config
|
||||
expected = temp_root / "config" / paths_instance.name
|
||||
assert config_path == expected
|
||||
assert config_path.exists()
|
||||
assert config_path.is_dir()
|
||||
|
||||
def test_data_property(self, paths_instance: AstrbotPaths, temp_root: Path) -> None:
|
||||
"""测试 data 属性."""
|
||||
data_path = paths_instance.data
|
||||
expected = temp_root / "data" / paths_instance.name
|
||||
assert data_path == expected
|
||||
assert data_path.exists()
|
||||
assert data_path.is_dir()
|
||||
|
||||
def test_log_property(self, paths_instance: AstrbotPaths, temp_root: Path) -> None:
|
||||
"""测试 log 属性."""
|
||||
log_path = paths_instance.log
|
||||
expected = temp_root / "logs" / paths_instance.name
|
||||
assert log_path == expected
|
||||
assert log_path.exists()
|
||||
assert log_path.is_dir()
|
||||
|
||||
def test_temp_property(self, paths_instance: AstrbotPaths, temp_root: Path) -> None:
|
||||
"""测试 temp 属性."""
|
||||
temp_path = paths_instance.temp
|
||||
expected = temp_root / "temp" / paths_instance.name
|
||||
assert temp_path == expected
|
||||
assert temp_path.exists()
|
||||
assert temp_path.is_dir()
|
||||
|
||||
def test_plugins_property(
|
||||
self, paths_instance: AstrbotPaths, temp_root: Path
|
||||
) -> None:
|
||||
"""测试 plugins 属性."""
|
||||
plugins_path = paths_instance.plugins
|
||||
expected = temp_root / "plugins" / paths_instance.name
|
||||
assert plugins_path == expected
|
||||
assert plugins_path.exists()
|
||||
assert plugins_path.is_dir()
|
||||
|
||||
def test_properties_create_nested_directories(
|
||||
self, paths_instance: AstrbotPaths, temp_root: Path
|
||||
) -> None:
|
||||
"""测试属性访问时创建嵌套目录."""
|
||||
# 清空目录
|
||||
import shutil
|
||||
|
||||
if temp_root.exists():
|
||||
for item in temp_root.iterdir():
|
||||
if item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
else:
|
||||
item.unlink()
|
||||
|
||||
# 访问所有属性
|
||||
_ = paths_instance.home
|
||||
_ = paths_instance.config
|
||||
_ = paths_instance.data
|
||||
_ = paths_instance.log
|
||||
_ = paths_instance.temp
|
||||
_ = paths_instance.plugins
|
||||
|
||||
# 验证所有目录都已创建
|
||||
assert (temp_root / "home" / paths_instance.name).exists()
|
||||
assert (temp_root / "config" / paths_instance.name).exists()
|
||||
assert (temp_root / "data" / paths_instance.name).exists()
|
||||
assert (temp_root / "logs" / paths_instance.name).exists()
|
||||
assert (temp_root / "temp" / paths_instance.name).exists()
|
||||
assert (temp_root / "plugins" / paths_instance.name).exists()
|
||||
|
||||
|
||||
class TestIsRoot:
|
||||
"""测试 is_root 类方法."""
|
||||
|
||||
def test_is_root_with_marker_file(self, temp_root: Path) -> None:
|
||||
"""测试带有标记文件的根目录识别."""
|
||||
marker_file = temp_root / ".astrbot"
|
||||
marker_file.touch()
|
||||
|
||||
assert AstrbotPaths.is_root(temp_root) is True
|
||||
|
||||
def test_is_root_without_marker_file(self, temp_root: Path) -> None:
|
||||
"""测试没有标记文件的目录."""
|
||||
marker_file = temp_root / ".astrbot"
|
||||
if marker_file.exists():
|
||||
marker_file.unlink()
|
||||
|
||||
assert AstrbotPaths.is_root(temp_root) is False
|
||||
|
||||
def test_is_root_with_non_existent_path(self) -> None:
|
||||
"""测试不存在的路径."""
|
||||
non_existent = Path("/definitely/not/exist/path")
|
||||
assert AstrbotPaths.is_root(non_existent) is False
|
||||
|
||||
def test_is_root_with_file_not_directory(self, temp_root: Path) -> None:
|
||||
"""测试路径是文件而非目录."""
|
||||
test_file = temp_root / "test.txt"
|
||||
test_file.touch()
|
||||
|
||||
assert AstrbotPaths.is_root(test_file) is False
|
||||
|
||||
|
||||
class TestReload:
|
||||
"""测试 reload 方法."""
|
||||
|
||||
def test_reload_updates_root(
|
||||
self, temp_root: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""测试 reload 更新根目录."""
|
||||
paths = AstrbotPaths.getPaths("test-reload")
|
||||
|
||||
# 修改环境变量
|
||||
new_root = temp_root / "new_root"
|
||||
new_root.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("ASTRBOT_ROOT", str(new_root))
|
||||
|
||||
# 重新加载
|
||||
paths.reload()
|
||||
|
||||
# 验证根目录已更新
|
||||
assert AstrbotPaths.astrbot_root == new_root.absolute()
|
||||
|
||||
def test_reload_clears_old_env(
|
||||
self, temp_root: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""测试 reload 在环境变量被删除后使用默认值."""
|
||||
paths = AstrbotPaths.getPaths("test-reload-default")
|
||||
|
||||
# 删除环境变量
|
||||
monkeypatch.delenv("ASTRBOT_ROOT", raising=False)
|
||||
|
||||
# 重新加载
|
||||
paths.reload()
|
||||
|
||||
# 应该使用默认值
|
||||
(Path.home() / ".astrbot").absolute()
|
||||
# 由于 .env 文件可能存在,实际结果可能不变
|
||||
# 所以我们只验证 reload 没有抛出异常
|
||||
assert AstrbotPaths.astrbot_root is not None
|
||||
assert isinstance(AstrbotPaths.astrbot_root, Path)
|
||||
|
||||
|
||||
class TestChdir:
|
||||
"""测试 chdir 上下文管理器."""
|
||||
|
||||
def test_chdir_changes_directory(
|
||||
self, paths_instance: AstrbotPaths, temp_root: Path
|
||||
) -> None:
|
||||
"""测试 chdir 切换目录."""
|
||||
original_cwd = Path.cwd()
|
||||
|
||||
# 创建目标目录
|
||||
target_path = temp_root / "home"
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with paths_instance.chdir("home") as target_dir:
|
||||
current_cwd = Path.cwd()
|
||||
expected_dir = temp_root / "home"
|
||||
assert current_cwd == expected_dir
|
||||
assert target_dir == expected_dir
|
||||
|
||||
# 验证已恢复原目录
|
||||
assert Path.cwd() == original_cwd
|
||||
|
||||
def test_chdir_restores_on_exception(
|
||||
self, paths_instance: AstrbotPaths, temp_root: Path
|
||||
) -> None:
|
||||
"""测试 chdir 在异常时恢复原目录."""
|
||||
original_cwd = Path.cwd()
|
||||
|
||||
# 创建目标目录
|
||||
target_path = temp_root / "home"
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
with paths_instance.chdir("home"):
|
||||
raise ValueError("Test exception")
|
||||
|
||||
# 验证已恢复原目录
|
||||
assert Path.cwd() == original_cwd
|
||||
|
||||
def test_chdir_with_different_subdirectories(
|
||||
self, paths_instance: AstrbotPaths, temp_root: Path
|
||||
) -> None:
|
||||
"""测试 chdir 使用不同的子目录."""
|
||||
original_cwd = Path.cwd()
|
||||
|
||||
# 创建测试目录
|
||||
test_dir = temp_root / "test_subdir"
|
||||
test_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with paths_instance.chdir("test_subdir") as target_dir:
|
||||
assert Path.cwd() == test_dir
|
||||
assert target_dir == test_dir
|
||||
|
||||
assert Path.cwd() == original_cwd
|
||||
|
||||
|
||||
class TestAchdir:
|
||||
"""测试 achdir 异步上下文管理器."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_achdir_changes_directory(
|
||||
self, paths_instance: AstrbotPaths, temp_root: Path
|
||||
) -> None:
|
||||
"""测试 achdir 异步切换目录."""
|
||||
original_cwd = Path.cwd()
|
||||
|
||||
# 创建目标目录
|
||||
target_path = temp_root / "home"
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async with paths_instance.achdir("home") as target_dir:
|
||||
current_cwd = Path.cwd()
|
||||
expected_dir = temp_root / "home"
|
||||
assert current_cwd == expected_dir
|
||||
assert target_dir == expected_dir
|
||||
|
||||
# 验证已恢复原目录
|
||||
assert Path.cwd() == original_cwd
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_achdir_restores_on_exception(
|
||||
self, paths_instance: AstrbotPaths, temp_root: Path
|
||||
) -> None:
|
||||
"""测试 achdir 在异常时恢复原目录."""
|
||||
original_cwd = Path.cwd()
|
||||
|
||||
# 创建目标目录
|
||||
target_path = temp_root / "home"
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
async with paths_instance.achdir("home"):
|
||||
raise ValueError("Test exception")
|
||||
|
||||
# 验证已恢复原目录
|
||||
assert Path.cwd() == original_cwd
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_achdir_with_different_subdirectories(
|
||||
self, paths_instance: AstrbotPaths, temp_root: Path
|
||||
) -> None:
|
||||
"""测试 achdir 使用不同的子目录."""
|
||||
original_cwd = Path.cwd()
|
||||
|
||||
# 创建测试目录
|
||||
test_dir = temp_root / "async_test_subdir"
|
||||
test_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async with paths_instance.achdir("async_test_subdir") as target_dir:
|
||||
assert Path.cwd() == test_dir
|
||||
assert target_dir == test_dir
|
||||
|
||||
assert Path.cwd() == original_cwd
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""集成测试."""
|
||||
|
||||
def test_multiple_modules_isolated(self, temp_root: Path) -> None:
|
||||
"""测试多个模块之间的隔离."""
|
||||
module_a = AstrbotPaths.getPaths("module-a")
|
||||
module_b = AstrbotPaths.getPaths("module-b")
|
||||
|
||||
# 访问各自的 home 目录
|
||||
home_a = module_a.home
|
||||
home_b = module_b.home
|
||||
|
||||
# 验证目录不同
|
||||
assert home_a != home_b
|
||||
assert home_a == temp_root / "home" / "module-a"
|
||||
assert home_b == temp_root / "home" / "module-b"
|
||||
|
||||
# 验证都存在
|
||||
assert home_a.exists()
|
||||
assert home_b.exists()
|
||||
|
||||
def test_full_workflow(self, temp_root: Path) -> None:
|
||||
"""测试完整工作流."""
|
||||
# 创建一个模块
|
||||
module = AstrbotPaths.getPaths("my-plugin")
|
||||
|
||||
# 创建各种文件
|
||||
config_file = module.config / "settings.json"
|
||||
config_file.write_text('{"key": "value"}')
|
||||
|
||||
data_file = module.data / "data.txt"
|
||||
data_file.write_text("some data")
|
||||
|
||||
log_file = module.log / "app.log"
|
||||
log_file.write_text("log entry")
|
||||
|
||||
# 验证文件存在
|
||||
assert config_file.exists()
|
||||
assert data_file.exists()
|
||||
assert log_file.exists()
|
||||
|
||||
# 验证内容
|
||||
assert config_file.read_text() == '{"key": "value"}'
|
||||
assert data_file.read_text() == "some data"
|
||||
assert log_file.read_text() == "log entry"
|
||||
|
||||
def test_singleton_pattern_thread_safe(self, temp_root: Path) -> None:
|
||||
"""测试单例模式的基本行为(注意:不是真正的线程安全测试)."""
|
||||
instances = []
|
||||
for _ in range(10):
|
||||
instances.append(AstrbotPaths.getPaths("singleton-test"))
|
||||
|
||||
# 所有实例应该是同一个对象
|
||||
first = instances[0]
|
||||
for instance in instances[1:]:
|
||||
assert instance is first
|
||||
@@ -1,151 +0,0 @@
|
||||
"""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"])
|
||||
Reference in New Issue
Block a user