Compare commits

..

124 Commits

Author SHA1 Message Date
Soulter
cb02dfe1a4 perf: 优化超时时间 2025-05-16 20:00:14 +08:00
Soulter
b50739e1af perf: 优化登录超时时间 2025-05-16 19:33:37 +08:00
Soulter
8da1b0212d Update README.md 2025-05-16 18:46:26 +08:00
Soulter
ca1f2acb33 Merge pull request #1551 from GowayLee/master
Feature: 添加对 MiniMax TTS API的支持
2025-05-16 18:32:49 +08:00
Soulter
c15f966669 fix: 修复 minimax 相关问题 2025-05-16 18:32:08 +08:00
Soulter
7705b8781a 📦 release: v3.5.10 2025-05-16 17:50:56 +08:00
Soulter
b2502746f0 perf: QQ 下,屏蔽 QQ 管家的消息事件 2025-05-16 17:49:17 +08:00
Soulter
ab68094386 docs: update platform tutprial map 2025-05-16 17:33:57 +08:00
Soulter
bbec701223 Merge pull request #1569 from xiamuceer-j/master
适配一个个人微信适配器——wechatpadpro
2025-05-16 17:29:57 +08:00
Soulter
b29d14e600 perf: 优化适配器终止流程 2025-05-16 17:29:33 +08:00
Soulter
86e51c5cd1 perf: 改进 wechatpadpro 超时重连 2025-05-16 17:22:10 +08:00
Soulter
cb8267be3f feat: wechatpadpro 支持图片接收 2025-05-16 17:18:42 +08:00
xiamuceer
eaed43915c Merge remote-tracking branch 'origin/master' 2025-05-16 17:18:04 +08:00
xiamuceer
bd91fd2c38 Merge branch 'master' of https://github.com/xiamuceer-j/AstrBot 2025-05-16 17:17:51 +08:00
xiamuceer
1203b214cd Merge branch 'master' of https://github.com/xiamuceer-j/AstrBot 2025-05-16 17:05:16 +08:00
xiamuceer
c3fec15f11 update: 添加ws超时重连机制,避免过长时间收不到消息 2025-05-16 17:00:06 +08:00
Soulter
0545653494 feat: 支持轮询消息 2025-05-16 16:54:49 +08:00
Soulter
db2989bdb4 perf: guess private message username 2025-05-16 15:42:33 +08:00
xiamuceer
587bd00a19 update: 新增send_by_session方法,接受处理来自AstrBot核心的消息 2025-05-16 14:30:05 +08:00
Soulter
960ff438e8 🎈perf: 旧消息丢弃 2025-05-16 13:26:45 +08:00
Raven95676
98e7ea85d3 fix: 正确导入WeChatPadProAdapter 2025-05-16 12:39:14 +08:00
xiamuceer
2549e44710 fix: 移除错误引用 2025-05-16 12:26:54 +08:00
xiamuceer
4d32b563ca fix: 对auth_key授权码进行脱敏处理 2025-05-16 12:08:49 +08:00
xiamuceer
3a4b732977 fix: 修复@消息适配,并写明适配器 2025-05-16 11:52:54 +08:00
夏目侧耳
500909a28e Update astrbot/core/platform/sources/wechatpadpro/wechatpadpro_message_event.py
Co-authored-by: 鸦羽 <Raven95676@gmail.com>
2025-05-16 11:47:52 +08:00
Soulter
07753eb25b Merge pull request #1561 from Raven95676/Fix/1554
fix(tts): record组件单独发送以保证兼容性
2025-05-16 11:10:45 +08:00
Soulter
c6eaf3d010 refactor: use aiohttp 2025-05-16 11:04:01 +08:00
Soulter
6723fe8271 🐛 fix: cannot save value when fullscreen editor mode 2025-05-16 10:37:30 +08:00
Raven95676
3348b70435 chore: add dependency 2025-05-16 10:30:29 +08:00
Soulter
35a8527c16 🎈 perf: update defaule value of minimax-timber-weight 2025-05-16 10:29:46 +08:00
Soulter
7afc475290 🐛 fix: value cannot displayed when fullscreen editior mode 2025-05-16 10:29:22 +08:00
Soulter
789bceaa3a Merge remote-tracking branch 'origin/master' into GowayLee/master 2025-05-16 10:23:30 +08:00
Soulter
abbc043969 Merge pull request #1575 from AstrBotDevs/feat-code-editor
Feature: WebUI 配置项支持代码编辑器模式
2025-05-16 10:22:16 +08:00
Soulter
654e5762f1 🐛 fix: 修复 VueMonacoEditor 的 v-model 绑定方式 2025-05-16 10:20:03 +08:00
Soulter
507c3e3629 feat: 配置项支持代码编辑器模式 2025-05-16 10:14:16 +08:00
Raven95676
991dfeb2f2 style: format code, disable redundant logs 2025-05-16 09:28:15 +08:00
夏目侧耳
26482fc2d3 Update astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-05-15 20:59:53 +08:00
夏目侧耳
e0ce6d9688 Update astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-05-15 20:57:22 +08:00
xiamuceer
946595216a 优化wechapadpro代码结构 2025-05-15 20:43:33 +08:00
anka
864b6bc56d fix: 🤠 修复指令后有@导致无法触发指令的问题 2025-05-15 20:00:46 +08:00
夏目侧耳
6ea5b7581f Update astrbot/core/platform/sources/wechatpadpro/wechatpadpro_message_event.py
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-05-15 19:12:42 +08:00
夏目侧耳
f70b8f0c10 Update astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-05-15 19:09:56 +08:00
夏目侧耳
1593bcb537 Update astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-05-15 17:50:29 +08:00
xiamuceer
bf7fc02c8d 适配一个个人微信适配器——wechatpadpro 2025-05-15 17:26:31 +08:00
Raven95676
143702b92b fix(tts): record组件单独发送以保证兼容性 2025-05-15 10:18:05 +08:00
Soulter
2ecb52a9b2 Merge pull request #1529 from anka-afk/1446-bug-mcp
feat: 😽将At字段(非唤起)添加至message_str,修正message_str构造方式
2025-05-14 23:06:25 +08:00
Li Haoyuan
25ef0039e4 refactor: Optimize MiniMax TTS API Provider 2025-05-14 20:59:45 +08:00
渡鸦95676
b6d1515d58 Merge pull request #1541 from Raven95676/fix/astrbot-reboot
fix: 回退至os.execl以兼容docker,改用双引号处理路径空格
2025-05-14 14:57:13 +08:00
Li Haoyuan
e01d4264e3 docs: Adjust MiniMax TTS timber_weights description 2025-05-14 14:40:25 +08:00
Li Haoyuan
2117b65487 feat: Support timber_weights for MiniMax TTS 2025-05-14 14:21:23 +08:00
Li Haoyuan
a7823b352f docs: Adjust MiniMax TTS configuration info 2025-05-14 13:09:09 +08:00
Li Haoyuan
c543b62a08 Merge branch 'AstrBotDevs:master' into master 2025-05-14 13:02:54 +08:00
Li Haoyuan
3923b87f08 feat: Add MiniMax TTS API provider 2025-05-14 13:02:31 +08:00
Soulter
b7ecdadb83 docs: update providers 2025-05-14 09:35:59 +08:00
Soulter
5ff121e1ed docs: PPIO 派欧云 2025-05-14 09:33:35 +08:00
Soulter
f486e5448f Merge pull request #1539 from Raven95676/Feature/ppio
feat: 接入PPIO派欧云
2025-05-14 09:07:38 +08:00
Raven95676
c5aae98558 fix: update reboot logic to handle executable paths correctly 2025-05-13 16:03:04 +08:00
Raven95676
6d8a3b9897 fix: 回退至os.execl以兼容docker,改用双引号处理路径空格 2025-05-13 10:18:11 +08:00
Raven95676
6d98780e19 feat: 接入PPIO派欧云 2025-05-12 18:22:02 +08:00
Raven95676
3ad2c46f3f perf: tg适配器同步aiocqhttp处理逻辑 2025-05-12 15:04:23 +08:00
Raven95676
a730cee7fd fix: at全体不加入message_str 2025-05-12 14:48:31 +08:00
anka
77c823c100 fix: 增加对全体成员的支持 2025-05-12 11:32:40 +08:00
anka
124f21c67a Merge remote-tracking branch 'origin/1446-bug-mcp' into 1446-bug-mcp 2025-05-12 11:24:09 +08:00
anka
e46cf20dd3 fix: 不再添加唤醒的@到message_str 2025-05-12 11:22:46 +08:00
Raven95676
4bef5e8313 fix: 避免message_str被覆盖 2025-05-12 00:21:48 +08:00
anka
22e93b0af4 Merge branch 'AstrBotDevs:master' into 1446-bug-mcp 2025-05-11 22:59:02 +08:00
anka
5aeca9662b feat: 对aiocqhttp中, At字段新增处理: 现在At字段同时也会被解析为文本信息(但消息链并没有修改, 只是在用于llm请求的文本中添加了At信息) 2025-05-11 22:57:50 +08:00
Raven95676
b996cf1f05 chore: update multiple dependencies 2025-05-11 22:16:16 +08:00
渡鸦95676
878a106877 fix changelog 2025-05-11 21:31:27 +08:00
Soulter
45d36f86fd fix: 优化限流逻辑,确保在达到限流阈值时正确处理请求 2025-05-11 21:22:14 +08:00
Soulter
b108ae403a docs: uvx 2025-05-11 20:31:46 +08:00
Soulter
887ed66768 docs: uvx 2025-05-11 20:30:30 +08:00
Soulter
dac840a887 📦release: v3.5.9 2025-05-11 20:08:14 +08:00
Soulter
238de4ba8c fix: 修复企业微信和微信公众平台下无法应用 api_base_url 的问题
fixes: #1505
2025-05-11 19:55:24 +08:00
Soulter
9a7bdade43 Merge pull request #1526 from AstrBotDevs/fix-weixin-kefu
Fix: 修复微信客服下接收消息时可能报错的问题
2025-05-11 19:46:14 +08:00
Soulter
aa84556204 🐛fix: 修复微信客服下接收消息时可能报错的问题
fixes #1504
2025-05-11 19:45:19 +08:00
Soulter
6b68069fcd Merge pull request #1525 from AstrBotDevs/fix-path-issue-cli
Fix: 修复 CLI 模式下路径问题导致 WebUI 和 MCP Server 无法加载的问题
2025-05-11 18:39:12 +08:00
Soulter
42c7034fb2 🐛 fix: 修复路径 2025-05-11 18:17:06 +08:00
Soulter
060c7e0145 🐛fix: 修复 CLI 模式下路径问题导致 WebUI 和 MCP Server 无法加载的问题 2025-05-11 18:09:36 +08:00
Soulter
b5b085dfb1 Merge pull request #1524 from AstrBotDevs/feat-provider-type-webui
Improve: 优化 WebUI 服务提供商的选择界面
2025-05-11 17:46:11 +08:00
Soulter
fc06ce9d7f perf: hint 2025-05-11 17:36:16 +08:00
Soulter
d8d81b05a7 feat: 更直观的模型提供商选择 2025-05-11 17:30:20 +08:00
Soulter
a60f42b1f2 feat: 在配置模板指定提供商能力类型 2025-05-11 04:04:05 -04:00
Soulter
6e18be88d0 Merge pull request #1519 from NanoRocky/master
Add Support for Azure TTS
2025-05-11 15:31:11 +08:00
Soulter
b45e439c48 Merge pull request #1520 from Raven95676/master
feat: 为部分组件提供register_to_file_service方法
2025-05-11 14:55:33 +08:00
Raven95676
b87061c18c feat: add file registration methods for audio, image, and file components 2025-05-11 10:08:55 +08:00
NanoRocky
f78aca7752 Fix provider_config by sourcery-ai 2025-05-11 02:15:37 +08:00
NanoRocky
3ccca2aa10 Update astrbot/core/provider/sources/azure_tts_source.py
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-05-11 02:11:03 +08:00
NanoRocky
6d7c40eb76 Fix AsyncClient 2025-05-11 01:54:44 +08:00
NanoRocky
da4cd7fb65 Add Support for Azure TTS 2025-05-11 01:20:17 +08:00
Soulter
c97cda6b84 Merge pull request #1517 from anchorAnc/fix-issue-1460
Fix issue 1460
2025-05-11 00:22:11 +08:00
Soulter
7a7fd4167a style: format code 2025-05-10 12:21:21 -04:00
Soulter
dffc1a43d5 Merge pull request #1518 from AstrBotDevs/fix-plugin-command
优化 plugin 指令的权限
2025-05-11 00:02:36 +08:00
Soulter
36897fea1e fix: 更正 plugin ls 指令提示 2025-05-10 12:01:49 -04:00
Soulter
c7b34735f0 fix: 更正 plugin help 指令提示 2025-05-10 12:00:48 -04:00
Soulter
5b07176c88 perf: 优化一些报错显示 2025-05-10 11:57:15 -04:00
Soulter
474b40d660 perf: 分离 plugin 指令为指令组,优化权限控制 2025-05-10 11:54:15 -04:00
Anchor
a62901b948 Merge branch 'AstrBotDevs:master' into fix-issue-1460 2025-05-10 23:02:18 +08:00
Anchor
25d8746327 补充一个import 2025-05-10 23:00:55 +08:00
Anchor
aff1698223 fix: 修复重启报错问题(关联 #1460)
使用subprocess.Popen启动新进程,修复原方案识别路径空格的问题
2025-05-10 22:54:38 +08:00
Raven95676
7f8941745f clean code 2025-05-10 22:51:50 +08:00
Raven95676
b858401098 chore: format code 2025-05-10 18:47:56 +08:00
渡鸦95676
d5a158b80f Merge pull request #1512 from Raven95676/Feature/cli-conf
feat: CLI支持部分配置文件项的设定
2025-05-10 16:42:53 +08:00
Raven95676
f315f284aa fix: improve error handling for config loading and setting 2025-05-10 16:24:52 +08:00
Raven95676
c367f5009d feat: CLI支持部分配置文件项的设定 2025-05-10 16:03:08 +08:00
渡鸦95676
6db1e63bda chore: add .astrbot to ignore file 2025-05-10 10:02:18 +08:00
渡鸦95676
e22ab2ede6 Merge pull request #1508 from Raven95676/master
fix: 设置thinking_budget前,先检查是否存在
2025-05-10 09:54:49 +08:00
Raven95676
b7d7e0b682 fix: 设置thinking_budget前,先检查是否存在 2025-05-10 09:51:30 +08:00
Raven95676
96bba15f2f chore: update version 2025-05-09 23:22:18 +08:00
Soulter
fcf965a595 Merge pull request #1480 from Raven95676/feature/cli
Feature: CLI功能增强,问题修复
2025-05-09 21:49:11 +08:00
渡鸦95676
e1a20d3c22 Merge branch 'master' into feature/cli 2025-05-09 20:22:33 +08:00
Soulter
2abd7d8c5d Merge pull request #1501 from AstrBotDevs/test
refactor: QQ 采用 http 回调的方式上报文件消息段中的文件信息。
2025-05-09 19:40:05 +08:00
Soulter
5b8f73cdd7 feat: 新增令牌超时时间 2025-05-09 07:29:37 -04:00
anka
7fd765421f fix: [File] remove unused tags "_downloaded" 2025-05-09 09:58:37 +00:00
Soulter
d9d94af022 perf: 优化异常处理和显示 2025-05-09 04:00:12 -04:00
Soulter
790b924e57 refactor: QQ 采用 http 回调的方式上报文件消息段中的文件信息。
fix: 修复 Lagrange 下合并转发消息失败的问题

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-05-09 03:47:19 -04:00
Soulter
4a62f877df 🐛 fix: 修复单独文件发送时被认为是空消息导致文件无法发送的问题 2025-05-09 10:45:50 +08:00
Raven95676
ac47c57bb7 perf: cli统一使用pathlib,修正typo 2025-05-08 20:25:12 +08:00
Raven95676
ccdbb01513 perf: 修改move为copy,clean code 2025-05-06 18:39:11 +08:00
Raven95676
5206d750ac refactor: 减少重复和嵌套 2025-05-06 18:29:55 +08:00
Raven95676
a800e3df67 chore: 添加依赖 2025-05-06 18:18:15 +08:00
Raven95676
ccb1f87a20 feat: cli支持插件自动热重载;cli支持插件管理;cli支持指定Dashboard端口 2025-05-06 17:56:56 +08:00
Raven95676
c111da4681 refactor: 修改框架路径获取方式,规范化路径拼接 2025-05-06 17:30:34 +08:00
Raven95676
f06be6ed21 refactor: 拆分cli以便后续拓展功能 2025-05-06 00:53:00 +08:00
93 changed files with 5380 additions and 2464 deletions

View File

@@ -20,4 +20,5 @@ dashboard/
data/
changelogs/
tests/
.ruff_cache/
.ruff_cache/
.astrbot

1
.gitignore vendored
View File

@@ -30,3 +30,4 @@ packages/python_interpreter/workplace
.conda/
.idea
pytest.ini
.astrbot

View File

@@ -33,7 +33,7 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
> [!NOTE]
>
> 个人微信接入所依赖的开源项目 Gewechat 近期已停止维护,我们正在评估其他方案(如 xxxbot 等)并将在数日内接入(很快!)。目前推荐微信用户暂时使用**微信官方**推出的企业微信接入方式和微信客服接入方式(版本 >= v3.5.7)。详情请前往 [#1443](https://github.com/AstrBotDevs/AstrBot/issues/1443) 讨论。
> 个人微信接入所依赖的开源项目 Gewechat 近期已停止维护,`v3.5.10` 已经支持接入 WeChatPadPro 替换 gewechat 方式。详见文档 [WeChatPadPro](https://astrbot.app/deploy/platform/wechat/wechatpadpro.html)
## ✨ 近期更新
@@ -78,14 +78,29 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
#### 手动部署
推荐使用 `uv`
> 推荐使用 `uv`。
首先,安装 uv
```bash
pip install uv
```
通过 Git Clone 安装 AstrBot
```bash
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
pip install uv
uv run main.py
```
或者,直接通过 uvx 安装 AstrBot
```bash
mkdir astrbot && cd astrbot
uvx astrbot init
# uvx astrbot run
```
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
#### Replit 部署
@@ -113,21 +128,26 @@ uv run main.py
| 名称 | 支持性 | 类型 | 备注 |
| -------- | ------- | ------- | ------- |
| OpenAI API | ✔ | 文本生成 | 也支持 DeepSeek、Google Gemini、GLM、Kimi、硅基流动、xAI 等兼容 OpenAI API 的服务 |
| OpenAI API | ✔ | 文本生成 | 也支持 DeepSeek、Google Gemini、GLM、Kimi、xAI 等兼容 OpenAI API 的服务 |
| Claude API | ✔ | 文本生成 | |
| Google Gemini API | ✔ | 文本生成 | |
| Dify | ✔ | LLMOps | |
| DashScope(阿里云百炼应用) | ✔ | LLMOps | |
| 阿里云百炼应用 | ✔ | LLMOps | |
| Ollama | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
| LM Studio | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
| LLMTuner | ✔ | 模型加载器 | 本地加载 lora 等微调模型 |
| 硅基流动 | ✔ | 模型 API 服务平台 | |
| PPIO 派欧云 | ✔ | 模型 API 服务平台 | |
| OneAPI | ✔ | LLM 分发系统 | |
| Whisper | ✔ | 语音转文本 | 支持 API、本地部署 |
| SenseVoice | ✔ | 语音转文本 | 本地部署 |
| OpenAI TTS API | ✔ | 文本转语音 | |
| GSVI | ✔ | 文本转语音 | GPT-Sovits-Inference |
| Fishaudio | ✔ | 文本转语音 | GPT-Sovits 作者参与的项目 |
| Edge-TTS | ✔ | 文本转语音 | Edge 浏览器的免费 TTS |
| FishAudio | ✔ | 文本转语音 | GPT-Sovits 作者参与的项目 |
| Edge TTS | ✔ | 文本转语音 | Edge 浏览器的免费 TTS |
| 阿里云百炼 TTS | ✔ | 文本转语音 | |
| Azure TTS | ✔ | 文本转语音 | Microsoft Azure TTS |
## ❤️ 贡献

1
astrbot/cli/__init__.py Normal file
View File

@@ -0,0 +1 @@
__version__ = "3.5.8"

View File

@@ -1,11 +1,11 @@
import asyncio
import os
import shutil
import sys
import click
from pathlib import Path
from astrbot.core.config.default import VERSION
"""
AstrBot CLI入口
"""
import click
import sys
from . import __version__
from .commands import init, run, plug, conf
logo_tmpl = r"""
___ _______.___________..______ .______ ______ .___________.
@@ -14,210 +14,25 @@ logo_tmpl = r"""
/ /_\ \ \ \ | | | / | _ < | | | | | |
/ _____ \ .----) | | | | |\ \----.| |_) | | `--' | | |
/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__|
"""
# utils
def _get_astrbot_root(path: str | None) -> Path:
"""获取astrbot根目录"""
match path:
case None:
match ASTRBOT_ROOT := os.getenv("ASTRBOT_ROOT"):
case None:
astrbot_root = Path.cwd() / "data"
case _:
astrbot_root = Path(ASTRBOT_ROOT).resolve()
case str():
astrbot_root = Path(path).resolve()
dot_astrbot = astrbot_root / ".astrbot"
if not dot_astrbot.exists():
if click.confirm(
f"运行前必须先执行初始化!请检查当前目录是否正确,回车以继续: {astrbot_root}",
default=True,
abort=True,
):
dot_astrbot.touch()
astrbot_root.mkdir(parents=True, exist_ok=True)
click.echo(f"Created {dot_astrbot}")
return astrbot_root
# 通过类型来验证先后,必须先获取 Path 对象才能对该目录进行检查
def _check_astrbot_root(astrbot_root: Path) -> None:
"""验证"""
dot_astrbot = astrbot_root / ".astrbot"
if not astrbot_root.exists():
click.echo(f"AstrBot root directory does not exist: {astrbot_root}")
click.echo("Please run 'astrbot init' to create the directory.")
sys.exit(1)
else:
click.echo(f"AstrBot root directory exists: {astrbot_root}")
if not dot_astrbot.exists():
click.echo(
"如果你确认这是 Astrbot root directory, 你需要在当前目录下创建一个 .astrbot 文件标记该目录为 AstrBot 的数据目录。"
)
if click.confirm(
f"请检查当前目录是否正确,确认正确请回车: {astrbot_root}",
default=True,
abort=True,
):
dot_astrbot.touch()
click.echo(f"Created {dot_astrbot}")
else:
click.echo(f"Welcome back! AstrBot root directory: {astrbot_root}")
async def _check_dashboard(astrbot_root: Path) -> None:
"""检查是否安装了dashboard"""
try:
from ..core.utils.io import get_dashboard_version, download_dashboard
except ImportError:
from astrbot.core.utils.io import get_dashboard_version, download_dashboard
try:
# 添加 create=True 参数以确保在初始化时不会抛出异常
dashboard_version = await get_dashboard_version()
match dashboard_version:
case None:
click.echo("未安装管理面板")
if click.confirm(
"是否安装管理面板?",
default=True,
abort=True,
):
click.echo("正在安装管理面板...")
# 确保使用 create=True 参数
await download_dashboard(
path="data/dashboard.zip", extract_path=str(astrbot_root)
)
click.echo("管理面板安装完成")
case str():
if dashboard_version == f"v{VERSION}":
click.echo("无需更新")
else:
try:
version = dashboard_version.split("v")[1]
click.echo(f"管理面板版本: {version}")
# 确保使用 create=True 参数
await download_dashboard(
path="data/dashboard.zip", extract_path=str(astrbot_root)
)
except Exception as e:
click.echo(f"下载管理面板失败: {e}")
return
except FileNotFoundError:
click.echo("初始化管理面板目录...")
# 初始化模式下,下载到指定位置
try:
await download_dashboard(
path=str(astrbot_root / "dashboard.zip"), extract_path=str(astrbot_root)
)
click.echo("管理面板初始化完成")
except Exception as e:
click.echo(f"下载管理面板失败: {e}")
return
@click.group(name="astrbot")
@click.group()
@click.version_option(__version__, prog_name="AstrBot")
def cli() -> None:
"""The AstrBot CLI"""
click.echo(logo_tmpl)
click.echo("Welcome to AstrBot CLI!")
click.echo(f"AstrBot version: {VERSION}")
click.echo(f"AstrBot CLI version: {__version__}")
# region init
@cli.command()
@click.option("--path", "-p", help="AstrBot 数据目录")
@click.option("--force", "-f", is_flag=True, help="强制初始化")
def init(path: str | None, force: bool) -> None:
"""Initialize AstrBot"""
click.echo("Initializing AstrBot...")
astrbot_root = _get_astrbot_root(path)
if force:
if click.confirm(
"强制初始化会删除当前目录下的所有文件,是否继续?",
default=False,
abort=True,
):
click.echo("正在删除当前目录下的所有文件...")
shutil.rmtree(astrbot_root, ignore_errors=True)
_check_astrbot_root(astrbot_root)
click.echo(f"AstrBot root directory: {astrbot_root}")
if not astrbot_root.exists():
# 创建目录
astrbot_root.mkdir(parents=True, exist_ok=True)
click.echo(f"Created directory: {astrbot_root}")
else:
click.echo(f"Directory already exists: {astrbot_root}")
config_path: Path = astrbot_root / "config"
plugins_path: Path = astrbot_root / "plugins"
temp_path: Path = astrbot_root / "temp"
config_path.mkdir(parents=True, exist_ok=True)
plugins_path.mkdir(parents=True, exist_ok=True)
temp_path.mkdir(parents=True, exist_ok=True)
click.echo(f"Created directories: {config_path}, {plugins_path}, {temp_path}")
# 检查是否安装了dashboard
asyncio.run(_check_dashboard(astrbot_root))
# region run
@cli.command()
@click.option("--path", "-p", help="AstrBot 数据目录")
def run(path: str | None = None) -> None:
"""Run AstrBot"""
# 解析为绝对路径
try:
from ..core.log import LogBroker
from ..core import db_helper
from ..core.initial_loader import InitialLoader
except ImportError:
from astrbot.core.log import LogBroker
from astrbot.core import db_helper
from astrbot.core.initial_loader import InitialLoader
astrbot_root = _get_astrbot_root(path)
_check_astrbot_root(astrbot_root)
asyncio.run(_check_dashboard(astrbot_root))
log_broker = LogBroker()
db = db_helper
core_lifecycle = InitialLoader(db, log_broker)
try:
asyncio.run(core_lifecycle.start())
except KeyboardInterrupt:
click.echo("接收到退出信号,正在关闭 AstrBot...")
except Exception as e:
click.echo(f"运行时出现错误: {e}")
# region Basic
@cli.command(name="version")
def version() -> None:
"""Show the version of AstrBot"""
click.echo(f"AstrBot version: {VERSION}")
@cli.command()
@click.command()
@click.argument("command_name", required=False, type=str)
def help(command_name: str | None) -> None:
"""Show help information for commands
"""显示命令的帮助信息
If COMMAND_NAME is provided, show detailed help for that command.
Otherwise, show general help information.
如果提供了 COMMAND_NAME,则显示该命令的详细帮助信息。
否则,显示通用帮助信息。
"""
ctx = click.get_current_context()
if command_name:
@@ -234,5 +49,11 @@ def help(command_name: str | None) -> None:
click.echo(cli.get_help(ctx))
cli.add_command(init)
cli.add_command(run)
cli.add_command(help)
cli.add_command(plug)
cli.add_command(conf)
if __name__ == "__main__":
cli()

View File

@@ -0,0 +1,6 @@
from .cmd_init import init
from .cmd_run import run
from .cmd_plug import plug
from .cmd_conf import conf
__all__ = ["init", "run", "plug", "conf"]

View File

@@ -0,0 +1,206 @@
import json
import click
import hashlib
import zoneinfo
from typing import Any, Callable
from ..utils import get_astrbot_root, check_astrbot_root
def _validate_log_level(value: str) -> str:
"""验证日志级别"""
value = value.upper()
if value not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
raise click.ClickException(
"日志级别必须是 DEBUG/INFO/WARNING/ERROR/CRITICAL 之一"
)
return value
def _validate_dashboard_port(value: str) -> int:
"""验证 Dashboard 端口"""
try:
port = int(value)
if port < 1 or port > 65535:
raise click.ClickException("端口必须在 1-65535 范围内")
return port
except ValueError:
raise click.ClickException("端口必须是数字")
def _validate_dashboard_username(value: str) -> str:
"""验证 Dashboard 用户名"""
if not value:
raise click.ClickException("用户名不能为空")
return value
def _validate_dashboard_password(value: str) -> str:
"""验证 Dashboard 密码"""
if not value:
raise click.ClickException("密码不能为空")
return hashlib.md5(value.encode()).hexdigest()
def _validate_timezone(value: str) -> str:
"""验证时区"""
try:
zoneinfo.ZoneInfo(value)
except Exception:
raise click.ClickException(f"无效的时区: {value}请使用有效的IANA时区名称")
return value
def _validate_callback_api_base(value: str) -> str:
"""验证回调接口基址"""
if not value.startswith("http://") and not value.startswith("https://"):
raise click.ClickException("回调接口基址必须以 http:// 或 https:// 开头")
return value
# 可通过CLI设置的配置项配置键到验证器函数的映射
CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
"timezone": _validate_timezone,
"log_level": _validate_log_level,
"dashboard.port": _validate_dashboard_port,
"dashboard.username": _validate_dashboard_username,
"dashboard.password": _validate_dashboard_password,
"callback_api_base": _validate_callback_api_base,
}
def _load_config() -> dict[str, Any]:
"""加载或初始化配置文件"""
root = get_astrbot_root()
if not check_astrbot_root(root):
raise click.ClickException(
f"{root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init"
)
config_path = root / "data" / "cmd_config.json"
if not config_path.exists():
from astrbot.core.config.default import DEFAULT_CONFIG
config_path.write_text(
json.dumps(DEFAULT_CONFIG, ensure_ascii=False, indent=2),
encoding="utf-8-sig",
)
try:
return json.loads(config_path.read_text(encoding="utf-8-sig"))
except json.JSONDecodeError as e:
raise click.ClickException(f"配置文件解析失败: {str(e)}")
def _save_config(config: dict[str, Any]) -> None:
"""保存配置文件"""
config_path = get_astrbot_root() / "data" / "cmd_config.json"
config_path.write_text(
json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8-sig"
)
def _set_nested_item(obj: dict[str, Any], path: str, value: Any) -> None:
"""设置嵌套字典中的值"""
parts = path.split(".")
for part in parts[:-1]:
if part not in obj:
obj[part] = {}
elif not isinstance(obj[part], dict):
raise click.ClickException(
f"配置路径冲突: {'.'.join(parts[: parts.index(part) + 1])} 不是字典"
)
obj = obj[part]
obj[parts[-1]] = value
def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
"""获取嵌套字典中的值"""
parts = path.split(".")
for part in parts:
obj = obj[part]
return obj
@click.group(name="conf")
def conf():
"""配置管理命令
支持的配置项:
- timezone: 时区设置 (例如: Asia/Shanghai)
- log_level: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
- dashboard.port: Dashboard 端口
- dashboard.username: Dashboard 用户名
- dashboard.password: Dashboard 密码
- callback_api_base: 回调接口基址
"""
pass
@conf.command(name="set")
@click.argument("key")
@click.argument("value")
def set_config(key: str, value: str):
"""设置配置项的值"""
if key not in CONFIG_VALIDATORS.keys():
raise click.ClickException(f"不支持的配置项: {key}")
config = _load_config()
try:
old_value = _get_nested_item(config, key)
validated_value = CONFIG_VALIDATORS[key](value)
_set_nested_item(config, key, validated_value)
_save_config(config)
click.echo(f"配置已更新: {key}")
if key == "dashboard.password":
click.echo(" 原值: ********")
click.echo(" 新值: ********")
else:
click.echo(f" 原值: {old_value}")
click.echo(f" 新值: {validated_value}")
except KeyError:
raise click.ClickException(f"未知的配置项: {key}")
except Exception as e:
raise click.UsageError(f"设置配置失败: {str(e)}")
@conf.command(name="get")
@click.argument("key", required=False)
def get_config(key: str = None):
"""获取配置项的值不提供key则显示所有可配置项"""
config = _load_config()
if key:
if key not in CONFIG_VALIDATORS.keys():
raise click.ClickException(f"不支持的配置项: {key}")
try:
value = _get_nested_item(config, key)
if key == "dashboard.password":
value = "********"
click.echo(f"{key}: {value}")
except KeyError:
raise click.ClickException(f"未知的配置项: {key}")
except Exception as e:
raise click.UsageError(f"获取配置失败: {str(e)}")
else:
click.echo("当前配置:")
for key in CONFIG_VALIDATORS.keys():
try:
value = (
"********"
if key == "dashboard.password"
else _get_nested_item(config, key)
)
click.echo(f" {key}: {value}")
except (KeyError, TypeError):
pass

View File

@@ -0,0 +1,55 @@
import asyncio
import click
from filelock import FileLock, Timeout
from ..utils import check_dashboard, get_astrbot_root
async def initialize_astrbot(astrbot_root) -> None:
"""执行 AstrBot 初始化逻辑"""
dot_astrbot = astrbot_root / ".astrbot"
if not dot_astrbot.exists():
click.echo(f"Current Directory: {astrbot_root}")
click.echo(
"如果你确认这是 Astrbot root directory, 你需要在当前目录下创建一个 .astrbot 文件标记该目录为 AstrBot 的数据目录。"
)
if click.confirm(
f"请检查当前目录是否正确,确认正确请回车: {astrbot_root}",
default=True,
abort=True,
):
dot_astrbot.touch()
click.echo(f"Created {dot_astrbot}")
paths = {
"data": astrbot_root / "data",
"config": astrbot_root / "data" / "config",
"plugins": astrbot_root / "data" / "plugins",
"temp": astrbot_root / "data" / "temp",
}
for name, path in paths.items():
path.mkdir(parents=True, exist_ok=True)
click.echo(f"{'Created' if not path.exists() else 'Directory exists'}: {path}")
await check_dashboard(astrbot_root / "data")
@click.command()
def init() -> None:
"""初始化 AstrBot"""
click.echo("Initializing AstrBot...")
astrbot_root = get_astrbot_root()
lock_file = astrbot_root / "astrbot.lock"
lock = FileLock(lock_file, timeout=5)
try:
with lock.acquire():
asyncio.run(initialize_astrbot(astrbot_root))
except Timeout:
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
except Exception as e:
raise click.ClickException(f"初始化失败: {e!s}")

View File

@@ -0,0 +1,247 @@
import re
from pathlib import Path
import click
import shutil
from ..utils import (
get_git_repo,
build_plug_list,
manage_plugin,
PluginStatus,
check_astrbot_root,
get_astrbot_root,
)
@click.group()
def plug():
"""插件管理"""
pass
def _get_data_path() -> Path:
base = get_astrbot_root()
if not check_astrbot_root(base):
raise click.ClickException(
f"{base}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init"
)
return (base / "data").resolve()
def display_plugins(plugins, title=None, color=None):
if title:
click.echo(click.style(title, fg=color, bold=True))
click.echo(f"{'名称':<20} {'版本':<10} {'状态':<10} {'作者':<15} {'描述':<30}")
click.echo("-" * 85)
for p in plugins:
desc = p["desc"][:30] + ("..." if len(p["desc"]) > 30 else "")
click.echo(
f"{p['name']:<20} {p['version']:<10} {p['status']:<10} "
f"{p['author']:<15} {desc:<30}"
)
@plug.command()
@click.argument("name")
def new(name: str):
"""创建新插件"""
base_path = _get_data_path()
plug_path = base_path / "plugins" / name
if plug_path.exists():
raise click.ClickException(f"插件 {name} 已存在")
author = click.prompt("请输入插件作者", type=str)
desc = click.prompt("请输入插件描述", type=str)
version = click.prompt("请输入插件版本", type=str)
if not re.match(r"^\d+\.\d+(\.\d+)?$", version.lower().lstrip("v")):
raise click.ClickException("版本号必须为 x.y 或 x.y.z 格式")
repo = click.prompt("请输入插件仓库:", type=str)
if not repo.startswith("http"):
raise click.ClickException("仓库地址必须以 http 开头")
click.echo("下载插件模板...")
get_git_repo(
"https://github.com/Soulter/helloworld",
plug_path,
)
click.echo("重写插件信息...")
# 重写 metadata.yaml
with open(plug_path / "metadata.yaml", "w", encoding="utf-8") as f:
f.write(
f"name: {name}\n"
f"desc: {desc}\n"
f"version: {version}\n"
f"author: {author}\n"
f"repo: {repo}\n"
)
# 重写 README.md
with open(plug_path / "README.md", "w", encoding="utf-8") as f:
f.write(f"# {name}\n\n{desc}\n\n# 支持\n\n[帮助文档](https://astrbot.app)\n")
# 重写 main.py
with open(plug_path / "main.py", "r", encoding="utf-8") as f:
content = f.read()
new_content = content.replace(
'@register("helloworld", "YourName", "一个简单的 Hello World 插件", "1.0.0")',
f'@register("{name}", "{author}", "{desc}", "{version}")',
)
with open(plug_path / "main.py", "w", encoding="utf-8") as f:
f.write(new_content)
click.echo(f"插件 {name} 创建成功")
@plug.command()
@click.option("--all", "-a", is_flag=True, help="列出未安装的插件")
def list(all: bool):
"""列出插件"""
base_path = _get_data_path()
plugins = build_plug_list(base_path / "plugins")
# 未发布的插件
not_published_plugins = [
p for p in plugins if p["status"] == PluginStatus.NOT_PUBLISHED
]
if not_published_plugins:
display_plugins(not_published_plugins, "未发布的插件", "red")
# 需要更新的插件
need_update_plugins = [
p for p in plugins if p["status"] == PluginStatus.NEED_UPDATE
]
if need_update_plugins:
display_plugins(need_update_plugins, "需要更新的插件", "yellow")
# 已安装的插件
installed_plugins = [p for p in plugins if p["status"] == PluginStatus.INSTALLED]
if installed_plugins:
display_plugins(installed_plugins, "已安装的插件", "green")
# 未安装的插件
not_installed_plugins = [
p for p in plugins if p["status"] == PluginStatus.NOT_INSTALLED
]
if not_installed_plugins and all:
display_plugins(not_installed_plugins, "未安装的插件", "blue")
if (
not any([not_published_plugins, need_update_plugins, installed_plugins])
and not all
):
click.echo("未安装任何插件")
@plug.command()
@click.argument("name")
@click.option("--proxy", help="代理服务器地址")
def install(name: str, proxy: str | None):
"""安装插件"""
base_path = _get_data_path()
plug_path = base_path / "plugins"
plugins = build_plug_list(base_path / "plugins")
plugin = next(
(
p
for p in plugins
if p["name"] == name and p["status"] == PluginStatus.NOT_INSTALLED
),
None,
)
if not plugin:
raise click.ClickException(f"未找到可安装的插件 {name},可能是不存在或已安装")
manage_plugin(plugin, plug_path, is_update=False, proxy=proxy)
@plug.command()
@click.argument("name")
def remove(name: str):
"""卸载插件"""
base_path = _get_data_path()
plugins = build_plug_list(base_path / "plugins")
plugin = next((p for p in plugins if p["name"] == name), None)
if not plugin or not plugin.get("local_path"):
raise click.ClickException(f"插件 {name} 不存在或未安装")
plugin_path = plugin["local_path"]
click.confirm(f"确定要卸载插件 {name} 吗?", default=False, abort=True)
try:
shutil.rmtree(plugin_path)
click.echo(f"插件 {name} 已卸载")
except Exception as e:
raise click.ClickException(f"卸载插件 {name} 失败: {e}")
@plug.command()
@click.argument("name", required=False)
@click.option("--proxy", help="Github代理地址")
def update(name: str, proxy: str | None):
"""更新插件"""
base_path = _get_data_path()
plug_path = base_path / "plugins"
plugins = build_plug_list(base_path / "plugins")
if name:
plugin = next(
(
p
for p in plugins
if p["name"] == name and p["status"] == PluginStatus.NEED_UPDATE
),
None,
)
if not plugin:
raise click.ClickException(f"插件 {name} 不需要更新或无法更新")
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
else:
need_update_plugins = [
p for p in plugins if p["status"] == PluginStatus.NEED_UPDATE
]
if not need_update_plugins:
click.echo("没有需要更新的插件")
return
click.echo(f"发现 {len(need_update_plugins)} 个插件需要更新")
for plugin in need_update_plugins:
plugin_name = plugin["name"]
click.echo(f"正在更新插件 {plugin_name}...")
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
@plug.command()
@click.argument("query")
def search(query: str):
"""搜索插件"""
base_path = _get_data_path()
plugins = build_plug_list(base_path / "plugins")
matched_plugins = [
p
for p in plugins
if query.lower() in p["name"].lower()
or query.lower() in p["desc"].lower()
or query.lower() in p["author"].lower()
]
if not matched_plugins:
click.echo(f"未找到匹配 '{query}' 的插件")
return
display_plugins(matched_plugins, f"搜索结果: '{query}'", "cyan")

View File

@@ -0,0 +1,63 @@
import os
import sys
from pathlib import Path
import click
import asyncio
import traceback
from filelock import FileLock, Timeout
from ..utils import check_dashboard, check_astrbot_root, get_astrbot_root
async def run_astrbot(astrbot_root: Path):
"""运行 AstrBot"""
from astrbot.core import logger, LogManager, LogBroker, db_helper
from astrbot.core.initial_loader import InitialLoader
await check_dashboard(astrbot_root / "data")
log_broker = LogBroker()
LogManager.set_queue_handler(logger, log_broker)
db = db_helper
core_lifecycle = InitialLoader(db, log_broker)
await core_lifecycle.start()
@click.option("--reload", "-r", is_flag=True, help="插件自动重载")
@click.option("--port", "-p", help="Astrbot Dashboard端口", required=False, type=str)
@click.command()
def run(reload: bool, port: str) -> None:
"""运行 AstrBot"""
try:
os.environ["ASTRBOT_CLI"] = "1"
astrbot_root = get_astrbot_root()
if not check_astrbot_root(astrbot_root):
raise click.ClickException(
f"{astrbot_root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init"
)
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
sys.path.insert(0, str(astrbot_root))
if port:
os.environ["DASHBOARD_PORT"] = port
if reload:
click.echo("启用插件自动重载")
os.environ["ASTRBOT_RELOAD"] = "1"
lock_file = astrbot_root / "astrbot.lock"
lock = FileLock(lock_file, timeout=5)
with lock.acquire():
asyncio.run(run_astrbot(astrbot_root))
except KeyboardInterrupt:
click.echo("AstrBot 已关闭...")
except Timeout:
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
except Exception as e:
raise click.ClickException(f"运行时出现错误: {e}\n{traceback.format_exc()}")

View File

@@ -0,0 +1,18 @@
from .basic import (
get_astrbot_root,
check_astrbot_root,
check_dashboard,
)
from .plugin import get_git_repo, manage_plugin, build_plug_list, PluginStatus
from .version_comparator import VersionComparator
__all__ = [
"get_astrbot_root",
"check_astrbot_root",
"check_dashboard",
"get_git_repo",
"manage_plugin",
"build_plug_list",
"VersionComparator",
"PluginStatus",
]

View File

@@ -0,0 +1,67 @@
from pathlib import Path
import click
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
def get_astrbot_root() -> Path:
"""获取Astrbot根目录路径"""
return Path.cwd()
async def check_dashboard(astrbot_root: Path) -> None:
"""检查是否安装了dashboard"""
from astrbot.core.utils.io import get_dashboard_version, download_dashboard
from astrbot.core.config.default import VERSION
from .version_comparator import VersionComparator
try:
dashboard_version = await get_dashboard_version()
match dashboard_version:
case None:
click.echo("未安装管理面板")
if click.confirm(
"是否安装管理面板?",
default=True,
abort=True,
):
click.echo("正在安装管理面板...")
await download_dashboard(
path="data/dashboard.zip", extract_path=str(astrbot_root)
)
click.echo("管理面板安装完成")
case str():
if VersionComparator.compare_version(VERSION, dashboard_version) <= 0:
click.echo("管理面板已是最新版本")
return
else:
try:
version = dashboard_version.split("v")[1]
click.echo(f"管理面板版本: {version}")
await download_dashboard(
path="data/dashboard.zip", extract_path=str(astrbot_root)
)
except Exception as e:
click.echo(f"下载管理面板失败: {e}")
return
except FileNotFoundError:
click.echo("初始化管理面板目录...")
try:
await download_dashboard(
path=str(astrbot_root / "dashboard.zip"), extract_path=str(astrbot_root)
)
click.echo("管理面板初始化完成")
except Exception as e:
click.echo(f"下载管理面板失败: {e}")
return

266
astrbot/cli/utils/plugin.py Normal file
View File

@@ -0,0 +1,266 @@
import shutil
import tempfile
import httpx
import yaml
import re
from enum import Enum
from io import BytesIO
from pathlib import Path
from zipfile import ZipFile
import click
from .version_comparator import VersionComparator
class PluginStatus(str, Enum):
INSTALLED = "已安装"
NEED_UPDATE = "需更新"
NOT_INSTALLED = "未安装"
NOT_PUBLISHED = "未发布"
def get_git_repo(url: str, target_path: Path, proxy: str | None = None):
"""从 Git 仓库下载代码并解压到指定路径"""
temp_dir = Path(tempfile.mkdtemp())
try:
# 解析仓库信息
repo_namespace = url.split("/")[-2:]
author = repo_namespace[0]
repo = repo_namespace[1]
# 尝试获取最新的 release
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
try:
with httpx.Client(
proxy=proxy if proxy else None, follow_redirects=True
) as client:
resp = client.get(release_url)
resp.raise_for_status()
releases = resp.json()
if releases:
# 使用最新的 release
download_url = releases[0]["zipball_url"]
else:
# 没有 release使用默认分支
click.echo(f"正在从默认分支下载 {author}/{repo}")
download_url = f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
except Exception as e:
click.echo(f"获取 release 信息失败: {e},将直接使用提供的 URL")
download_url = url
# 应用代理
if proxy:
download_url = f"{proxy}/{download_url}"
# 下载并解压
with httpx.Client(
proxy=proxy if proxy else None, follow_redirects=True
) as client:
resp = client.get(download_url)
resp.raise_for_status()
zip_content = BytesIO(resp.content)
with ZipFile(zip_content) as z:
z.extractall(temp_dir)
namelist = z.namelist()
root_dir = Path(namelist[0]).parts[0] if namelist else ""
if target_path.exists():
shutil.rmtree(target_path)
shutil.move(temp_dir / root_dir, target_path)
finally:
if temp_dir.exists():
shutil.rmtree(temp_dir, ignore_errors=True)
def load_yaml_metadata(plugin_dir: Path) -> dict:
"""从 metadata.yaml 文件加载插件元数据
Args:
plugin_dir: 插件目录路径
Returns:
dict: 包含元数据的字典,如果读取失败则返回空字典
"""
yaml_path = plugin_dir / "metadata.yaml"
if yaml_path.exists():
try:
return yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
except Exception as e:
click.echo(f"读取 {yaml_path} 失败: {e}", err=True)
return {}
def extract_py_metadata(plugin_dir: Path) -> dict:
"""从 Python 文件中提取插件元数据
Args:
plugin_dir: 插件目录路径
Returns:
dict: 包含元数据的字典,如果提取失败则返回空字典
"""
# 检查 main.py 或与目录同名的 py 文件
for pattern in ["main.py", f"{plugin_dir.name}.py"]:
for py_file in plugin_dir.glob(pattern):
try:
content = py_file.read_text(encoding="utf-8")
register_match = re.search(
r'@register_star\s*\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"(?:\s*,\s*"?([^")]+)"?)?\s*\)',
content,
)
if register_match:
# 映射匹配组到元数据键
metadata = {}
keys = ["name", "author", "desc", "version", "repo"]
for i, key in enumerate(keys):
if i + 1 <= len(
register_match.groups()
) and register_match.group(i + 1):
metadata[key] = register_match.group(i + 1)
return metadata
except Exception as e:
click.echo(f"读取 {py_file} 失败: {e}", err=True)
return {}
def build_plug_list(plugins_dir: Path) -> list:
"""构建插件列表,包含本地和在线插件信息
Args:
plugins_dir (Path): 插件目录路径
Returns:
list: 包含插件信息的字典列表
"""
# 获取本地插件信息
result = []
if plugins_dir.exists():
for plugin_name in [d.name for d in plugins_dir.glob("*") if d.is_dir()]:
plugin_dir = plugins_dir / plugin_name
# 从不同来源加载元数据
metadata = load_yaml_metadata(plugin_dir)
# 如果元数据不完整,尝试从 Python 文件提取
if not metadata or not all(
k in metadata for k in ["name", "desc", "version", "author", "repo"]
):
py_metadata = extract_py_metadata(plugin_dir)
# 合并元数据,保留已有的值
for key, value in py_metadata.items():
if key not in metadata or not metadata[key]:
metadata[key] = value
# 如果成功提取元数据,添加到结果列表
if metadata:
result.append(
{
"name": str(metadata.get("name", "")),
"desc": str(metadata.get("desc", "")),
"version": str(metadata.get("version", "")),
"author": str(metadata.get("author", "")),
"repo": str(metadata.get("repo", "")),
"status": PluginStatus.INSTALLED,
"local_path": str(plugin_dir),
}
)
# 获取在线插件列表
online_plugins = []
try:
with httpx.Client() as client:
resp = client.get("https://api.soulter.top/astrbot/plugins")
resp.raise_for_status()
data = resp.json()
for plugin_id, plugin_info in data.items():
online_plugins.append(
{
"name": str(plugin_id),
"desc": str(plugin_info.get("desc", "")),
"version": str(plugin_info.get("version", "")),
"author": str(plugin_info.get("author", "")),
"repo": str(plugin_info.get("repo", "")),
"status": PluginStatus.NOT_INSTALLED,
"local_path": None,
}
)
except Exception as e:
click.echo(f"获取在线插件列表失败: {e}", err=True)
# 与在线插件比对,更新状态
online_plugin_names = {plugin["name"] for plugin in online_plugins}
for local_plugin in result:
if local_plugin["name"] in online_plugin_names:
# 查找对应的在线插件
online_plugin = next(
p for p in online_plugins if p["name"] == local_plugin["name"]
)
if (
VersionComparator.compare_version(
local_plugin["version"], online_plugin["version"]
)
< 0
):
local_plugin["status"] = PluginStatus.NEED_UPDATE
else:
# 本地插件未在线上发布
local_plugin["status"] = PluginStatus.NOT_PUBLISHED
# 添加未安装的在线插件
for online_plugin in online_plugins:
if not any(plugin["name"] == online_plugin["name"] for plugin in result):
result.append(online_plugin)
return result
def manage_plugin(
plugin: dict, plugins_dir: Path, is_update: bool = False, proxy: str | None = None
) -> None:
"""安装或更新插件
Args:
plugin (dict): 插件信息字典
plugins_dir (Path): 插件目录
is_update (bool, optional): 是否为更新操作. 默认为 False
proxy (str, optional): 代理服务器地址
"""
plugin_name = plugin["name"]
repo_url = plugin["repo"]
# 如果是更新且有本地路径,直接使用本地路径
if is_update and plugin.get("local_path"):
target_path = Path(plugin["local_path"])
else:
target_path = plugins_dir / plugin_name
backup_path = Path(f"{target_path}_backup") if is_update else None
# 检查插件是否存在
if is_update and not target_path.exists():
raise click.ClickException(f"插件 {plugin_name} 未安装,无法更新")
# 备份现有插件
if is_update and backup_path.exists():
shutil.rmtree(backup_path)
if is_update:
shutil.copytree(target_path, backup_path)
try:
click.echo(
f"正在从 {repo_url} {'更新' if is_update else '下载'}插件 {plugin_name}..."
)
get_git_repo(repo_url, target_path, proxy)
# 更新成功,删除备份
if is_update and backup_path.exists():
shutil.rmtree(backup_path)
click.echo(f"插件 {plugin_name} {'更新' if is_update else '安装'}成功")
except Exception as e:
if target_path.exists():
shutil.rmtree(target_path, ignore_errors=True)
if is_update and backup_path.exists():
shutil.move(backup_path, target_path)
raise click.ClickException(
f"{'更新' if is_update else '安装'}插件 {plugin_name} 时出错: {e}"
)

View File

@@ -0,0 +1,92 @@
"""
拷贝自 astrbot.core.utils.version_comparator
"""
import re
class VersionComparator:
@staticmethod
def compare_version(v1: str, v2: str) -> int:
"""根据 Semver 语义版本规范来比较版本号的大小。支持不仅局限于 3 个数字的版本号,并处理预发布标签。
参考: https://semver.org/lang/zh-CN/
返回 1 表示 v1 > v2返回 -1 表示 v1 < v2返回 0 表示 v1 = v2。
"""
v1 = v1.lower().replace("v", "")
v2 = v2.lower().replace("v", "")
def split_version(version):
match = re.match(
r"^([0-9]+(?:\.[0-9]+)*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+(.+))?$",
version,
)
if not match:
return [], None
major_minor_patch = match.group(1).split(".")
prerelease = match.group(2)
# buildmetadata = match.group(3) # 构建元数据在比较时忽略
parts = [int(x) for x in major_minor_patch]
prerelease = VersionComparator._split_prerelease(prerelease)
return parts, prerelease
v1_parts, v1_prerelease = split_version(v1)
v2_parts, v2_prerelease = split_version(v2)
# 比较数字部分
length = max(len(v1_parts), len(v2_parts))
v1_parts.extend([0] * (length - len(v1_parts)))
v2_parts.extend([0] * (length - len(v2_parts)))
for i in range(length):
if v1_parts[i] > v2_parts[i]:
return 1
elif v1_parts[i] < v2_parts[i]:
return -1
# 比较预发布标签
if v1_prerelease is None and v2_prerelease is not None:
return 1 # 没有预发布标签的版本高于有预发布标签的版本
elif v1_prerelease is not None and v2_prerelease is None:
return -1 # 有预发布标签的版本低于没有预发布标签的版本
elif v1_prerelease is not None and v2_prerelease is not None:
len_pre = max(len(v1_prerelease), len(v2_prerelease))
for i in range(len_pre):
p1 = v1_prerelease[i] if i < len(v1_prerelease) else None
p2 = v2_prerelease[i] if i < len(v2_prerelease) else None
if p1 is None and p2 is not None:
return -1
elif p1 is not None and p2 is None:
return 1
elif isinstance(p1, int) and isinstance(p2, str):
return -1
elif isinstance(p1, str) and isinstance(p2, int):
return 1
elif isinstance(p1, int) and isinstance(p2, int):
if p1 > p2:
return 1
elif p1 < p2:
return -1
elif isinstance(p1, str) and isinstance(p2, str):
if p1 > p2:
return 1
elif p1 < p2:
return -1
return 0 # 预发布标签完全相同
return 0 # 数字部分和预发布标签都相同
@staticmethod
def _split_prerelease(prerelease):
if not prerelease:
return None
parts = prerelease.split(".")
result = []
for part in parts:
if part.isdigit():
result.append(int(part))
else:
result.append(part)
return result

View File

@@ -7,27 +7,28 @@ from astrbot.core.utils.pip_installer import PipInstaller
from astrbot.core.db.sqlite import SQLiteDatabase
from astrbot.core.config.default import DB_PATH
from astrbot.core.config import AstrBotConfig
from astrbot.core.file_token_service import FileTokenService
from .utils.astrbot_path import get_astrbot_data_path
# 初始化数据存储文件夹
os.makedirs("data", exist_ok=True)
os.makedirs(get_astrbot_data_path(), exist_ok=True)
WEBUI_SK = "Advanced_System_for_Text_Response_and_Bot_Operations_Tool"
DEMO_MODE = os.getenv("DEMO_MODE", False)
astrbot_config = AstrBotConfig()
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
html_renderer = HtmlRenderer(t2i_base_url)
logger = LogManager.GetLogger(log_name="astrbot")
if os.environ.get("TESTING", ""):
logger.setLevel("DEBUG")
db_helper = SQLiteDatabase(DB_PATH)
sp = (
SharedPreferences()
) # 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
# 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
sp = SharedPreferences()
# 文件令牌服务
file_token_service = FileTokenService()
pip_installer = PipInstaller(
astrbot_config.get("pip_install_arg", ""),
astrbot_config.get("pypi_index_url", None),
)
web_chat_queue = asyncio.Queue(maxsize=32)
web_chat_back_queue = asyncio.Queue(maxsize=32)
WEBUI_SK = "Advanced_System_for_Text_Response_and_Bot_Operations_Tool"
DEMO_MODE = os.getenv("DEMO_MODE", False)

View File

@@ -4,8 +4,9 @@ import logging
import enum
from .default import DEFAULT_CONFIG, DEFAULT_VALUE_MAP
from typing import Dict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
ASTRBOT_CONFIG_PATH = "data/cmd_config.json"
ASTRBOT_CONFIG_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
logger = logging.getLogger("astrbot")
@@ -45,8 +46,6 @@ class AstrBotConfig(dict):
with open(config_path, "r", encoding="utf-8-sig") as f:
conf_str = f.read()
if conf_str.startswith("/ufeff"): # remove BOM
conf_str = conf_str.encode("utf8")[3:].decode("utf8")
conf = json.loads(conf_str)
# 检查配置完整性,并插入

View File

@@ -2,8 +2,11 @@
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
"""
VERSION = "3.5.8"
DB_PATH = "data/data_v3.db"
import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "3.5.10"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db")
# 默认配置
DEFAULT_CONFIG = {
@@ -104,6 +107,7 @@ DEFAULT_CONFIG = {
"knowledge_db": {},
"persona": [],
"timezone": "",
"callback_api_base": "",
}
@@ -151,6 +155,16 @@ CONFIG_METADATA_2 = {
"host": "这里填写你的局域网IP或者公网服务器IP",
"port": 11451,
},
"wechatpadpro(微信)": {
"id": "wechatpadpro",
"type": "wechatpadpro",
"enable": False,
"admin_key": "stay33",
"host": "这里填写你的局域网IP或者公网服务器IP",
"port": 8059,
"wpp_active_message_poll": False,
"wpp_active_message_poll_interval": 3,
},
"weixin_official_account(微信公众平台)": {
"id": "weixin_official_account",
"type": "weixin_official_account",
@@ -206,6 +220,16 @@ CONFIG_METADATA_2 = {
},
},
"items": {
"wpp_active_message_poll": {
"description": "是否启用主动消息轮询",
"type": "bool",
"hint": "只有当你发现微信消息没有按时同步到 AstrBot 时,才需要启用这个功能,默认不启用。"
},
"wpp_active_message_poll_interval": {
"description": "主动消息轮询间隔",
"type": "int",
"hint": "主动消息轮询间隔,单位为秒,默认 3 秒,最大不要超过 60 秒,否则可能被认为是旧消息。"
},
"kf_name": {
"description": "微信客服账号名",
"type": "string",
@@ -493,6 +517,7 @@ CONFIG_METADATA_2 = {
"OpenAI": {
"id": "openai",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.openai.com/v1",
@@ -501,9 +526,10 @@ CONFIG_METADATA_2 = {
"model": "gpt-4o-mini",
},
},
"Azure_OpenAI": {
"Azure OpenAI": {
"id": "azure",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"api_version": "2024-05-01-preview",
"key": [],
@@ -513,9 +539,10 @@ CONFIG_METADATA_2 = {
"model": "gpt-4o-mini",
},
},
"xAI(grok)": {
"xAI": {
"id": "xai",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.x.ai/v1",
@@ -524,9 +551,10 @@ CONFIG_METADATA_2 = {
"model": "grok-2-latest",
},
},
"Anthropic(claude)": {
"Anthropic": {
"id": "claude",
"type": "anthropic_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.anthropic.com/v1",
@@ -539,6 +567,7 @@ CONFIG_METADATA_2 = {
"Ollama": {
"id": "ollama_default",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": ["ollama"], # ollama 的 key 默认是 ollama
"api_base": "http://localhost:11434/v1",
@@ -546,9 +575,10 @@ CONFIG_METADATA_2 = {
"model": "llama3.1-8b",
},
},
"LM_Studio": {
"LM Studio": {
"id": "lm_studio",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": ["lmstudio"],
"api_base": "http://localhost:1234/v1",
@@ -559,6 +589,7 @@ CONFIG_METADATA_2 = {
"Gemini(OpenAI兼容)": {
"id": "gemini_default",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
@@ -567,9 +598,10 @@ CONFIG_METADATA_2 = {
"model": "gemini-1.5-flash",
},
},
"Gemini(googlegenai原生)": {
"Gemini": {
"id": "gemini_default",
"type": "googlegenai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://generativelanguage.googleapis.com/",
@@ -593,6 +625,7 @@ CONFIG_METADATA_2 = {
"DeepSeek": {
"id": "deepseek_default",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.deepseek.com/v1",
@@ -601,9 +634,10 @@ CONFIG_METADATA_2 = {
"model": "deepseek-chat",
},
},
"Zhipu(智谱)": {
"智谱 AI": {
"id": "zhipu_default",
"type": "zhipu_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
@@ -612,9 +646,10 @@ CONFIG_METADATA_2 = {
"model": "glm-4-flash",
},
},
"SiliconFlow(硅基流动)": {
"硅基流动": {
"id": "siliconflow",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
@@ -623,9 +658,10 @@ CONFIG_METADATA_2 = {
"model": "deepseek-ai/DeepSeek-V3",
},
},
"MoonShot(Kimi)": {
"Kimi": {
"id": "moonshot",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
@@ -634,9 +670,22 @@ CONFIG_METADATA_2 = {
"model": "moonshot-v1-8k",
},
},
"PPIO派欧云": {
"id": "ppio",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.ppinfra.com/v3/openai",
"timeout": 120,
"model_config": {
"model": "deepseek/deepseek-r1",
},
},
"LLMTuner": {
"id": "llmtuner_default",
"type": "llm_tuner",
"provider_type": "chat_completion",
"enable": True,
"base_model_path": "",
"adapter_model_path": "",
@@ -647,6 +696,7 @@ CONFIG_METADATA_2 = {
"Dify": {
"id": "dify_app_default",
"type": "dify",
"provider_type": "chat_completion",
"enable": True,
"dify_api_type": "chat",
"dify_api_key": "",
@@ -656,9 +706,10 @@ CONFIG_METADATA_2 = {
"variables": {},
"timeout": 60,
},
"Dashscope(阿里云百炼应用)": {
"阿里云百炼应用": {
"id": "dashscope",
"type": "dashscope",
"provider_type": "chat_completion",
"enable": True,
"dashscope_app_type": "agent",
"dashscope_api_key": "",
@@ -674,6 +725,7 @@ CONFIG_METADATA_2 = {
"FastGPT": {
"id": "fastgpt",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.fastgpt.in/api/v1",
@@ -682,6 +734,7 @@ CONFIG_METADATA_2 = {
"Whisper(API)": {
"id": "whisper",
"type": "openai_whisper_api",
"provider_type": "speech_to_text",
"enable": False,
"api_key": "",
"api_base": "",
@@ -689,22 +742,25 @@ CONFIG_METADATA_2 = {
},
"Whisper(本地加载)": {
"whisper_hint": "(不用修改我)",
"type": "openai_whisper_selfhost",
"provider_type": "speech_to_text",
"enable": False,
"id": "whisper",
"type": "openai_whisper_selfhost",
"model": "tiny",
},
"sensevoice(本地加载)": {
"SenseVoice(本地加载)": {
"sensevoice_hint": "(不用修改我)",
"type": "sensevoice_stt_selfhost",
"provider_type": "speech_to_text",
"enable": False,
"id": "sensevoice",
"type": "sensevoice_stt_selfhost",
"stt_model": "iic/SenseVoiceSmall",
"is_emotion": False,
},
"OpenAI_TTS(API)": {
"OpenAI TTS(API)": {
"id": "openai_tts",
"type": "openai_tts_api",
"provider_type": "text_to_speech",
"enable": False,
"api_key": "",
"api_base": "",
@@ -712,43 +768,118 @@ CONFIG_METADATA_2 = {
"openai-tts-voice": "alloy",
"timeout": "20",
},
"Edge_TTS": {
"Edge TTS": {
"edgetts_hint": "提示:使用这个服务前需要安装有 ffmpeg并且可以直接在终端调用 ffmpeg 指令。",
"id": "edge_tts",
"type": "edge_tts",
"provider_type": "text_to_speech",
"enable": False,
"edge-tts-voice": "zh-CN-XiaoxiaoNeural",
"timeout": 20,
},
"GSVI_TTS(API)": {
"GSVI TTS(API)": {
"id": "gsvi_tts",
"type": "gsvi_tts_api",
"provider_type": "text_to_speech",
"api_base": "http://127.0.0.1:5000",
"character": "",
"emotion": "default",
"enable": False,
"timeout": 20,
},
"FishAudio_TTS(API)": {
"FishAudio TTS(API)": {
"id": "fishaudio_tts",
"type": "fishaudio_tts_api",
"provider_type": "text_to_speech",
"enable": False,
"api_key": "",
"api_base": "https://api.fish.audio/v1",
"fishaudio-tts-character": "可莉",
"timeout": "20",
},
"阿里云百炼_TTS(API)": {
"阿里云百炼 TTS(API)": {
"id": "dashscope_tts",
"type": "dashscope_tts",
"provider_type": "text_to_speech",
"enable": False,
"api_key": "",
"model": "cosyvoice-v1",
"dashscope_tts_voice": "loongstella",
"timeout": "20",
},
"Azure TTS": {
"id": "azure_tts",
"type": "azure_tts",
"provider_type": "text_to_speech",
"enable": True,
"azure_tts_voice": "zh-CN-YunxiaNeural",
"azure_tts_style": "cheerful",
"azure_tts_role": "Boy",
"azure_tts_rate": "1",
"azure_tts_volume": "100",
"azure_tts_subscription_key": "",
"azure_tts_region": "eastus"
},
"MiniMax TTS(API)": {
"id": "minimax_tts",
"type": "minimax_tts_api",
"provider_type": "text_to_speech",
"enable": False,
"api_key": "",
"api_base": "https://api.minimax.chat/v1/t2a_v2",
"minimax-group-id": "",
"model": "speech-02-turbo",
"minimax-langboost": "auto",
"minimax-voice-speed": 1.0,
"minimax-voice-vol": 1.0,
"minimax-voice-pitch": 0,
"minimax-is-timber-weight": False,
"minimax-voice-id": "female-shaonv",
"minimax-timber-weight": '[\n {\n "voice_id": "Chinese (Mandarin)_Warm_Girl",\n "weight": 25\n },\n {\n "voice_id": "Chinese (Mandarin)_BashfulGirl",\n "weight": 50\n }\n]',
"minimax-voice-emotion": "neutral",
"minimax-voice-latex": False,
"minimax-voice-english-normalization": False,
"timeout": 20,
},
},
"items": {
"azure_tts_voice": {
"type": "string",
"description": "音色设置",
"hint": "API 音色"
},
"azure_tts_style": {
"type": "string",
"description": "风格设置",
"hint": "声音特定的讲话风格。 可以表达快乐、同情和平静等情绪。"
},
"azure_tts_role": {
"type": "string",
"description": "模仿设置(可选)",
"hint": "讲话角色扮演。 声音可以模仿不同的年龄和性别,但声音名称不会更改。 例如,男性语音可以提高音调和改变语调来模拟女性语音,但语音名称不会更改。 如果角色缺失或不受声音的支持,则会忽略此属性。",
"options": ["Boy","Girl","YoungAdultFemale","YoungAdultMale","OlderAdultFemale","OlderAdultMale","SeniorFemale","SeniorMale","禁用"]
},
"azure_tts_rate": {
"type": "string",
"description": "语速设置",
"hint": "指示文本的讲出速率。可在字词或句子层面应用语速。 速率变化应为原始音频的 0.5 到 2 倍。"
},
"azure_tts_volume": {
"type": "string",
"description": "语音音量设置",
"hint": "指示语音的音量级别。 可在句子层面应用音量的变化。以从 0.0 到 100.0(从最安静到最大声,例如 75的数字表示。 默认值为 100.0。"
},
"azure_tts_region": {
"type": "string",
"description": "API 地区",
"hint": "Azure_TTS 处理数据所在区域,具体参考 https://learn.microsoft.com/zh-cn/azure/ai-services/speech-service/regions",
"options": ["southafricanorth", "eastasia", "southeastasia", "australiaeast", "centralindia", "japaneast", "japanwest", "koreacentral", "canadacentral", "northeurope", "westeurope", "francecentral", "germanywestcentral", "norwayeast", "swedencentral", "switzerlandnorth", "switzerlandwest", "uksouth", "uaenorth", "brazilsouth", "qatarcentral", "centralus", "eastus", "eastus2", "northcentralus", "southcentralus", "westcentralus", "westus", "westus2", "westus3"]
},
"azure_tts_subscription_key": {
"type": "string",
"description": "服务订阅密钥",
"hint": "Azure_TTS 服务的订阅密钥(注意不是令牌)"
},
"dashscope_tts_voice": {
"description": "语音合成模型",
"type": "string",
@@ -833,6 +964,64 @@ CONFIG_METADATA_2 = {
},
},
},
"minimax-group-id": {
"type": "string",
"description": "用户组",
"hint": "于账户管理->基本信息中可见",
},
"minimax-langboost": {
"type": "string",
"description": "指定语言/方言",
"hint": "增强对指定的小语种和方言的识别能力,设置后可以提升在指定小语种/方言场景下的语音表现",
"options": [ "Chinese","Chinese,Yue","English","Arabic","Russian","Spanish","French","Portuguese","German","Turkish","Dutch","Ukrainian","Vietnamese","Indonesian","Japanese","Italian","Korean","Thai","Polish","Romanian","Greek","Czech","Finnish","Hindi","auto",],
},
"minimax-voice-speed": {
"type": "float",
"description": "语速",
"hint": "生成声音的语速, 取值[0.5, 2], 默认为1.0, 取值越大,语速越快",
},
"minimax-voice-vol": {
"type": "float",
"description": "音量",
"hint": "生成声音的音量, 取值(0, 10], 默认为1.0, 取值越大,音量越高",
},
"minimax-voice-pitch": {
"type": "int",
"description": "语调",
"hint": "生成声音的语调, 取值[-12, 12], 默认为0",
},
"minimax-is-timber-weight": {
"type": "bool",
"description": "启用混合音色",
"hint": "启用混合音色, 支持以自定义权重混合最多四种音色, 启用后自动忽略单一音色设置",
},
"minimax-timber-weight": {
"type": "string",
"description": "混合音色",
"editor_mode": True,
"hint": "混合音色及其权重, 最多支持四种音色, 权重为整数, 取值[1, 100]. 可在官网API语音调试台预览代码获得预设以及编写模板, 需要严格按照json字符串格式编写, 可以查看控制台判断是否解析成功. 具体结构可参照默认值以及官网代码预览.",
},
"minimax-voice-id": {
"type": "string",
"description": "单一音色",
"hint": "单一音色编号, 详见官网文档",
},
"minimax-voice-emotion": {
"type": "string",
"description": "情绪",
"hint": "控制合成语音的情绪",
"options": ["happy","sad","angry","fearful","disgusted","surprised","neutral",],
},
"minimax-voice-latex": {
"type": "bool",
"description": "支持朗读latex公式",
"hint": "朗读latex公式, 但是需要确保输入文本按官网要求格式化",
},
"minimax-voice-english-normalization": {
"type": "bool",
"description": "支持英语文本规范化",
"hint": "可提升数字阅读场景的性能,但会略微增加延迟",
},
"rag_options": {
"description": "RAG 选项",
"type": "object",
@@ -930,7 +1119,12 @@ CONFIG_METADATA_2 = {
"hint": "ID 不能和其它的服务提供商重复,否则将发生严重冲突。",
},
"type": {
"description": "模型提供商类",
"description": "模型提供商",
"type": "string",
"invisible": True,
},
"provider_type": {
"description": "模型提供商能力种类",
"type": "string",
"invisible": True,
},
@@ -1283,6 +1477,12 @@ CONFIG_METADATA_2 = {
"obvious_hint": True,
"hint": "时区设置。请填写 IANA 时区名称, 如 Asia/Shanghai, 为空时使用系统默认时区。所有时区请查看: https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab",
},
"callback_api_base": {
"description": "对外可达的回调接口地址",
"type": "string",
"obvious_hint": True,
"hint": "外部服务可能会通过 AstrBot 生成的回调链接(如文件下载链接)访问 AstrBot 后端。由于 AstrBot 无法自动判断部署环境中对外可达的主机地址host因此需要通过此配置项显式指定 “外部服务如何访问 AstrBot” 的地址。如 http://localhost:6185https://example.com 等。"
},
"log_level": {
"description": "控制台日志级别",
"type": "string",

View File

@@ -3,8 +3,9 @@ import aiosqlite
import os
from typing import Any
from .plugin_storage import PluginStorage
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
DBPATH = "data/plugin_data/sqlite/plugin_data.db"
DBPATH = os.path.join(get_astrbot_data_path(), "plugin_data", "sqlite", "plugin_data.db")
class SQLitePluginStorage(PluginStorage):

View File

@@ -0,0 +1,68 @@
import asyncio
import os
import uuid
import time
class FileTokenService:
"""维护一个简单的基于令牌的文件下载服务,支持超时和懒清除。"""
def __init__(self, default_timeout: float = 300):
self.lock = asyncio.Lock()
self.staged_files = {} # token: (file_path, expire_time)
self.default_timeout = default_timeout
async def _cleanup_expired_tokens(self):
"""清理过期的令牌"""
now = time.time()
expired_tokens = [token for token, (_, expire) in self.staged_files.items() if expire < now]
for token in expired_tokens:
self.staged_files.pop(token, None)
async def register_file(self, file_path: str, timeout: float = None) -> str:
"""向令牌服务注册一个文件。
Args:
file_path(str): 文件路径
timeout(float): 超时时间,单位秒(可选)
Returns:
str: 一个单次令牌
Raises:
FileNotFoundError: 当路径不存在时抛出
"""
async with self.lock:
await self._cleanup_expired_tokens()
if not os.path.exists(file_path):
raise FileNotFoundError(f"文件不存在: {file_path}")
file_token = str(uuid.uuid4())
expire_time = time.time() + (timeout if timeout is not None else self.default_timeout)
self.staged_files[file_token] = (file_path, expire_time)
return file_token
async def handle_file(self, file_token: str) -> str:
"""根据令牌获取文件路径,使用后令牌失效。
Args:
file_token(str): 注册时返回的令牌
Returns:
str: 文件路径
Raises:
KeyError: 当令牌不存在或已过期时抛出
FileNotFoundError: 当文件本身已被删除时抛出
"""
async with self.lock:
await self._cleanup_expired_tokens()
if file_token not in self.staged_files:
raise KeyError(f"无效或过期的文件 token: {file_token}")
file_path, _ = self.staged_files.pop(file_token)
if not os.path.exists(file_path):
raise FileNotFoundError(f"文件不存在: {file_path}")
return file_path

View File

@@ -22,16 +22,19 @@ 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
import asyncio
import typing as T
import uuid
from enum import Enum
from pydantic.v1 import BaseModel
from astrbot.core import logger
from astrbot.core.utils.io import download_image_by_url, file_to_base64, download_file
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
class ComponentType(Enum):
@@ -167,7 +170,8 @@ class Record(BaseMessageComponent):
elif self.file and self.file.startswith("base64://"):
bs64_data = self.file.removeprefix("base64://")
image_bytes = base64.b64decode(bs64_data)
file_path = f"data/temp/{uuid.uuid4()}.jpg"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.jpg")
with open(file_path, "wb") as f:
f.write(image_bytes)
return os.path.abspath(file_path)
@@ -198,6 +202,29 @@ class Record(BaseMessageComponent):
bs64_data = bs64_data.removeprefix("base64://")
return bs64_data
async def register_to_file_service(self) -> str:
"""
将语音注册到文件服务。
Returns:
str: 注册后的URL
Raises:
Exception: 如果未配置 callback_api_base
"""
callback_host = astrbot_config.get("callback_api_base")
if not callback_host:
raise Exception("未配置 callback_api_base文件服务不可用")
file_path = await self.convert_to_file_path()
token = await file_token_service.register_file(file_path)
logger.debug(f"已注册:{callback_host}/api/file/{token}")
return f"{callback_host}/api/file/{token}"
class Video(BaseMessageComponent):
type: ComponentType = "Video"
@@ -371,7 +398,8 @@ class Image(BaseMessageComponent):
elif url and url.startswith("base64://"):
bs64_data = url.removeprefix("base64://")
image_bytes = base64.b64decode(bs64_data)
image_file_path = f"data/temp/{uuid.uuid4()}.jpg"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
image_file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.jpg")
with open(image_file_path, "wb") as f:
f.write(image_bytes)
return os.path.abspath(image_file_path)
@@ -403,6 +431,29 @@ class Image(BaseMessageComponent):
bs64_data = bs64_data.removeprefix("base64://")
return bs64_data
async def register_to_file_service(self) -> str:
"""
将图片注册到文件服务。
Returns:
str: 注册后的URL
Raises:
Exception: 如果未配置 callback_api_base
"""
callback_host = astrbot_config.get("callback_api_base")
if not callback_host:
raise Exception("未配置 callback_api_base文件服务不可用")
file_path = await self.convert_to_file_path()
token = await file_token_service.register_file(file_path)
logger.debug(f"已注册:{callback_host}/api/file/{token}")
return f"{callback_host}/api/file/{token}"
class Reply(BaseMessageComponent):
type: ComponentType = "Reply"
@@ -462,10 +513,10 @@ class Node(BaseMessageComponent):
type: ComponentType = "Node"
id: T.Optional[int] = 0 # 忽略
name: T.Optional[str] = "" # qq昵称
uin: T.Optional[int] = 0 # qq号
uin: T.Optional[str] = "0" # qq号
content: T.Optional[T.Union[str, list, dict]] = "" # 子消息段列表
seq: T.Optional[T.Union[str, list]] = "" # 忽略
time: T.Optional[int] = 0
time: T.Optional[int] = 0 # 忽略
def __init__(self, content: T.Union[str, list, dict, "Node", T.List["Node"]], **_):
if isinstance(content, list):
@@ -494,7 +545,14 @@ class Nodes(BaseMessageComponent):
super().__init__(nodes=nodes, **_)
def toDict(self):
return {"messages": [node.toDict() for node in self.nodes]}
ret = {
"messages": [],
}
for node in self.nodes:
d = node.toDict()
d["data"]["uin"] = str(node.uin) # 转为字符串
ret["messages"].append(d)
return ret
class Xml(BaseMessageComponent):
@@ -559,12 +617,12 @@ class File(BaseMessageComponent):
type: ComponentType = "File"
name: T.Optional[str] = "" # 名字
_file: T.Optional[str] = "" # 本地路径
file_: T.Optional[str] = "" # 本地路径
url: T.Optional[str] = "" # url
_downloaded: bool = False # 是否已经下载
def __init__(self, name: str = "", file: str = "", url: str = ""):
super().__init__(name=name, _file=file, url=url)
def __init__(self, name: str, file: str = "", url: str = ""):
"""文件消息段。"""
super().__init__(name=name, file_=file, url=url)
@property
def file(self) -> str:
@@ -574,23 +632,27 @@ class File(BaseMessageComponent):
Returns:
str: 文件路径
"""
if self._file and os.path.exists(self._file):
return self._file
if self.file_ and os.path.exists(self.file_):
return os.path.abspath(self.file_)
if self.url and not self._downloaded:
if self.url:
try:
loop = asyncio.get_event_loop()
if loop.is_running():
logger.warning(
"不可以在异步上下文中同步等待下载! 请使用 await get_file() 代替"
(
"不可以在异步上下文中同步等待下载! "
"这个警告通常发生于某些逻辑试图通过 <File>.file 获取文件消息段的文件内容。"
"请使用 await get_file() 代替直接获取 <File>.file 字段"
)
)
return ""
else:
# 等待下载完成
loop.run_until_complete(self._download_file())
if self._file and os.path.exists(self._file):
return self._file
if self.file_ and os.path.exists(self.file_):
return os.path.abspath(self.file_)
except Exception as e:
logger.error(f"文件下载失败: {e}")
@@ -607,38 +669,59 @@ class File(BaseMessageComponent):
if value.startswith("http://") or value.startswith("https://"):
self.url = value
else:
self._file = value
self.file_ = value
async def get_file(self) -> str:
"""
异步获取文件
To 插件开发者: 请注意在使用后清理下载的文件, 以免占用过多空间
async def get_file(self, allow_return_url: bool = False) -> str:
"""异步获取文件。请注意在使用后清理下载的文件, 以免占用过多空间
Args:
allow_return_url: 是否允许以文件 http 下载链接的形式返回,这允许您自行控制是否需要下载文件。
注意,如果为 True也可能返回文件路径。
Returns:
str: 文件路径
str: 文件路径或者 http 下载链接
"""
if self._file and os.path.exists(self._file):
return self._file
if allow_return_url and self.url:
return self.url
if self.file_ and os.path.exists(self.file_):
return os.path.abspath(self.file_)
if self.url:
await self._download_file()
return self._file
return os.path.abspath(self.file_)
return ""
async def _download_file(self):
"""下载文件"""
if self._downloaded:
return
os.makedirs("data/download", exist_ok=True)
filename = self.name or f"{uuid.uuid4().hex}"
file_path = f"data/download/{filename}"
download_dir = os.path.join(get_astrbot_data_path(), "temp")
os.makedirs(download_dir, exist_ok=True)
file_path = os.path.join(download_dir, f"{uuid.uuid4().hex}")
await download_file(self.url, file_path)
self.file_ = os.path.abspath(file_path)
self._file = file_path
self._downloaded = True
async def register_to_file_service(self):
"""
将文件注册到文件服务。
Returns:
str: 注册后的URL
Raises:
Exception: 如果未配置 callback_api_base
"""
callback_host = astrbot_config.get("callback_api_base")
if not callback_host:
raise Exception("未配置 callback_api_base文件服务不可用")
file_path = await self.get_file()
token = await file_token_service.register_file(file_path)
logger.debug(f"已注册:{callback_host}/api/file/{token}")
return f"{callback_host}/api/file/{token}"
class WechatEmoji(BaseMessageComponent):

View File

@@ -58,33 +58,30 @@ class RateLimitStage(Stage):
now = datetime.now()
async with self.locks[session_id]: # 确保同一会话不会并发修改队列
timestamps = self.event_timestamps[session_id]
# 检查并处理限流,可能需要多次检查直到满足条件
while True:
timestamps = self.event_timestamps[session_id]
self._remove_expired_timestamps(timestamps, now)
self._remove_expired_timestamps(timestamps, now)
if len(timestamps) < self.rate_limit_count:
timestamps.append(now)
break
else:
next_window_time = timestamps[0] + self.rate_limit_time
stall_duration = (next_window_time - now).total_seconds() + 0.3
if len(timestamps) >= self.rate_limit_count:
# 达到限流阈值,计算下一个窗口的时间
next_window_time = timestamps[0] + self.rate_limit_time
stall_duration = (next_window_time - now).total_seconds()
match self.rl_strategy:
case RateLimitStrategy.STALL.value:
logger.info(
f"会话 {session_id} 被限流。根据限流策略,此会话处理将被暂停 {stall_duration:.2f} 秒。"
)
await asyncio.sleep(stall_duration)
case RateLimitStrategy.DISCARD.value:
# event.set_result(MessageEventResult().message(f"会话 {session_id} 被限流。根据限流策略,此请求已被丢弃,直到您的限额于 {stall_duration:.2f} 秒后重置。"))
logger.info(
f"会话 {session_id} 被限流。根据限流策略,此请求已被丢弃,直到限额于 {stall_duration:.2f} 秒后重置。"
)
return event.stop_event()
self._remove_expired_timestamps(
timestamps, now + timedelta(seconds=stall_duration)
)
timestamps.append(now)
match self.rl_strategy:
case RateLimitStrategy.STALL.value:
logger.info(
f"会话 {session_id} 被限流。根据限流策略,此会话处理将被暂停 {stall_duration:.2f} 秒。"
)
await asyncio.sleep(stall_duration)
now = datetime.now()
case RateLimitStrategy.DISCARD.value:
logger.info(
f"会话 {session_id} 被限流。根据限流策略,此请求已被丢弃,直到限额于 {stall_duration:.2f} 秒后重置。"
)
return event.stop_event()
def _remove_expired_timestamps(
self, timestamps: Deque[datetime], now: datetime

View File

@@ -26,33 +26,14 @@ class RespondStage(Stage):
Comp.Record: lambda comp: bool(comp.file), # 语音
Comp.Video: lambda comp: bool(comp.file), # 视频
Comp.At: lambda comp: bool(comp.qq) or bool(comp.name), # @
Comp.AtAll: lambda comp: True, # @所有人
Comp.RPS: lambda comp: True, # 不知道是啥(未完成)
Comp.Dice: lambda comp: True, # 骰子(未完成)
Comp.Shake: lambda comp: True, # 摇一摇(未完成)
Comp.Anonymous: lambda comp: True, # 匿名(未完成)
Comp.Share: lambda comp: bool(comp.url) and bool(comp.title), # 分享
Comp.Contact: lambda comp: True, # 联系人(未完成)
Comp.Location: lambda comp: bool(comp.lat and comp.lon), # 位置
Comp.Music: lambda comp: bool(comp._type)
and bool(comp.url)
and bool(comp.audio), # 音乐
Comp.Image: lambda comp: bool(comp.file), # 图片
Comp.Reply: lambda comp: bool(comp.id) and comp.sender_id is not None, # 回复
Comp.RedBag: lambda comp: bool(comp.title), # 红包
Comp.Poke: lambda comp: comp.id != 0 and comp.qq != 0, # 戳一戳
Comp.Forward: lambda comp: bool(comp.id and comp.id.strip()), # 转发
Comp.Node: lambda comp: bool(comp.name)
and comp.uin != 0
and bool(comp.content), # 一个转发节点
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
Comp.Xml: lambda comp: bool(comp.data and comp.data.strip()), # XML
Comp.Json: lambda comp: bool(comp.data), # JSON
Comp.CardImage: lambda comp: bool(comp.file), # 卡片图片
Comp.TTS: lambda comp: bool(comp.text and comp.text.strip()), # 语音合成
Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()), # 未知消息
Comp.File: lambda comp: bool(comp.file), # 文件
Comp.WechatEmoji: lambda comp: bool(comp.md5), # 微信表情
Comp.File: lambda comp: bool(comp.file_ or comp.url),
}
async def initialize(self, ctx: PipelineContext):
@@ -129,8 +110,6 @@ class RespondStage(Stage):
if comp_type in self._component_validators:
if self._component_validators[comp_type](comp):
return False
else:
logger.info(f"空内容检查: 无法识别的组件类型: {comp_type.__name__}")
# 如果所有组件都为空
return True
@@ -175,6 +154,11 @@ class RespondStage(Stage):
except Exception as e:
logger.warning(f"空内容检查异常: {e}")
record_comps = [c for c in result.chain if isinstance(c, Comp.Record)]
non_record_comps = [
c for c in result.chain if not isinstance(c, Comp.Record)
]
if self.enable_seg and (
(self.only_llm_result and result.is_llm_result())
or not self.only_llm_result
@@ -192,8 +176,18 @@ class RespondStage(Stage):
decorated_comps.append(comp)
result.chain.remove(comp)
break
for rcomp in record_comps:
i = await self._calc_comp_interval(rcomp)
await asyncio.sleep(i)
try:
await event.send(MessageChain([rcomp]))
except Exception as e:
logger.error(f"发送消息失败: {e} chain: {result.chain}")
break
# 分段回复
for comp in result.chain:
for comp in non_record_comps:
i = await self._calc_comp_interval(comp)
await asyncio.sleep(i)
try:
@@ -202,11 +196,18 @@ class RespondStage(Stage):
logger.error(f"发送消息失败: {e} chain: {result.chain}")
break
else:
for rcomp in record_comps:
try:
await event.send(MessageChain([rcomp]))
except Exception as e:
logger.error(f"发送消息失败: {e} chain: {result.chain}")
try:
await event.send(result)
await event.send(MessageChain(non_record_comps))
except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"发送消息失败: {e} chain: {result.chain}")
await event._post_send()
logger.info(
f"AstrBot -> {event.get_sender_name()}/{event.get_sender_id()}: {event._outline_chain(result.chain)}"

View File

@@ -137,7 +137,7 @@ class WakingCheckStage(Stage):
if self.no_permission_reply:
await event.send(
MessageChain().message(
f"ID {event.get_sender_id()} 权限不足。通过 /sid 获取 ID 并请管理员添加。"
f"您(ID: {event.get_sender_id()})的权限不足以使用此指令。通过 /sid 获取 ID 并请管理员添加。"
)
)
await event._post_send()

View File

@@ -62,6 +62,10 @@ class PlatformManager:
from .sources.gewechat.gewechat_platform_adapter import (
GewechatPlatformAdapter, # noqa: F401
)
case "wechatpadpro":
from .sources.wechatpadpro.wechatpadpro_adapter import (
WeChatPadProAdapter, # noqa: F401
)
case "lark":
from .sources.lark.lark_adapter import LarkPlatformAdapter # noqa: F401
case "dingtalk":

View File

@@ -3,8 +3,9 @@ import re
from typing import AsyncGenerator, Dict, List
from aiocqhttp import CQHttp
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import At, Image, Node, Nodes, Plain, Record
from astrbot.api.message_components import At, Image, Node, Nodes, Plain, Record, File
from astrbot.api.platform import Group, MessageMember
from astrbot.core import file_token_service, astrbot_config, logger
class AiocqhttpMessageEvent(AstrMessageEvent):
@@ -34,24 +35,16 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
}
elif isinstance(segment, At):
d["data"] = {
"qq": str(segment.qq) # 转换为字符串
"qq": str(segment.qq), # 转换为字符串
}
ret.append(d)
return ret
async def send(self, message: MessageChain):
ret = await AiocqhttpMessageEvent._parse_onebot_json(message)
if not ret:
return
send_one_by_one = False
for seg in message.chain:
if isinstance(seg, (Node, Nodes)):
# 转发消息不能和普通消息混在一起发送
send_one_by_one = True
break
# 转发消息、文件消息不能和普通消息混在一起发送
send_one_by_one = any(
isinstance(seg, (Node, Nodes, File)) for seg in message.chain
)
if send_one_by_one:
for seg in message.chain:
if isinstance(seg, (Node, Nodes)):
@@ -70,6 +63,26 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
await self.bot.call_action(
"send_private_forward_msg", **payload
)
elif isinstance(seg, File):
d = seg.toDict()
url_or_path = await seg.get_file(allow_return_url=True)
if url_or_path.startswith("http"):
payload_file = url_or_path
elif callback_host := astrbot_config.get("callback_api_base"):
callback_host = str(callback_host).removesuffix("/")
token = await file_token_service.register_file(url_or_path)
payload_file = f"{callback_host}/api/file/{token}"
logger.debug(f"Generated file callback link: {payload_file}")
else:
payload_file = url_or_path
d["data"] = {
"name": seg.name,
"file": payload_file,
}
await self.bot.send(
self.message_obj.raw_message,
[d],
)
else:
await self.bot.send(
self.message_obj.raw_message,
@@ -79,6 +92,9 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
)
await asyncio.sleep(0.5)
else:
ret = await AiocqhttpMessageEvent._parse_onebot_json(message)
if not ret:
return
await self.bot.send(self.message_obj.raw_message, ret)
await super().send(message)

View File

@@ -103,6 +103,9 @@ class AiocqhttpAdapter(Platform):
if event["post_type"] == "message":
abm = await self._convert_handle_message_event(event)
if abm.sender.user_id == "2854196310":
# 屏蔽 QQ 管家的消息
return
elif event["post_type"] == "notice":
abm = await self._convert_handle_notice_event(event)
elif event["post_type"] == "request":
@@ -217,9 +220,9 @@ class AiocqhttpAdapter(Platform):
for t, m_group in itertools.groupby(event.message, key=lambda x: x["type"]):
a = None
if t == "text":
# 合并相邻文本段
message_str = "".join(m["data"]["text"] for m in m_group).strip()
a = ComponentTypes[t](text=message_str) # noqa: F405
current_text = "".join(m["data"]["text"] for m in m_group).strip()
message_str += current_text
a = ComponentTypes[t](text=current_text) # noqa: F405
abm.message.append(a)
elif t == "file":
@@ -287,6 +290,42 @@ class AiocqhttpAdapter(Platform):
logger.error(f"获取引用消息失败: {e}")
a = ComponentTypes[t](**m["data"]) # noqa: F405
abm.message.append(a)
elif t == "at":
first_at_self_processed = False
for m in m_group:
try:
if m["data"]["qq"] == "all":
abm.message.append(At(qq="all", name="全体成员"))
continue
at_info = await self.bot.call_action(
action="get_stranger_info",
user_id=int(m["data"]["qq"]),
)
if at_info:
nickname = at_info.get("nick", "")
is_at_self = str(m["data"]["qq"]) in {abm.self_id, "all"}
abm.message.append(
At(
qq=m["data"]["qq"],
name=nickname,
)
)
if is_at_self and not first_at_self_processed:
# 第一个@是机器人不添加到message_str
first_at_self_processed = True
else:
# 非第一个@机器人或@其他用户添加到message_str
message_str += f" @{nickname} "
else:
abm.message.append(At(qq=str(m["data"]["qq"]), name=""))
except ActionFailed as e:
logger.error(f"获取 @ 用户信息失败: {e},此消息段将被忽略。")
except BaseException as e:
logger.error(f"获取 @ 用户信息失败: {e},此消息段将被忽略。")
else:
for m in m_group:
a = ComponentTypes[t](**m["data"]) # noqa: F405

View File

@@ -1,4 +1,5 @@
import asyncio
import os
import uuid
import aiohttp
import dingtalk_stream
@@ -19,6 +20,7 @@ from ...register import register_platform_adapter
from astrbot import logger
from dingtalk_stream import AckMessage
from astrbot.core.utils.io import download_file
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
class MyEventHandler(dingtalk_stream.EventHandler):
@@ -152,7 +154,8 @@ class DingtalkPlatformAdapter(Platform):
"downloadCode": download_code,
"robotCode": robot_code,
}
f_path = f"data/dingtalk_file_{uuid.uuid4()}.{ext}"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
f_path = os.path.join(temp_dir, f"dingtalk_file_{uuid.uuid4()}.{ext}")
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.dingtalk.com/v1.0/robot/messageFiles/download",

View File

@@ -15,6 +15,7 @@ from astrbot.api.message_components import Plain, Image, At, Record, Video
from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType
from astrbot.core.utils.io import download_image_by_url
from .downloader import GeweDownloader
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
try:
from .xml_data_parser import GeweDataParser
@@ -250,7 +251,10 @@ class SimpleGewechatClient:
# 语音消息
if "ImgBuf" in d and "buffer" in d["ImgBuf"]:
voice_data = base64.b64decode(d["ImgBuf"]["buffer"])
file_path = f"data/temp/gewe_voice_{abm.message_id}.silk"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
file_path = os.path.join(
temp_dir, f"gewe_voice_{abm.message_id}.silk"
)
async with await anyio.open_file(file_path, "wb") as f:
await f.write(voice_data)
@@ -458,8 +462,10 @@ class SimpleGewechatClient:
retry_cnt -= 1
# 需要验证码
if os.path.exists("data/temp/gewe_code"):
with open("data/temp/gewe_code", "r") as f:
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
code_file_path = os.path.join(temp_dir, "gewe_code")
if os.path.exists(code_file_path):
with open(code_file_path, "r") as f:
code = f.read().strip()
if not code:
logger.warning(
@@ -470,9 +476,9 @@ class SimpleGewechatClient:
payload["captchCode"] = code
logger.info(f"使用验证码: {code}")
try:
os.remove("data/temp/gewe_code")
os.remove(code_file_path)
except Exception:
logger.warning("删除验证码文件 data/temp/gewe_code 失败。")
logger.warning(f"删除验证码文件 {code_file_path} 失败。")
async with aiohttp.ClientSession() as session:
async with session.post(

View File

@@ -6,7 +6,7 @@ import traceback
import os
from typing import AsyncGenerator
from astrbot.core.utils.io import save_temp_img, download_file
from astrbot.core.utils.io import download_file
from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -21,6 +21,7 @@ from astrbot.api.message_components import (
WechatEmoji as Emoji,
)
from .client import SimpleGewechatClient
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
def get_wav_duration(file_path):
@@ -106,7 +107,8 @@ class GewechatPlatformEvent(AstrMessageEvent):
# 根据 url 下载视频
if video_url.startswith("http"):
video_filename = f"{uuid.uuid4()}.mp4"
video_path = f"data/temp/{video_filename}"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
video_path = os.path.join(temp_dir, video_filename)
await download_file(video_url, video_path)
else:
video_path = video_url
@@ -115,7 +117,10 @@ class GewechatPlatformEvent(AstrMessageEvent):
video_callback_url = f"{client.file_server_url}/{video_token}"
# 获取视频第一帧
thumb_path = f"data/temp/gewechat_video_thumb_{uuid.uuid4()}.jpg"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
thumb_path = os.path.join(
temp_dir, f"gewechat_video_thumb_{uuid.uuid4()}.jpg"
)
video_path = video_path.replace(" ", "\\ ")
try:
@@ -154,7 +159,8 @@ class GewechatPlatformEvent(AstrMessageEvent):
record_url = comp.file
record_path = await comp.convert_to_file_path()
silk_path = f"data/temp/{uuid.uuid4()}.silk"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
silk_path = os.path.join(temp_dir, f"{uuid.uuid4()}.silk")
try:
duration = await wav_to_tencent_silk(record_path, silk_path)
except Exception as e:
@@ -173,7 +179,10 @@ class GewechatPlatformEvent(AstrMessageEvent):
if file_path.startswith("file:///"):
file_path = file_path[8:]
elif file_path.startswith("http"):
await download_file(file_path, f"data/temp/{file_name}")
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
temp_file_path = os.path.join(temp_dir, file_name)
await download_file(file_path, temp_file_path)
file_path = temp_file_path
else:
file_path = file_path

View File

@@ -1,4 +1,5 @@
import json
import os
import uuid
import base64
import lark_oapi as lark
@@ -9,6 +10,7 @@ from astrbot.api.message_components import Plain, Image as AstrBotImage, At
from astrbot.core.utils.io import download_image_by_url
from lark_oapi.api.im.v1 import *
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
class LarkMessageEvent(AstrMessageEvent):
@@ -40,7 +42,8 @@ class LarkMessageEvent(AstrMessageEvent):
base64_str = comp.file.removeprefix("base64://")
image_data = base64.b64decode(base64_str)
# save as temp file
file_path = f"data/temp/{uuid.uuid4()}_test.jpg"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
file_path = os.path.join(temp_dir, f"{uuid.uuid4()}_test.jpg")
with open(file_path, "wb") as f:
f.write(BytesIO(image_data).getvalue())
else:

View File

@@ -282,10 +282,12 @@ class TelegramPlatformAdapter(Platform):
entity.offset + 1 : entity.offset + entity.length
]
message.message.append(Comp.At(qq=name, name=name))
plain_text = (
plain_text[: entity.offset]
+ plain_text[entity.offset + entity.length :]
)
# 如果mention是当前bot则移除否则保留
if name.lower() == context.bot.username.lower():
plain_text = (
plain_text[: entity.offset]
+ plain_text[entity.offset + entity.length :]
)
if plain_text:
message.message.append(Comp.Plain(plain_text))

View File

@@ -1,3 +1,4 @@
import os
import asyncio
import telegramify_markdown
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -13,6 +14,7 @@ from astrbot.api.message_components import (
from telegram.ext import ExtBot
from astrbot.core.utils.io import download_file
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
class TelegramPlatformEvent(AstrMessageEvent):
@@ -75,7 +77,8 @@ class TelegramPlatformEvent(AstrMessageEvent):
await client.send_photo(photo=image_path, **payload)
elif isinstance(i, File):
if i.file.startswith("https://"):
path = "data/temp/" + i.name
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, i.name)
await download_file(i.file, path)
i.file = path
@@ -126,7 +129,8 @@ class TelegramPlatformEvent(AstrMessageEvent):
continue
elif isinstance(i, File):
if i.file.startswith("https://"):
path = "data/temp/" + i.name
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, i.name)
await download_file(i.file, path)
i.file = path

View File

@@ -17,6 +17,7 @@ from astrbot.core import web_chat_queue
from .webchat_event import WebChatMessageEvent
from astrbot.core.platform.astr_message_event import MessageSesion
from ...register import register_platform_adapter
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
class QueueListener:
@@ -40,7 +41,8 @@ class WebChatAdapter(Platform):
self.config = platform_config
self.settings = platform_settings
self.unique_session = platform_settings["unique_session"]
self.imgs_dir = "data/webchat/imgs"
self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
os.makedirs(self.imgs_dir, exist_ok=True)
self.metadata = PlatformMetadata(
name="webchat", description="webchat", id=self.config.get("id")

View File

@@ -6,8 +6,9 @@ from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import Plain, Image, Record
from astrbot.core.utils.io import download_image_by_url
from astrbot.core import web_chat_back_queue
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
imgs_dir = "data/webchat/imgs"
imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
class WebChatMessageEvent(AstrMessageEvent):

View File

@@ -0,0 +1,629 @@
import asyncio
import json
import os
import time
from typing import Optional
import aiohttp
import websockets
from astrbot import logger
from astrbot.api.message_components import Plain, Image
from astrbot.api.platform import Platform, PlatformMetadata
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.astrbot_message import (
AstrBotMessage,
MessageMember,
MessageType,
)
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.platform.astr_message_event import MessageSesion
from ...register import register_platform_adapter
from .wechatpadpro_message_event import WeChatPadProMessageEvent
@register_platform_adapter("wechatpadpro", "WeChatPadPro 消息平台适配器")
class WeChatPadProAdapter(Platform):
def __init__(
self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
) -> None:
super().__init__(event_queue)
self._shutdown_event = None
self.wxnewpass = None
self.config = platform_config
self.settings = platform_settings
self.unique_session = platform_settings.get("unique_session", False)
self.metadata = PlatformMetadata(
name="wechatpadpro",
description="WeChatPadPro 消息平台适配器",
id=self.config.get("id", "wechatpadpro"),
)
# 保存配置信息
self.admin_key = self.config.get("admin_key")
self.host = self.config.get("host")
self.port = self.config.get("port")
self.active_mesasge_poll: bool = self.config.get(
"wpp_active_message_poll", False
)
self.active_message_poll_interval: int = self.config.get(
"wpp_active_message_poll_interval", 5
)
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.ws_handle_task = None
async def run(self) -> None:
"""
启动平台适配器的运行实例。
"""
logger.info("WeChatPadPro 适配器正在启动...")
if loaded_credentials := self.load_credentials():
self.auth_key = loaded_credentials.get("auth_key")
self.wxid = loaded_credentials.get("wxid")
# 检查在线状态
if self.auth_key and await self.check_online_status():
logger.info("WeChatPadPro 设备已在线,跳过扫码登录。")
# 如果在线,连接 WebSocket 接收消息
self.ws_handle_task = asyncio.create_task(self.connect_websocket())
else:
logger.info("WeChatPadPro 设备不在线或无可用凭据,开始扫码登录流程。")
# 1. 生成授权码
await self.generate_auth_key()
if not self.auth_key:
logger.error("无法获取授权码WeChatPadPro 适配器启动失败。")
return
# 2. 获取登录二维码
qr_code_url = await self.get_login_qr_code()
if qr_code_url:
logger.info(f"请扫描以下二维码登录: {qr_code_url}")
else:
logger.error("无法获取登录二维码。")
return
# 3. 检测扫码状态
login_successful = await self.check_login_status()
if login_successful:
# 登录成功后,连接 WebSocket 接收消息
self.ws_handle_task = asyncio.create_task(self.connect_websocket())
else:
logger.warning("登录失败或超时WeChatPadPro 适配器将关闭。")
await self.terminate()
return
self._shutdown_event = asyncio.Event()
await self._shutdown_event.wait()
logger.info("WeChatPadPro 适配器已停止。")
def load_credentials(self):
"""
从文件中加载 auth_key 和 wxid。
"""
if os.path.exists(self.credentials_file):
try:
with open(self.credentials_file, "r") as f:
credentials = json.load(f)
logger.info("成功加载 WeChatPadPro 凭据。")
return credentials
except Exception as e:
logger.error(f"加载 WeChatPadPro 凭据失败: {e}")
return None
def save_credentials(self):
"""
将 auth_key 和 wxid 保存到文件。
"""
credentials = {
"auth_key": self.auth_key,
"wxid": self.wxid,
}
try:
# 确保数据目录存在
data_dir = os.path.dirname(self.credentials_file)
os.makedirs(data_dir, exist_ok=True)
with open(self.credentials_file, "w") as f:
json.dump(credentials, f)
logger.info("成功保存 WeChatPadPro 凭据。")
except Exception as e:
logger.error(f"保存 WeChatPadPro 凭据失败: {e}")
async def check_online_status(self):
"""
检查 WeChatPadPro 设备是否在线。
"""
url = f"{self.base_url}/login/GetLoginStatus"
params = {"key": self.auth_key}
async with aiohttp.ClientSession() as session:
try:
async with session.get(url, params=params) as response:
response_data = await response.json()
# 根据提供的在线接口返回示例,成功状态码是 200loginState 为 1 表示在线
if response.status == 200 and response_data.get("Code") == 200:
login_state = response_data.get("Data", {}).get("loginState")
if login_state == 1:
logger.info("WeChatPadPro 设备当前在线。")
return True
else:
logger.info(
f"WeChatPadPro 设备不在线,登录状态: {login_state}"
)
return False
else:
logger.error(
f"检查在线状态失败: {response.status}, {response_data}"
)
return False
except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
return False
except Exception as e:
logger.error(f"检查在线状态时发生错误: {e}")
return False
async def generate_auth_key(self):
"""
生成授权码。
"""
url = f"{self.base_url}/admin/GenAuthKey1"
params = {"key": self.admin_key}
payload = {"Count": 1, "Days": 30} # 生成一个有效期30天的授权码
async with aiohttp.ClientSession() as session:
try:
async with session.post(url, params=params, json=payload) as response:
response_data = await response.json()
# 修正成功判断条件和授权码提取路径
if response.status == 200 and response_data.get("Code") == 200:
# 授权码在 Data 字段的列表中
if (
response_data.get("Data")
and isinstance(response_data["Data"], list)
and len(response_data["Data"]) > 0
):
self.auth_key = response_data["Data"][0]
logger.info("成功获取授权码")
else:
logger.error(
f"生成授权码成功但未找到授权码: {response_data}"
)
else:
logger.error(
f"生成授权码失败: {response.status}, {response_data}"
)
except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
except Exception as e:
logger.error(f"生成授权码时发生错误: {e}")
async def get_login_qr_code(self):
"""
获取登录二维码地址。
"""
url = f"{self.base_url}/login/GetLoginQrCodeNew"
params = {"key": self.auth_key}
payload = {} # 根据文档,这个接口的 body 可以为空
async with aiohttp.ClientSession() as session:
try:
async with session.post(url, params=params, json=payload) as response:
response_data = await response.json()
# 修正成功判断条件和数据提取路径
if response.status == 200 and response_data.get("Code") == 200:
# 二维码地址在 Data.QrCodeUrl 字段中
if response_data.get("Data") and response_data["Data"].get(
"QrCodeUrl"
):
return response_data["Data"]["QrCodeUrl"]
else:
logger.error(
f"获取登录二维码成功但未找到二维码地址: {response_data}"
)
return None
else:
logger.error(
f"获取登录二维码失败: {response.status}, {response_data}"
)
return None
except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
return None
except Exception as e:
logger.error(f"获取登录二维码时发生错误: {e}")
return None
async def check_login_status(self):
"""
循环检测扫码状态。
尝试 6 次后跳出循环,添加倒计时。
返回 True 如果登录成功,否则返回 False。
"""
url = f"{self.base_url}/login/CheckLoginStatus"
params = {"key": self.auth_key}
attempts = 0 # 初始化尝试次数
max_attempts = 36 # 最大尝试次数
countdown = 180 # 倒计时时长
logger.info(f"请在 {countdown} 秒内扫码登录。")
while attempts < max_attempts:
async with aiohttp.ClientSession() as session:
try:
async with session.get(url, params=params) as response:
response_data = await response.json()
# 成功判断条件和数据提取路径
if response.status == 200 and response_data.get("Code") == 200:
if (
response_data.get("Data")
and response_data["Data"].get("state") is not None
):
status = response_data["Data"]["state"]
logger.info(
f"{attempts + 1} 次尝试,当前登录状态: {status},还剩{countdown - attempts * 5}"
)
if status == 2: # 状态 2 表示登录成功
self.wxid = response_data["Data"].get("wxid")
self.wxnewpass = response_data["Data"].get(
"wxnewpass"
)
logger.info(
f"登录成功wxid: {self.wxid}, wxnewpass: {self.wxnewpass}"
)
self.save_credentials() # 登录成功后保存凭据
return True
elif status == -2: # 二维码过期
logger.error("二维码已过期,请重新获取。")
return False
else:
logger.error(
f"检测登录状态成功但未找到登录状态: {response_data}"
)
elif response_data.get("Code") == 300:
# "不存在状态"
pass
else:
logger.info(
f"检测登录状态失败: {response.status}, {response_data}"
)
except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
await asyncio.sleep(5)
attempts += 1
continue
except Exception as e:
logger.error(f"检测登录状态时发生错误: {e}")
attempts += 1
continue
attempts += 1
await asyncio.sleep(5) # 每隔5秒检测一次
logger.warning("登录检测超过最大尝试次数,退出检测。")
return False
async def connect_websocket(self):
"""
建立 WebSocket 连接并处理接收到的消息。
"""
os.environ["no_proxy"] = f"localhost,127.0.0.1,{self.host}"
ws_url = f"ws://{self.host}:{self.port}/ws/GetSyncMsg?key={self.auth_key}"
logger.info(
f"正在连接 WebSocket: ws://{self.host}:{self.port}/ws/GetSyncMsg?key=***"
)
while True:
try:
async with websockets.connect(ws_url) as websocket:
logger.info("WebSocket 连接成功。")
# 设置空闲超时重连
wait_time = (
self.active_message_poll_interval
if self.active_mesasge_poll
else 120
)
while True:
try:
message = await asyncio.wait_for(
websocket.recv(), timeout=wait_time
)
logger.info(message)
asyncio.create_task(self.handle_websocket_message(message))
except asyncio.TimeoutError:
logger.warning(
f"WebSocket 连接空闲超过 {wait_time} s"
)
break
except websockets.exceptions.ConnectionClosedOK:
logger.info("WebSocket 连接正常关闭。")
break
except Exception as e:
logger.error(f"处理 WebSocket 消息时发生错误: {e}")
break
except Exception as e:
logger.error(f"WebSocket 连接失败: {e}")
await asyncio.sleep(5)
async def handle_websocket_message(self, message: str):
"""
处理从 WebSocket 接收到的消息。
"""
logger.debug(f"收到 WebSocket 消息: {message}")
try:
message_data = json.loads(message)
if (
message_data.get("msg_id") is not None
and message_data.get("from_user_name") is not None
):
abm = await self.convert_message(message_data)
if abm:
# 创建 WeChatPadProMessageEvent 实例
message_event = WeChatPadProMessageEvent(
message_str=abm.message_str,
message_obj=abm,
platform_meta=self.meta(),
session_id=abm.session_id,
# 传递适配器实例,以便在事件中调用 send 方法
adapter=self,
)
# 提交事件到事件队列
self.commit_event(message_event)
else:
logger.warning(f"收到未知结构的 WebSocket 消息: {message_data}")
except json.JSONDecodeError:
logger.error(f"无法解析 WebSocket 消息为 JSON: {message}")
except Exception as e:
logger.error(f"处理 WebSocket 消息时发生错误: {e}")
async def convert_message(self, raw_message: dict) -> AstrBotMessage | None:
"""
将 WeChatPadPro 原始消息转换为 AstrBotMessage。
"""
abm = AstrBotMessage()
abm.raw_message = raw_message
abm.message_id = str(raw_message.get("msg_id"))
abm.timestamp = raw_message.get("create_time")
abm.self_id = self.wxid
if int(time.time()) - abm.timestamp > 180:
logger.warning(
f"忽略 3 分钟前的旧消息:消息时间戳 {abm.timestamp} 超过当前时间 {int(time.time())}"
)
return None
from_user_name = raw_message.get("from_user_name", {}).get("str", "")
to_user_name = raw_message.get("to_user_name", {}).get("str", "")
content = raw_message.get("content", {}).get("str", "")
push_content = raw_message.get("push_content", "")
msg_type = raw_message.get("msg_type")
abm.message_str = ""
abm.message = []
# 如果是机器人自己发送的消息、回显消息或系统消息,忽略
if from_user_name == self.wxid:
logger.info("忽略来自自己的消息。")
return None
if from_user_name in ["weixin", "newsapp", "newsapp_wechat"]:
logger.info("忽略来自微信团队的消息。")
return None
# 先判断群聊/私聊并设置基本属性
if await self._process_chat_type(
abm, raw_message, from_user_name, to_user_name, content, push_content
):
# 再根据消息类型处理消息内容
await self._process_message_content(abm, raw_message, msg_type, content)
return abm
return None
async def _process_chat_type(
self,
abm: AstrBotMessage,
raw_message: dict,
from_user_name: str,
to_user_name: str,
content: str,
push_content: str,
):
"""
判断消息是群聊还是私聊,并设置 AstrBotMessage 的基本属性。
"""
if from_user_name == "weixin":
return False
if "@chatroom" in from_user_name:
abm.type = MessageType.GROUP_MESSAGE
abm.group_id = from_user_name
parts = content.split(":\n", 1)
sender_wxid = parts[0] if len(parts) == 2 else ""
abm.sender = MessageMember(user_id=sender_wxid, nickname="")
# 获取群聊发送者的nickname
if sender_wxid:
accurate_nickname = await self._get_group_member_nickname(
abm.group_id, sender_wxid
)
if accurate_nickname:
abm.sender.nickname = accurate_nickname
# 对于群聊session_id 可以是群聊 ID 或发送者 ID + 群聊 ID (如果 unique_session 为 True)
if self.unique_session:
abm.session_id = f"{from_user_name}_{to_user_name}"
else:
abm.session_id = from_user_name
else:
abm.type = MessageType.FRIEND_MESSAGE
abm.group_id = ""
nick_name = ""
if push_content and " : " in push_content:
nick_name = push_content.split(" : ")[0]
abm.sender = MessageMember(user_id=from_user_name, nickname=nick_name)
abm.session_id = from_user_name
return True
async def _get_group_member_nickname(
self, group_id: str, member_wxid: str
) -> Optional[str]:
"""
通过接口获取群成员的昵称。
"""
url = f"{self.base_url}/group/GetChatroomMemberDetail"
params = {"key": self.auth_key}
payload = {
"ChatRoomName": group_id,
}
async with aiohttp.ClientSession() as session:
try:
async with session.post(url, params=params, json=payload) as response:
response_data = await response.json()
if response.status == 200 and response_data.get("Code") == 200:
# 从返回数据中查找对应成员的昵称
member_list = (
response_data.get("Data", {})
.get("member_data", {})
.get("chatroom_member_list", [])
)
for member in member_list:
if member.get("user_name") == member_wxid:
return member.get("nick_name")
logger.warning(
f"在群 {group_id} 中未找到成员 {member_wxid} 的昵称"
)
else:
logger.error(
f"获取群成员详情失败: {response.status}, {response_data}"
)
return None
except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
return None
except Exception as e:
logger.error(f"获取群成员详情时发生错误: {e}")
return None
async def _download_raw_image(
self, from_user_name: str, to_user_name: str, msg_id: int
):
"""下载原始图片。"""
url = f"{self.base_url}/message/GetMsgBigImg"
params = {"key": self.auth_key}
payload = {
"CompressType": 0,
"FromUserName": from_user_name,
"MsgId": msg_id,
"Section": {"DataLen": 61440, "StartPos": 0},
"ToUserName": to_user_name,
"TotalLen": 0,
}
async with aiohttp.ClientSession() as session:
try:
async with session.post(url, params=params, json=payload) as response:
if response.status == 200:
return await response.json()
else:
logger.error(f"下载图片失败: {response.status}")
return None
except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
return None
except Exception as e:
logger.error(f"下载图片时发生错误: {e}")
return None
async def _process_message_content(
self, abm: AstrBotMessage, raw_message: dict, msg_type: int, content: str
):
"""
根据消息类型处理消息内容,填充 AstrBotMessage 的 message 列表。
"""
if msg_type == 1: # 文本消息
abm.message_str = content
if abm.type == MessageType.GROUP_MESSAGE:
parts = content.split(":\n", 1)
if len(parts) == 2:
abm.message_str = parts[1]
abm.message.append(Plain(abm.message_str))
else:
abm.message.append(Plain(abm.message_str))
else: # 私聊消息
abm.message.append(Plain(abm.message_str))
elif msg_type == 3:
# 图片消息
from_user_name = raw_message.get("from_user_name", {}).get("str", "")
to_user_name = raw_message.get("to_user_name", {}).get("str", "")
msg_id = raw_message.get("msg_id")
image_resp = await self._download_raw_image(
from_user_name, to_user_name, msg_id
)
image_bs64_data = (
image_resp.get("Data", {}).get("Data", {}).get("Buffer", None)
)
if image_bs64_data:
abm.message.append(Image.fromBase64(image_bs64_data))
elif msg_type == 47:
# 视频消息 (注意:表情消息也是 47需要区分)
logger.warning("收到视频消息,待实现。")
elif msg_type == 50:
# 语音/视频
logger.warning("收到语音/视频消息,待实现。")
elif msg_type == 49:
# 引用消息
logger.warning("收到引用消息,待实现。")
else:
logger.warning(f"收到未处理的消息类型: {msg_type}")
async def terminate(self):
"""
终止一个平台的运行实例。
"""
logger.info("终止 WeChatPadPro 适配器。")
try:
if self.ws_handle_task:
self.ws_handle_task.cancel()
self._shutdown_event.set()
except Exception:
pass
def meta(self) -> PlatformMetadata:
"""
得到一个平台的元数据。
"""
return self.metadata
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
dummy_message_obj = AstrBotMessage()
dummy_message_obj.session_id = session.session_id
# 根据 session_id 判断消息类型
if "@chatroom" in session.session_id:
dummy_message_obj.type = MessageType.GROUP_MESSAGE
dummy_message_obj.group_id = session.session_id
dummy_message_obj.sender = MessageMember(user_id="", nickname="")
else:
dummy_message_obj.type = MessageType.FRIEND_MESSAGE
dummy_message_obj.group_id = ""
dummy_message_obj.sender = MessageMember(user_id="", nickname="")
sending_event = WeChatPadProMessageEvent(
message_str="",
message_obj=dummy_message_obj,
platform_meta=self.meta(),
session_id=session.session_id,
adapter=self,
)
# 调用实例方法 send
await sending_event.send(message_chain)

View File

@@ -0,0 +1,117 @@
import asyncio
import base64
import io
from typing import TYPE_CHECKING
import aiohttp
from PIL import Image as PILImage # 使用别名避免冲突
from astrbot import logger
from astrbot.core.message.components import Image, Plain # Import Image
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageType
from astrbot.core.platform.platform_metadata import PlatformMetadata
if TYPE_CHECKING:
from .wechatpadpro_adapter import WeChatPadProAdapter
class WeChatPadProMessageEvent(AstrMessageEvent):
def __init__(
self,
message_str: str,
message_obj: AstrBotMessage,
platform_meta: PlatformMetadata,
session_id: str,
adapter: "WeChatPadProAdapter", # 传递适配器实例
):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.message_obj = message_obj # Save the full message object
self.adapter = adapter # Save the adapter instance
async def send(self, message: MessageChain):
async with aiohttp.ClientSession() as session:
for comp in message.chain:
await asyncio.sleep(1)
if isinstance(comp, Plain):
await self._send_text(session, comp.text)
elif isinstance(comp, Image):
await self._send_image(session, comp)
await super().send(message)
async def _send_image(self, session: aiohttp.ClientSession, comp: Image):
b64 = await comp.convert_to_base64()
raw = self._validate_base64(b64)
b64c = self._compress_image(raw)
payload = {
"MsgItem": [
{"ImageContent": b64c, "MsgType": 3, "ToUserName": self.session_id}
]
}
url = f"{self.adapter.base_url}/message/SendImageNewMessage"
await self._post(session, url, payload)
async def _send_text(self, session: aiohttp.ClientSession, text: str):
if (
self.message_obj.type == MessageType.GROUP_MESSAGE # 确保是群聊消息
and self.adapter.settings.get(
"reply_with_mention", False
) # 检查适配器设置是否启用 reply_with_mention
and self.message_obj.sender # 确保有发送者信息
and (
self.message_obj.sender.user_id or self.message_obj.sender.nickname
) # 确保发送者有 ID 或昵称
):
# 优先使用 nickname如果没有则使用 user_id
mention_text = (
self.message_obj.sender.nickname or self.message_obj.sender.user_id
)
message_text = f"@{mention_text} {text}"
# logger.info(f"已添加 @ 信息: {message_text}")
else:
message_text = text
payload = {
"MsgItem": [
{"MsgType": 1, "TextContent": message_text, "ToUserName": self.session_id}
]
}
url = f"{self.adapter.base_url}/message/SendTextMessage"
await self._post(session, url, payload)
@staticmethod
def _validate_base64(b64: str) -> bytes:
return base64.b64decode(b64, validate=True)
@staticmethod
def _compress_image(data: bytes) -> str:
img = PILImage.open(io.BytesIO(data))
buf = io.BytesIO()
if img.format == "JPEG":
img.save(buf, "JPEG", quality=80)
else:
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(buf, "JPEG", quality=80)
# logger.info("图片处理完成!!!")
return base64.b64encode(buf.getvalue()).decode()
async def _post(self, session, url, payload):
params = {"key": self.adapter.auth_key}
try:
async with session.post(url, params=params, json=payload) as resp:
data = await resp.json()
if resp.status != 200 or data.get("Code") != 200:
logger.error(f"{url} failed: {resp.status} {data}")
except Exception as e:
logger.error(f"{url} error: {e}")
# TODO: 添加对其他消息组件类型的处理 (Record, Video, At等)
# elif isinstance(component, Record):
# pass
# elif isinstance(component, Video):
# pass
# elif isinstance(component, At):
# pass
# ...

View File

@@ -1,31 +1,31 @@
import asyncio
import os
import sys
import uuid
import asyncio
import quart
import aiohttp
import quart
from requests import Response
from wechatpy.enterprise import WeChatClient, parse_message
from wechatpy.enterprise.crypto import WeChatCrypto
from wechatpy.enterprise.messages import ImageMessage, TextMessage, VoiceMessage
from wechatpy.exceptions import InvalidSignatureException
from wechatpy.messages import BaseMessage
from astrbot.api.event import MessageChain
from astrbot.api.message_components import Image, Plain, Record
from astrbot.api.platform import (
Platform,
AstrBotMessage,
MessageMember,
PlatformMetadata,
MessageType,
Platform,
PlatformMetadata,
register_platform_adapter,
)
from astrbot.api.event import MessageChain
from astrbot.api.message_components import Plain, Image, Record
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.api.platform import register_platform_adapter
from astrbot.core import logger
from requests import Response
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from wechatpy.enterprise.crypto import WeChatCrypto
from wechatpy.enterprise import WeChatClient
from wechatpy.enterprise.messages import TextMessage, ImageMessage, VoiceMessage
from wechatpy.messages import BaseMessage
from wechatpy.exceptions import InvalidSignatureException
from wechatpy.enterprise import parse_message
from .wecom_event import WecomPlatformEvent
from .wecom_kf import WeChatKF
from .wecom_kf_message import WeChatKFMessage
@@ -146,7 +146,7 @@ class WecomPlatformAdapter(Platform):
self.client.kf = self.wechat_kf_api
self.client.kf_message = self.wechat_kf_message_api
self.client.API_BASE_URL = self.api_base_url
self.client.API_BASE_URL = self.api_base_url
async def callback(msg: BaseMessage):
if msg.type == "unknown" and msg._data["Event"] == "kf_msg_or_event":
@@ -257,14 +257,15 @@ class WecomPlatformAdapter(Platform):
resp: Response = await asyncio.get_event_loop().run_in_executor(
None, self.client.media.download, msg.media_id
)
path = f"data/temp/wecom_{msg.media_id}.amr"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, f"wecom_{msg.media_id}.amr")
with open(path, "wb") as f:
f.write(resp.content)
try:
from pydub import AudioSegment
path_wav = f"data/temp/wecom_{msg.media_id}.wav"
path_wav = os.path.join(temp_dir, f"wecom_{msg.media_id}.wav")
audio = AudioSegment.from_file(path)
audio.export(path_wav, format="wav")
except Exception as e:
@@ -296,11 +297,12 @@ class WecomPlatformAdapter(Platform):
external_userid = msg.get("external_userid", None)
abm = AstrBotMessage()
abm.raw_message = msg
abm.raw_message["_wechat_kf_flag"] = None # 方便处理
abm.raw_message["_wechat_kf_flag"] = None # 方便处理
abm.self_id = msg["open_kfid"]
abm.sender = MessageMember(external_userid, external_userid)
abm.session_id = external_userid
abm.type = MessageType.FRIEND_MESSAGE
abm.message_id = msg.get("msgid", uuid.uuid4().hex[:8])
if msgtype == "text":
text = msg.get("text", {}).get("content", "").strip()
abm.message = [Plain(text=text)]

View File

@@ -1,3 +1,4 @@
import os
import uuid
import asyncio
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -7,6 +8,7 @@ from wechatpy.enterprise import WeChatClient
from .wecom_kf_message import WeChatKFMessage
from astrbot.api import logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
try:
import pydub
@@ -152,7 +154,8 @@ class WecomPlatformEvent(AstrMessageEvent):
elif isinstance(comp, Record):
record_path = await comp.convert_to_file_path()
# 转成amr
record_path_amr = f"data/temp/{uuid.uuid4()}.amr"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
record_path_amr = os.path.join(temp_dir, f"{uuid.uuid4()}.amr")
pydub.AudioSegment.from_wav(record_path).export(
record_path_amr, format="amr"
)

View File

@@ -136,6 +136,8 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
self.config["secret"].strip(),
)
self.client.API_BASE_URL = self.api_base_url
async def callback(msg):
try:
await self.convert_message(msg)

View File

@@ -12,6 +12,8 @@ from contextlib import AsyncExitStack
from astrbot import logger
from astrbot.core.utils.log_pipe import LogPipe
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
try:
import mcp
from mcp.client.sse import sse_client
@@ -238,8 +240,7 @@ class FuncCall:
}
```
"""
current_dir = os.path.dirname(os.path.abspath(__file__))
data_dir = os.path.abspath(os.path.join(current_dir, "../../../data"))
data_dir = get_astrbot_data_path()
mcp_json_file = os.path.join(data_dir, "mcp_server.json")
if not os.path.exists(mcp_json_file):

View File

@@ -202,6 +202,14 @@ class ProviderManager:
from .sources.dashscope_tts import (
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
)
case "azure_tts":
from .sources.azure_tts_source import (
AzureTTSProvider as AzureTTSProvider,
)
case "minimax_tts_api":
from .sources.minimax_tts_api_source import (
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
)
except (ImportError, ModuleNotFoundError) as e:
logger.critical(
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。"

View File

@@ -0,0 +1,210 @@
import uuid
import time
import json
import re
import hashlib
import random
import asyncio
from pathlib import Path
from typing import Dict
from xml.sax.saxutils import escape
from httpx import AsyncClient, Timeout
from astrbot.core.config.default import VERSION
from ..entities import ProviderType
from ..provider import TTSProvider
from ..register import register_provider_adapter
TEMP_DIR = Path("data/temp/azure_tts")
TEMP_DIR.mkdir(parents=True, exist_ok=True)
class OTTSProvider:
def __init__(self, config: Dict):
self.skey = config["OTTS_SKEY"]
self.api_url = config["OTTS_URL"]
self.auth_time_url = config["OTTS_AUTH_TIME"]
self.time_offset = 0
self.last_sync_time = 0
self.timeout = Timeout(10.0)
self.retry_count = 3
self.client = None
async def __aenter__(self):
self.client = AsyncClient(timeout=self.timeout)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.client:
await self.client.aclose()
async def _sync_time(self):
try:
response = await self.client.get(self.auth_time_url)
response.raise_for_status()
server_time = int(response.json()["timestamp"])
local_time = int(time.time())
self.time_offset = server_time - local_time
self.last_sync_time = local_time
except Exception as e:
if time.time() - self.last_sync_time > 3600:
raise RuntimeError("时间同步失败") from e
async def _generate_signature(self) -> str:
await self._sync_time()
timestamp = int(time.time()) + self.time_offset
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()}"
async def get_audio(self, text: str, voice_params: Dict) -> str:
file_path = TEMP_DIR / f"otts-{uuid.uuid4()}.wav"
signature = await self._generate_signature()
for attempt in range(self.retry_count):
try:
response = await self.client.post(
f"{self.api_url}?sign={signature}",
data={
"text": text,
"voice": voice_params["voice"],
"style": voice_params["style"],
"role": voice_params["role"],
"rate": voice_params["rate"],
"volume": voice_params["volume"]
},
headers={
"User-Agent": f"AstrBot/{VERSION}",
"UAK": "AstrBot/AzureTTS"
}
)
response.raise_for_status()
file_path.parent.mkdir(parents=True, exist_ok=True)
with file_path.open("wb") as f:
async for chunk in response.aiter_bytes(4096):
f.write(chunk)
return str(file_path.resolve())
except Exception as e:
if attempt == self.retry_count - 1:
raise RuntimeError(f"OTTS请求失败: {str(e)}") from e
await asyncio.sleep(0.5 * (attempt + 1))
class AzureNativeProvider(TTSProvider):
def __init__(self, provider_config: dict, provider_settings: dict):
super().__init__(provider_config, provider_settings)
self.subscription_key = provider_config.get("azure_tts_subscription_key", "").strip()
if not re.fullmatch(r'^[a-zA-Z0-9]{32}$', self.subscription_key):
raise ValueError("无效的Azure订阅密钥")
self.region = provider_config.get("azure_tts_region", "eastus").strip()
self.endpoint = f"https://{self.region}.tts.speech.microsoft.com/cognitiveservices/v1"
self.client = None
self.token = None
self.token_expire = 0
self.voice_params = {
"voice": provider_config.get("azure_tts_voice", "zh-CN-YunxiaNeural"),
"style": provider_config.get("azure_tts_style", "cheerful"),
"role": provider_config.get("azure_tts_role", "Boy"),
"rate": provider_config.get("azure_tts_rate", "1"),
"volume": provider_config.get("azure_tts_volume", "100")
}
async def __aenter__(self):
self.client = AsyncClient(headers={
"User-Agent": f"AstrBot/{VERSION}",
"Content-Type": "application/ssml+xml",
"X-Microsoft-OutputFormat": "riff-48khz-16bit-mono-pcm"
})
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.client:
await self.client.aclose()
async def _refresh_token(self):
token_url = f"https://{self.region}.api.cognitive.microsoft.com/sts/v1.0/issuetoken"
response = await self.client.post(
token_url,
headers={"Ocp-Apim-Subscription-Key": self.subscription_key}
)
response.raise_for_status()
self.token = response.text
self.token_expire = time.time() + 540
async def get_audio(self, text: str) -> str:
if not self.token or time.time() > self.token_expire:
await self._refresh_token()
file_path = TEMP_DIR / f"azure-{uuid.uuid4()}.wav"
ssml = f"""<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis'
xmlns:mstts='http://www.w3.org/2001/mstts' xml:lang='zh-CN'>
<voice name='{escape(self.voice_params["voice"])}'>
<mstts:express-as style='{escape(self.voice_params["style"])}'
role='{escape(self.voice_params["role"])}'>
<prosody rate='{escape(self.voice_params["rate"])}'
volume='{escape(self.voice_params["volume"])}'>
{escape(text)}
</prosody>
</mstts:express-as>
</voice>
</speak>"""
response = await self.client.post(
self.endpoint,
content=ssml,
headers={
"Authorization": f"Bearer {self.token}",
"User-Agent": f"AstrBot/{VERSION}"
}
)
response.raise_for_status()
file_path.parent.mkdir(parents=True, exist_ok=True)
with file_path.open("wb") as f:
for chunk in response.iter_bytes(4096):
f.write(chunk)
return str(file_path.resolve())
@register_provider_adapter("azure_tts", "Azure TTS", ProviderType.TEXT_TO_SPEECH)
class AzureTTSProvider(TTSProvider):
def __init__(self, provider_config: dict, provider_settings: dict):
super().__init__(provider_config, provider_settings)
key_value = provider_config.get("azure_tts_subscription_key", "")
self.provider = self._parse_provider(key_value, provider_config)
def _parse_provider(self, key_value: str, config: dict) -> TTSProvider:
if key_value.lower().startswith("other["):
try:
match = re.match(r"other\[(.*)\]", key_value, re.DOTALL)
if not match:
raise ValueError("无效的other[...]格式,应形如 other[{...}]")
json_str = match.group(1).strip()
otts_config = json.loads(json_str)
required = {"OTTS_SKEY", "OTTS_URL", "OTTS_AUTH_TIME"}
if missing := required - otts_config.keys():
raise ValueError(f"缺少OTTS参数: {', '.join(missing)}")
return OTTSProvider(otts_config)
except json.JSONDecodeError as e:
error_msg = (
f"JSON解析失败请检查格式错误位置{e.lineno}{e.colno}\n"
f"错误详情: {e.msg}\n"
f"错误上下文: {json_str[max(0, e.pos-30):e.pos+30]}"
)
raise ValueError(error_msg) from e
except KeyError as e:
raise ValueError(f"配置错误: 缺少必要参数 {e}") from e
if re.fullmatch(r'^[a-zA-Z0-9]{32}$', key_value):
return AzureNativeProvider(config, self.provider_settings)
raise ValueError("订阅密钥格式无效应为32位字母数字或other[...]格式")
async def get_audio(self, text: str) -> str:
if isinstance(self.provider, OTTSProvider):
async with self.provider as provider:
return await provider.get_audio(
text,
{
"voice": self.provider_config.get("azure_tts_voice"),
"style": self.provider_config.get("azure_tts_style"),
"role": self.provider_config.get("azure_tts_role"),
"rate": self.provider_config.get("azure_tts_rate"),
"volume": self.provider_config.get("azure_tts_volume")
}
)
else:
async with self.provider as provider:
return await provider.get_audio(text)

View File

@@ -1,3 +1,4 @@
import os
import dashscope
import uuid
import asyncio
@@ -5,6 +6,7 @@ from dashscope.audio.tts_v2 import *
from ..provider import TTSProvider
from ..entities import ProviderType
from ..register import register_provider_adapter
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@register_provider_adapter(
@@ -24,7 +26,8 @@ class ProviderDashscopeTTSAPI(TTSProvider):
dashscope.api_key = self.chosen_api_key
async def get_audio(self, text: str) -> str:
path = f"data/temp/dashscope_tts_{uuid.uuid4()}.wav"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, f"dashscope_tts_{uuid.uuid4()}.wav")
self.synthesizer = SpeechSynthesizer(
model=self.get_model(),
voice=self.voice,

View File

@@ -1,5 +1,5 @@
import astrbot.core.message.components as Comp
import os
from typing import List
from .. import Provider, Personality
from ..entities import LLMResponse
@@ -10,6 +10,7 @@ from astrbot.core.utils.dify_api_client import DifyAPIClient
from astrbot.core.utils.io import download_image_by_url, download_file
from astrbot.core import logger, sp
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@register_provider_adapter("dify", "Dify APP 适配器。")
@@ -227,7 +228,8 @@ class ProviderDify(Provider):
return Comp.Image(file=item["url"], url=item["url"])
case "audio":
# 仅支持 wav
path = f"data/temp/{item['filename']}.wav"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, f"{item['filename']}.wav")
await download_file(item["url"], path)
return Comp.Image(file=item["url"], url=item["url"])
case "video":

View File

@@ -7,6 +7,7 @@ from ..provider import TTSProvider
from ..entities import ProviderType
from ..register import register_provider_adapter
from astrbot.core import logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
"""
edge_tts 方式能够免费、快速生成语音使用需要先安装edge-tts库
@@ -40,9 +41,9 @@ class ProviderEdgeTTS(TTSProvider):
self.set_model("edge_tts")
async def get_audio(self, text: str) -> str:
os.makedirs("data/temp", exist_ok=True)
mp3_path = f"data/temp/edge_tts_temp_{uuid.uuid4()}.mp3"
wav_path = f"data/temp/edge_tts_{uuid.uuid4()}.wav"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
mp3_path = os.path.join(temp_dir, f"edge_tts_temp_{uuid.uuid4()}.mp3")
wav_path = os.path.join(temp_dir, f"edge_tts_{uuid.uuid4()}.wav")
# 构建 Edge TTS 参数
kwargs = {"text": text, "voice": self.voice}

View File

@@ -1,3 +1,4 @@
import os
import uuid
import ormsgpack
from pydantic import BaseModel, conint
@@ -6,6 +7,7 @@ from typing import Annotated, Literal
from ..provider import TTSProvider
from ..entities import ProviderType
from ..register import register_provider_adapter
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
class ServeReferenceAudio(BaseModel):
@@ -87,7 +89,8 @@ class ProviderFishAudioTTSAPI(TTSProvider):
)
async def get_audio(self, text: str) -> str:
path = f"data/temp/fishaudio_tts_api_{uuid.uuid4()}.wav"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, f"fishaudio_tts_api_{uuid.uuid4()}.wav")
self.headers["content-type"] = "application/msgpack"
request = await self._generate_request(text)
async with AsyncClient(base_url=self.api_base).stream(

View File

@@ -189,6 +189,7 @@ class ProviderGoogleGenAI(Provider):
),
)
if "gemini-2.5-flash" in self.get_model()
and hasattr(types.ThinkingConfig, "thinking_budget")
else None,
automatic_function_calling=types.AutomaticFunctionCallingConfig(
disable=True

View File

@@ -1,9 +1,11 @@
import os
import uuid
import aiohttp
import urllib.parse
from ..provider import TTSProvider
from ..entities import ProviderType
from ..register import register_provider_adapter
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@register_provider_adapter(
@@ -23,7 +25,8 @@ class ProviderGSVITTS(TTSProvider):
self.emotion = provider_config.get("emotion")
async def get_audio(self, text: str) -> str:
path = f"data/temp/gsvi_tts_{uuid.uuid4()}.wav"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, f"gsvi_tts_{uuid.uuid4()}.wav")
params = {"text": text}
if self.character:

View File

@@ -0,0 +1,149 @@
import json
import os
import uuid
import aiohttp
from typing import Dict, List, Union, AsyncIterator
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.api import logger
from ..entities import ProviderType
from ..provider import TTSProvider
from ..register import register_provider_adapter
@register_provider_adapter(
"minimax_tts_api", "MiniMax TTS API", provider_type=ProviderType.TEXT_TO_SPEECH
)
class ProviderMiniMaxTTSAPI(TTSProvider):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
super().__init__(provider_config, provider_settings)
self.chosen_api_key: str = provider_config.get("api_key", "")
self.api_base: str = provider_config.get(
"api_base", "https://api.minimax.chat/v1/t2a_v2"
)
self.group_id: str = provider_config.get("minimax-group-id", "")
self.set_model(provider_config.get("model", ""))
self.lang_boost: str = provider_config.get("minimax-langboost", "auto")
self.is_timber_weight: bool = provider_config.get(
"minimax-is-timber-weight", False
)
self.timber_weight: List[Dict[str, Union[str, int]]] = json.loads(
provider_config.get(
"minimax-timber-weight",
'[{"voice_id": "Chinese (Mandarin)_Warm_Girl", "weight": 1}]',
)
)
self.voice_setting: dict = {
"speed": provider_config.get("minimax-voice-speed", 1.0),
"vol": provider_config.get("minimax-voice-vol", 1.0),
"pitch": provider_config.get("minimax-voice-pitch", 0),
"voice_id": ""
if self.is_timber_weight
else provider_config.get("minimax-voice-id", ""),
"emotion": provider_config.get("minimax-voice-emotion", "neutral"),
"latex_read": provider_config.get("minimax-voice-latex", False),
"english_normalization": provider_config.get(
"minimax-voice-english-normalization", False
),
}
self.audio_setting: dict = {
"sample_rate": 32000,
"bitrate": 128000,
"format": "mp3",
}
self.concat_base_url: str = f"{self.api_base}?GroupId={self.group_id}"
self.headers = {
"Authorization": f"Bearer {self.chosen_api_key}",
"accept": "application/json, text/plain, */*",
"content-type": "application/json",
}
def _build_tts_stream_body(self, text: str):
"""构建流式请求体"""
dict_body: Dict[str, object] = {
"model": self.model_name,
"text": text,
"stream": True,
"language_boost": self.lang_boost,
"voice_setting": self.voice_setting,
"audio_setting": self.audio_setting,
}
if self.is_timber_weight:
dict_body["timber_weights"] = self.timber_weight
return json.dumps(dict_body)
async def _call_tts_stream(self, text: str) -> AsyncIterator[bytes]:
"""进行流式请求"""
try:
async with aiohttp.ClientSession() as session:
async with session.post(
self.concat_base_url,
headers=self.headers,
data=self._build_tts_stream_body(text),
timeout=aiohttp.ClientTimeout(total=60),
) as response:
response.raise_for_status()
buffer = b""
while True:
chunk = await response.content.read(8192)
if not chunk:
break
buffer += chunk
while b"\n\n" in buffer:
try:
message, buffer = buffer.split(b"\n\n", 1)
if message.startswith(b"data: "):
try:
data = json.loads(message[6:])
if "extra_info" in data:
continue
audio = data.get("data", {}).get("audio")
if audio is not None:
yield audio
except json.JSONDecodeError:
logger.warning(
"Failed to parse JSON data from SSE message"
)
continue
except ValueError:
buffer = buffer[-1024:]
except aiohttp.ClientError as e:
raise Exception(f"MiniMax TTS API请求失败: {str(e)}")
async def _audio_play(self, audio_stream: AsyncIterator[str]) -> bytes:
"""解码数据流到 audio 比特流"""
chunks = []
async for chunk in audio_stream:
if chunk.strip():
chunks.append(bytes.fromhex(chunk.strip()))
return b"".join(chunks)
async def get_audio(self, text: str) -> str:
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
os.makedirs(temp_dir, exist_ok=True)
path = os.path.join(temp_dir, f"minimax_tts_api_{uuid.uuid4()}.mp3")
try:
# 直接将异步生成器传递给 _audio_play 方法
audio_stream = self._call_tts_stream(text)
audio = await self._audio_play(audio_stream)
# 结果保存至文件
with open(path, "wb") as file:
file.write(audio)
return path
except aiohttp.ClientError as e:
raise e

View File

@@ -1,8 +1,10 @@
import os
import uuid
from openai import AsyncOpenAI, NOT_GIVEN
from ..provider import TTSProvider
from ..entities import ProviderType
from ..register import register_provider_adapter
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@register_provider_adapter(
@@ -31,7 +33,8 @@ class ProviderOpenAITTSAPI(TTSProvider):
self.set_model(provider_config.get("model", None))
async def get_audio(self, text: str) -> str:
path = f"data/temp/openai_tts_api_{uuid.uuid4()}.wav"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, f"openai_tts_api_{uuid.uuid4()}.wav")
async with self.client.audio.speech.with_streaming_response.create(
model=self.model_name, voice=self.voice, response_format="wav", input=text
) as response:

View File

@@ -7,6 +7,7 @@ from astrbot.core.utils.io import download_file
from ..register import register_provider_adapter
from astrbot.core import logger
from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@register_provider_adapter(
@@ -50,7 +51,8 @@ class ProviderOpenAIWhisperAPI(STTProvider):
is_tencent = True
name = str(uuid.uuid4())
path = os.path.join("data/temp", name)
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, name)
await download_file(audio_url, path)
audio_url = path
@@ -61,7 +63,8 @@ class ProviderOpenAIWhisperAPI(STTProvider):
is_silk = await self._is_silk_file(audio_url)
if is_silk:
logger.info("Converting silk file to wav ...")
output_path = os.path.join("data/temp", str(uuid.uuid4()) + ".wav")
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav")
await tencent_silk_to_wav(audio_url, output_path)
audio_url = output_path

View File

@@ -8,6 +8,7 @@ from astrbot.core.utils.io import download_file
from ..register import register_provider_adapter
from astrbot.core import logger
from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@register_provider_adapter(
@@ -53,7 +54,8 @@ class ProviderOpenAIWhisperSelfHost(STTProvider):
is_tencent = True
name = str(uuid.uuid4())
path = os.path.join("data/temp", name)
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, name)
await download_file(audio_url, path)
audio_url = path
@@ -64,7 +66,8 @@ class ProviderOpenAIWhisperSelfHost(STTProvider):
is_silk = await self._is_silk_file(audio_url)
if is_silk:
logger.info("Converting silk file to wav ...")
output_path = os.path.join("data/temp", str(uuid.uuid4()) + ".wav")
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav")
await tencent_silk_to_wav(audio_url, output_path)
audio_url = output_path

View File

@@ -3,11 +3,12 @@ from typing import List, Dict
from astrbot.core import logger
from .store import Store
from astrbot.core.config import AstrBotConfig
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
class KnowledgeDBManager:
def __init__(self, astrbot_config: AstrBotConfig) -> None:
self.db_path = "data/knowledge_db/"
self.db_path = os.path.join(get_astrbot_data_path(), "knowledge_db")
self.config = astrbot_config.get("knowledge_db", {})
self.astrbot_config = astrbot_config
if not os.path.exists(self.db_path):

View File

@@ -4,12 +4,14 @@ from typing import List, Dict
from astrbot.api import logger
from ..embedding.openai_source import SimpleOpenAIEmbedding
from . import Store
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
class ChromaVectorStore(Store):
def __init__(self, name: str, embedding_cfg: Dict) -> None:
import os
self.chroma_client = chromadb.PersistentClient(
path="data/long_term_memory_chroma.db"
path=os.path.join(get_astrbot_data_path(), "long_term_memory_chroma.db")
)
self.collection = self.chroma_client.get_or_create_collection(name=name)
self.embedding = None

View File

@@ -5,6 +5,7 @@
from typing import Union
import os
import json
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
def load_config(namespace: str) -> Union[dict, bool]:
@@ -13,7 +14,7 @@ def load_config(namespace: str) -> Union[dict, bool]:
namespace: str, 配置的唯一识别符,也就是配置文件的名字。
返回值: 当配置文件存在时,返回 namespace 对应配置文件的内容dict否则返回 False。
"""
path = f"data/config/{namespace}.json"
path = os.path.join(get_astrbot_data_path(), "config", f"{namespace}.json")
if not os.path.exists(path):
return False
with open(path, "r", encoding="utf-8-sig") as f:
@@ -43,7 +44,10 @@ def put_config(namespace: str, name: str, key: str, value, description: str):
raise ValueError("key 只支持 str 类型。")
if not isinstance(value, (str, int, float, bool, list)):
raise ValueError("value 只支持 str, int, float, bool, list 类型。")
path = f"data/config/{namespace}.json"
config_dir = os.path.join(get_astrbot_data_path(), "config")
path = os.path.join(config_dir, f"{namespace}.json")
if not os.path.exists(path):
with open(path, "w", encoding="utf-8-sig") as f:
f.write("{}")
@@ -71,7 +75,7 @@ def update_config(namespace: str, key: str, value):
key: str, 配置项的键。
value: str, int, float, bool, list, 配置项的值。
"""
path = f"data/config/{namespace}.json"
path = os.path.join(get_astrbot_data_path(), "config", f"{namespace}.json")
if not os.path.exists(path):
raise FileNotFoundError(f"配置文件 {namespace}.json 不存在。")
with open(path, "r", encoding="utf-8-sig") as f:

View File

@@ -113,7 +113,7 @@ class CommandGroupFilter(HandlerFilter):
+ self.print_cmd_tree(self.sub_command_filters, event=event, cfg=cfg)
)
raise ValueError(
f"指令组 {self.group_name} 未填写完全。这个指令组下有如下指令:\n"
f"参数不足。{self.group_name} 指令组下有如下指令,请参考\n"
+ tree
)

View File

@@ -2,28 +2,40 @@
插件的重载、启停、安装、卸载等操作。
"""
import inspect
import asyncio
import functools
import inspect
import json
import logging
import os
import sys
import json
import traceback
import yaml
import logging
import asyncio
from types import ModuleType
from typing import List
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core import logger, sp, pip_installer
from .context import Context
from . import StarMetadata
from .updator import PluginUpdator
from astrbot.core.utils.io import remove_dir
from .star import star_registry, star_map
from .star_handler import star_handlers_registry
from astrbot.core.provider.register import llm_tools
from .filter.permission import PermissionTypeFilter, PermissionType
import yaml
from astrbot.core import logger, pip_installer, sp
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.provider.register import llm_tools
from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_plugin_path,
)
from astrbot.core.utils.io import remove_dir
from . import StarMetadata
from .context import Context
from .filter.permission import PermissionType, PermissionTypeFilter
from .star import star_map, star_registry
from .star_handler import star_handlers_registry
from .updator import PluginUpdator
try:
from watchfiles import PythonFilter, awatch
except ImportError:
if os.getenv("ASTRBOT_RELOAD", "0") == "1":
logger.warning("未安装 watchfiles无法实现插件的热重载。")
class PluginManager:
@@ -34,17 +46,9 @@ class PluginManager:
self.context._star_manager = self
self.config = config
self.plugin_store_path = os.path.abspath(
os.path.join(
os.path.dirname(os.path.abspath(__file__)), "../../../data/plugins"
)
)
self.plugin_store_path = get_astrbot_plugin_path()
"""存储插件的路径。即 data/plugins"""
self.plugin_config_path = os.path.abspath(
os.path.join(
os.path.dirname(os.path.abspath(__file__)), "../../../data/config"
)
)
self.plugin_config_path = get_astrbot_config_path()
"""存储插件配置的路径。data/config"""
self.reserved_plugin_path = os.path.abspath(
os.path.join(
@@ -56,6 +60,58 @@ class PluginManager:
"""插件配置 Schema 文件名"""
self.failed_plugin_info = ""
if os.getenv("ASTRBOT_RELOAD", "0") == "1":
asyncio.create_task(self._watch_plugins_changes())
async def _watch_plugins_changes(self):
"""监视插件文件变化"""
try:
async for changes in awatch(
self.plugin_store_path,
self.reserved_plugin_path,
watch_filter=PythonFilter(),
recursive=True,
):
# 处理文件变化
await self._handle_file_changes(changes)
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"插件热重载监视任务异常: {str(e)}")
logger.error(traceback.format_exc())
async def _handle_file_changes(self, changes):
"""处理文件变化"""
logger.info(f"检测到文件变化: {changes}")
plugins_to_check = []
for star in star_registry:
if not star.activated:
continue
if star.root_dir_name is None:
continue
if star.reserved:
plugin_dir_path = os.path.join(
self.reserved_plugin_path, star.root_dir_name
)
else:
plugin_dir_path = os.path.join(
self.plugin_store_path, star.root_dir_name
)
plugins_to_check.append((plugin_dir_path, star.name))
reloaded_plugins = set()
for change in changes:
_, file_path = change
for plugin_dir_path, plugin_name in plugins_to_check:
if (
os.path.commonpath([plugin_dir_path])
== os.path.commonpath([plugin_dir_path, file_path])
and plugin_name not in reloaded_plugins
):
logger.info(f"检测到插件 {plugin_name} 文件变化,正在重载...")
await self.reload(plugin_name)
reloaded_plugins.add(plugin_name)
break
def _get_classes(self, arg: ModuleType):
"""获取指定模块(可以理解为一个 python 文件)下所有的类"""
@@ -84,13 +140,11 @@ class PluginManager:
if os.path.exists(os.path.join(path, d, "main.py")) or os.path.exists(
os.path.join(path, d, d + ".py")
):
modules.append(
{
"pname": d,
"module": module_str,
"module_path": os.path.join(path, d, module_str),
}
)
modules.append({
"pname": d,
"module": module_str,
"module_path": os.path.join(path, d, module_str),
})
return modules
def _get_plugin_modules(self) -> List[dict]:

View File

@@ -1,4 +1,6 @@
import inspect
import os
from pathlib import Path
from typing import Union, Awaitable, List, Optional, ClassVar
from astrbot.core.message.components import BaseMessageComponent
from astrbot.core.message.message_event_result import MessageChain
@@ -6,7 +8,7 @@ from astrbot.api.platform import MessageMember, AstrBotMessage
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.star.context import Context
from astrbot.core.star.star import star_map
from pathlib import Path
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
class StarTools:
@@ -180,7 +182,7 @@ class StarTools:
plugin_name = metadata.name
data_dir = Path("data/plugin_data") / plugin_name
data_dir = Path(os.path.join(get_astrbot_data_path(), "plugin_data", plugin_name))
try:
data_dir.mkdir(parents=True, exist_ok=True)

View File

@@ -6,16 +6,13 @@ from ..updator import RepoZipUpdator
from astrbot.core.utils.io import remove_dir, on_error
from ..star.star import StarMetadata
from astrbot.core import logger
from astrbot.core.utils.astrbot_path import get_astrbot_plugin_path
class PluginUpdator(RepoZipUpdator):
def __init__(self, repo_mirror: str = "") -> None:
super().__init__(repo_mirror)
self.plugin_store_path = os.path.abspath(
os.path.join(
os.path.dirname(os.path.abspath(__file__)), "../../../data/plugins"
)
)
self.plugin_store_path = get_astrbot_plugin_path()
def get_plugin_store_path(self) -> str:
return self.plugin_store_path

View File

@@ -6,6 +6,7 @@ from .zip_updator import ReleaseInfo, RepoZipUpdator
from astrbot.core import logger
from astrbot.core.config.default import VERSION
from astrbot.core.utils.io import download_file
from astrbot.core.utils.astrbot_path import get_astrbot_path
class AstrBotUpdator(RepoZipUpdator):
@@ -16,9 +17,7 @@ class AstrBotUpdator(RepoZipUpdator):
def __init__(self, repo_mirror: str = "") -> None:
super().__init__(repo_mirror)
self.MAIN_PATH = os.path.abspath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../")
)
self.MAIN_PATH = get_astrbot_path()
self.ASTRBOT_RELEASE_API = "https://api.soulter.top/releases"
def terminate_child_processes(self):
@@ -45,13 +44,26 @@ class AstrBotUpdator(RepoZipUpdator):
def _reboot(self, delay: int = 3):
"""重启当前程序
在指定的延迟后,终止所有子进程并重新启动程序
这里只能使用 os.exec* 来重启程序
"""
py = sys.executable
time.sleep(delay)
self.terminate_child_processes()
py = py.replace(" ", "\\ ")
if os.name == "nt":
py = f'"{sys.executable}"'
else:
py = sys.executable
try:
os.execl(py, py, *sys.argv)
if "astrbot" in os.path.basename(sys.argv[0]): # 兼容cli
if os.name == "nt":
args = [
f'"{arg}"' if " " in arg else arg for arg in sys.argv[1:]
]
else:
args = sys.argv[1:]
os.execl(sys.executable, py, "-m", "astrbot.cli.__main__", *args)
else:
os.execl(sys.executable, py, *sys.argv)
except Exception as e:
logger.error(f"重启失败({py}, {e}),请尝试手动重启。")
raise e
@@ -67,6 +79,9 @@ class AstrBotUpdator(RepoZipUpdator):
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
file_url = None
if os.environ.get("ASTRBOT_CLI"):
raise Exception("不支持更新CLI启动的AstrBot") # 避免版本管理混乱
if latest:
latest_version = update_data[0]["tag_name"]
if self.compare_version(VERSION, latest_version) >= 0:

View File

@@ -0,0 +1,41 @@
"""
Astrbot统一路径获取
项目路径:固定为源码所在路径
根目录路径:默认为当前工作目录,可通过环境变量 ASTRBOT_ROOT 指定
数据目录路径:固定为根目录下的 data 目录
配置文件路径:固定为数据目录下的 config 目录
插件目录路径:固定为数据目录下的 plugins 目录
"""
import os
def get_astrbot_path() -> str:
"""获取Astrbot项目路径"""
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)
else:
return os.path.realpath(os.getcwd())
def get_astrbot_data_path() -> str:
"""获取Astrbot数据目录路径"""
return os.path.realpath(os.path.join(get_astrbot_root(), "data"))
def get_astrbot_config_path() -> str:
"""获取Astrbot配置文件路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "config"))
def get_astrbot_plugin_path() -> str:
"""获取Astrbot插件目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "plugins"))

View File

@@ -14,6 +14,7 @@ import certifi
from typing import Union
from PIL import Image
from .astrbot_path import get_astrbot_data_path
def on_error(func, path, exc_info):
@@ -49,11 +50,11 @@ def port_checker(port: int, host: str = "localhost"):
def save_temp_img(img: Union[Image.Image, str]) -> str:
os.makedirs("data/temp", exist_ok=True)
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
# 获得文件创建时间,清除超过 12 小时的
try:
for f in os.listdir("data/temp"):
path = os.path.join("data/temp", f)
for f in os.listdir(temp_dir):
path = os.path.join(temp_dir, f)
if os.path.isfile(path):
ctime = os.path.getctime(path)
if time.time() - ctime > 3600 * 12:
@@ -63,7 +64,7 @@ def save_temp_img(img: Union[Image.Image, str]) -> str:
# 获得时间戳
timestamp = f"{int(time.time())}_{uuid.uuid4().hex[:8]}"
p = f"data/temp/{timestamp}.jpg"
p = os.path.join(temp_dir, f"{timestamp}.jpg")
if isinstance(img, Image.Image):
img.save(p)
@@ -201,28 +202,29 @@ def get_local_ip_addresses():
async def get_dashboard_version():
if os.path.exists("data/dist"):
if os.path.exists("data/dist/assets/version"):
with open("data/dist/assets/version", "r") as f:
dist_dir = os.path.join(get_astrbot_data_path(), "dist")
if os.path.exists(dist_dir):
version_file = os.path.join(dist_dir, "assets", "version")
if os.path.exists(version_file):
with open(version_file, "r") as f:
v = f.read().strip()
return v
return None
async def download_dashboard(path: str = "data/dashboard.zip", extract_path: str = "data"):
async def download_dashboard(path: str = None, extract_path: str = "data"):
"""下载管理面板文件"""
if path is None:
path = os.path.join(get_astrbot_data_path(), "dashboard.zip")
dashboard_release_url = "https://astrbot-registry.soulter.top/download/astrbot-dashboard/latest/dist.zip"
try:
await download_file(
dashboard_release_url, path, show_progress=True
)
await download_file(dashboard_release_url, path, show_progress=True)
except BaseException as _:
dashboard_release_url = (
"https://github.com/Soulter/AstrBot/releases/latest/download/dist.zip"
)
await download_file(
dashboard_release_url, path, show_progress=True
)
await download_file(dashboard_release_url, path, show_progress=True)
print("解压管理面板文件中...")
with zipfile.ZipFile(path, "r") as z:
z.extractall(extract_path)

View File

@@ -1,70 +1,72 @@
import os
from astrbot.core import logger
def path_Mapping(mappings, srcPath: str)->str:
"""路径映射处理函数。尝试支援 Windows 和 Linux 的路径映射。
Args:
mappings: 映射规则列表
srcPath: 原路径
Returns:
str: 处理后的路径
"""
for mapping in mappings:
rule = mapping.split(":")
if len(rule) == 2:
from_, to_ = mapping.split(":")
elif len(rule) > 4 or len(rule) == 1:
# 切割后大于4个项目或者只有1个项目那肯定是错误的只能是234个项目
logger.warning(f"路径映射规则错误: {mapping}")
continue
def path_Mapping(mappings, srcPath: str) -> str:
"""路径映射处理函数。尝试支援 Windows 和 Linux 的路径映射。
Args:
mappings: 映射规则列表
srcPath: 原路径
Returns:
str: 处理后的路径
"""
for mapping in mappings:
rule = mapping.split(":")
if len(rule) == 2:
from_, to_ = mapping.split(":")
elif len(rule) > 4 or len(rule) == 1:
# 切割后大于4个项目或者只有1个项目那肯定是错误的只能是234个项目
logger.warning(f"路径映射规则错误: {mapping}")
continue
else:
# rule.len == 3 or 4
if os.path.exists(rule[0] + ":" + rule[1]):
# 前面两个项目合并路径存在说明是本地Window路径。后面一个或两个项目组成的路径本地大概率无法解析直接拼接
from_ = rule[0] + ":" + rule[1]
if len(rule) == 3:
to_ = rule[2]
else:
to_ = rule[2] + ":" + rule[3]
else:
# rule.len == 3 or 4
if(os.path.exists(rule[0]+":"+rule[1])):
# 前面两个项目合并路径存在说明是本地Window路径。后面一个或两个项目组成的路径本地大概率无法解析直接拼接
from_ = rule[0] + ":" + rule[1]
if len(rule) == 3:
to_ = rule[2]
else:
to_ = rule[2] + ":" + rule[3]
# 前面两个项目合并路径不存在说明第一个项目是本地Linux路径后面一个或两个项目直接拼接。
from_ = rule[0]
if len(rule) == 3:
to_ = rule[1] + ":" + rule[2]
else:
# 前面两个项目合并路径不存在说明第一个项目是本地Linux路径后面一个或两个项目直接拼接。
from_ = rule[0]
if len(rule) == 3:
to_ = rule[1] + ":" + rule[2]
else:
# 这种情况下存在四个项目,说明规则也是错误的
logger.warning(f"路径映射规则错误: {mapping}")
continue
# 这种情况下存在四个项目,说明规则也是错误的
logger.warning(f"路径映射规则错误: {mapping}")
continue
from_ = from_.removesuffix("/")
from_ = from_.removesuffix("\\")
to_ = to_.removesuffix("/")
to_ = to_.removesuffix("\\")
# logger.debug(f"\t路径映射-规则(处理): {from_} -> {to_}")
from_ = from_.removesuffix("/")
from_ = from_.removesuffix("\\")
to_ = to_.removesuffix("/")
to_ = to_.removesuffix("\\")
# logger.debug(f"\t路径映射-规则(处理): {from_} -> {to_}")
url = srcPath.removeprefix("file://")
if url.startswith(from_):
srcPath = url.replace(from_, to_, 1)
if ":" in srcPath:
# Windows路径处理
srcPath = srcPath.replace("/", "\\")
else:
has_replaced_processed = False
if srcPath.startswith("."):
# 相对路径处理。如果是相对路径可能是Linux路径也可能是Windows路径
sign = srcPath[1]
# 处理两个点的情况
if sign == ".":
sign = srcPath[2]
if sign == "/":
srcPath = srcPath.replace("\\", "/")
has_replaced_processed = True
elif sign == "\\":
srcPath = srcPath.replace("/", "\\")
has_replaced_processed = True
if has_replaced_processed == False:
# 如果不是相对路径或不能处理默认按照Linux路径处理
url = srcPath.removeprefix("file://")
if url.startswith(from_):
srcPath = url.replace(from_, to_, 1)
if ":" in srcPath:
# Windows路径处理
srcPath = srcPath.replace("/", "\\")
else:
has_replaced_processed = False
if srcPath.startswith("."):
# 相对路径处理。如果是相对路径可能是Linux路径也可能是Windows路径
sign = srcPath[1]
# 处理两个点的情况
if sign == ".":
sign = srcPath[2]
if sign == "/":
srcPath = srcPath.replace("\\", "/")
logger.info(f"路径映射: {url} -> {srcPath}")
return srcPath
return srcPath
has_replaced_processed = True
elif sign == "\\":
srcPath = srcPath.replace("/", "\\")
has_replaced_processed = True
if not has_replaced_processed:
# 如果不是相对路径或不能处理默认按照Linux路径处理
srcPath = srcPath.replace("\\", "/")
logger.info(f"路径映射: {url} -> {srcPath}")
return srcPath
return srcPath

View File

@@ -1,9 +1,12 @@
import json
import os
from .astrbot_path import get_astrbot_data_path
class SharedPreferences:
def __init__(self, path="data/shared_preferences.json"):
def __init__(self, path=None):
if path is None:
path = os.path.join(get_astrbot_data_path(), "shared_preferences.json")
self.path = path
self._data = self._load_preferences()

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ from .static_file import StaticFileRoute
from .chat import ChatRoute
from .tools import ToolsRoute # 导入新的ToolsRoute
from .conversation import ConversationRoute
from .file import FileRoute
__all__ = [
@@ -19,6 +20,7 @@ __all__ = [
"LogRoute",
"StaticFileRoute",
"ChatRoute",
"ToolsRoute", # 添加新的ToolsRoute
"ToolsRoute",
"ConversationRoute",
"FileRoute",
]

View File

@@ -8,6 +8,7 @@ from astrbot.core.db import BaseDatabase
import asyncio
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
class ChatRoute(Route):
@@ -33,7 +34,8 @@ class ChatRoute(Route):
self.db = db
self.core_lifecycle = core_lifecycle
self.register_routes()
self.imgs_dir = "data/webchat/imgs"
self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
os.makedirs(self.imgs_dir, exist_ok=True)
self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]

View File

@@ -0,0 +1,24 @@
from .route import Route, RouteContext
from astrbot import logger
from quart import abort, send_file
from astrbot.core import file_token_service
class FileRoute(Route):
def __init__(
self,
context: RouteContext,
) -> None:
super().__init__(context)
self.routes = {
"/file/<file_token>": ("GET", self.serve_file),
}
self.register_routes()
async def serve_file(self, file_token: str):
try:
file_path = await file_token_service.handle_file(file_token)
return await send_file(file_path)
except (FileNotFoundError, KeyError) as e:
logger.warning(str(e))
return abort(404)

View File

@@ -28,7 +28,7 @@ class StaticFileRoute(Route):
@self.app.errorhandler(404)
async def page_not_found(e):
return "404 Not found。如果你初次使用打开面板发现 404, 请参考文档: https://astrbot.app/faq.html。"
return "404 Not found。如果你初次使用打开面板发现 404, 请参考文档: https://astrbot.app/faq.html。如果你正在测试回调地址可达性,显示这段文字说明测试成功了。"
async def index(self):
return await self.app.send_static_file("index.html")

View File

@@ -1,11 +1,15 @@
import os
import json
import aiohttp
import os
import traceback
from .route import Route, Response, RouteContext
import aiohttp
from quart import request
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from .route import Response, Route, RouteContext
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
@@ -28,8 +32,7 @@ class ToolsRoute(Route):
@property
def mcp_config_path(self):
current_dir = os.path.dirname(os.path.abspath(__file__))
data_dir = os.path.abspath(os.path.join(current_dir, "../../../data"))
data_dir = get_astrbot_data_path()
return os.path.join(data_dir, "mcp_server.json")
def load_mcp_config(self):
@@ -130,13 +133,11 @@ class ToolsRoute(Route):
if self.save_mcp_config(config):
# 动态初始化新MCP客户端
await self.tool_mgr.mcp_service_queue.put(
{
"type": "init",
"name": name,
"cfg": config["mcpServers"][name],
}
)
await self.tool_mgr.mcp_service_queue.put({
"type": "init",
"name": name,
"cfg": config["mcpServers"][name],
})
return Response().ok(None, f"成功添加 MCP 服务器 {name}").__dict__
else:
return Response().error("保存配置失败").__dict__
@@ -194,37 +195,29 @@ class ToolsRoute(Route):
if active:
# 如果要激活服务器或者配置已更改
if name in self.tool_mgr.mcp_client_dict or not only_update_active:
await self.tool_mgr.mcp_service_queue.put(
{
"type": "terminate",
"name": name,
}
)
await self.tool_mgr.mcp_service_queue.put(
{
"type": "init",
"name": name,
"cfg": config["mcpServers"][name],
}
)
await self.tool_mgr.mcp_service_queue.put({
"type": "terminate",
"name": name,
})
await self.tool_mgr.mcp_service_queue.put({
"type": "init",
"name": name,
"cfg": config["mcpServers"][name],
})
else:
# 客户端不存在,初始化
await self.tool_mgr.mcp_service_queue.put(
{
"type": "init",
"name": name,
"cfg": config["mcpServers"][name],
}
)
await self.tool_mgr.mcp_service_queue.put({
"type": "init",
"name": name,
"cfg": config["mcpServers"][name],
})
else:
# 如果要停用服务器
if name in self.tool_mgr.mcp_client_dict:
self.tool_mgr.mcp_service_queue.put_nowait(
{
"type": "terminate",
"name": name,
}
)
self.tool_mgr.mcp_service_queue.put_nowait({
"type": "terminate",
"name": name,
})
return Response().ok(None, f"成功更新 MCP 服务器 {name}").__dict__
else:
@@ -252,12 +245,10 @@ class ToolsRoute(Route):
if self.save_mcp_config(config):
# 关闭并删除MCP客户端
if name in self.tool_mgr.mcp_client_dict:
self.tool_mgr.mcp_service_queue.put_nowait(
{
"type": "terminate",
"name": name,
}
)
self.tool_mgr.mcp_service_queue.put_nowait({
"type": "terminate",
"name": name,
})
return Response().ok(None, f"成功删除 MCP 服务器 {name}").__dict__
else:
@@ -269,9 +260,11 @@ class ToolsRoute(Route):
async def get_mcp_markets(self):
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 10, type=int)
BASE_URL = "https://api.soulter.top/astrbot/mcpservers?page={}&page_size={}".format(
page,
page_size,
BASE_URL = (
"https://api.soulter.top/astrbot/mcpservers?page={}&page_size={}".format(
page,
page_size,
)
)
try:
async with aiohttp.ClientSession() as session:
@@ -287,4 +280,4 @@ class ToolsRoute(Route):
)
except Exception as _:
logger.error(traceback.format_exc())
return Response().error("获取市场数据失败").__dict__
return Response().error("获取市场数据失败").__dict__

View File

@@ -13,10 +13,7 @@ from .routes.route import RouteContext, Response
from astrbot.core import logger, WEBUI_SK
from astrbot.core.db import BaseDatabase
from astrbot.core.utils.io import get_local_ip_addresses
DATAPATH = os.path.abspath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../data")
)
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
class AstrBotDashboard:
@@ -28,7 +25,7 @@ class AstrBotDashboard:
) -> None:
self.core_lifecycle = core_lifecycle
self.config = core_lifecycle.astrbot_config
self.data_path = os.path.abspath(os.path.join(DATAPATH, "dist"))
self.data_path = os.path.abspath(os.path.join(get_astrbot_data_path(), "dist"))
self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
self.app.config["MAX_CONTENT_LENGTH"] = (
128 * 1024 * 1024
@@ -52,15 +49,15 @@ class AstrBotDashboard:
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
self.tools_root = ToolsRoute(self.context, core_lifecycle)
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
self.file_route = FileRoute(self.context)
self.shutdown_event = shutdown_event
async def auth_middleware(self):
if not request.path.startswith("/api"):
return
if request.path == "/api/auth/login":
return
if request.path == "/api/chat/get_file":
allowed_endpoints = ["/api/auth/login", "/api/chat/get_file", "/api/file"]
if any(request.path.startswith(prefix) for prefix in allowed_endpoints):
return
# claim jwt
token = request.headers.get("Authorization")
@@ -125,7 +122,10 @@ class AstrBotDashboard:
def run(self):
ip_addr = []
port = self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185)
if p := os.environ.get("DASHBOARD_PORT"):
port = p
else:
port = self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185)
host = self.core_lifecycle.astrbot_config["dashboard"].get("host", "0.0.0.0")
logger.info(f"正在启动 WebUI, 监听地址: http://{host}:{port}")

11
changelogs/v3.5.10.md Normal file
View File

@@ -0,0 +1,11 @@
# What's Changed
1. 新增: 支持接入个人微信WeChatPadPro替换 gewechat 方式。
2. 新增:接入 PPIO 派欧云
3. 新增:支持接入 Minimax TTS
3.修复Docker 下重启 AstrBot 会导致 astrbot 容器进程退出的问题。
4. 优化:速率限制功能
5. 优化QQ 和 Telegram 下,群聊的 @ 信息也将发送给模型以获得更好的回复、QQ 支持 @ 全体成员的解析。
6. 优化WebUI 配置项支持代码编辑器模式!
7. 优化:语音组件将单独发送以保证全平台兼容性
8. 优化QQ 下,屏蔽 QQ 管家(qq=2854196310) 的所有消息。

View File

@@ -1,5 +1,5 @@
# What's Changed
1. 支持接入微信公众平台,详见 [AstrBot - 微信公众平台](https://astrbot.app/deploy/platform/weixin-official-account.html) @Soulter
2. 优化 gemini_source 方法默认参数 @Raven95678
2. 优化 gemini_source 方法默认参数 @Raven95676
3. 优化 persona 错误显示 @Soulter

13
changelogs/v3.5.9.md Normal file
View File

@@ -0,0 +1,13 @@
# What's Changed
1. 重构: 采用更好的方式将文件上传到 NapCat 协议端,无需映射路径。**(需要前往 配置->其他配置 中配置`对外可达的回调接口地址`** @Soulter @anka-afk
2. 修复: 单独发送文件时被认为是空消息导致文件无法发送的问题 @Soulter
3. 修复: Lagrange 下合并转发消息失败的问题 @Soulter
4. 修复: CLI 模式下路径问题导致 WebUI 和 MCP Server 无法加载的问题 @Soulter
5. 修复: 设置 Gemini 的 thinking_budget 前,先检查是否存在 @Raven95676
6. 修复: 修复企业微信和微信公众平台下无法应用 api_base_url 的问题 @Soulter
7. 优化: 分离 plugin 指令为指令组,优化 plugin 指令权限控制 @Soulter
8. 优化: WebUI 更直观的模型提供商选择 @Soulter
9. 优化: AstrBot 的重启逻辑 @Anchor
10. 新增: CLI 支持部分配置文件项的设定、支持插件管理和检测到插件文件变化时自动热重载 @Raven95676
11. 新增: 现已支持 Azure TTS @NanoRocky

View File

@@ -1,6 +0,0 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.06129 13.2253L4.31871 15.9975L1.60458 16.0549C0.793457 14.5504 0.333374 12.8292 0.333374 11C0.333374 9.23119 0.763541 7.56319 1.52604 6.09448H1.52662L3.94296 6.53748L5.00146 8.93932C4.77992 9.58519 4.65917 10.2785 4.65917 11C4.65925 11.783 4.80108 12.5332 5.06129 13.2253Z" fill="#FBBB00"/>
<path d="M21.4804 9.00732C21.6029 9.65257 21.6668 10.3189 21.6668 11C21.6668 11.7637 21.5865 12.5086 21.4335 13.2271C20.9143 15.6722 19.5575 17.8073 17.678 19.3182L17.6774 19.3177L14.6339 19.1624L14.2031 16.4734C15.4503 15.742 16.425 14.5974 16.9384 13.2271H11.2346V9.00732H17.0216H21.4804Z" fill="#518EF8"/>
<path d="M17.6772 19.3176L17.6777 19.3182C15.8498 20.7875 13.5277 21.6666 11 21.6666C6.93783 21.6666 3.40612 19.3962 1.60449 16.0549L5.0612 13.2253C5.96199 15.6294 8.28112 17.3408 11 17.3408C12.1686 17.3408 13.2634 17.0249 14.2029 16.4734L17.6772 19.3176Z" fill="#28B446"/>
<path d="M17.8085 2.78892L14.353 5.61792C13.3807 5.01017 12.2313 4.65908 11 4.65908C8.21963 4.65908 5.85713 6.44896 5.00146 8.93925L1.52658 6.09442H1.526C3.30125 2.67171 6.8775 0.333252 11 0.333252C13.5881 0.333252 15.9612 1.25517 17.8085 2.78892Z" fill="#F14336"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,18 +0,0 @@
<svg width="46" height="55" viewBox="0 0 46 55" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0)">
<path d="M19.6667 55C8.82205 55 0 46.2504 0 35.4968C0 24.7431 8.82292 15.9935 19.6667 15.9935C30.5105 15.9935 39.3334 24.7431 39.3334 35.4968C39.3334 46.2504 30.5122 55 19.6667 55ZM19.6667 17.8563C9.8587 17.8563 1.87839 25.7686 1.87839 35.4959C1.87839 45.2233 9.8587 53.1355 19.6667 53.1355C29.4747 53.1355 37.4559 45.2215 37.4559 35.4942C37.4559 25.7668 29.4765 17.8563 19.6667 17.8563Z" fill="#2196F3"/>
<path d="M33.9387 36.3618C33.3269 34.1133 27.7188 33.8706 24.3807 34.6949C22.6326 35.1283 20.846 35.6917 19.0034 36.0159C20.3521 37.2026 21.8005 38.3251 23.879 38.6042C29.0361 39.2942 32.2404 37.6898 33.9387 36.3618Z" fill="#2196F3"/>
<path d="M23.8788 38.6042C21.7959 38.3251 20.3519 37.2026 19.0032 36.016C16.9159 34.1792 15.0594 32.189 11.4154 32.9379C5.62198 34.1289 4.85978 40.9247 9.3333 45.2917C11.254 47.2864 13.7197 48.6822 16.4284 49.3079C19.137 49.9336 21.9709 49.7621 24.5828 48.8144C27.1946 47.8667 29.4709 46.1839 31.1327 43.9724C32.7945 41.7608 33.7696 39.1165 33.9385 36.3635C32.2402 37.6898 29.0358 39.2942 23.8788 38.6042Z" fill="#673AB7"/>
<path d="M26.9105 23.8962C26.1876 25.4331 32.6321 27.1381 33.4031 32.2419C33.7746 27.2178 27.8046 21.9962 26.9105 23.8962Z" fill="#2196F3"/>
<path d="M13.3649 30.3107C14.5267 29.8335 15.0784 28.5126 14.5972 27.3604C14.116 26.2083 12.784 25.6611 11.6222 26.1384C10.4604 26.6156 9.90867 27.9365 10.3899 29.0887C10.8712 30.2408 12.2031 30.7879 13.3649 30.3107Z" fill="#673AB7"/>
<path d="M18.5351 24.1103C19.0786 23.5714 19.0786 22.6977 18.5351 22.1587C17.9917 21.6198 17.1106 21.6198 16.5672 22.1587C16.0238 22.6977 16.0238 23.5714 16.5672 24.1103C17.1106 24.6492 17.9917 24.6492 18.5351 24.1103Z" fill="#2196F3"/>
<path d="M23.4513 15.2376C25.4617 9.3485 24.1103 4.64345 19.9786 2.40881C17.1544 2.97831 15.4779 4.334 14.5444 6.20544C20.0843 5.76077 23.5999 9.1994 23.4513 15.2376Z" fill="#2196F3"/>
<path d="M46.0001 10.0923C36.0487 6.55051 29.7685 7.76491 28.7808 15.8349C34.4841 21.6703 40.2286 18.8774 46.0001 10.0923Z" fill="#2196F3"/>
<path d="M38.0851 6.89635C38.5466 4.94082 38.7861 2.6299 38.8219 0C28.5017 2.27885 23.8473 6.6337 27.3584 13.9782C27.5333 14.0198 27.7011 14.0536 27.8698 14.0883C28.6905 8.34132 32.3031 6.2133 38.0851 6.89635Z" fill="#2196F3"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="46" height="55" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 9H9C7.89543 9 7 9.89543 7 11V17C7 18.1046 7.89543 19 9 19H19C20.1046 19 21 18.1046 21 17V11C21 9.89543 20.1046 9 19 9Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 16C15.1046 16 16 15.1046 16 14C16 12.8954 15.1046 12 14 12C12.8954 12 12 12.8954 12 14C12 15.1046 12.8954 16 14 16Z" fill="#90CAF9"/>
<path d="M17 9V7C17 6.46957 16.7893 5.96086 16.4142 5.58579C16.0391 5.21071 15.5304 5 15 5H5C4.46957 5 3.96086 5.21071 3.58579 5.58579C3.21071 5.96086 3 6.46957 3 7V13C3 13.5304 3.21071 14.0391 3.58579 14.4142C3.96086 14.7893 4.46957 15 5 15H7" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 794 B

View File

@@ -1,12 +0,0 @@
<svg width="92" height="32" viewBox="0 0 92 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M33.085 26.4841V12.3839H37.9541C39.6408 12.3839 40.9202 12.7131 41.7922 13.3717C42.6642 14.0237 43.1002 14.9825 43.1002 16.2478C43.1002 16.9387 42.9251 17.5488 42.5751 18.0782C42.225 18.6011 41.7381 18.9853 41.1143 19.2306C41.8272 19.4114 42.3873 19.7761 42.7947 20.3249C43.2084 20.8737 43.4152 21.5452 43.4152 22.3392C43.4152 23.695 42.9888 24.7215 42.1359 25.4188C41.283 26.1161 40.0673 26.4712 38.4888 26.4841H33.085ZM35.9492 20.3443V24.1502H38.4028C39.0775 24.1502 39.6026 23.9888 39.9781 23.666C40.36 23.3367 40.551 22.8848 40.551 22.3102C40.551 21.0189 39.8922 20.3637 38.5747 20.3443H35.9492ZM35.9492 18.2912H38.0687C39.5135 18.2654 40.236 17.6811 40.236 16.5384C40.236 15.8992 40.0514 15.4408 39.6822 15.1632C39.3194 14.8792 38.7434 14.7371 37.9541 14.7371H35.9492V18.2912ZM53.9365 20.3733H48.4371V24.1502H54.8913V26.4841H45.573V12.3839H54.8723V14.7371H48.4371V18.0976H53.9365V20.3733ZM61.7175 21.3224H59.436V26.4841H56.5717V12.3839H61.7365C63.379 12.3839 64.6455 12.7551 65.5365 13.4975C66.4276 14.24 66.8734 15.2891 66.8734 16.6449C66.8734 17.6069 66.6661 18.4107 66.2527 19.0563C65.8455 19.6954 65.2248 20.2055 64.3907 20.5864L67.3985 26.3485V26.4841H64.3242L61.7175 21.3224ZM59.436 18.9691H61.746C62.4656 18.9691 63.0226 18.7851 63.417 18.4172C63.8114 18.0427 64.0092 17.5294 64.0092 16.8773C64.0092 16.2124 63.8214 15.6894 63.4455 15.3085C63.0768 14.9276 62.5069 14.7371 61.7365 14.7371H59.436V18.9691ZM74.2058 21.3224H71.9237V26.4841H69.0594V12.3839H74.2248C75.8667 12.3839 77.1337 12.7551 78.0248 13.4975C78.9159 14.24 79.3611 15.2891 79.3611 16.6449C79.3611 17.6069 79.1544 18.4107 78.7404 19.0563C78.3332 19.6954 77.7125 20.2055 76.879 20.5864L79.8863 26.3485V26.4841H76.8119L74.2058 21.3224ZM71.9237 18.9691H74.2343C74.9533 18.9691 75.5103 18.7851 75.9052 18.4172C76.2997 18.0427 76.4969 17.5294 76.4969 16.8773C76.4969 16.2124 76.3092 15.6894 75.9337 15.3085C75.5645 14.9276 74.9946 14.7371 74.2248 14.7371H71.9237V18.9691ZM85.8823 18.7367L88.7751 12.3839H91.9064L87.3427 21.3708V26.4841H84.4309V21.3708L79.8673 12.3839H83.008L85.8823 18.7367Z" fill="#212121"/>
<path d="M10.987 31.5841C4.92849 31.5841 0 26.626 0 20.5323C0 14.4385 4.92899 9.48041 10.987 9.48041C17.045 9.48041 21.974 14.4385 21.974 20.5323C21.974 26.626 17.0459 31.5841 10.987 31.5841ZM10.987 10.536C5.50765 10.536 1.04938 15.0196 1.04938 20.5318C1.04938 26.044 5.50765 30.5275 10.987 30.5275C16.4663 30.5275 20.9251 26.0429 20.9251 20.5308C20.9251 15.0186 16.4673 10.536 10.987 10.536Z" fill="#2196F3"/>
<path d="M18.96 21.0225C18.6182 19.7483 15.4851 19.6108 13.6203 20.0779C12.6437 20.3235 11.6456 20.6428 10.6162 20.8265C11.3697 21.4989 12.1788 22.135 13.34 22.2932C16.2211 22.6842 18.0112 21.775 18.96 21.0225Z" fill="#2196F3"/>
<path d="M13.34 22.2932C12.1764 22.135 11.3697 21.4989 10.6162 20.8265C9.45013 19.7857 8.41298 18.6579 6.37723 19.0823C3.14069 19.7572 2.71488 23.6081 5.21404 26.0828C6.28706 27.2131 7.66455 28.0041 9.17779 28.3586C10.691 28.7132 12.2742 28.616 13.7333 28.079C15.1924 27.5419 16.4641 26.5883 17.3925 25.3352C18.3209 24.0819 18.8656 22.5835 18.96 21.0235C18.0112 21.775 16.221 22.6842 13.34 22.2932Z" fill="#673AB7"/>
<path d="M15.034 13.9586C14.6301 14.8295 18.2304 15.7957 18.6611 18.6879C18.8687 15.8409 15.5335 12.882 15.034 13.9586Z" fill="#2196F3"/>
<path d="M7.46619 17.5935C8.11524 17.3231 8.42345 16.5746 8.15463 15.9217C7.8858 15.2688 7.14167 14.9587 6.49262 15.2292C5.84357 15.4996 5.53536 16.2481 5.80418 16.9011C6.07306 17.5539 6.81714 17.8639 7.46619 17.5935Z" fill="#673AB7"/>
<path d="M10.3549 14.08C10.6585 13.7746 10.6585 13.2795 10.3549 12.9741C10.0513 12.6687 9.55909 12.6687 9.25551 12.9741C8.95194 13.2795 8.95194 13.7746 9.25551 14.08C9.55909 14.3854 10.0513 14.3854 10.3549 14.08Z" fill="#2196F3"/>
<path d="M13.1014 9.05206C14.2245 5.7149 13.4696 3.04871 11.1614 1.78241C9.58359 2.10513 8.647 2.87335 8.12549 3.93383C11.2204 3.68185 13.1844 5.63041 13.1014 9.05206Z" fill="#2196F3"/>
<path d="M25.6983 6.13641C20.1389 4.1294 16.6304 4.81756 16.0786 9.39055C19.2648 12.6973 22.474 11.1146 25.6983 6.13641Z" fill="#2196F3"/>
<path d="M21.2765 4.32541C21.5343 3.21728 21.6681 1.90776 21.6881 0.41748C15.9226 1.70883 13.3224 4.17658 15.2839 8.33846C15.3816 8.36203 15.4754 8.38119 15.5696 8.40085C16.0281 5.14422 18.0463 3.93835 21.2765 4.32541Z" fill="#2196F3"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1,12 +0,0 @@
<svg width="92" height="32" viewBox="0 0 92 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M33.085 26.4841V12.3839H37.9541C39.6408 12.3839 40.9202 12.7131 41.7922 13.3717C42.6642 14.0237 43.1002 14.9825 43.1002 16.2478C43.1002 16.9387 42.9251 17.5488 42.5751 18.0782C42.225 18.6011 41.7381 18.9853 41.1143 19.2306C41.8272 19.4114 42.3873 19.7761 42.7947 20.3249C43.2084 20.8737 43.4152 21.5452 43.4152 22.3392C43.4152 23.695 42.9888 24.7215 42.1359 25.4188C41.283 26.1161 40.0673 26.4712 38.4888 26.4841H33.085ZM35.9492 20.3443V24.1502H38.4028C39.0775 24.1502 39.6026 23.9888 39.9781 23.666C40.36 23.3367 40.551 22.8848 40.551 22.3102C40.551 21.0189 39.8922 20.3637 38.5747 20.3443H35.9492ZM35.9492 18.2912H38.0687C39.5135 18.2654 40.236 17.6811 40.236 16.5384C40.236 15.8992 40.0514 15.4408 39.6822 15.1632C39.3194 14.8792 38.7434 14.7371 37.9541 14.7371H35.9492V18.2912ZM53.9365 20.3733H48.4371V24.1502H54.8913V26.4841H45.573V12.3839H54.8723V14.7371H48.4371V18.0976H53.9365V20.3733ZM61.7175 21.3224H59.436V26.4841H56.5717V12.3839H61.7365C63.379 12.3839 64.6455 12.7551 65.5365 13.4975C66.4276 14.24 66.8734 15.2891 66.8734 16.6449C66.8734 17.6069 66.6661 18.4107 66.2527 19.0563C65.8455 19.6954 65.2248 20.2055 64.3907 20.5864L67.3985 26.3485V26.4841H64.3242L61.7175 21.3224ZM59.436 18.9691H61.746C62.4656 18.9691 63.0226 18.7851 63.417 18.4172C63.8114 18.0427 64.0092 17.5294 64.0092 16.8773C64.0092 16.2124 63.8214 15.6894 63.4455 15.3085C63.0768 14.9276 62.5069 14.7371 61.7365 14.7371H59.436V18.9691ZM74.2058 21.3224H71.9237V26.4841H69.0594V12.3839H74.2248C75.8667 12.3839 77.1337 12.7551 78.0248 13.4975C78.9159 14.24 79.3611 15.2891 79.3611 16.6449C79.3611 17.6069 79.1544 18.4107 78.7404 19.0563C78.3332 19.6954 77.7125 20.2055 76.879 20.5864L79.8863 26.3485V26.4841H76.8119L74.2058 21.3224ZM71.9237 18.9691H74.2343C74.9533 18.9691 75.5103 18.7851 75.9052 18.4172C76.2997 18.0427 76.4969 17.5294 76.4969 16.8773C76.4969 16.2124 76.3092 15.6894 75.9337 15.3085C75.5645 14.9276 74.9946 14.7371 74.2248 14.7371H71.9237V18.9691ZM85.8823 18.7367L88.7751 12.3839H91.9064L87.3427 21.3708V26.4841H84.4309V21.3708L79.8673 12.3839H83.008L85.8823 18.7367Z" fill="#ffffff"/>
<path d="M10.987 31.5841C4.92849 31.5841 0 26.626 0 20.5323C0 14.4385 4.92899 9.48041 10.987 9.48041C17.045 9.48041 21.974 14.4385 21.974 20.5323C21.974 26.626 17.0459 31.5841 10.987 31.5841ZM10.987 10.536C5.50765 10.536 1.04938 15.0196 1.04938 20.5318C1.04938 26.044 5.50765 30.5275 10.987 30.5275C16.4663 30.5275 20.9251 26.0429 20.9251 20.5308C20.9251 15.0186 16.4673 10.536 10.987 10.536Z" fill="#2196F3"/>
<path d="M18.96 21.0225C18.6182 19.7483 15.4851 19.6108 13.6203 20.0779C12.6437 20.3235 11.6456 20.6428 10.6162 20.8265C11.3697 21.4989 12.1788 22.135 13.34 22.2932C16.2211 22.6842 18.0112 21.775 18.96 21.0225Z" fill="#2196F3"/>
<path d="M13.34 22.2932C12.1764 22.135 11.3697 21.4989 10.6162 20.8265C9.45013 19.7857 8.41298 18.6579 6.37723 19.0823C3.14069 19.7572 2.71488 23.6081 5.21404 26.0828C6.28706 27.2131 7.66455 28.0041 9.17779 28.3586C10.691 28.7132 12.2742 28.616 13.7333 28.079C15.1924 27.5419 16.4641 26.5883 17.3925 25.3352C18.3209 24.0819 18.8656 22.5835 18.96 21.0235C18.0112 21.775 16.221 22.6842 13.34 22.2932Z" fill="#673AB7"/>
<path d="M15.034 13.9586C14.6301 14.8295 18.2304 15.7957 18.6611 18.6879C18.8687 15.8409 15.5335 12.882 15.034 13.9586Z" fill="#2196F3"/>
<path d="M7.46619 17.5935C8.11524 17.3231 8.42345 16.5746 8.15463 15.9217C7.8858 15.2688 7.14167 14.9587 6.49262 15.2292C5.84357 15.4996 5.53536 16.2481 5.80418 16.9011C6.07306 17.5539 6.81714 17.8639 7.46619 17.5935Z" fill="#673AB7"/>
<path d="M10.3549 14.08C10.6585 13.7746 10.6585 13.2795 10.3549 12.9741C10.0513 12.6687 9.55909 12.6687 9.25551 12.9741C8.95194 13.2795 8.95194 13.7746 9.25551 14.08C9.55909 14.3854 10.0513 14.3854 10.3549 14.08Z" fill="#2196F3"/>
<path d="M13.1014 9.05206C14.2245 5.7149 13.4696 3.04871 11.1614 1.78241C9.58359 2.10513 8.647 2.87335 8.12549 3.93383C11.2204 3.68185 13.1844 5.63041 13.1014 9.05206Z" fill="#2196F3"/>
<path d="M25.6983 6.13641C20.1389 4.1294 16.6304 4.81756 16.0786 9.39055C19.2648 12.6973 22.474 11.1146 25.6983 6.13641Z" fill="#2196F3"/>
<path d="M21.2765 4.32541C21.5343 3.21728 21.6681 1.90776 21.6881 0.41748C15.9226 1.70883 13.3224 4.17658 15.2839 8.33846C15.3816 8.36203 15.4754 8.38119 15.5696 8.40085C16.0281 5.14422 18.0463 3.93835 21.2765 4.32541Z" fill="#2196F3"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1,34 +0,0 @@
<svg width="676" height="391" viewBox="0 0 676 391" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.09">
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(0.866041 -0.499972 -0.866041 -0.499972 4.49127 197.53)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(-0.866041 -0.499972 -0.866041 0.499972 342.315 387.578)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(0.866041 -0.499972 -0.866041 -0.499972 28.0057 211.105)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(-0.866041 -0.499972 -0.866041 0.499972 365.829 374.002)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(0.866041 -0.499972 -0.866041 -0.499972 51.52 224.68)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(-0.866041 -0.499972 -0.866041 0.499972 389.344 360.428)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(0.866041 -0.499972 -0.866041 -0.499972 75.0345 238.255)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(-0.866041 -0.499972 -0.866041 0.499972 412.858 346.852)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(0.866041 -0.499972 -0.866041 -0.499972 98.5488 251.83)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(-0.866041 -0.499972 -0.866041 0.499972 436.372 333.277)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(0.866041 -0.499972 -0.866041 -0.499972 122.063 265.405)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(-0.866041 -0.499972 -0.866041 0.499972 459.887 319.703)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(0.866041 -0.499972 -0.866041 -0.499972 145.578 278.979)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(-0.866041 -0.499972 -0.866041 0.499972 483.401 306.127)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(0.866041 -0.499972 -0.866041 -0.499972 169.092 292.556)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(-0.866041 -0.499972 -0.866041 0.499972 506.916 292.551)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(0.866041 -0.499972 -0.866041 -0.499972 192.597 306.127)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(-0.866041 -0.499972 -0.866041 0.499972 530.43 278.977)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(0.866041 -0.499972 -0.866041 -0.499972 216.111 319.703)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(-0.866041 -0.499972 -0.866041 0.499972 553.944 265.402)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(0.866041 -0.499972 -0.866041 -0.499972 239.626 333.277)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(-0.866041 -0.499972 -0.866041 0.499972 577.459 251.827)" stroke="black"/>
<path d="M263.231 346.905L601.064 151.871" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(-0.866041 -0.499972 -0.866041 0.499972 600.973 238.252)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(0.866041 -0.499972 -0.866041 -0.499972 286.654 360.428)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(-0.866041 -0.499972 -0.866041 0.499972 624.487 224.677)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(0.866041 -0.499972 -0.866041 -0.499972 310.169 374.002)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(-0.866041 -0.499972 -0.866041 0.499972 648.002 211.102)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(0.866041 -0.499972 -0.866041 -0.499972 333.683 387.578)" stroke="black"/>
<line y1="-0.5" x2="390.089" y2="-0.5" transform="matrix(-0.866041 -0.499972 -0.866041 0.499972 671.516 197.527)" stroke="black"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,43 +0,0 @@
<svg width="676" height="395" viewBox="0 0 676 395" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="26.998" height="26.8293" transform="matrix(0.866041 -0.499972 0.866041 0.499972 361.873 290.126)" fill="#E3F2FD"/>
<rect width="24.2748" height="24.1231" transform="matrix(0.866041 -0.499972 0.866041 0.499972 364.249 291.115)" fill="#90CAF9"/>
<rect width="26.998" height="26.8293" transform="matrix(0.866041 -0.499972 0.866041 0.499972 291.67 86.4912)" fill="#E3F2FD"/>
<rect width="24.2748" height="24.1231" transform="matrix(0.866041 -0.499972 0.866041 0.499972 294.046 87.48)" fill="#90CAF9"/>
<g filter="url(#filter0_d)">
<path d="M370.694 211.828L365.394 208.768V215.835L365.404 215.829C365.459 216.281 365.785 216.724 366.383 217.069L417.03 246.308C418.347 247.068 420.481 247.068 421.798 246.308L468.671 219.248C469.374 218.842 469.702 218.301 469.654 217.77V210.861L464.282 213.962L418.024 187.257C416.708 186.497 414.573 186.497 413.257 187.257L370.694 211.828Z" fill="url(#paint0_linear)"/>
</g>
<rect width="59.6284" height="63.9858" rx="5" transform="matrix(0.866041 -0.499972 0.866041 0.499972 364 208.812)" fill="#90CAF9"/>
<rect width="59.6284" height="63.9858" rx="5" transform="matrix(0.866041 -0.499972 0.866041 0.499972 364 208.812)" fill="url(#paint1_linear)"/>
<rect width="56.6816" height="60.8238" rx="5" transform="matrix(0.866041 -0.499972 0.866041 0.499972 366.645 208.761)" fill="url(#paint2_linear)"/>
<path d="M421.238 206.161C421.238 206.434 421.62 206.655 422.092 206.655L432.159 206.656C435.164 206.656 437.6 208.063 437.601 209.798C437.602 211.533 435.166 212.939 432.162 212.938L422.09 212.937C421.62 212.937 421.24 213.157 421.24 213.428L421.241 215.814C421.241 216.087 421.624 216.308 422.096 216.308L432.689 216.309C438.917 216.31 443.967 213.395 443.965 209.799C443.964 206.202 438.914 203.286 432.684 203.286L422.086 203.284C421.617 203.284 421.236 203.504 421.237 203.775L421.238 206.161Z" fill="#1E88E5"/>
<path d="M413.422 213.43C413.422 213.157 413.039 212.936 412.567 212.936L402.896 212.935C399.891 212.935 397.455 211.528 397.454 209.793C397.453 208.059 399.889 206.652 402.894 206.653L412.57 206.654C413.039 206.654 413.419 206.435 413.419 206.164L413.418 203.777C413.418 203.504 413.035 203.283 412.563 203.283L402.366 203.282C396.138 203.281 391.089 206.197 391.09 209.793C391.091 213.389 396.141 216.305 402.371 216.306L412.573 216.307C413.042 216.307 413.423 216.088 413.423 215.817L413.422 213.43Z" fill="#1E88E5"/>
<path d="M407.999 198.145L411.211 201.235C411.266 201.288 411.332 201.336 411.405 201.379C411.813 201.614 412.461 201.669 412.979 201.49C413.59 201.278 413.787 200.821 413.421 200.469L410.209 197.379C409.843 197.027 409.051 196.913 408.441 197.124C407.831 197.335 407.633 197.793 407.999 198.145Z" fill="#1E88E5"/>
<path d="M416.235 200.853C416.235 201.058 416.38 201.244 416.613 201.379C416.846 201.513 417.168 201.597 417.524 201.597C418.236 201.596 418.813 201.263 418.813 200.852L418.812 197.021C418.811 196.61 418.234 196.277 417.522 196.277C416.811 196.278 416.234 196.611 416.234 197.022L416.235 200.853Z" fill="#1E88E5"/>
<path d="M421.627 200.47C421.317 200.769 421.412 201.143 421.82 201.379C421.893 201.421 421.977 201.459 422.069 201.491C422.68 201.703 423.472 201.588 423.838 201.236L427.047 198.147C427.413 197.794 427.215 197.337 426.605 197.126C425.994 196.915 425.203 197.029 424.836 197.381L421.627 200.47Z" fill="#1E88E5"/>
<path d="M427.056 221.447L423.844 218.357C423.478 218.005 422.686 217.891 422.076 218.102C421.466 218.314 421.268 218.771 421.634 219.123L424.846 222.213C424.901 222.266 424.967 222.314 425.04 222.357C425.448 222.592 426.097 222.647 426.614 222.468C427.225 222.257 427.423 221.799 427.056 221.447Z" fill="#1E88E5"/>
<path d="M418.82 218.739C418.82 218.328 418.243 217.995 417.531 217.995C416.819 217.995 416.242 218.329 416.242 218.74L416.243 222.57C416.244 222.776 416.388 222.962 416.621 223.096C416.854 223.231 417.177 223.314 417.533 223.314C418.245 223.314 418.822 222.981 418.821 222.57L418.82 218.739Z" fill="#1E88E5"/>
<path d="M413.428 219.122C413.794 218.77 413.596 218.312 412.986 218.101C412.375 217.89 411.584 218.004 411.217 218.356L408.008 221.445C407.698 221.744 407.793 222.118 408.201 222.354C408.274 222.396 408.358 222.434 408.45 222.466C409.061 222.678 409.853 222.563 410.219 222.211L413.428 219.122Z" fill="#1E88E5"/>
<defs>
<filter id="filter0_d" x="301.394" y="186.687" width="232.264" height="208.191" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="84"/>
<feGaussianBlur stdDeviation="32"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.129412 0 0 0 0 0.588235 0 0 0 0 0.952941 0 0 0 0.2 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<linearGradient id="paint0_linear" x1="417.526" y1="205.789" x2="365.394" y2="216.782" gradientUnits="userSpaceOnUse">
<stop stop-color="#2196F3"/>
<stop offset="1" stop-color="#B1DCFF"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="0.503035" y1="2.68177" x2="20.3032" y2="42.2842" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAFAFA" stop-opacity="0.74"/>
<stop offset="1" stop-color="#91CBFA"/>
</linearGradient>
<linearGradient id="paint2_linear" x1="-18.5494" y1="-44.8799" x2="14.7845" y2="40.5766" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAFAFA" stop-opacity="0.74"/>
<stop offset="1" stop-color="#91CBFA"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -1,42 +0,0 @@
<svg width="710" height="391" viewBox="0 0 710 391" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="26.9258" height="26.7576" transform="matrix(0.866041 -0.499972 0.866041 0.499972 161.088 154.333)" fill="#EDE7F6"/>
<rect width="24.9267" height="24.7709" transform="matrix(0.866041 -0.499972 0.866041 0.499972 162.809 155.327)" fill="#B39DDB"/>
<rect width="26.9258" height="26.7576" transform="matrix(0.866041 -0.499972 0.866041 0.499972 536.744 181.299)" fill="#EDE7F6"/>
<rect width="24.9267" height="24.7709" transform="matrix(0.866041 -0.499972 0.866041 0.499972 538.465 182.292)" fill="#B39DDB"/>
<g filter="url(#filter0_d)">
<path d="M67.7237 137.573V134.673H64.009V140.824L64.0177 140.829C64.0367 141.477 64.4743 142.121 65.3305 142.615L103.641 164.733C105.393 165.744 108.232 165.744 109.983 164.733L204.044 110.431C204.879 109.949 205.316 109.324 205.355 108.693L205.355 108.692V108.68C205.358 108.628 205.358 108.576 205.355 108.523L205.362 102.335L200.065 104.472L165.733 84.6523C163.982 83.6413 161.142 83.6413 159.391 84.6523L67.7237 137.573Z" fill="url(#paint0_linear)"/>
</g>
<rect width="115.933" height="51.5596" rx="5" transform="matrix(0.866041 -0.499972 0.866041 0.499972 62.1588 134.683)" fill="#673AB7"/>
<rect width="115.933" height="51.5596" rx="5" transform="matrix(0.866041 -0.499972 0.866041 0.499972 62.1588 134.683)" fill="url(#paint1_linear)" fill-opacity="0.3"/>
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="64" y="78" width="141" height="81">
<rect width="115.933" height="51.5596" rx="5" transform="matrix(0.866041 -0.499972 0.866041 0.499972 62.1588 134.683)" fill="#673AB7"/>
</mask>
<g mask="url(#mask0)">
</g>
<mask id="mask1" mask-type="alpha" maskUnits="userSpaceOnUse" x="64" y="78" width="141" height="81">
<rect width="115.933" height="51.5596" rx="5" transform="matrix(0.866041 -0.499972 0.866041 0.499972 62.1588 134.683)" fill="#673AB7"/>
</mask>
<g mask="url(#mask1)">
<rect width="64.3732" height="64.3732" rx="5" transform="matrix(0.866041 -0.499972 0.866041 0.499972 111.303 81.6006)" fill="#5E35B1"/>
<rect opacity="0.7" x="0.866041" width="63.3732" height="63.3732" rx="4.5" transform="matrix(0.866041 -0.499972 0.866041 0.499972 79.1848 87.8305)" stroke="#5E35B1"/>
</g>
<defs>
<filter id="filter0_d" x="0.0090332" y="83.894" width="269.353" height="229.597" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="84"/>
<feGaussianBlur stdDeviation="32"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.403922 0 0 0 0 0.227451 0 0 0 0 0.717647 0 0 0 0.2 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<linearGradient id="paint0_linear" x1="200.346" y1="102.359" x2="71.0293" y2="158.071" gradientUnits="userSpaceOnUse">
<stop stop-color="#A491C8"/>
<stop offset="1" stop-color="#D7C5F8"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="8.1531" y1="-0.145767" x2="57.1962" y2="72.3003" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -1,27 +0,0 @@
<svg width="676" height="391" viewBox="0 0 676 391" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M267.744 237.142L279.699 230.24L300.636 242.329L288.682 249.231L313.566 263.598L286.344 279.314L261.46 264.947L215.984 291.203L197.779 282.558L169.334 211.758L169.092 211.618L196.313 195.902L267.744 237.142ZM219.359 265.077L240.523 252.859L204.445 232.029L205.487 234.589L219.359 265.077Z" fill="#FFAB91"/>
<path d="M469.959 120.206L481.913 113.304L502.851 125.392L490.897 132.294L515.78 146.661L488.559 162.377L463.675 148.011L418.199 174.266L399.994 165.621L371.548 94.8211L371.307 94.6816L398.528 78.9654L469.959 120.206ZM421.574 148.141L442.737 135.922L406.66 115.093L407.701 117.653L421.574 148.141Z" fill="#FFAB91"/>
<path d="M204.523 235.027V232.237L219.401 265.014L240.555 252.926V255.018L218.936 267.339L204.523 235.027Z" fill="#D84315"/>
<path d="M406.738 118.09V115.301L421.616 148.078L442.77 135.99V138.082L421.151 150.402L406.738 118.09Z" fill="#D84315"/>
<rect width="109.114" height="136.405" transform="matrix(0.866025 -0.5 0.866025 0.5 220.507 181.925)" fill="url(#paint0_linear)"/>
<rect width="40.2357" height="70.0545" transform="matrix(0.866025 -0.5 0.866025 0.5 280.437 201.886)" fill="url(#paint1_linear)"/>
<rect x="25.1147" width="80.1144" height="107.405" transform="matrix(0.866025 -0.5 0.866025 0.5 223.872 194.482)" stroke="#1565C0" stroke-width="29"/>
<rect x="25.1147" width="80.1144" height="107.405" transform="matrix(0.866025 -0.5 0.866025 0.5 223.872 194.482)" stroke="url(#paint2_linear)" stroke-width="29"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M279.517 230.177L267.662 237.15L196.064 195.772L168.866 211.58L169.331 212.097L170.096 214.002L196.436 198.795L267.866 240.035L279.821 233.133L298.211 243.751L300.787 242.265L279.517 230.177ZM291.278 250.695L288.804 252.124L311.1 264.996L313.805 263.418L291.278 250.695Z" fill="#D84315"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M481.732 113.24L469.877 120.214L398.279 78.8359L371.081 94.6433L371.546 95.1603L372.311 97.0652L398.651 81.8581L470.081 123.099L482.036 116.196L500.426 126.814L503.002 125.328L481.732 113.24ZM493.493 133.759L491.019 135.187L513.315 148.06L516.02 146.482L493.493 133.759Z" fill="#D84315"/>
<path d="M288.674 252.229V249.207L291.929 251.067L288.674 252.229Z" fill="#D84315"/>
<defs>
<linearGradient id="paint0_linear" x1="77.7511" y1="139.902" x2="-10.8629" y2="8.75671" gradientUnits="userSpaceOnUse">
<stop stop-color="#3076C8"/>
<stop offset="0.992076" stop-color="#91CBFA"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="25.8162" y1="51.0447" x2="68.7073" y2="-5.41524" gradientUnits="userSpaceOnUse">
<stop stop-color="#2E75C7"/>
<stop offset="1" stop-color="#4283CC"/>
</linearGradient>
<linearGradient id="paint2_linear" x1="-16.1224" y1="-47.972" x2="123.494" y2="290.853" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 250 KiB

View File

@@ -1,3 +1,26 @@
<script setup>
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { ref } from 'vue'
const dialog = ref(false)
const currentEditingKey = ref('')
const currentEditingLanguage = ref('json')
const currentEditingTheme = ref('vs-light')
let currentEditingKeyIterable = null
function openEditorDialog(key, value, theme, language) {
currentEditingKey.value = key
currentEditingLanguage.value = language || 'json'
currentEditingTheme.value = theme || 'vs-light'
currentEditingKeyIterable = value
dialog.value = true
}
function saveEditedContent() {
dialog.value = false
}
</script>
<template>
<div class="config-section" v-if="iterable && metadata[metadataKey]?.type === 'object'">
<v-list-item-title class="config-title">
@@ -67,6 +90,28 @@
class="config-field"
hide-details
></v-select>
<!-- Code Editor with Full Screen Option -->
<div v-else-if="metadata[metadataKey].items[key]?.editor_mode && !metadata[metadataKey].items[key]?.invisible" class="editor-container">
<VueMonacoEditor
:theme="metadata[metadataKey].items[key]?.editor_theme || 'vs-light'"
:language="metadata[metadataKey].items[key]?.editor_language || 'json'"
style="min-height: 100px; flex-grow: 1; border: 1px solid rgba(0, 0, 0, 0.1);"
v-model:value="iterable[key]"
>
</VueMonacoEditor>
<v-btn
icon
size="small"
variant="text"
color="primary"
class="editor-fullscreen-btn"
@click="openEditorDialog(key, iterable, metadata[metadataKey].items[key]?.editor_theme, metadata[metadataKey].items[key]?.editor_language)"
title="全屏编辑"
>
<v-icon>mdi-fullscreen</v-icon>
</v-btn>
</div>
<!-- String input -->
<v-text-field
@@ -235,6 +280,31 @@
<v-divider class="my-2 config-divider"></v-divider>
</div>
</v-card-text>
<!-- Full Screen Editor Dialog -->
<v-dialog v-model="dialog" fullscreen transition="dialog-bottom-transition" scrollable>
<v-card>
<v-toolbar color="primary" dark>
<v-btn icon @click="dialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title>编辑内容 - {{ currentEditingKey }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items>
<v-btn variant="text" @click="saveEditedContent">保存</v-btn>
</v-toolbar-items>
</v-toolbar>
<v-card-text class="pa-0">
<VueMonacoEditor
:theme="currentEditingTheme"
:language="currentEditingLanguage"
style="height: calc(100vh - 64px);"
v-model:value="currentEditingKeyIterable[currentEditingKey]"
>
</VueMonacoEditor>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script>
@@ -357,6 +427,25 @@ export default {
margin: 4px 0;
}
.editor-container {
position: relative;
display: flex;
width: 100%;
}
.editor-fullscreen-btn {
position: absolute;
top: 4px;
right: 4px;
z-index: 10;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 4px;
}
.editor-fullscreen-btn:hover {
background-color: rgba(0, 0, 0, 0.5);
}
@media (max-width: 600px) {
.nested-object {
padding-left: 8px;

View File

@@ -17,10 +17,12 @@ export const useCommonStore = defineStore({
"qq_official": "https://astrbot.app/deploy/platform/qqofficial/websockets.html",
"aiocqhttp": "https://astrbot.app/deploy/platform/aiocqhttp/napcat.html",
"wecom": "https://astrbot.app/deploy/platform/wecom.html",
"gewechat": "https://astrbot.app/deploy/platform/gewechat.html",
"gewechat": "https://astrbot.app/deploy/platform/wechat/gewechat.html",
"lark": "https://astrbot.app/deploy/platform/lark.html",
"telegram": "https://astrbot.app/deploy/platform/telegram.html",
"dingtalk": "https://astrbot.app/deploy/platform/dingtalk.html",
"wechatpadpro": "https://astrbot.app/deploy/platform/wechat/wechatpadpro.html",
"weixin_official_account": "https://astrbot.app/deploy/platform/weixin-official-account.html",
},
pluginMarketData: [],

View File

@@ -20,23 +20,9 @@
<span class="text-h6">服务提供商</span>
<v-chip color="info" size="small" class="ml-2">{{ config_data.provider?.length || 0 }}</v-chip>
<v-spacer></v-spacer>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" v-bind="props">
新增服务提供商
</v-btn>
</template>
<v-list @update:selected="addFromDefaultConfigTmpl($event)">
<v-list-item
v-for="(item, index) in metadata['provider_group']?.metadata?.provider?.config_template || {}"
:key="index"
rounded="xl"
:value="index"
>
<v-list-item-title>{{ index }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true">
新增服务提供商
</v-btn>
</v-card-title>
<v-divider></v-divider>
@@ -44,7 +30,7 @@
<v-card-text class="px-4 py-3">
<item-card-grid
:items="config_data.provider || []"
title-field="id"
title-field="id"
enabled-field="enable"
empty-icon="mdi-api-off"
empty-text="暂无服务提供商点击 新增服务提供商 添加"
@@ -56,7 +42,7 @@
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-tag</v-icon>
<span class="text-caption text-medium-emphasis">
提供商类型:
提供商类型:
<v-chip size="x-small" color="primary" class="ml-1">{{ item.type }}</v-chip>
</span>
</div>
@@ -97,6 +83,71 @@
</v-card>
</v-container>
<!-- 添加提供商对话框 -->
<v-dialog v-model="showAddProviderDialog" max-width="1100px" min-height="95%">
<v-card class="provider-selection-dialog">
<v-card-title class="bg-primary text-white py-3 px-4" style="display: flex; align-items: center;">
<v-icon color="white" class="me-2">mdi-plus-circle</v-icon>
<span>服务提供商</span>
<v-spacer></v-spacer>
<v-btn icon variant="text" color="white" @click="showAddProviderDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4" style="overflow-y: auto;">
<v-tabs v-model="activeProviderTab" grow slider-color="primary" bg-color="background">
<v-tab value="chat_completion" class="font-weight-medium px-3">
<v-icon start>mdi-message-text</v-icon>
基本
</v-tab>
<v-tab value="speech_to_text" class="font-weight-medium px-3">
<v-icon start>mdi-microphone-message</v-icon>
语音转文字
</v-tab>
<v-tab value="text_to_speech" class="font-weight-medium px-3">
<v-icon start>mdi-volume-high</v-icon>
文字转语音
</v-tab>
</v-tabs>
<v-window v-model="activeProviderTab" class="mt-4">
<v-window-item v-for="tabType in ['chat_completion', 'speech_to_text', 'text_to_speech']"
:key="tabType"
:value="tabType">
<v-row class="mt-1">
<v-col v-for="(template, name) in getTemplatesByType(tabType)"
:key="name"
cols="12" sm="6" md="4">
<v-card variant="outlined" hover class="provider-card" @click="selectProviderTemplate(name)">
<v-card-item>
<template v-slot:prepend>
<v-avatar color="primary" variant="tonal" class="mr-3">
<img :src="getProviderIcon(name)" v-if="getProviderIcon(name)" width="24" height="24">
<span v-else style="font-weight: 1000;">
{{ name[0].toUpperCase() }}
</span>
</v-avatar>
</template>
<v-card-title style="font-size: 15px;">{{ name }}</v-card-title>
</v-card-item>
<v-card-text class="text-caption text-medium-emphasis">
{{ getProviderDescription(template, name) }}
</v-card-text>
</v-card>
</v-col>
<v-col v-if="Object.keys(getTemplatesByType(tabType)).length === 0" cols="12">
<v-alert type="info" variant="tonal">
暂无{{ getTabTypeName(tabType) }}类型的提供商模板
</v-alert>
</v-col>
</v-row>
</v-window-item>
</v-window>
</v-card-text>
</v-card>
</v-dialog>
<!-- 配置对话框 -->
<v-dialog v-model="showProviderCfg" width="900" persistent>
<v-card>
@@ -104,17 +155,17 @@
<v-icon color="white" class="me-2">{{ updatingMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
<span>{{ updatingMode ? '编辑' : '新增' }} {{ newSelectedProviderName }} 服务提供商</span>
</v-card-title>
<v-card-text class="py-4">
<AstrBotConfig
<AstrBotConfig
:iterable="newSelectedProviderConfig"
:metadata="metadata['provider_group']?.metadata"
metadataKey="provider"
metadataKey="provider"
/>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showProviderCfg = false" :disabled="loading">
@@ -132,7 +183,7 @@
location="top">
{{ save_message }}
</v-snackbar>
<WaitingForRestart ref="wfr"></WaitingForRestart>
</div>
</template>
@@ -170,6 +221,10 @@ export default {
save_message_success: "success",
showConsole: false,
// 新增提供商对话框相关
showAddProviderDialog: false,
activeProviderTab: 'chat_completion',
}
},
@@ -188,13 +243,84 @@ export default {
});
},
addFromDefaultConfigTmpl(index) {
this.newSelectedProviderName = index[0];
// 按提供商类型获取模板列表
getTemplatesByType(type) {
const templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {};
const filtered = {};
for (const [name, template] of Object.entries(templates)) {
if (template.provider_type === type) {
filtered[name] = template;
}
}
return filtered;
},
// 获取提供商类型对应的图标
getProviderIcon(type) {
const icons = {
'OpenAI': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg',
'Azure OpenAI': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg',
'Whisper': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg',
'xAI': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/xai.svg',
'Anthropic': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/anthropic.svg',
'Ollama': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/anthropic.svg',
'Gemini': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini-color.svg',
'Gemini(OpenAI兼容)': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini-color.svg',
'DeepSeek': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek.svg',
'智谱 AI': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/zhipu.svg',
'硅基流动': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/siliconcloud.svg',
'Kimi': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg',
'PPIO派欧云': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ppio.svg',
'Dify': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/dify-color.svg',
'阿里云百炼': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/alibabacloud-color.svg',
'FastGPT': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fastgpt-color.svg',
'LM Studio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/lmstudio.svg',
'FishAudio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fishaudio.svg',
'Azure': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/azure.svg',
'MiniMax': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/minimax.svg',
};
for (const key in icons) {
if (type.startsWith(key)) {
return icons[key];
}
}
return ''
},
// 获取Tab类型的中文名称
getTabTypeName(tabType) {
const names = {
'chat_completion': '基本对话',
'speech_to_text': '语音转文本',
'text_to_speech': '文本转语音'
};
return names[tabType] || tabType;
},
// 获取提供商简介
getProviderDescription(template, name) {
if (name == 'OpenAI') {
return `${template.type} 服务提供商。同时也支持所有兼容 OpenAI API 的模型提供商。`;
}
return `${template.type} 服务提供商`;
},
// 选择提供商模板
selectProviderTemplate(name) {
this.newSelectedProviderName = name;
this.showProviderCfg = true;
this.updatingMode = false;
this.newSelectedProviderConfig = JSON.parse(JSON.stringify(
this.metadata['provider_group']?.metadata?.provider?.config_template[index[0]] || {}
this.metadata['provider_group']?.metadata?.provider?.config_template[name] || {}
));
this.showAddProviderDialog = false;
},
// 废弃旧方法,保留为兼容
addFromDefaultConfigTmpl(index) {
this.selectProviderTemplate(index[0]);
},
configExistingProvider(provider) {
@@ -210,7 +336,7 @@ export default {
break;
}
}
const mergeConfigWithOrder = (target, source, reference) => {
// 首先复制所有source中的属性到target
if (source && typeof source === 'object' && !Array.isArray(source)) {
@@ -224,7 +350,7 @@ export default {
}
}
}
// 然后根据reference的结构添加或覆盖属性
for (let key in reference) {
if (typeof reference[key] === 'object' && reference[key] !== null) {
@@ -232,8 +358,8 @@ export default {
target[key] = Array.isArray(reference[key]) ? [] : {};
}
mergeConfigWithOrder(
target[key],
source && source[key] ? source[key] : {},
target[key],
source && source[key] ? source[key] : {},
reference[key]
);
} else if (!(key in target)) {
@@ -242,7 +368,7 @@ export default {
}
}
};
if (defaultConfig) {
mergeConfigWithOrder(this.newSelectedProviderConfig, provider, defaultConfig);
}
@@ -293,7 +419,7 @@ export default {
providerStatusChange(provider) {
provider.enable = !provider.enable; // 切换状态
axios.post('/api/config/provider/update', {
id: provider.id,
config: provider
@@ -305,13 +431,13 @@ export default {
this.showError(err.response?.data?.message || err.message);
});
},
showSuccess(message) {
this.save_message = message;
this.save_message_success = "success";
this.save_message_snack = true;
},
showError(message) {
this.save_message = message;
this.save_message_success = "error";
@@ -326,4 +452,29 @@ export default {
padding: 20px;
padding-top: 8px;
}
</style>
.provider-selection-dialog .v-card-title {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.provider-card {
transition: all 0.3s ease;
height: 100%;
cursor: pointer;
}
.provider-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.05);
border-color: var(--v-primary-base);
}
.v-tabs {
border-radius: 8px;
}
.v-window {
border-radius: 4px;
}
</style>

View File

@@ -1,3 +1,4 @@
import os
import aiohttp
import datetime
import builtins
@@ -13,6 +14,7 @@ from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.platform.message_type import MessageType
from astrbot.core.provider.sources.dify_source import ProviderDify
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.star.star_handler import star_handlers_registry, StarHandlerMetadata
from astrbot.core.star.star import star_map
from astrbot.core.star.star_manager import PluginManager
@@ -205,105 +207,118 @@ class Main(star.Star):
self.context.deactivate_llm_tool(tool.name)
event.set_result(MessageEventResult().message("停用所有工具成功。"))
@filter.command("plugin")
async def plugin(
self, event: AstrMessageEvent, oper1: str = None, oper2: str = None
):
"""插件管理"""
if oper1 is None:
plugin_list_info = "已加载的插件:\n"
for plugin in self.context.get_all_stars():
plugin_list_info += (
f"- `{plugin.name}` By {plugin.author}: {plugin.desc}"
)
if not plugin.activated:
plugin_list_info += " (未启用)"
plugin_list_info += "\n"
if plugin_list_info.strip() == "":
plugin_list_info = "没有加载任何插件。"
@filter.command_group("plugin")
def plugin(self):
pass
plugin_list_info += "\n使用 /plugin <插件名> 查看插件帮助和加载的指令。\n使用 /plugin on/off <插件名> 启用或者禁用插件。"
event.set_result(
MessageEventResult().message(f"{plugin_list_info}").use_t2i(False)
@plugin.command("ls")
async def plugin_ls(self, event: AstrMessageEvent):
"""获取已经安装的插件列表。"""
plugin_list_info = "已加载的插件:\n"
for plugin in self.context.get_all_stars():
plugin_list_info += (
f"- `{plugin.name}` By {plugin.author}: {plugin.desc}"
)
else:
if oper1 == "off":
# 禁用插件
if oper2 is None:
event.set_result(
MessageEventResult().message("/plugin off <插件名> 禁用插件。")
)
return
await self.context._star_manager.turn_off_plugin(oper2)
event.set_result(MessageEventResult().message(f"插件 {oper2} 已禁用。"))
elif oper1 == "on":
# 启用插件
if oper2 is None:
event.set_result(
MessageEventResult().message("/plugin on <插件名> 启用插件。")
)
return
await self.context._star_manager.turn_on_plugin(oper2)
event.set_result(MessageEventResult().message(f"插件 {oper2} 已启用。"))
elif oper1 == "get":
if not oper2:
raise Exception("请输入插件地址。")
if not event.is_admin():
raise Exception(
"改指令限制仅管理员使用,且无法通过 /alter_cmd 更改。"
)
if not oper2.startswith("http"):
oper2 = f"https://github.com/{oper2}"
if not plugin.activated:
plugin_list_info += " (未启用)"
plugin_list_info += "\n"
if plugin_list_info.strip() == "":
plugin_list_info = "没有加载任何插件。"
logger.info(f"准备从 {oper2} 获取插件。")
plugin_list_info += "\n使用 /plugin help <插件名> 查看插件帮助和加载的指令。\n使用 /plugin on/off <插件名> 启用或者禁用插件。"
event.set_result(
MessageEventResult().message(f"{plugin_list_info}").use_t2i(False)
)
if self.context._star_manager:
star_mgr: PluginManager = self.context._star_manager
try:
await star_mgr.install_plugin(oper2)
event.set_result(MessageEventResult().message("获取插件成功。"))
except Exception as e:
logger.error(f"获取插件失败: {e}")
event.set_result(
MessageEventResult().message(f"获取插件失败: {e}")
)
return
else:
# 获取插件帮助
plugin = self.context.get_registered_star(oper1)
if plugin is None:
event.set_result(MessageEventResult().message("未找到此插件。"))
return
help_msg = ""
help_msg += f"\n\n✨ 作者: {plugin.author}\n✨ 版本: {plugin.version}"
command_handlers = []
command_names = []
for handler in star_handlers_registry:
assert isinstance(handler, StarHandlerMetadata)
if handler.handler_module_path != plugin.module_path:
continue
for filter_ in handler.event_filters:
if isinstance(filter_, CommandFilter):
command_handlers.append(handler)
command_names.append(filter_.command_name)
break
elif isinstance(filter_, CommandGroupFilter):
command_handlers.append(handler)
command_names.append(filter_.group_name)
@filter.permission_type(filter.PermissionType.ADMIN)
@plugin.command("off")
async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = None):
"""禁用插件"""
if not plugin_name:
event.set_result(
MessageEventResult().message("/plugin off <插件名> 禁用插件。")
)
return
await self.context._star_manager.turn_off_plugin(plugin_name)
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已禁用。"))
if len(command_handlers) > 0:
help_msg += "\n\n🔧 指令列表:\n"
for i in range(len(command_handlers)):
help_msg += f"- {command_names[i]}"
if command_handlers[i].desc:
help_msg += f": {command_handlers[i].desc}"
help_msg += "\n"
@filter.permission_type(filter.PermissionType.ADMIN)
@plugin.command("on")
async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = None):
"""启用插件"""
if not plugin_name:
event.set_result(
MessageEventResult().message("/plugin on <插件名> 启用插件。")
)
return
await self.context._star_manager.turn_on_plugin(plugin_name)
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已启用。"))
help_msg += "\nTip: 指令的触发需要添加唤醒前缀,默认为 /。"
@filter.permission_type(filter.PermissionType.ADMIN)
@plugin.command("get")
async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = None):
"""安装插件"""
if not plugin_repo:
event.set_result(
MessageEventResult().message("/plugin get <插件仓库地址> 安装插件")
)
return
logger.info(f"准备从 {plugin_repo} 安装插件。")
if self.context._star_manager:
star_mgr: PluginManager = self.context._star_manager
try:
await star_mgr.install_plugin(plugin_repo)
event.set_result(MessageEventResult().message("安装插件成功。"))
except Exception as e:
logger.error(f"安装插件失败: {e}")
event.set_result(
MessageEventResult().message(f"安装插件失败: {e}")
)
return
@plugin.command("help")
async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = None):
"""获取插件帮助"""
if not plugin_name:
event.set_result(
MessageEventResult().message("/plugin help <插件名> 查看插件信息。")
)
return
plugin = self.context.get_registered_star(plugin_name)
if plugin is None:
event.set_result(MessageEventResult().message("未找到此插件。"))
return
help_msg = ""
help_msg += f"\n\n✨ 作者: {plugin.author}\n✨ 版本: {plugin.version}"
command_handlers = []
command_names = []
for handler in star_handlers_registry:
assert isinstance(handler, StarHandlerMetadata)
if handler.handler_module_path != plugin.module_path:
continue
for filter_ in handler.event_filters:
if isinstance(filter_, CommandFilter):
command_handlers.append(handler)
command_names.append(filter_.command_name)
break
elif isinstance(filter_, CommandGroupFilter):
command_handlers.append(handler)
command_names.append(filter_.group_name)
if len(command_handlers) > 0:
help_msg += "\n\n🔧 指令列表:\n"
for i in range(len(command_handlers)):
help_msg += f"- {command_names[i]}"
if command_handlers[i].desc:
help_msg += f": {command_handlers[i].desc}"
help_msg += "\n"
help_msg += "\nTip: 指令的触发需要添加唤醒前缀,默认为 /。"
ret = f"🧩 插件 {plugin_name} 帮助信息:\n" + help_msg
ret += "更多帮助信息请查看插件仓库 README。"
event.set_result(MessageEventResult().message(ret).use_t2i(False))
ret = f"🧩 插件 {oper1} 帮助信息:\n" + help_msg
ret += "更多帮助信息请查看插件仓库 README。"
event.set_result(MessageEventResult().message(ret).use_t2i(False))
@filter.command("t2i")
async def t2i(self, event: AstrMessageEvent):
@@ -1161,7 +1176,8 @@ UID: {user_id} 此 ID 可用于设置管理员。
@filter.command("gewe_code")
async def gewe_code(self, event: AstrMessageEvent, code: str):
"""保存 gewechat 验证码"""
with open("data/temp/gewe_code", "w", encoding="utf-8") as f:
code_path = os.path.join(get_astrbot_data_path(), "temp","gewe_code")
with open(code_path, "w", encoding="utf-8") as f:
f.write(code)
yield event.plain_result("验证码已保存。")

View File

@@ -15,6 +15,7 @@ from astrbot.api.event import filter
from astrbot.api.provider import ProviderRequest
from astrbot.api.message_components import Image, File
from astrbot.core.utils.io import download_image_by_url, download_file
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
PROMPT = """
## Task
@@ -90,7 +91,7 @@ DEFAULT_CONFIG = {
},
"docker_host_astrbot_abs_path": "",
}
PATH = "data/config/python_interpreter.json"
PATH = os.path.join(get_astrbot_data_path(), "config", "python_interpreter.json")
@star.register(
@@ -212,7 +213,8 @@ class Main(star.Star):
if isinstance(comp, File):
if comp.file.startswith("http"):
name = comp.name if comp.name else uuid.uuid4().hex[:8]
path = f"data/temp/{name}"
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, name)
await download_file(comp.file, path)
else:
path = comp.file

View File

@@ -8,6 +8,7 @@ from astrbot.api.event import filter
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.api import llm_tool, logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@star.register(
@@ -29,10 +30,11 @@ class Main(star.Star):
self.scheduler = AsyncIOScheduler(timezone=self.timezone)
# set and load config
if not os.path.exists("data/astrbot-reminder.json"):
with open("data/astrbot-reminder.json", "w", encoding="utf-8") as f:
reminder_file = os.path.join(get_astrbot_data_path(), "astrbot-reminder.json")
if not os.path.exists(reminder_file):
with open(reminder_file, "w", encoding="utf-8") as f:
f.write("{}")
with open("data/astrbot-reminder.json", "r", encoding="utf-8") as f:
with open(reminder_file, "r", encoding="utf-8") as f:
self.reminder_data = json.load(f)
self._init_scheduler()
@@ -82,7 +84,8 @@ class Main(star.Star):
async def _save_data(self):
"""Save the reminder data."""
with open("data/astrbot-reminder.json", "w", encoding="utf-8") as f:
reminder_file = os.path.join(get_astrbot_data_path(), "astrbot-reminder.json")
with open(reminder_file, "w", encoding="utf-8") as f:
json.dump(self.reminder_data, f, ensure_ascii=False)
def _parse_cron_expr(self, cron_expr: str):

View File

@@ -1,33 +1,34 @@
[project]
name = "AstrBot"
version = "3.5.8"
version = "3.5.10"
description = "易上手的多平台 LLM 聊天机器人及开发框架"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"aiocqhttp>=1.4.4",
"aiodocker>=0.24.0",
"aiohttp>=3.11.14",
"anthropic>=0.49.0",
"aiohttp>=3.11.18",
"anthropic>=0.51.0",
"apscheduler>=3.11.0",
"beautifulsoup4>=4.13.3",
"certifi>=2025.1.31",
"beautifulsoup4>=4.13.4",
"certifi>=2025.4.26",
"chardet~=5.1.0",
"colorlog>=6.9.0",
"cryptography>=44.0.2",
"dashscope>=1.22.2",
"cryptography>=44.0.3",
"dashscope>=1.23.2",
"defusedxml>=0.7.1",
"dingtalk-stream>=0.22.1",
"docstring-parser>=0.16",
"google-genai>=1.10.0",
"filelock>=3.18.0",
"google-genai>=1.14.0",
"googlesearch-python>=1.3.0",
"lark-oapi>=1.4.12",
"lxml-html-clean>=0.4.1",
"mcp>=1.5.0",
"openai>=1.68.2",
"ormsgpack>=1.9.0",
"pillow>=11.1.0",
"pip>=25.0.1",
"lark-oapi>=1.4.15",
"lxml-html-clean>=0.4.2",
"mcp>=1.8.0",
"openai>=1.78.0",
"ormsgpack>=1.9.1",
"pillow>=11.2.1",
"pip>=25.1.1",
"psutil>=5.8.0",
"pydantic~=2.10.3",
"pydub>=0.25.1",
@@ -35,9 +36,11 @@ dependencies = [
"python-telegram-bot>=22.0",
"qq-botpy>=1.2.1",
"quart>=0.20.0",
"readability-lxml>=0.8.1",
"readability-lxml>=0.8.4.1",
"silk-python>=0.2.6",
"telegramify-markdown>=0.5.0",
"telegramify-markdown>=0.5.1",
"watchfiles>=1.0.5",
"websockets>=15.0.1",
"wechatpy>=1.8.18",
]

View File

@@ -1,5 +1,6 @@
pydantic~=2.10.3
aiohttp
pydantic~=2.10.3
psutil>=5.8.0
openai
anthropic
qq-botpy
@@ -17,7 +18,6 @@ apscheduler
docstring_parser
aiodocker
silk-python
psutil>=5.8.0
lark-oapi
ormsgpack
cryptography
@@ -30,4 +30,8 @@ mcp
certifi
pip
telegramify-markdown
google-genai
google-genai
click
filelock
watchfiles
websockets

2624
uv.lock generated

File diff suppressed because it is too large Load Diff