Compare commits

..

137 Commits

Author SHA1 Message Date
Soulter 8995e62e73 🐛fix: 更新v-slot类型定义以增强类型安全性 2025-02-23 20:18:00 +08:00
Soulter 316147a8db v3.4.31 2025-02-23 20:11:39 +08:00
Soulter 1fdcfc7a30 Merge pull request #587 from Raven95676/master
🐛fix: 修复aiocqhttp_platform_adapter文件相关判断逻辑
2025-02-23 19:57:50 +08:00
Soulter 8e2c633cd4 feat: 前端支持以列表展示正式版和开发版的列表 2025-02-23 19:53:55 +08:00
渡鸦95676 786b0e4a54 Update aiocqhttp_platform_adapter.py
else尾随空格
2025-02-23 18:16:39 +08:00
Raven95676 c38c1c3c35 🐛fix: 修复aiocqhttp_platform_adapter文件相关判断逻辑 2025-02-23 18:05:45 +08:00
Soulter 7d856756f4 🐛 fix: 修复 gemini 请求时出现多次不支持函数工具调用最后 429 的问题 2025-02-23 17:24:37 +08:00
Soulter f0d1d365e0 Merge branch 'refactor-hot-load' 2025-02-23 17:04:36 +08:00
Soulter 8e2d666ff8 feat: 优化关于页面和配置页面样式,添加重启按钮功能 2025-02-23 16:57:48 +08:00
Soulter 38d7be1d5f feat: 优化提示框样式并更新关于页面内容 2025-02-23 16:29:57 +08:00
Soulter 431e2fad72 feat: 支持插件禁止默认的llm调用 #579 2025-02-23 16:10:32 +08:00
Soulter b3b63be8fc Merge pull request #584 from Soulter/refactor-hot-load
🍺 refactor: 支持更大范围的热重载以及管理面板将平台和提供商配置独立化
2025-02-23 15:56:04 +08:00
Soulter 071fc7d6ef feat: 调整适配器类型显示样式并添加API Base信息 2025-02-23 15:52:30 +08:00
Soulter 2a37f7edac feat: 在聊天页面添加粘贴图片的快捷键提示 2025-02-23 15:41:34 +08:00
Soulter c656ad5e2c feat: 消息平台和服务提供商页面支持显示日志 2025-02-23 15:27:05 +08:00
Soulter da14a89490 🍺 refactor: 支持更大范围的热重载以及管理面板将平台和提供商配置独立化 2025-02-23 12:54:25 +08:00
Soulter cf22eae467 fix: save config 2025-02-22 23:20:25 +08:00
Soulter b199bddb0b feat: 适配多节点的转发消息(OneBot V11) 2025-02-22 21:07:57 +08:00
崔永亮 2188ea82de feat: 支持 AstrBot 更新使用 Github 加速地址 2025-02-22 18:17:34 +08:00
Soulter 1fa13d0177 Merge pull request #577 from Soulter/perf-autoScroll-switch
perf: 添加控制台关闭自动滚动按钮
2025-02-22 17:16:52 +08:00
崔永亮 ed508af424 perf: 添加控制台关闭自动滚动按钮 2025-02-22 17:10:53 +08:00
Fridemn 5df26864d5 Merge pull request #574 from Soulter/perf-port-check
🎈 perf: 启动时检查端口占用
2025-02-22 17:01:53 +08:00
崔永亮 837111b17e perf: 填加具体占用进程显示 2025-02-22 16:23:50 +08:00
崔永亮 a6b363b433 🎈 perf: 启动时检查端口占用 2025-02-22 16:10:46 +08:00
Soulter 2807e1e892 feat: add template of FastGPT 2025-02-22 15:43:14 +08:00
Soulter 0a2abd8214 Merge pull request #572 from Soulter/feat-dashscope
支持阿里云百炼应用智能体、工作流
2025-02-22 15:04:46 +08:00
Soulter 8beb7acdb1 feat: 支持为 dify 和 dashscope 提供商设置默认固定变量 #552 2025-02-22 14:48:18 +08:00
Soulter 466c80b94d feat: 阿里云百炼应用工作流支持自定义动态变量 #552 2025-02-22 14:32:37 +08:00
Soulter 36c0cfc9a9 feat: 支持阿里云百炼应用智能体、工作流
#552
2025-02-22 14:08:51 +08:00
Soulter 35ba1b3345 fix: gewechat verify code 2025-02-22 11:37:34 +08:00
Soulter d00821d1c7 Update README.md 2025-02-22 10:07:18 +08:00
Soulter 6c1b3f242b Merge pull request #568 from Raven95676/master
🐛 fix: 修复webchat未处理base64的问题
2025-02-22 01:07:20 +08:00
Raven95676 9f9da1e0c9 🐛 fix: 修复webchat未处理base64的问题 2025-02-21 23:39:53 +08:00
崔永亮 14fb4b70bd feat: 支持 gewechat 设置验证码 #448 2025-02-21 23:08:23 +08:00
崔永亮 b1049540a4 feat: claude 支持纯图片 2025-02-21 22:26:31 +08:00
Fridemn 5e2909df33 Merge pull request #559 from Rt39/feat-claude-api
添加对Anthropic Claude API的支持
2025-02-21 21:12:52 +08:00
崔永亮 c122dad21f feat: 添加自定义api base 2025-02-21 21:07:59 +08:00
Rt39 48ae686602 feat: add claude template 2025-02-20 23:58:10 -05:00
Rt39 bf2c3a1a81 fix: 根据Codacy Production / Codacy Static Code Analysis修改格式问题 2025-02-20 21:15:07 -05:00
Rt39 96e7a93886 feat: 添加对Claude API的支持 2025-02-20 19:59:16 -05:00
Soulter dba1ed1e19 v3.4.30 2025-02-21 01:31:36 +08:00
Soulter a24514876b fix: 修复 dify 无法使用事件钩子的问题以及出现 GeneratorExit 的问题 #533 #264 2025-02-21 01:14:13 +08:00
Soulter 466a1c1c41 🐛 fix: 修复某些情况下导致插件报错 AttributeError 的问题 #549 2025-02-21 00:38:08 +08:00
Soulter a2d5e9f40f feat: add xAI template 2025-02-20 16:34:32 +08:00
Soulter 1bbff1d161 v3.4.29 2025-02-19 20:05:33 +08:00
Soulter 0948bae99b feat: 添加代码执行器 Docker 宿主机绝对路径配置及相关功能
Co-authored-by: Bocity <haolovej@vip.qq.com>
2025-02-19 19:56:31 +08:00
Soulter 850db41596 feat: gemini source 初步支持对 API Key 进行负载均衡请求 #534 2025-02-19 19:06:37 +08:00
Soulter 7bafc87e2b 🐛 fix: 修复部分单指令失效的问题 2025-02-19 19:04:23 +08:00
Soulter 1a0de02a15 fix: 尝试修复gewechat群聊用户名出现unknown 2025-02-19 17:07:11 +08:00
Soulter 6d5d278624 fix: 尝试修复 gewechat 微信群聊情况下可能导致 unknown 的问题 #537 2025-02-19 16:42:30 +08:00
Soulter 3b4cc48fa0 👌 perf: 开启对话隔离的群聊以及私聊下,非op可以可以使用 /del 和 /reset #519 2025-02-19 16:22:42 +08:00
Soulter c908461088 Merge pull request #543 from Soulter/refactor-command-group
更换为预编译指令的方式处理指令组指令并且让事件钩子也支持 yield 的方式发送消息
2025-02-19 15:54:26 +08:00
Soulter 53d1398d30 fix: 修复子指令组不能被调用的问题 2025-02-19 15:53:01 +08:00
Soulter 782c0367d0 feat: 事件钩子支持 yield 方式发送消息 2025-02-19 15:29:10 +08:00
Soulter 4678222e9b 👌 refactor: 更换为预编译指令的方式处理指令组指令 2025-02-19 14:55:14 +08:00
Soulter f71dc3e4be 🐛 fix: reminder time zone issue 2025-02-19 00:15:14 +08:00
Soulter f6233893bd 🐛 fix: 修复 reminder rm失败 #529 2025-02-19 00:10:18 +08:00
Soulter 6427bcf130 👌perf: 查询模型列表时,可以显示当前使用的模型名称 #523 2025-02-17 22:35:45 +08:00
Soulter 8fa41b706c Merge pull request #522 from yuanxinlyx/fix-keyerror-ls-command
fix: resolve KeyError when current conversation is not in paginated list
2025-02-17 21:45:40 +08:00
YuanxinLu 4706c4438d fix: resolve KeyError when current conversation is not in paginated list 2025-02-17 03:15:59 +08:00
Soulter 0c8ebc2b06 chore: clean up 2025-02-16 16:52:13 +08:00
Soulter b3b5ebc2ca v3.4.28 2025-02-16 16:19:03 +08:00
Soulter b8aa23ccc5 🐛fix: 修复转发消息的字数阈值功能#510 2025-02-16 15:54:29 +08:00
Soulter 364843db29 Merge pull request #389 from Nothingness-Void/新增过滤掉正则表达式内容
新增过滤掉正则表达式内容
2025-02-16 15:28:51 +08:00
Soulter aa56c8f7e6 Merge branch 'master' into 新增过滤掉正则表达式内容 2025-02-16 15:27:30 +08:00
Soulter 8e9fd27058 merge branch master 2025-02-16 15:17:44 +08:00
Soulter b75908cb2a Merge pull request #517 from Cvandia/master
 feat: 添加命令和命令组的别名支持
2025-02-16 14:51:47 +08:00
Soulter af6df49ce1 perf: 补充别名为可选参数以前向兼容 2025-02-16 14:50:49 +08:00
Cvandia bd3bdb5769 feat: 添加命令和命令组的别名支持 2025-02-16 14:44:17 +08:00
Soulter 98fe193b21 Merge pull request #477 from AraragiEro/master
[Feature] 希望添加更为灵活的filter.permission_type使用方式,使用户能自定义权限类型
2025-02-16 13:53:07 +08:00
Soulter 26cbc9e8b1 chore: cleanup 2025-02-16 13:32:28 +08:00
Alero ebb8c43fd0 bug: 尝试修复cleancode错误 2025-02-16 10:56:17 +08:00
Soulter 8c7344f1c4 👌perf(qq): supports to pass OneBot notice, request event 2025-02-16 01:04:08 +08:00
Soulter 5c32a17787 👌perf: 优化了分段回复和回复时at,引用都打开时的一些体验性问题 2025-02-15 19:29:34 +08:00
Soulter aff520e69a fix: 修复 Dify 下无法主动回复的问题 #494 2025-02-15 18:31:21 +08:00
Alero 45e627c33c fix: a bug when add filter to root command group 2025-02-14 23:52:31 +08:00
Alero 7a1b158f83 fix: cleancode err 2025-02-14 22:46:22 +08:00
Alero 6374c5d49d fix: add & | operation to customfilter 2025-02-14 22:33:32 +08:00
Alero fd460b19d4 fix: cleancode err 2025-02-14 20:43:54 +08:00
Alero dff7cc4ca5 feat: when custom filter cant pass, won't raise error anymore.
and when you use a command group and dont have custom filter access, the return group tree wont contain the command that you dont have permisson.
2025-02-14 20:34:31 +08:00
Alero d013320bec feat: more powerful CustomFilter 2025-02-14 19:15:19 +08:00
Soulter fc6dcfaf21 🐛 fix: cannot search plugin 2025-02-14 18:45:56 +08:00
Soulter a001270bd2 feat: webui supports to search plugin via name 2025-02-14 18:43:04 +08:00
Soulter 9e67883fbd 🐛 fix: add no_proxy env vars to support localhost requests, fix 502 error when use ollama #504 2025-02-14 16:51:02 +08:00
Soulter f1a448708c 🐛 fix: segmented reply caused incomplete non-llm-response #503 2025-02-14 16:19:09 +08:00
Soulter a4bfa96502 feat: 支持自定义 Dify 工作流文本输入变量名 #441 2025-02-14 15:41:02 +08:00
Soulter 595b83a256 🐛 FIX: cannot send file in private chat when turn on the reply with quote #262 2025-02-14 14:41:41 +08:00
Soulter 8d34f77321 v3.4.27 2025-02-14 01:53:26 +08:00
Soulter 67095f97b1 🐛 fix: delete conversation
 feat: supports active reply whitelist
2025-02-14 01:43:52 +08:00
Soulter 50740c94ab 🐛 fix: cannot input text before mention in gewechat #492 2025-02-14 01:09:48 +08:00
Soulter 4db4cfeda2 👌 perf: format datetime labels in MessageStat component #460 2025-02-14 00:30:34 +08:00
Soulter ad13cef89c 👌perf: sort models by id when listing models #384 2025-02-14 00:08:12 +08:00
Soulter 855fc6fcd1 Display the Japanese translation entry 2025-02-13 23:36:50 +08:00
Soulter 8f12244e51 Merge pull request #491 from eltociear/add-japanese-readme
docs: add Japanese README
2025-02-13 22:56:21 +08:00
Ikko Eltociear Ashimine fe0213465c docs: add Japanese README
I created Japanese translated README.
2025-02-13 14:45:52 +09:00
Soulter f984047004 fix: unable to send c2c message using webhook qqofficial platform #484 2025-02-13 00:01:16 +08:00
Soulter 19e9e2d090 fix: fix dify cannot set/unset variables #482 2025-02-12 23:58:04 +08:00
Soulter 7fe3b97d00 fix: improve content safety check handling for at or wake commands 2025-02-12 23:42:32 +08:00
Soulter 9cd243da47 fix: handle empty content in gemini context 2025-02-12 23:39:41 +08:00
Soulter e43208c2e9 fix: update session_id assignment logic for group messages 2025-02-12 14:04:55 +08:00
Soulter dc016fc22f feat: update validate_config to return a tuple contains casted data 2025-02-12 13:50:24 +08:00
Alero c6f037cae2 fix: a undefine mistake 2025-02-12 03:25:01 +08:00
Alero f049830e28 Merge branch 'master' of github.com:AraragiEro/AstrBot 2025-02-12 03:06:23 +08:00
Alero dd1995ae0b feat: add a way to define custom permission filter. 2025-02-12 03:05:51 +08:00
Soulter 23dc233569 chore: remove useless config items 2025-02-12 02:32:57 +08:00
Soulter 0977aa7d0d chore: fix the default port of qo webhook 2025-02-12 02:28:15 +08:00
Soulter 24862b0672 docs: update the comments of register_llm_tool 2025-02-12 02:27:39 +08:00
Soulter f05a57efc3 chore: v3.4.26 2025-02-12 01:55:36 +08:00
Soulter 65331a9d7c feat: 支持基于对数函数的分段回复延时时间计算 2025-02-12 01:44:08 +08:00
Soulter f7ae287e40 fix: ensure result is retrieved again to handle potential plugin chain replacements 2025-02-12 00:27:25 +08:00
Soulter 45f380b1f6 feat: add configuable port for dashboard and improve the method of getting local ip address 2025-02-11 23:00:24 +08:00
Soulter 9e6b329df4 Merge pull request #472 from Akuma-real/master
fix: correct dashboard update tooltip typo
2025-02-11 22:04:19 +08:00
Soulter 43cd34d94c feat: supports to check the content safety of LLM output #474 2025-02-11 22:03:44 +08:00
Soulter 9fa00aff9a 支持完善的 Dify Chat 模式对话管理 2025-02-11 21:30:17 +08:00
Soulter 9a56dcb1be fix: cannot reset conversation in dify chat mode #469 2025-02-11 21:29:28 +08:00
鬼鬼Sama fdfe7bbe59 fix: correct dashboard update tooltip typo 2025-02-11 20:16:09 +08:00
Soulter 3a99a60792 perf: gewechat send all events to pipeline 2025-02-11 20:00:39 +08:00
Soulter fa2b4e14df fix: gewechat cannot send message directly 2025-02-11 19:49:20 +08:00
Soulter 35322a6900 Merge pull request #465 from Soulter/feat-qo-webhook
支持 Webhook 方式接入 QQ 官方机器人平台
2025-02-11 18:10:14 +08:00
Soulter 2ccf29d61e Update README.md 2025-02-11 17:28:03 +08:00
Soulter b068013343 perf: better handle in qq official send 2025-02-11 01:25:17 -05:00
Soulter d839e72998 feat: 支持 Webhook 方式接入 QQ 官方机器人接口 2025-02-11 01:18:25 -05:00
Soulter d7c9a8ed29 chore: webhook server, client 2025-02-11 11:19:50 +08:00
Soulter 6837d4d692 chore: update version 2025-02-11 02:05:06 +08:00
Soulter 8aba83735b Update README.md 2025-02-11 01:31:31 +08:00
Soulter aa51187747 perf(core): change log level to debug for platform and provider adapter instantiation 2025-02-11 01:25:52 +08:00
Soulter 5f07a9ae95 perf(core): better handle in loading platforms 2025-02-11 01:23:50 +08:00
Soulter a2ca767bf4 v3.4.25 2025-02-11 01:12:23 +08:00
Soulter 5806c74e7c chore(core): display the unsupported message segments 2025-02-11 01:10:17 +08:00
Soulter 0481e1d45e fix(core): github mirror not applied successfully 2025-02-11 01:10:17 +08:00
Soulter 3177b61421 feat(platform): support lark platform 2025-02-11 01:07:14 +08:00
Soulter 6009cf5dfa feat: 添加 moonshot 配置模板 #446 2025-02-10 18:54:59 +08:00
Soulter 0a970e8c31 feat: 支持gewechat文件输出 2025-02-10 18:46:54 +08:00
Soulter aa276ca6af fix: 修复gewechat无法at人和发语音失败的问题 #447 #438 2025-02-10 18:11:22 +08:00
Soulter 9f02dd13ff fix: 修复qq在@和回复开启的情况下转发消息异常的问题 2025-02-10 13:07:09 +08:00
Xu Void 7155b4f0ac Update default.py 2025-02-08 10:16:31 +08:00
Xu Void 0021cfc4bc 新增过滤掉正则表达式内容
Fixes #338

新增过滤掉正则表达式内容

---

For more details, open the [Copilot Workspace session](https://copilot-workspace.githubnext.com/Soulter/AstrBot/issues/338?shareId=XXXX-XXXX-XXXX-XXXX).
2025-02-06 15:28:28 +08:00
97 changed files with 3891 additions and 1119 deletions
+7 -2
View File
@@ -17,8 +17,13 @@ addons/plugins
tests/astrbot_plugin_openai
chroma
node_modules/
dashboard/node_modules/
dashboard/dist/
.DS_Store
package-lock.json
package.json
venv/*
venv/*
packages/python_interpreter/workplace
.venv/*
.conda/
+4 -7
View File
@@ -13,11 +13,12 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/Soulter/AstrBot)](https://github.com/Soulter/AstrBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg"/></a>
<img alt="Static Badge" src="https://img.shields.io/badge/QQ群-322154837-purple">
<img alt="Static Badge" src="https://img.shields.io/badge/QQ群-630166526-purple">
[![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e.svg)](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fstats&query=v&label=7%E6%97%A5%E6%B6%88%E6%81%AF%E4%B8%8A%E8%A1%8C%E9%87%8F&cacheSeconds=3600)
[![codecov](https://codecov.io/gh/Soulter/AstrBot/graph/badge.svg?token=FF3P5967B8)](https://codecov.io/gh/Soulter/AstrBot)
<a href="https://github.com/Soulter/AstrBot/blob/master/README_ja.md">日本語</a>
<a href="https://astrbot.app/">查看文档</a>
<a href="https://github.com/Soulter/AstrBot/issues">问题提交</a>
</div>
@@ -27,7 +28,7 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
## ✨ 主要功能
1. **大语言模型对话**。支持各种大语言模型,包括 OpenAI API、Google Gemini、Llama、Deepseek、ChatGLM 等,支持接入本地部署的大模型,通过 Ollama、LLMTuner。具有多轮对话、人格情境、多模态能力,支持图片理解、语音转文字(Whisper)。
2. **多消息平台接入**。支持接入 QQ(OneBot)、QQ 频道、微信(Gewechat、VChat)、Telegram。后续将支持钉钉、飞书、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核。
2. **多消息平台接入**。支持接入 QQ(OneBot)、QQ 频道、微信(Gewechat、飞书、Telegram。后续将支持钉钉、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核。
3. **Agent**。原生支持部分 Agent 能力,如代码执行器、自然语言待办、网页搜索。对接 [Dify 平台](https://astrbot.app/others/dify.html),便捷接入 Dify 智能助手、知识库和 Dify 工作流。
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,极简开发。已支持安装多个插件。
5. **可视化管理面板**。支持可视化修改配置、插件管理、日志查看等功能,降低配置难度。集成 WebChat,可在面板上与大模型对话。
@@ -72,8 +73,8 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
| 微信(个人号) | ✔ | 微信个人号私聊、群聊 | 文字、图片、语音 |
| [Telegram](https://github.com/Soulter/astrbot_plugin_telegram) | ✔ | 私聊、群聊 | 文字、图片 |
| [微信(企业微信)](https://github.com/Soulter/astrbot_plugin_wecom) | ✔ | 私聊 | 文字、图片、语音 |
| 飞书 | ✔ | 群聊 | 文字、图片 |
| 微信对话开放平台 | 🚧 | 计划内 | - |
| 飞书 | 🚧 | 计划内 | - |
| Discord | 🚧 | 计划内 | - |
| WhatsApp | 🚧 | 计划内 | - |
| 小爱音响 | 🚧 | 计划内 | - |
@@ -147,10 +148,6 @@ _✨ 内置 Web Chat,在线与机器人交互 ✨_
</div>
## Sponsors
[<img src="https://api.gitsponsors.com/api/badge/img?id=575865240" height="20">](https://api.gitsponsors.com/api/badge/link?p=XEpbdGxlitw/RbcwiTX93UMzNK/jgDYC8NiSzamIPMoKvG2lBFmyXhSS/b0hFoWlBBMX2L5X5CxTDsUdyvcIEHTOfnkXz47UNOZvMwyt5CzbYpq0SEzsSV1OJF1cCo90qC/ZyYKYOWedal3MhZ3ikw==)
## Disclaimer
1. The project is protected under the `AGPL-v3` opensource license.
+170
View File
@@ -0,0 +1,170 @@
<p align="center">
![6e1279651f16d7fdf4727558b72bbaf1](https://github.com/user-attachments/assets/ead4c551-fc3c-48f7-a6f7-afbfdb820512)
</p>
<div align="center">
_✨ 簡単に使えるマルチプラットフォーム LLM チャットボットおよび開発フレームワーク ✨_
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/Soulter/AstrBot)](https://github.com/Soulter/AstrBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg"/></a>
<img alt="Static Badge" src="https://img.shields.io/badge/QQ群-630166526-purple">
[![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e.svg)](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fstats&query=v&label=7%E6%97%A5%E6%B6%88%E6%81%AF%E4%B8%8A%E8%A1%8C%E9%87%8F&cacheSeconds=3600)
[![codecov](https://codecov.io/gh/Soulter/AstrBot/graph/badge.svg?token=FF3P5967B8)](https://codecov.io/gh/Soulter/AstrBot)
<a href="https://astrbot.app/">ドキュメントを見る</a>
<a href="https://github.com/Soulter/AstrBot/issues">問題を報告する</a>
</div>
AstrBot は、疎結合、非同期、複数のメッセージプラットフォームに対応したデプロイ、使いやすいプラグインシステム、および包括的な大規模言語モデル(LLM)接続機能を備えたチャットボットおよび開発フレームワークです。
## ✨ 主な機能
1. **大規模言語モデルの対話**。OpenAI API、Google Gemini、Llama、Deepseek、ChatGLM など、さまざまな大規模言語モデルをサポートし、Ollama、LLMTuner を介してローカルにデプロイされた大規模モデルをサポートします。多輪対話、人格シナリオ、多モーダル機能を備え、画像理解、音声からテキストへの変換(Whisper)をサポートします。
2. **複数のメッセージプラットフォームの接続**。QQOneBot)、QQ チャンネル、WeChatGewechat)、Feishu、Telegram への接続をサポートします。今後、DingTalk、Discord、WhatsApp、Xiaoai 音響をサポートする予定です。レート制限、ホワイトリスト、キーワードフィルタリング、Baidu コンテンツ監査をサポートします。
3. **エージェント**。一部のエージェント機能をネイティブにサポートし、コードエグゼキューター、自然言語タスク、ウェブ検索などを提供します。[Dify プラットフォーム](https://astrbot.app/others/dify.html)と連携し、Dify スマートアシスタント、ナレッジベース、Dify ワークフローを簡単に接続できます。
4. **プラグインの拡張**。深く最適化されたプラグインメカニズムを備え、[プラグインの開発](https://astrbot.app/dev/plugin.html)をサポートし、機能を拡張できます。複数のプラグインのインストールをサポートします。
5. **ビジュアル管理パネル**。設定の視覚的な変更、プラグイン管理、ログの表示などをサポートし、設定の難易度を低減します。WebChat を統合し、パネル上で大規模モデルと対話できます。
6. **高い安定性と高いモジュール性**。イベントバスとパイプラインに基づくアーキテクチャ設計により、高度にモジュール化され、低結合です。
> [!TIP]
> 管理パネルのオンラインデモを体験する: [https://demo.astrbot.app/](https://demo.astrbot.app/)
>
> ユーザー名: `astrbot`, パスワード: `astrbot`。LLM が設定されていないため、チャットページで大規模モデルを使用することはできません。(デモのログインパスワードを変更しないでください 😭)
## ✨ 使用方法
#### Docker デプロイ
公式ドキュメント [Docker を使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) を参照してください。
#### Windows ワンクリックインストーラーのデプロイ
コンピュータに Python(>3.10)がインストールされている必要があります。公式ドキュメント [Windows ワンクリックインストーラーを使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/windows.html) を参照してください。
#### Replit デプロイ
[![Run on Repl.it](https://repl.it/badge/github/Soulter/AstrBot)](https://repl.it/github/Soulter/AstrBot)
#### CasaOS デプロイ
コミュニティが提供するデプロイ方法です。
公式ドキュメント [ソースコードを使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/casaos.html) を参照してください。
#### 手動デプロイ
公式ドキュメント [ソースコードを使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/cli.html) を参照してください。
## ⚡ メッセージプラットフォームのサポート状況
| プラットフォーム | サポート状況 | 詳細 | メッセージタイプ |
| -------- | ------- | ------- | ------ |
| QQ(公式ロボットインターフェース) | ✔ | プライベートチャット、グループチャット、QQ チャンネルプライベートチャット、グループチャット | テキスト、画像 |
| QQ(OneBot) | ✔ | プライベートチャット、グループチャット | テキスト、画像、音声 |
| WeChat(個人アカウント) | ✔ | WeChat 個人アカウントのプライベートチャット、グループチャット | テキスト、画像、音声 |
| [Telegram](https://github.com/Soulter/astrbot_plugin_telegram) | ✔ | プライベートチャット、グループチャット | テキスト、画像 |
| [WeChat(企業 WeChat)](https://github.com/Soulter/astrbot_plugin_wecom) | ✔ | プライベートチャット | テキスト、画像、音声 |
| Feishu | ✔ | グループチャット | テキスト、画像 |
| WeChat 対話オープンプラットフォーム | 🚧 | 計画中 | - |
| Discord | 🚧 | 計画中 | - |
| WhatsApp | 🚧 | 計画中 | - |
| Xiaoai 音響 | 🚧 | 計画中 | - |
# 🦌 今後のロードマップ
> [!TIP]
> Issue でさらに多くの提案を歓迎します <3
- [ ] 現在のすべてのプラットフォームアダプターの機能の一貫性を確保し、改善する
- [ ] プラグインインターフェースの最適化
- [ ] GPT-Sovits などの TTS サービスをデフォルトでサポート
- [ ] "チャット強化" 部分を完成させ、永続的な記憶をサポート
- [ ] i18n の計画
## ❤️ 貢献
Issue や Pull Request を歓迎します!このプロジェクトに変更を加えるだけです :)
新機能の追加については、まず Issue で議論してください。
## 🌟 サポート
- このプロジェクトに Star を付けてください!
- [愛発電](https://afdian.com/a/soulter)で私をサポートしてください!
- [WeChat](https://drive.soulter.top/f/pYfA/d903f4fa49a496fda3f16d2be9e023b5.png)で私をサポートしてください~
## ✨ デモ
> [!NOTE]
> コードエグゼキューターのファイル入力/出力は現在 Napcat(QQ)、Lagrange(QQ) でのみテストされています
<div align='center'>
<img src="https://github.com/user-attachments/assets/4ee688d9-467d-45c8-99d6-368f9a8a92d8" width="600">
_✨ Docker ベースのサンドボックス化されたコードエグゼキューター(ベータテスト中)✨_
<img src="https://github.com/user-attachments/assets/0378f407-6079-4f64-ae4c-e97ab20611d2" height=500>
_✨ 多モーダル、ウェブ検索、長文の画像変換(設定可能)✨_
<img src="https://github.com/user-attachments/assets/8ec12797-e70f-460a-959e-48eca39ca2bb" height=100>
_✨ 自然言語タスク ✨_
<img src="https://github.com/user-attachments/assets/e137a9e1-340a-4bf2-bb2b-771132780735" height=150>
<img src="https://github.com/user-attachments/assets/480f5e82-cf6a-4955-a869-0d73137aa6e1" height=150>
_✨ プラグインシステム - 一部のプラグインの展示 ✨_
<img src="https://github.com/user-attachments/assets/592a8630-14c7-4e06-b496-9c0386e4f36c" width="600">
_✨ 管理パネル ✨_
![webchat](https://drive.soulter.top/f/vlsA/ezgif-5-fb044b2542.gif)
_✨ 内蔵 Web Chat、オンラインでボットと対話 ✨_
</div>
## ⭐ Star History
> [!TIP]
> このプロジェクトがあなたの生活や仕事に役立った場合、またはこのプロジェクトの将来の発展に関心がある場合は、プロジェクトに Star を付けてください。これはこのオープンソースプロジェクトを維持するためのモチベーションです <3
<div align="center">
[![Star History Chart](https://api.star-history.com/svg?repos=soulter/astrbot&type=Date)](https://star-history.com/#soulter/astrbot&Date)
</div>
## スポンサー
[<img src="https://api.gitsponsors.com/api/badge/img?id=575865240" height="20">](https://api.gitsponsors.com/api/badge/link?p=XEpbdGxlitw/RbcwiTX93UMzNK/jgDYC8NiSzamIPMoKvG2lBFmyXhSS/b0hFoWlBBMX2L5X5CxTDsUdyvcIEHTOfnkXz47UNOZvMwyt5CzbYpq0SEzsSV1OJF1cCo90qC/ZyYKYOWedal3MhZ3ikw==)
## 免責事項
1. このプロジェクトは `AGPL-v3` オープンソースライセンスの下で保護されています。
2. WeChat(個人アカウント)のデプロイメントには [Gewechat](https://github.com/Devo919/Gewechat) サービスを利用しています。AstrBot は Gewechat との接続を保証するだけであり、アカウントのリスク管理に関しては、このプロジェクトの著者は一切の責任を負いません。
3. このプロジェクトを使用する際は、現地の法律および規制を遵守してください。
<!-- ## ✨ ATRI [ベータテスト]
この機能はプラグインとしてロードされます。プラグインリポジトリのアドレス:[astrbot_plugin_atri](https://github.com/Soulter/astrbot_plugin_atri)
1. 《ATRI ~ My Dear Moments》の主人公 ATRI のキャラクターセリフを微調整データセットとして使用した `Qwen1.5-7B-Chat Lora` 微調整モデル。
2. 長期記憶
3. ミームの理解と返信
4. TTS
-->
_私は、高性能ですから!_
+4
View File
@@ -5,6 +5,7 @@ from astrbot.core.star.register import (
register_regex as regex,
register_platform_adapter_type as platform_adapter_type,
register_permission_type as permission_type,
register_custom_filter as custom_filter,
register_on_llm_request as on_llm_request,
register_on_llm_response as on_llm_response,
register_llm_tool as llm_tool,
@@ -15,6 +16,7 @@ from astrbot.core.star.register import (
from astrbot.core.star.filter.event_message_type import EventMessageTypeFilter, EventMessageType
from astrbot.core.star.filter.platform_adapter_type import PlatformAdapterTypeFilter, PlatformAdapterType
from astrbot.core.star.filter.permission import PermissionTypeFilter, PermissionType
from astrbot.core.star.filter.custom_filter import CustomFilter
__all__ = [
'command',
@@ -28,6 +30,8 @@ __all__ = [
'PlatformAdapterTypeFilter',
'PlatformAdapterType',
'PermissionTypeFilter',
'CustomFilter',
'custom_filter',
'PermissionType',
'on_llm_request',
'llm_tool',
+154 -7
View File
@@ -2,7 +2,7 @@
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
"""
VERSION = "3.4.24"
VERSION = "3.4.31"
DB_PATH = "data/data_v3.db"
# 默认配置
@@ -28,8 +28,12 @@ DEFAULT_CONFIG = {
"segmented_reply": {
"enable": False,
"only_llm_result": True,
"interval_method": "random",
"interval": "1.5,3.5",
"regex": ".*?[。?!~…]+|.+$"
"log_base": 2.6,
"words_count_threshold": 150,
"regex": ".*?[。?!~…]+|.+$",
"content_cleanup_rule": "",
},
"no_permission_reply": True,
},
@@ -63,9 +67,11 @@ DEFAULT_CONFIG = {
"method": "possibility_reply",
"possibility_reply": 0.1,
"prompt": "",
"whitelist": []
}
},
"content_safety": {
"also_use_in_response": False,
"internal_keywords": {"enable": True, "extra_keywords": []},
"baidu_aip": {"enable": False, "app_id": "", "api_key": "", "secret_key": ""},
},
@@ -79,6 +85,7 @@ DEFAULT_CONFIG = {
"enable": True,
"username": "astrbot",
"password": "77b90590a8945a7d36c963981a307dc9",
"port": 6185
},
"platform": [],
"wake_prefix": ["/"],
@@ -109,6 +116,14 @@ CONFIG_METADATA_2 = {
"enable_group_c2c": True,
"enable_guild_direct_message": True,
},
"qq_official_webhook(QQ)": {
"id": "default",
"type": "qq_official_webhook",
"enable": False,
"appid": "",
"secret": "",
"port": 6196
},
"aiocqhtp(QQ)": {
"id": "default",
"type": "aiocqhttp",
@@ -125,12 +140,22 @@ CONFIG_METADATA_2 = {
"host": "这里填写你的局域网IP或者公网服务器IP",
"port": 11451,
},
"lark(飞书)": {
"id": "lark",
"type": "lark",
"enable": False,
"lark_bot_name": "",
"app_id": "",
"app_secret": "",
"domain": "https://open.feishu.cn"
},
},
"items": {
"id": {
"description": "ID",
"type": "string",
"hint": "提供商 ID 名,用于在多实例下方便管理和识别。自定义,ID 不能重复。",
"obvious_hint": True,
"hint": "ID 不能和其它的平台适配器重复,否则将发生严重冲突。",
},
"type": {
"description": "适配器类型",
@@ -172,6 +197,12 @@ CONFIG_METADATA_2 = {
"type": "int",
"hint": "aiocqhttp 适配器的反向 Websocket 端口。",
},
"lark_bot_name": {
"description": "飞书机器人的名字",
"type": "string",
"hint": "请务必填对,否则 @ 机器人将无法唤醒,只能通过前缀唤醒。",
"obvious_hint": True
}
},
},
"platform_settings": {
@@ -215,10 +246,26 @@ CONFIG_METADATA_2 = {
"description": "仅对 LLM 结果分段",
"type": "bool",
},
"interval_method": {
"description": "间隔时间计算方法",
"type": "string",
"options": ["random", "log"],
"hint": "分段回复的间隔时间计算方法。random 为随机时间,log 为根据消息长度计算,$y=log_{log\_base}(x)$x为字数,y的单位为秒。",
},
"interval": {
"description": "随机间隔时间(秒)",
"type": "string",
"hint": "每一段回复的间隔时间,格式为 `最小时间,最大时间`。如 `0.75,2.5`",
"hint": "`random` 方法用。每一段回复的间隔时间,格式为 `最小时间,最大时间`。如 `0.75,2.5`",
},
"log_base": {
"description": "对数函数底数",
"type": "float",
"hint": "`log` 方法用。对数函数的底数。默认为 2.6",
},
"words_count_threshold": {
"description": "字数阈值",
"type": "int",
"hint": "超过这个字数的消息不会被分段回复。默认为 150",
},
"regex": {
"description": "正则表达式",
@@ -226,6 +273,12 @@ CONFIG_METADATA_2 = {
"obvious_hint": True,
"hint": "用于分隔一段消息。默认情况下会根据句号、问号等标点符号分隔。re.findall(r'<regex>', text)",
},
"content_cleanup_rule": {
"description": "过滤分段后的内容",
"type": "string",
"obvious_hint": True,
"hint": "移除分段后的内容中的指定的内容。支持正则表达式。如填写 `[。?!]` 将移除所有的句号、问号、感叹号。re.sub(r'<regex>', '', text)",
},
},
},
"reply_prefix": {
@@ -285,6 +338,11 @@ CONFIG_METADATA_2 = {
"description": "内容安全",
"type": "object",
"items": {
"also_use_in_response": {
"description": "对大模型响应安全审核",
"type": "bool",
"hint": "启用后,大模型的响应也会通过内容安全审核。",
},
"baidu_aip": {
"description": "百度内容审核配置",
"type": "object",
@@ -352,6 +410,29 @@ CONFIG_METADATA_2 = {
"model": "gpt-4o-mini",
},
},
"xAI": {
"id": "xai",
"type": "openai_chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.x.ai/v1",
"timeout": 120,
"model_config": {
"model": "grok-2-latest",
},
},
"anthropic(claude)": {
"id": "claude",
"type": "anthropic_chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.anthropic.com/v1",
"timeout": 120,
"model_config": {
"model": "claude-3-5-sonnet-latest",
"max_tokens": 4096,
},
},
"ollama": {
"id": "ollama_default",
"type": "openai_chat_completion",
@@ -406,7 +487,7 @@ CONFIG_METADATA_2 = {
"model": "glm-4-flash",
},
},
"硅基流动": {
"siliconflow": {
"id": "siliconflow",
"type": "openai_chat_completion",
"enable": True,
@@ -417,6 +498,17 @@ CONFIG_METADATA_2 = {
"model": "deepseek-ai/DeepSeek-V3",
},
},
"moonshot(kimi)": {
"id": "moonshot",
"type": "openai_chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://api.moonshot.cn/v1",
"model_config": {
"model": "moonshot-v1-8k",
},
},
"llmtuner": {
"id": "llmtuner_default",
"type": "llm_tuner",
@@ -435,6 +527,26 @@ CONFIG_METADATA_2 = {
"dify_api_key": "",
"dify_api_base": "https://api.dify.ai/v1",
"dify_workflow_output_key": "",
"dify_query_input_key": "astrbot_text_query",
"variables": {},
"timeout": 60,
},
"dashscope": {
"id": "dashscope",
"type": "dashscope",
"enable": True,
"dashscope_app_type": "agent",
"dashscope_api_key": "",
"dashscope_app_id": "",
"variables": {},
"timeout": 60,
},
"fastgpt": {
"id": "fastgpt",
"type": "openai_chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.fastgpt.in/api/v1",
"timeout": 60,
},
"whisper(API)": {
@@ -473,6 +585,26 @@ CONFIG_METADATA_2 = {
},
},
"items": {
# "variables": {
# "description": "工作流固定输入变量",
# "type": "object",
# "obvious_hint": True,
# "hint": "可选。工作流固定输入变量,将会作为工作流的输入。也可以在对话时使用 /set 指令动态设置变量。如果变量名冲突,优先使用动态设置的变量。",
# },
# "fastgpt_app_type": {
# "description": "应用类型",
# "type": "string",
# "hint": "FastGPT 应用的应用类型。",
# "options": ["agent", "workflow", "plugin"],
# "obvious_hint": True,
# },
"dashscope_app_type": {
"description": "应用类型",
"type": "string",
"hint": "阿里云百炼应用的应用类型。",
"options": ["agent", "agent-arrange", "dialog-workflow", "task-workflow"],
"obvious_hint": True,
},
"timeout": {
"description": "超时时间",
"type": "int",
@@ -499,7 +631,8 @@ CONFIG_METADATA_2 = {
"id": {
"description": "ID",
"type": "string",
"hint": "提供商 ID 名,用于在多实例下方便管理和识别。自定义,ID 不能重复。",
"obvious_hint": True,
"hint": "ID 不能和其它的服务提供商重复,否则将发生严重冲突。",
},
"type": {
"description": "模型提供商类型",
@@ -586,6 +719,12 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "Dify Workflow 输出变量名。当应用类型为 workflow 时才使用。默认为 astrbot_wf_output。",
},
"dify_query_input_key": {
"description": "Prompt 输入变量名",
"type": "string",
"hint": "发送的消息文本内容对应的输入变量名。默认为 astrbot_text_query。",
"obvious": True,
}
},
},
"provider_settings": {
@@ -755,6 +894,13 @@ CONFIG_METADATA_2 = {
"obvious_hint": True,
"hint": "启用后,会根据触发概率主动回复群聊内的对话。QQ官方API(qq_official)不可用",
},
"whitelist": {
"description": "主动回复白名单",
"type": "list",
"items": {"type": "string"},
"obvious_hint": True,
"hint": "启用后,只有在白名单内的群聊会被主动回复。为空时不启用白名单过滤。需要通过 /sid 获取 SID 添加到这里。",
},
"method": {
"description": "回复方法",
"type": "string",
@@ -829,7 +975,8 @@ CONFIG_METADATA_2 = {
"plugin_repo_mirror": {
"description": "插件仓库镜像",
"type": "string",
"hint": "插件仓库的镜像地址,用于加速插件的下载。",
"hint": "已废弃,请使用管理面板->设置页的代理地址选择",
"obvious_hint": True,
"options": [
"default",
"https://ghp.ci/",
+1
View File
@@ -50,6 +50,7 @@ class ConversationManager():
cid=conversation_id
)
del self.session_conversations[unified_msg_origin]
sp.put("session_conversation", self.session_conversations)
async def get_curr_conversation_id(self, unified_msg_origin: str) -> str:
'''获取会话当前的对话 ID'''
+7 -10
View File
@@ -27,7 +27,8 @@ class AstrBotCoreLifecycle:
os.environ['https_proxy'] = self.astrbot_config['http_proxy']
os.environ['http_proxy'] = self.astrbot_config['http_proxy']
os.environ['no_proxy'] = 'localhost,127.0.0.1'
async def initialize(self):
logger.info("AstrBot v"+ VERSION)
if os.environ.get("TESTING", ""):
@@ -62,9 +63,6 @@ class AstrBotCoreLifecycle:
await self.provider_manager.initialize()
'''根据配置实例化各个 Provider'''
await self.platform_manager.initialize()
'''根据配置实例化各个平台适配器'''
self.pipeline_scheduler = PipelineScheduler(PipelineContext(self.astrbot_config, self.plugin_manager))
await self.pipeline_scheduler.initialize()
'''初始化消息事件流水线调度器'''
@@ -73,19 +71,18 @@ class AstrBotCoreLifecycle:
self.event_bus = EventBus(self.event_queue, self.pipeline_scheduler)
self.start_time = int(time.time())
self.curr_tasks: List[asyncio.Task] = []
await self.platform_manager.initialize()
'''根据配置实例化各个平台适配器'''
def _load(self):
platform_tasks = self.load_platform()
event_bus_task = asyncio.create_task(self.event_bus.dispatch(), name="event_bus")
extra_tasks = []
for task in self.star_context._register_tasks:
extra_tasks.append(asyncio.create_task(task, name=task.__name__))
# self.curr_tasks = [event_bus_task, *platform_tasks, *extra_tasks]
tasks_ = [event_bus_task, *platform_tasks, *extra_tasks]
tasks_ = [event_bus_task, *extra_tasks]
for task in tasks_:
self.curr_tasks.append(asyncio.create_task(self._task_wrapper(task), name=task.get_name()))
+4
View File
@@ -247,6 +247,10 @@ class SQLiteDatabase(BaseDatabase):
res = c.fetchone()
c.close()
if not res:
return
return Conversation(*res)
def new_conversation(self, user_id: str, cid: str):
+1 -1
View File
@@ -58,7 +58,7 @@ class LogManager:
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_formatter = colorlog.ColoredFormatter(
fmt='%(log_color)s [%(asctime)s| %(levelname)s] [%(filename)s:%(lineno)d]: %(message)s %(reset)s',
fmt='%(log_color)s [%(asctime)s] [%(levelname)-5s] [%(filename)s:%(lineno)d]: %(message)s %(reset)s',
datefmt='%H:%M:%S',
log_colors=log_color_config
)
+33 -18
View File
@@ -30,11 +30,19 @@ from enum import Enum
from pydantic.v1 import BaseModel
class ComponentType(Enum):
Plain = "Plain"
Face = "Face"
Record = "Record"
Video = "Video"
At = "At"
Plain = "Plain" # 纯文本消息
Face = "Face" # QQ表情
Record = "Record" # 语音
Video = "Video" # 视频
At = "At" # At
Node = "Node" # 转发消息的一个节点
Nodes = "Nodes" # 转发消息的多个节点
Poke = "Poke" # QQ 戳一戳
Image = "Image" # 图片
Reply = "Reply" # 回复
Forward = "Forward" # 转发消息
File = "File" # 文件
RPS = "RPS" # TODO
Dice = "Dice" # TODO
Shake = "Shake" # TODO
@@ -43,18 +51,12 @@ class ComponentType(Enum):
Contact = "Contact" # TODO
Location = "Location" # TODO
Music = "Music"
Image = "Image"
Reply = "Reply"
RedBag = "RedBag"
Poke = "Poke"
Forward = "Forward"
Node = "Node"
Xml = "Xml"
Json = "Json"
CardImage = "CardImage"
TTS = "TTS"
Unknown = "Unknown"
File = "File"
class BaseMessageComponent(BaseModel):
@@ -341,14 +343,14 @@ class Forward(BaseMessageComponent):
def __init__(self, **_):
super().__init__(**_)
class Node(BaseMessageComponent): # 该 component 仅支持使用 sendGroupForwardMessage 发送
class Node(BaseMessageComponent):
'''群合并转发消息'''
type: ComponentType = "Node"
id: T.Optional[int] = 0
name: T.Optional[str] = ""
uin: T.Optional[int] = 0
content: T.Optional[T.Union[str, list]] = ""
seq: T.Optional[T.Union[str, list]] = "" # 不清楚是什么
id: T.Optional[int] = 0 # 忽略
name: T.Optional[str] = "" # qq昵称
uin: T.Optional[int] = 0 # qq号
content: T.Optional[T.Union[str, list]] = "" # 子消息段列表
seq: T.Optional[T.Union[str, list]] = "" # 忽略
time: T.Optional[int] = 0
def __init__(self, content: T.Union[str, list], **_):
@@ -362,6 +364,18 @@ class Node(BaseMessageComponent): # 该 component 仅支持使用 sendGroupForw
def toString(self):
# logger.warn("Protocol: node doesn't support stringify")
return ""
class Nodes(BaseMessageComponent):
type: ComponentType = "Nodes"
nodes: T.List[Node]
def __init__(self, nodes: T.List[Node], **_):
super().__init__(nodes=nodes, **_)
def toDict(self):
return {
"messages": [node.toDict() for node in self.nodes]
}
class Xml(BaseMessageComponent):
@@ -451,6 +465,7 @@ ComponentTypes = {
"poke": Poke,
"forward": Forward,
"node": Node,
"nodes": Nodes,
"xml": Xml,
"json": Json,
"cardimage": CardImage,
@@ -17,12 +17,14 @@ class ContentSafetyCheckStage(Stage):
config = ctx.astrbot_config['content_safety']
self.strategy_selector = StrategySelector(config)
async def process(self, event: AstrMessageEvent) -> Union[None, AsyncGenerator[None, None]]:
async def process(self, event: AstrMessageEvent, check_text: str = None) -> Union[None, AsyncGenerator[None, None]]:
'''检查内容安全'''
ok, info = self.strategy_selector.check(event.get_message_str())
text = check_text if check_text else event.get_message_str()
ok, info = self.strategy_selector.check(text)
if not ok:
event.set_result(MessageEventResult().message("你的消息中包含不适当的内容,已被屏蔽。"))
if event.is_at_or_wake_command:
event.set_result(MessageEventResult().message("你的消息或者大模型的响应中包含不适当的内容,已被屏蔽。"))
yield
event.stop_event()
logger.info(f"内容安全检查不通过,原因:{info}")
return
event.continue_event()
@@ -1,64 +0,0 @@
'''
Dify 调用 Stage
'''
import traceback
from typing import Union, AsyncGenerator
from ...context import PipelineContext
from ..stage import Stage
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.message.message_event_result import MessageEventResult, ResultContentType
from astrbot.core.message.components import Image
from astrbot.core import logger
from astrbot.core.utils.metrics import Metric
from astrbot.core.provider.entites import ProviderRequest
class DifyRequestSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
async def process(self, event: AstrMessageEvent) -> Union[None, AsyncGenerator[None, None]]:
req: ProviderRequest = None
provider = self.ctx.plugin_manager.context.get_using_provider()
if not provider:
return
if provider.meta().type != "dify":
return
if event.get_extra("provider_request"):
req = event.get_extra("provider_request")
assert isinstance(req, ProviderRequest), "provider_request 必须是 ProviderRequest 类型。"
else:
req = ProviderRequest(prompt="", image_urls=[])
if self.ctx.astrbot_config['provider_settings']['wake_prefix']:
if not event.message_str.startswith(self.ctx.astrbot_config['provider_settings']['wake_prefix']):
return
req.prompt = event.message_str[len(self.ctx.astrbot_config['provider_settings']['wake_prefix']):]
for comp in event.message_obj.message:
if isinstance(comp, Image):
image_url = comp.url if comp.url else comp.file
req.image_urls.append(image_url)
req.session_id = event.session_id
event.set_extra("provider_request", req)
if not req.prompt:
return
try:
logger.debug(f"Dify 请求 Payload: {req.__dict__}")
llm_response = await provider.text_chat(**req.__dict__) # 请求 LLM
await Metric.upload(llm_tick=1, model_name=provider.get_model(), provider_type=provider.meta().type)
if llm_response.role == 'assistant':
# text completion
event.set_result(MessageEventResult().message(llm_response.completion_text)
.set_result_content_type(ResultContentType.LLM_RESULT))
yield # rick roll
except BaseException as e:
logger.error(traceback.format_exc())
event.set_result(MessageEventResult().message("AstrBot 请求 Dify 失败:" + str(e)))
return
@@ -13,6 +13,7 @@ from astrbot.core import logger
from astrbot.core.utils.metrics import Metric
from astrbot.core.provider.entites import ProviderRequest, LLMResponse
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star import star_map
class LLMRequestSubStage(Stage):
@@ -54,7 +55,7 @@ class LLMRequestSubStage(Stage):
conversation_id = await self.conv_manager.get_curr_conversation_id(event.unified_msg_origin)
if not conversation_id:
conversation_id = await self.conv_manager.new_conversation(event.unified_msg_origin)
req.session_id = conversation_id
req.session_id = event.unified_msg_origin
conversation = await self.conv_manager.get_conversation(event.unified_msg_origin, conversation_id)
req.conversation = conversation
req.contexts = json.loads(conversation.history)
@@ -64,11 +65,12 @@ class LLMRequestSubStage(Stage):
if not req.prompt and not req.image_urls:
return
# 执行请求 LLM 前事件。
# 执行请求 LLM 前事件钩子
# 装饰 system_prompt 等功能
handlers = star_handlers_registry.get_handlers_by_event_type(EventType.OnLLMRequestEvent)
for handler in handlers:
try:
logger.debug(f"hook(on_llm_request) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}")
await handler.handler(event, req)
except BaseException:
logger.error(traceback.format_exc())
@@ -82,10 +84,11 @@ class LLMRequestSubStage(Stage):
req.func_tool = None # 暂时不支持递归工具调用
llm_response = await provider.text_chat(**req.__dict__) # 请求 LLM
# 执行 LLM 响应后的事件。
# 执行 LLM 响应后的事件钩子
handlers = star_handlers_registry.get_handlers_by_event_type(EventType.OnLLMResponseEvent)
for handler in handlers:
try:
logger.debug(f"hook(on_llm_response) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}")
await handler.handler(event, llm_response)
except BaseException:
logger.error(traceback.format_exc())
@@ -111,10 +114,10 @@ class LLMRequestSubStage(Stage):
# 尝试调用工具函数
wrapper = self._call_handler(self.ctx, event, func_tool.handler, **func_tool_args)
async for resp in wrapper:
if resp is not None:
if resp is not None: # 有 return 返回
function_calling_result[func_tool_name] = resp
else:
yield
yield # 有生成器返回
event.clear_result() # 清除上一个 handler 的结果
except BaseException as e:
logger.warning(traceback.format_exc())
@@ -154,6 +157,6 @@ class LLMRequestSubStage(Stage):
contexts_to_save = list(filter(lambda item: '_no_save' not in item, contexts))
await self.conv_manager.update_conversation(
event.unified_msg_origin,
req.session_id,
req.conversation.cid,
history=contexts_to_save
)
@@ -28,10 +28,8 @@ class StarRequestSubStage(Stage):
params = handlers_parsed_params.get(handler.handler_full_name, {})
try:
if handler.handler_module_path not in star_map:
# 孤立无援的 star handler
continue
logger.debug(f"执行插件 handler {handler.handler_full_name}")
logger.debug(f"plugin -> {star_map.get(handler.handler_module_path).name} - {handler.handler_name}")
wrapper = self._call_handler(self.ctx, event, handler.handler, **params)
async for ret in wrapper:
yield ret
+6 -14
View File
@@ -3,7 +3,6 @@ from ..stage import Stage, register_stage
from ..context import PipelineContext
from .method.llm_request import LLMRequestSubStage
from .method.star_request import StarRequestSubStage
from .method.dify_request import DifyRequestSubStage
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.star_handler import StarHandlerMetadata
from astrbot.core.provider.entites import ProviderRequest
@@ -21,9 +20,6 @@ class ProcessStage(Stage):
self.star_request_sub_stage = StarRequestSubStage()
await self.star_request_sub_stage.initialize(ctx)
self.dify_request_sub_stage = DifyRequestSubStage()
await self.dify_request_sub_stage.initialize(ctx)
async def process(self, event: AstrMessageEvent) -> Union[None, AsyncGenerator[None, None]]:
'''处理事件
@@ -35,7 +31,6 @@ class ProcessStage(Stage):
# 生成器返回值处理
if isinstance(resp, ProviderRequest):
# Handler 的 LLM 请求
logger.debug(f"llm request -> {resp.prompt}")
event.set_extra("provider_request", resp)
_t = False
async for _ in self.llm_request_sub_stage.process(event):
@@ -46,22 +41,19 @@ class ProcessStage(Stage):
else:
yield
# 调用提供商相关请求
# 调用 LLM 相关请求
if not self.ctx.astrbot_config['provider_settings'].get('enable', True):
return
if not event._has_send_oper and event.is_at_or_wake_command:
if not event._has_send_oper and event.is_at_or_wake_command and not event.call_llm:
# 是否有过发送操作 and 是否是被 @ 或者通过唤醒前缀
if (event.get_result() and not event.get_result().is_stopped()) or not event.get_result():
# 事件没有终止传播
provider = self.ctx.plugin_manager.context.get_using_provider()
if not provider:
logger.info("未找到可用的 LLM 提供商,请先前往配置服务提供商。")
return
match provider.meta().type:
case "dify":
async for _ in self.dify_request_sub_stage.process(event):
yield
case _:
async for _ in self.llm_request_sub_stage.process(event):
yield
async for _ in self.llm_request_sub_stage.process(event):
yield
@@ -73,8 +73,6 @@ class RateLimitStage(Stage):
timestamps.append(now)
return event.continue_event()
def _remove_expired_timestamps(self, timestamps: Deque[datetime], now: datetime) -> None:
"""
移除时间窗口外的时间戳。
+53 -6
View File
@@ -1,21 +1,31 @@
import random
import asyncio
import math
import traceback
from typing import Union, AsyncGenerator
from ..stage import register_stage, Stage
from ..context import PipelineContext
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core import logger
from astrbot.core.message.message_event_result import BaseMessageComponent
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star import star_map
from astrbot.core.message.components import Plain, Reply, At
@register_stage
class RespondStage(Stage):
async def initialize(self, ctx: PipelineContext):
self.ctx = ctx
self.reply_with_mention = ctx.astrbot_config['platform_settings']['reply_with_mention']
self.reply_with_quote = ctx.astrbot_config['platform_settings']['reply_with_quote']
# 分段回复
self.enable_seg: bool = ctx.astrbot_config['platform_settings']['segmented_reply']['enable']
self.only_llm_result = ctx.astrbot_config['platform_settings']['segmented_reply']['only_llm_result']
self.interval_method = ctx.astrbot_config['platform_settings']['segmented_reply']['interval_method']
self.log_base = float(ctx.astrbot_config['platform_settings']['segmented_reply']['log_base'])
interval_str: str = ctx.astrbot_config['platform_settings']['segmented_reply']['interval']
interval_str_ls = interval_str.replace(" ", "").split(",")
try:
@@ -24,7 +34,27 @@ class RespondStage(Stage):
logger.error(f'解析分段回复的间隔时间失败。{e}')
self.interval = [1.5, 3.5]
logger.info(f"分段回复间隔时间:{self.interval}")
async def _word_cnt(self, text: str) -> int:
'''分段回复 统计字数'''
if all(ord(c) < 128 for c in text):
word_count = len(text.split())
else:
word_count = len([c for c in text if c.isalnum()])
return word_count
async def _calc_comp_interval(self, comp: BaseMessageComponent) -> float:
'''分段回复 计算间隔时间'''
if self.interval_method == 'log':
if isinstance(comp, Plain):
wc = await self._word_cnt(comp.text)
i = math.log(wc + 1, self.log_base)
return random.uniform(i, i + 0.5)
else:
return random.uniform(1, 1.75)
else:
# random
return random.uniform(self.interval[0], self.interval[1])
async def process(self, event: AstrMessageEvent) -> Union[None, AsyncGenerator[None, None]]:
result = event.get_result()
@@ -35,10 +65,24 @@ class RespondStage(Stage):
await event._pre_send()
if self.enable_seg and ((self.only_llm_result and result.is_llm_result()) or not self.only_llm_result):
decorated_comps = []
if self.reply_with_mention:
for comp in result.chain:
if isinstance(comp, At):
decorated_comps.append(comp)
result.chain.remove(comp)
break
if self.reply_with_quote:
for comp in result.chain:
if isinstance(comp, Reply):
decorated_comps.append(comp)
result.chain.remove(comp)
break
# 分段回复
for comp in result.chain:
await event.send(MessageChain([comp]))
await asyncio.sleep(random.uniform(self.interval[0], self.interval[1]))
i = await self._calc_comp_interval(comp)
await asyncio.sleep(i)
await event.send(MessageChain([*decorated_comps, comp]))
else:
await event.send(result)
await event._post_send()
@@ -46,7 +90,10 @@ class RespondStage(Stage):
handlers = star_handlers_registry.get_handlers_by_event_type(EventType.OnAfterMessageSentEvent)
for handler in handlers:
# TODO: 如何让这里的 handler 也能使用 LLM 能力。也许需要将 LLMRequestSubStage 提取出来。
await handler.handler(event)
try:
logger.debug(f"hook(on_after_message_sent) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}")
await handler.handler(event)
except BaseException:
logger.error(traceback.format_exc())
event.clear_result()
+81 -17
View File
@@ -2,17 +2,18 @@ import time
import re
import traceback
from typing import Union, AsyncGenerator
from ..stage import register_stage
from ..stage import Stage, register_stage, registered_stages
from ..context import PipelineContext
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.platform.message_type import MessageType
from astrbot.core import logger
from astrbot.core.message.components import Plain, Image, At, Reply, Record
from astrbot.core.message.components import Plain, Image, At, Reply, Record, File, Node
from astrbot.core import html_renderer
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star import star_map
@register_stage
class ResultDecorateStage:
class ResultDecorateStage(Stage):
async def initialize(self, ctx: PipelineContext):
self.ctx = ctx
self.reply_prefix = ctx.astrbot_config['platform_settings']['reply_prefix']
@@ -25,21 +26,56 @@ class ResultDecorateStage:
self.t2i_word_threshold = 50
except BaseException:
self.t2i_word_threshold = 150
self.forward_threshold = ctx.astrbot_config['platform_settings']['forward_threshold']
# 分段回复
self.words_count_threshold = int(ctx.astrbot_config['platform_settings']['segmented_reply']['words_count_threshold'])
self.enable_segmented_reply = ctx.astrbot_config['platform_settings']['segmented_reply']['enable']
self.only_llm_result = ctx.astrbot_config['platform_settings']['segmented_reply']['only_llm_result']
self.regex = ctx.astrbot_config['platform_settings']['segmented_reply']['regex']
self.content_cleanup_rule = ctx.astrbot_config['platform_settings']['segmented_reply']['content_cleanup_rule']
# exception
self.content_safe_check_reply = ctx.astrbot_config['content_safety']['also_use_in_response']
self.content_safe_check_stage = None
if self.content_safe_check_reply:
for stage in registered_stages:
if stage.__class__.__name__ == "ContentSafetyCheckStage":
self.content_safe_check_stage = stage
async def process(self, event: AstrMessageEvent) -> Union[None, AsyncGenerator[None, None]]:
result = event.get_result()
if result is None or not result.chain:
return
# 回复时检查内容安全
if self.content_safe_check_reply and self.content_safe_check_stage and result.is_llm_result():
text = ""
for comp in result.chain:
if isinstance(comp, Plain):
text += comp.text
async for _ in self.content_safe_check_stage.process(event, check_text=text):
yield
# 发送消息前事件钩子
handlers = star_handlers_registry.get_handlers_by_event_type(EventType.OnDecoratingResultEvent)
for handler in handlers:
try:
logger.debug(f"hook(on_decorating_result) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}")
await handler.handler(event)
if event.get_result() is None or not event.get_result().chain:
logger.debug(f"hook(on_decorating_result) -> {star_map[handler.handler_module_path].name} - {handler.handler_name} 将消息结果清空。")
except BaseException:
logger.error(traceback.format_exc())
# 需要再获取一次。插件可能直接对 chain 进行了替换。
result = event.get_result()
if result is None:
return
handlers = star_handlers_registry.get_handlers_by_event_type(EventType.OnDecoratingResultEvent)
for handler in handlers:
await handler.handler(event)
if len(result.chain) > 0:
# 回复前缀
if self.reply_prefix:
@@ -54,12 +90,20 @@ class ResultDecorateStage:
new_chain = []
for comp in result.chain:
if isinstance(comp, Plain):
split_response = re.findall(self.regex, comp.text)
if len(comp.text) > self.words_count_threshold:
# 不分段回复
new_chain.append(comp)
continue
split_response = []
for line in comp.text.split("\n"):
split_response.extend(re.findall(self.regex, line))
if not split_response:
new_chain.append(comp)
continue
for seg in split_response:
if seg:
if self.content_cleanup_rule:
seg = re.sub(self.content_cleanup_rule, "", seg)
if seg.strip():
new_chain.append(Plain(seg))
else:
# 非 Plain 类型的消息段不分段
@@ -109,12 +153,32 @@ class ResultDecorateStage:
if url:
result.chain = [Image.fromURL(url)]
# at 回复
if self.reply_with_mention and event.get_message_type() != MessageType.FRIEND_MESSAGE:
result.chain.insert(0, At(qq=event.get_sender_id(), name=event.get_sender_name()))
if len(result.chain) > 1 and isinstance(result.chain[1], Plain):
result.chain[1].text = "\n" + result.chain[1].text
# 触发转发消息
has_forwarded = False
if event.get_platform_name() == 'aiocqhttp':
word_cnt = 0
for comp in result.chain:
if isinstance(comp, Plain):
word_cnt += len(comp.text)
if word_cnt > self.forward_threshold:
node = Node(
uin=event.get_self_id(),
name="AstrBot",
content=[
*result.chain
]
)
result.chain = [node]
has_forwarded = True
# 引用回复
if self.reply_with_quote:
result.chain.insert(0, Reply(id=event.message_obj.message_id))
if not has_forwarded:
# at 回复
if self.reply_with_mention and event.get_message_type() != MessageType.FRIEND_MESSAGE:
result.chain.insert(0, At(qq=event.get_sender_id(), name=event.get_sender_name()))
if len(result.chain) > 1 and isinstance(result.chain[1], Plain):
result.chain[1].text = "\n" + result.chain[1].text
# 引用回复
if self.reply_with_quote:
if not any(isinstance(item, File) for item in result.chain):
result.chain.insert(0, Reply(id=event.message_obj.message_id))
+2 -2
View File
@@ -12,14 +12,14 @@ class PipelineScheduler():
async def initialize(self):
for stage in registered_stages:
logger.debug(f"初始化阶段 {stage.__class__ .__name__}")
# logger.debug(f"初始化阶段 {stage.__class__ .__name__}")
await stage.initialize(self.ctx)
async def _process_stages(self, event: AstrMessageEvent, from_stage=0):
for i in range(from_stage, len(registered_stages)):
stage = registered_stages[i]
logger.debug(f"执行阶段 {stage.__class__ .__name__}")
# logger.debug(f"执行阶段 {stage.__class__ .__name__}")
coro = stage.process(event)
if isinstance(coro, AsyncGenerator):
async for _ in coro:
+6 -3
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import abc
import inspect
from astrbot.api import logger
from typing import List, AsyncGenerator, Union, Awaitable
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from .context import PipelineContext
@@ -36,16 +37,18 @@ class Stage(abc.ABC):
ctx: PipelineContext,
event: AstrMessageEvent,
handler: Awaitable,
**params
*args,
**kwargs,
) -> AsyncGenerator[None, None]:
'''调用 Handler。'''
# 判断 handler 是否是类方法(通过装饰器注册的没有 __self__ 属性)
ready_to_call = None
try:
ready_to_call = handler(event, **params)
ready_to_call = handler(event, *args, **kwargs)
except TypeError as e:
# 向下兼容
ready_to_call = handler(event, ctx.plugin_manager.context, **params)
logger.debug(str(e))
ready_to_call = handler(event, ctx.plugin_manager.context, *args, **kwargs)
if isinstance(ready_to_call, AsyncGenerator):
async for ret in ready_to_call:
+7 -26
View File
@@ -5,6 +5,7 @@ from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.message.message_event_result import MessageEventResult, MessageChain
from astrbot.core.message.components import At, Reply
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star import star_map
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.filter.permission import PermissionTypeFilter
@@ -76,34 +77,17 @@ class WakingCheckStage(Stage):
# 检查插件的 handler filter
activated_handlers = []
handlers_parsed_params = {} # 注册了指令的 handler
for handler in star_handlers_registry.get_handlers_by_event_type(EventType.AdapterMessageEvent):
# filter 需满足 AND 逻辑关系
passed = True
child_command_handler_md = None
# filter 需满足 AND 逻辑关系
passed = True
permission_not_pass = False
if len(handler.event_filters) == 0:
# 不可能有这种情况, 也不允许有这种情况
continue
if 'sub_command' in handler.extras_configs:
# 如果是子指令
continue
for filter in handler.event_filters:
try:
if isinstance(filter, CommandGroupFilter):
"""如果指令组过滤成功, 会返回叶子指令的 StarHandlerMetadata"""
ok, child_command_handler_md = filter.filter(
event, self.ctx.astrbot_config
)
if not ok:
passed = False
else:
handler = child_command_handler_md # handler 覆盖
break
elif isinstance(filter, PermissionTypeFilter):
if isinstance(filter, PermissionTypeFilter):
if not filter.filter(event, self.ctx.astrbot_config):
permission_not_pass = True
else:
@@ -111,19 +95,15 @@ class WakingCheckStage(Stage):
passed = False
break
except Exception as e:
# event.set_result(MessageEventResult().message(f"插件 {handler.handler_full_name} 报错:{e}"))
# yield
await event.send(
MessageEventResult().message(
f"插件 {handler.handler_full_name} 报错:{e}"
f"插件 {star_map[handler.handler_module_path].name}: {e}"
)
)
event.stop_event()
passed = False
break
if passed:
if permission_not_pass:
if self.no_permission_reply:
await event.send(MessageChain().message(f"ID {event.get_sender_id()} 权限不足。通过 /sid 获取 ID 并请管理员添加。"))
@@ -138,6 +118,7 @@ class WakingCheckStage(Stage):
handlers_parsed_params[handler.handler_full_name] = event.get_extra(
"parsed_params"
)
event.clear_extra()
event.set_extra("activated_handlers", activated_handlers)
+11 -2
View File
@@ -57,7 +57,8 @@ class AstrMessageEvent(abc.ABC):
self._has_send_oper = False
'''是否有过至少一次发送操作'''
self.call_llm = False
'''是否在此消息事件中禁止默认的 LLM 请求'''
# back_compability
self.platform = platform_meta
@@ -242,7 +243,15 @@ class AstrMessageEvent(abc.ABC):
'''
if self._result is None:
return False # 默认是继续传播
return self._result.is_stopped()
return self._result.is_stopped()
def should_call_llm(self, call_llm: bool):
'''
是否在此消息事件中禁止默认的 LLM 请求。
只会阻止 AstrBot 默认的 LLM 请求链路,不会阻止插件中的 LLM 请求。
'''
self.call_llm = call_llm
def get_result(self) -> MessageEventResult:
'''
+87 -22
View File
@@ -1,3 +1,5 @@
import traceback
import asyncio
from astrbot.core.config.astrbot_config import AstrBotConfig
from .platform import Platform
from typing import List
@@ -11,40 +13,103 @@ class PlatformManager():
self.platform_insts: List[Platform] = []
'''加载的 Platform 的实例'''
self._inst_map = {}
self.platforms_config = config['platform']
self.settings = config['platform_settings']
self.event_queue = event_queue
async def initialize(self):
'''初始化所有平台适配器'''
for platform in self.platforms_config:
if not platform['enable']:
continue
match platform['type']:
await self.load_platform(platform)
# 网页聊天
webchat_inst = WebChatAdapter({}, self.settings, self.event_queue)
self.platform_insts.append(webchat_inst)
asyncio.create_task(self._task_wrapper(asyncio.create_task(webchat_inst.run(), name="webchat")))
async def load_platform(self, platform_config: dict):
'''实例化一个平台'''
if not platform_config['enable']:
return
logger.info(f"载入 {platform_config['type']}({platform_config['id']}) 平台适配器 ...")
# 动态导入
try:
match platform_config['type']:
case "aiocqhttp":
from .sources.aiocqhttp.aiocqhttp_platform_adapter import AiocqhttpAdapter # noqa: F401
case "qq_official":
from .sources.qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter # noqa: F401
case "vchat":
try:
from .sources.vchat.vchat_platform_adapter import VChatPlatformAdapter # noqa: F401
except BaseException:
logger.warning("当前 astrbot 已不维护 vchat 的接入,如有需要请 pip 安装 vchat 然后重启")
case "qq_official_webhook":
from .sources.qqofficial_webhook.qo_webhook_adapter import QQOfficialWebhookPlatformAdapter # noqa: F401
case "gewechat":
from .sources.gewechat.gewechat_platform_adapter import GewechatPlatformAdapter # noqa: F401
case "lark":
from .sources.lark.lark_adapter import LarkPlatformAdapter # noqa: F401
except (ImportError, ModuleNotFoundError) as e:
logger.error(f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。")
except Exception as e:
logger.error(f"加载平台适配器 {platform_config['type']} 失败,原因:{e}")
if platform_config['type'] not in platform_cls_map:
logger.error(f"未找到适用于 {platform_config['type']}({platform_config['id']}) 平台适配器,请检查是否已经安装或者名称填写错误。")
return
cls_type = platform_cls_map[platform_config['type']]
inst = cls_type(platform_config, self.settings, self.event_queue)
self._inst_map[platform_config['id']] = inst
self.platform_insts.append(inst)
async def initialize(self):
for platform in self.platforms_config:
if not platform['enable']:
continue
if platform['type'] not in platform_cls_map:
logger.error(f"未找到适用于 {platform['type']}({platform['id']}) 平台适配器,请检查是否已经安装或者名称填写错误。已跳过。")
continue
cls_type = platform_cls_map[platform['type']]
logger.info(f"尝试实例化 {platform['type']}({platform['id']}) 平台适配器 ...")
inst = cls_type(platform, self.settings, self.event_queue)
self.platform_insts.append(inst)
asyncio.create_task(self._task_wrapper(asyncio.create_task(inst.run(), name=platform_config['id'] + "_platform")))
self.platform_insts.append(WebChatAdapter({}, self.settings, self.event_queue))
async def _task_wrapper(self, task: asyncio.Task):
try:
await task
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"------- 任务 {task.get_name()} 发生错误: {e}")
for line in traceback.format_exc().split("\n"):
logger.error(f"| {line}")
logger.error("-------")
async def reload(self, platform_config: dict):
# 还未实现完成,不要调用此方法
if platform_config['id'] in self._inst_map:
# 正在运行
if getattr(self._inst_map[platform_config['id']], 'terminate', None):
logger.info(f"正在尝试终止 {platform_config['id']} 平台适配器 ...")
await self._inst_map[platform_config['id']].terminate()
logger.info(f"{platform_config['id']} 平台适配器已终止。")
del self._inst_map[platform_config['id']]
self.platform_insts.remove(self._inst_map[platform_config['id']])
else:
logger.warning(f"可能无法正常终止 {platform_config['id']} 平台适配器。")
# 再启动新的实例
await self.load_platform(platform_config)
else:
# 先将 _inst_map 中在 platform_config 中不存在的实例删除
config_ids = [platform['id'] for platform in self.platforms_config]
for key in list(self._inst_map.keys()):
if key not in config_ids:
if getattr(self._inst_map[key], 'terminate', None):
logger.info(f"正在尝试终止 {key} 平台适配器 ...")
await self._inst_map[key].terminate()
logger.info(f"{key} 平台适配器已终止。")
del self._inst_map[key]
self.platform_insts.remove(self._inst_map[key])
else:
logger.warning(f"可能无法正常终止 {key} 平台适配器。")
# 再启动新的实例
await self.load_platform(platform_config)
def get_insts(self):
return self.platform_insts
+6
View File
@@ -20,6 +20,12 @@ class Platform(abc.ABC):
'''
raise NotImplementedError
async def terminate(self):
'''
终止一个平台的运行实例。
'''
pass
@abc.abstractmethod
def meta(self) -> PlatformMetadata:
'''
@@ -1,7 +1,7 @@
import os
import asyncio
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import Plain, Image, Record, At
from astrbot.api.message_components import Plain, Image, Record, At, Node, Nodes
from aiocqhttp import CQHttp
from astrbot.core.utils.io import file_to_base64, download_image_by_url
@@ -18,7 +18,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
d = segment.toDict()
if isinstance(segment, Plain):
d['type'] = 'text'
if isinstance(segment, (Image, Record)):
elif isinstance(segment, (Image, Record)):
# convert to base64
if segment.file and segment.file.startswith("file:///"):
bs64_data = file_to_base64(segment.file[8:])
@@ -26,12 +26,14 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
elif segment.file and segment.file.startswith("http"):
image_file_path = await download_image_by_url(segment.file)
bs64_data = file_to_base64(image_file_path)
elif segment.file and segment.file.startswith("base64://"):
bs64_data = segment.file
else:
bs64_data = file_to_base64(segment.file)
d['data'] = {
'file': bs64_data,
}
if isinstance(segment, At):
elif isinstance(segment, At):
d['data'] = {
'qq': str(segment.qq) # 转换为字符串
}
@@ -40,7 +42,29 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
async def send(self, message: MessageChain):
ret = await AiocqhttpMessageEvent._parse_onebot_json(message)
if os.environ.get('TEST_MODE', 'off') == 'on':
return
await self.bot.send(self.message_obj.raw_message, ret)
send_one_by_one = False
for seg in message.chain:
if isinstance(seg, (Node, Nodes)):
# 转发消息不能和普通消息混在一起发送
send_one_by_one = True
break
if send_one_by_one:
for seg in message.chain:
if isinstance(seg, Nodes):
# 带有多个节点的合并转发消息
payload = seg.toDict()
if self.get_group_id():
payload['group_id'] = self.get_group_id()
await self.bot.call_action('send_group_forward_msg', **payload)
else:
payload['user_id'] = self.get_sender_id()
await self.bot.call_action('send_private_forward_msg', **payload)
else:
await self.bot.send(self.message_obj.raw_message, await AiocqhttpMessageEvent._parse_onebot_json(MessageChain([seg])))
await asyncio.sleep(0.5)
else:
await self.bot.send(self.message_obj.raw_message, ret)
await super().send(message)
@@ -2,6 +2,7 @@ import os
import time
import asyncio
import logging
import uuid
from typing import Awaitable, Any
from aiocqhttp import CQHttp, Event
from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, MessageType, PlatformMetadata
@@ -15,7 +16,7 @@ from ...register import register_platform_adapter
from aiocqhttp.exceptions import ActionFailed
from astrbot.core.utils.io import download_file
@register_platform_adapter("aiocqhttp", "适用于 OneBot 标准的消息平台适配器,支持反向 WebSockets。")
@register_platform_adapter("aiocqhttp", "适用于 OneBot V11 标准的消息平台适配器,支持反向 WebSockets。")
class AiocqhttpAdapter(Platform):
def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None:
super().__init__(event_queue)
@@ -31,6 +32,8 @@ class AiocqhttpAdapter(Platform):
"适用于 OneBot 标准的消息平台适配器,支持反向 WebSockets。",
)
self.stop = False
async def send_by_session(self, session: MessageSesion, message_chain: MessageChain):
ret = await AiocqhttpMessageEvent._parse_onebot_json(message_chain)
match session.message_type.value:
@@ -46,19 +49,82 @@ class AiocqhttpAdapter(Platform):
await super().send_by_session(session, message_chain)
async def convert_message(self, event: Event) -> AstrBotMessage:
logger.debug(f"[aiocqhttp] RawMessage {event}")
if event['post_type'] == 'message':
abm = await self._convert_handle_message_event(event)
elif event['post_type'] == 'notice':
abm = await self._convert_handle_notice_event(event)
elif event['post_type'] == 'request':
abm = await self._convert_handle_request_event(event)
return abm
async def _convert_handle_request_event(self, event: Event) -> AstrBotMessage:
'''OneBot V11 请求类事件'''
abm = AstrBotMessage()
abm.self_id = str(event.self_id)
abm.tag = "aiocqhttp"
abm.sender = MessageMember(
user_id=event.user_id,
nickname=event.user_id
)
abm.type = MessageType.OTHER_MESSAGE
if 'group_id' in event and event['group_id']:
abm.type = MessageType.GROUP_MESSAGE
abm.group_id = str(event.group_id)
else:
abm.type = MessageType.FRIEND_MESSAGE
if self.unique_session and abm.type == MessageType.GROUP_MESSAGE:
abm.session_id = abm.sender.user_id + "_" + str(event.group_id)
abm.message_str = ''
abm.message = []
abm.timestamp = int(time.time())
abm.message_id = uuid.uuid4().hex
abm.raw_message = event
return abm
async def _convert_handle_notice_event(self, event: Event) -> AstrBotMessage:
'''OneBot V11 通知类事件'''
abm = AstrBotMessage()
abm.self_id = str(event.self_id)
abm.sender = MessageMember(
user_id=event.user_id,
nickname=event.user_id
)
abm.type = MessageType.OTHER_MESSAGE
if 'group_id' in event and event['group_id']:
abm.group_id = str(event.group_id)
abm.type = MessageType.GROUP_MESSAGE
else:
abm.type = MessageType.FRIEND_MESSAGE
if self.unique_session and abm.type == MessageType.GROUP_MESSAGE:
abm.session_id = abm.sender.user_id + "_" + str(event.group_id) # 也保留群组 id
else:
abm.session_id = str(event.group_id) if abm.type == MessageType.GROUP_MESSAGE else abm.sender.user_id
abm.message_str = ""
abm.message = []
abm.raw_message = event
abm.timestamp = int(time.time())
abm.message_id = uuid.uuid4().hex
abm.sender = MessageMember(str(event.sender['user_id']), event.sender['nickname'])
if 'sub_type' in event:
if event['sub_type'] == 'poke' and 'target_id' in event:
abm.message.append(Poke(qq=str(event['target_id']), type='poke')) # noqa: F405
return abm
async def _convert_handle_message_event(self, event: Event) -> AstrBotMessage:
'''OneBot V11 消息类事件'''
abm = AstrBotMessage()
abm.self_id = str(event.self_id)
abm.sender = MessageMember(str(event.sender['user_id']), event.sender['nickname'])
if event['message_type'] == 'group':
abm.type = MessageType.GROUP_MESSAGE
abm.group_id = str(event.group_id)
elif event['message_type'] == 'private':
abm.type = MessageType.FRIEND_MESSAGE
if self.unique_session:
if self.unique_session and abm.type == MessageType.GROUP_MESSAGE:
abm.session_id = abm.sender.user_id + "_" + str(event.group_id) # 也保留群组 id
else:
abm.session_id = str(event.group_id) if abm.type == MessageType.GROUP_MESSAGE else abm.sender.user_id
@@ -75,14 +141,18 @@ class AiocqhttpAdapter(Platform):
except BaseException as e:
logger.error(f"回复消息失败: {e}")
return
logger.debug(f"aiocqhttp: 收到消息: {event.message}")
# 按消息段类型类型适配
for m in event.message:
t = m['type']
a = None
if t == 'text':
message_str += m['data']['text'].strip()
a = ComponentTypes[t](**m['data']) # noqa: F405
abm.message.append(a)
elif t == 'file':
if m['data']['url'] and m['data']['url'].startswith("http"):
if m['data'].get('url') and m['data'].get('url').startswith("http"):
# Lagrange
logger.info("guessing lagrange")
@@ -94,6 +164,8 @@ class AiocqhttpAdapter(Platform):
"file": path,
"name": file_name
}
a = ComponentTypes[t](**m['data']) # noqa: F405
abm.message.append(a)
else:
try:
@@ -108,16 +180,21 @@ class AiocqhttpAdapter(Platform):
"file": ret['file'],
"name": ret['file_name']
}
a = ComponentTypes[t](**m['data']) # noqa: F405
abm.message.append(a)
except ActionFailed as e:
logger.error(f"获取文件失败: {e},此消息段将被忽略。")
except BaseException as e:
logger.error(f"获取文件失败: {e},此消息段将被忽略。")
a = ComponentTypes[t](**m['data']) # noqa: F405
abm.message.append(a)
else:
a = ComponentTypes[t](**m['data']) # noqa: F405
abm.message.append(a)
abm.timestamp = int(time.time())
abm.message_str = message_str
abm.raw_message = event
return abm
def run(self) -> Awaitable[Any]:
@@ -127,6 +204,19 @@ class AiocqhttpAdapter(Platform):
self.port = 6199
self.bot = CQHttp(use_ws_reverse=True, import_name='aiocqhttp', api_timeout_sec=180)
@self.bot.on_request()
async def request(event: Event):
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
@self.bot.on_notice()
async def notice(event: Event):
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
@self.bot.on_message('group')
async def group(event: Event):
abm = await self.convert_message(event)
@@ -151,11 +241,15 @@ class AiocqhttpAdapter(Platform):
return bot
async def terminate(self):
self.stop = True
await asyncio.sleep(1)
def meta(self) -> PlatformMetadata:
return self.metadata
async def shutdown_trigger_placeholder(self):
while not self._event_queue.closed:
while not self._event_queue.closed and not self.stop:
await asyncio.sleep(1)
logger.info("aiocqhttp 适配器已关闭。")
@@ -169,4 +263,4 @@ class AiocqhttpAdapter(Platform):
bot=self.bot
)
self.commit_event(message_event)
self.commit_event(message_event)
+105 -22
View File
@@ -4,7 +4,8 @@ import aiohttp
import quart
import base64
import datetime
import re
import os
from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType
from astrbot.api.message_components import Plain, Image, At, Record
from astrbot.api import logger, sp
@@ -51,6 +52,10 @@ class SimpleGewechatClient():
self.event_queue = event_queue
self.multimedia_downloader = None
self.userrealnames = {}
self.stop = False
async def get_token_id(self):
async with aiohttp.ClientSession() as session:
@@ -100,7 +105,8 @@ class SimpleGewechatClient():
content = _t[1]
if '\u2005' in content:
# at
content = content.split('\u2005')[1]
# content = content.split('\u2005')[1]
content = re.sub(r'@[^\u2005]*\u2005', '', content)
abm.group_id = from_user_name
# at
msg_source = d['MsgSource']
@@ -117,9 +123,25 @@ class SimpleGewechatClient():
if at_me:
abm.message.insert(0, At(qq=abm.self_id))
user_real_name = d.get('PushContent', 'unknown : ').split(' : ')[0] \
.replace('在群聊中@了你', '') \
.replace('在群聊中发了一段语音', '') # 真实昵称
# 解析用户真实名字
user_real_name = "unknown"
if abm.group_id:
if abm.group_id not in self.userrealnames or user_id not in self.userrealnames[abm.group_id]:
# 获取群成员列表,并且缓存
if abm.group_id not in self.userrealnames:
self.userrealnames[abm.group_id] = {}
member_list = await self.get_chatroom_member_list(abm.group_id)
logger.debug(f"获取到 {abm.group_id} 的群成员列表。")
if member_list and 'memberList' in member_list:
for member in member_list['memberList']:
self.userrealnames[abm.group_id][member['wxid']] = member['nickName']
if user_id in self.userrealnames[abm.group_id]:
user_real_name = self.userrealnames[abm.group_id][user_id]
else:
user_real_name = self.userrealnames[abm.group_id][user_id]
else:
user_real_name = d.get('PushContent', 'unknown : ').split(' : ')[0]
abm.sender = MessageMember(user_id, user_real_name)
abm.raw_message = d
abm.message_str = ""
@@ -153,12 +175,11 @@ class SimpleGewechatClient():
with open(file_path, "wb") as f:
f.write(voice_data)
abm.message.append(Record(file=file_path, url=file_path))
case _:
logger.info(f"未实现的消息类型: {d['MsgType']}")
return
abm.raw_message = d
logger.info(f"abm: {abm}")
logger.debug(f"abm: {abm}")
return abm
async def callback(self):
@@ -201,7 +222,7 @@ class SimpleGewechatClient():
logger.info(f"设置回调结果: {json_blob}")
if json_blob['ret'] != 200:
raise Exception(f"设置回调失败: {json_blob}")
logger.info(f"将在 {self.callback_url} 上接收 gewechat 下发的消息。如果一直没收到消息请先尝试重启 AstrBot。")
logger.info(f"将在 {self.callback_url} 上接收 gewechat 下发的消息。如果一直没收到消息请先尝试重启 AstrBot。如果仍没收到请到管理面板聊天页输入 /gewe_logout 重新登录。")
async def start_polling(self):
threading.Thread(target=asyncio.run, args=(self._set_callback_url(),)).start()
@@ -212,7 +233,7 @@ class SimpleGewechatClient():
)
async def shutdown_trigger_placeholder(self):
while not self.event_queue.closed:
while not self.event_queue.closed and not self.stop:
await asyncio.sleep(1)
logger.info("gewechat 适配器已关闭。")
@@ -284,8 +305,25 @@ class SimpleGewechatClient():
"uuid": qr_uuid,
"appId": appid
})
verify_flag = False
while retry_cnt > 0:
retry_cnt -= 1
# 需要验证码
if verify_flag or os.path.exists("data/temp/gewe_code"):
with open("data/temp/gewe_code", "r") as f:
code = f.read().strip()
if not code:
logger.warning("未找到验证码,请在管理面板聊天页输入 /gewe_code 验证码 来验证,如 /gewe_code 123456")
await asyncio.sleep(5)
continue
payload['captchCode'] = code
logger.info(f"使用验证码: {code}")
try:
os.remove("data/temp/gewe_code")
except:
logger.warning("删除验证码文件 data/temp/gewe_code 失败。")
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/login/checkLogin",
@@ -294,17 +332,26 @@ class SimpleGewechatClient():
) as resp:
json_blob = await resp.json()
logger.info(f"检查登录状态: {json_blob}")
status = json_blob['data']['status']
nickname = json_blob['data'].get('nickName', '')
if status == 1:
logger.info(f"等待确认...{nickname}")
elif status == 2:
logger.info(f"绿泡泡平台登录成功: {nickname}")
break
elif status == 0:
logger.info("等待扫码...")
ret = json_blob['ret']
msg = ''
if json_blob['data'] and 'msg' in json_blob['data']:
msg = json_blob['data']['msg']
if ret == 500 and '安全验证码' in msg:
logger.warning("此次登录需要安全验证码,请在管理面板聊天页输入 /gewe_code 验证码 来验证,如 /gewe_code 123456")
verify_flag = True
else:
logger.warning(f"未知状态: {status}")
status = json_blob['data']['status']
nickname = json_blob['data'].get('nickName', '')
if status == 1:
logger.info(f"等待确认...{nickname}")
elif status == 2:
logger.info(f"绿泡泡平台登录成功: {nickname}")
break
elif status == 0:
logger.info("等待扫码...")
else:
logger.warning(f"未知状态: {status}")
await asyncio.sleep(5)
if appid:
@@ -312,12 +359,31 @@ class SimpleGewechatClient():
self.appid = appid
logger.info(f"已保存 APPID: {appid}")
async def post_text(self, to_wxid, content: str):
'''API'''
async def get_chatroom_member_list(self, chatroom_wxid: str):
payload = {
"appId": self.appid,
"chatroomId": chatroom_wxid
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/group/getChatroomMemberList",
headers=self.headers,
json=payload
) as resp:
json_blob = await resp.json()
return json_blob['data']
async def post_text(self, to_wxid, content: str, ats: str = ""):
payload = {
"appId": self.appid,
"toWxid": to_wxid,
"content": content,
}
if ats:
payload['ats'] = ats
async with aiohttp.ClientSession() as session:
async with session.post(
@@ -361,4 +427,21 @@ class SimpleGewechatClient():
json=payload
) as resp:
json_blob = await resp.json()
logger.debug(f"发送语音结果: {json_blob}")
logger.debug(f"发送语音结果: {json_blob}")
async def post_file(self, to_wxid, file_url: str, file_name: str):
payload = {
"appId": self.appid,
"toWxid": to_wxid,
"fileUrl": file_url,
"fileName": file_name
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/message/postFile",
headers=self.headers,
json=payload
) as resp:
json_blob = await resp.json()
logger.debug(f"发送文件结果: {json_blob}")
@@ -1,12 +1,13 @@
import wave
import uuid
import traceback
import os
from astrbot.core.utils.io import save_temp_img, download_image_by_url, 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
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image, Record
from astrbot.api.message_components import Plain, Image, Record, At, File
from .client import SimpleGewechatClient
def get_wav_duration(file_path):
@@ -15,6 +16,8 @@ def get_wav_duration(file_path):
n_channels, sampwidth, framerate, n_frames = wav_file.getparams()[:4]
if n_frames == 2147483647:
duration = (file_size - 44) / (n_channels * sampwidth * framerate)
elif n_frames == 0:
duration = (file_size - 44) / (n_channels * sampwidth * framerate)
else:
duration = n_frames / float(framerate)
return duration
@@ -43,9 +46,31 @@ class GewechatPlatformEvent(AstrMessageEvent):
logger.error("无法获取到 to_wxid。")
return
# 检查@
ats = []
ats_names = []
for comp in message.chain:
if isinstance(comp, At):
ats.append(comp.qq)
ats_names.append(comp.name)
has_at = False
for comp in message.chain:
if isinstance(comp, Plain):
await self.client.post_text(to_wxid, comp.text)
text = comp.text
payload = {
"to_wxid": to_wxid,
"content": text,
}
if not has_at and ats:
ats = f"{','.join(ats)}"
ats_names = f"@{' @'.join(ats_names)}"
text = f"{ats_names} {text}"
payload["content"] = text
payload["ats"] = ats
has_at = True
await self.client.post_text(**payload)
elif isinstance(comp, Image):
img_url = comp.file
img_path = ""
@@ -80,23 +105,35 @@ class GewechatPlatformEvent(AstrMessageEvent):
record_path = record_url
silk_path = f"data/temp/{uuid.uuid4()}.silk"
duration = await wav_to_tencent_silk(record_path, silk_path)
print(f"duration: {duration}, {silk_path}")
# 检查 record_path 是否在 data/temp 目录中, record_path 可能是绝对路径
# temp_directory = os.path.abspath('data/temp')
# record_path = os.path.abspath(record_path)
# if os.path.commonpath([temp_directory, record_path]) != temp_directory:
# with open(record_path, "rb") as f:
# record_path = f"data/temp/{uuid.uuid4()}.wav"
# with open(record_path, "wb") as f2:
# f2.write(f.read())
try:
duration = await wav_to_tencent_silk(record_path, silk_path)
except Exception as e:
logger.error(traceback.format_exc())
await self.send(MessageChain().message(f"语音文件转换失败。{str(e)}"))
logger.info("Silk 语音文件格式转换至: " + record_path)
if duration == 0:
duration = get_wav_duration(record_path)
file_id = os.path.basename(silk_path)
record_url = f"{self.client.file_server_url}/{file_id}"
logger.debug(f"gewe callback record url: {record_url}")
await self.client.post_voice(to_wxid, record_url, duration*1000)
elif isinstance(comp, File):
file_path = comp.file
file_name = comp.name
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}")
else:
file_path = file_path
file_id = os.path.basename(file_path)
file_url = f"{self.client.file_server_url}/{file_id}"
logger.debug(f"gewe callback file url: {file_url}")
await self.client.post_file(to_wxid, file_url, file_id)
elif isinstance(comp, At):
pass
else:
logger.debug(f"gewechat 忽略: {comp.type}")
await super().send(message)
@@ -30,10 +30,6 @@ class GewechatPlatformAdapter(Platform):
@override
async def send_by_session(self, session: MessageSesion, message_chain: MessageChain):
to_wxid = session.session_id
if "_" in to_wxid:
# 群聊,开启了独立会话
_, to_wxid = to_wxid.split("_")
if not to_wxid:
logger.error("无法获取到 to_wxid。")
return
@@ -51,6 +47,10 @@ class GewechatPlatformAdapter(Platform):
"基于 gewechat 的 Wechat 适配器",
)
async def terminate(self):
self.client.stop = True
await asyncio.sleep(1)
@override
def run(self):
self.client = SimpleGewechatClient(
@@ -0,0 +1,175 @@
import base64
import time
import asyncio
import json
import re
from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, MessageType, PlatformMetadata
from astrbot.api.event import MessageChain
from typing import Union, List
from astrbot.api.message_components import Image, Plain, At
from astrbot.core.platform.astr_message_event import MessageSesion
from .lark_event import LarkMessageEvent
from ...register import register_platform_adapter
from astrbot.core.message.components import BaseMessageComponent
from astrbot import logger
import lark_oapi as lark
from lark_oapi.api.im.v1 import *
@register_platform_adapter("lark", "飞书机器人官方 API 适配器")
class LarkPlatformAdapter(Platform):
def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None:
super().__init__(event_queue)
self.config = platform_config
self.unique_session = platform_settings['unique_session']
self.appid = platform_config['app_id']
self.appsecret = platform_config['app_secret']
self.domain = platform_config.get('domain', lark.FEISHU_DOMAIN)
self.bot_name = platform_config.get('lark_bot_name', "astrbot")
if not self.bot_name:
logger.warning("未设置飞书机器人名称,@ 机器人可能得不到回复。")
async def on_msg_event_recv(event: lark.im.v1.P2ImMessageReceiveV1):
await self.convert_msg(event)
def do_v2_msg_event(event: lark.im.v1.P2ImMessageReceiveV1):
asyncio.create_task(on_msg_event_recv(event))
self.event_handler = lark.EventDispatcherHandler.builder("", "") \
.register_p2_im_message_receive_v1(do_v2_msg_event) \
.build()
self.client = lark.ws.Client(
app_id=self.appid,
app_secret=self.appsecret,
log_level=lark.LogLevel.ERROR,
domain=self.domain,
event_handler=self.event_handler
)
self.lark_api = (
lark.Client.builder()
.app_id(self.appid)
.app_secret(self.appsecret)
.build()
)
async def send_by_session(self, session: MessageSesion, message_chain: MessageChain):
raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session")
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
"lark",
"飞书机器人官方 API 适配器",
)
async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1):
message = event.event.message
abm = AstrBotMessage()
abm.timestamp = int(message.create_time) / 1000
abm.message = []
abm.type = MessageType.GROUP_MESSAGE if message.chat_type == 'group' else MessageType.FRIEND_MESSAGE
if message.chat_type == 'group':
abm.group_id = message.chat_id
abm.self_id = self.bot_name
abm.message_str = ""
at_list = {}
if message.mentions:
for m in message.mentions:
at_list[m.key] = At(qq=m.id.open_id, name=m.name)
if m.name == self.bot_name:
abm.self_id = m.id.open_id
content_json_b = json.loads(message.content)
if message.message_type == 'text':
message_str_raw = content_json_b['text'] # 带有 @ 的消息
at_pattern = r"(@_user_\d+)" # 可以根据需求修改正则
at_users = re.findall(at_pattern, message_str_raw)
# 拆分文本,去掉AT符号部分
parts = re.split(at_pattern, message_str_raw)
for i in range(len(parts)):
s = parts[i].strip()
if not s:
continue
if s in at_list:
abm.message.append(at_list[s])
else:
abm.message.append(Plain(parts[i].strip()))
elif message.message_type == 'post':
_ls = []
content_ls = content_json_b.get('content', [])
for comp in content_ls:
if isinstance(comp, list):
_ls.extend(comp)
elif isinstance(comp, dict):
_ls.append(comp)
content_json_b = _ls
elif message.message_type == 'image':
content_json_b = [
{"tag": "img", "image_key": content_json_b["image_key"], "style": []}
]
if message.message_type in ('post', 'image'):
for comp in content_json_b:
if comp['tag'] == 'at':
abm.message.append(at_list[comp['user_id']])
elif comp['tag'] == 'text' and comp['text'].strip():
abm.message.append(Plain(comp['text'].strip()))
elif comp['tag'] == 'img':
image_key = comp['image_key']
request = GetMessageResourceRequest.builder() \
.message_id(message.message_id) \
.file_key(image_key) \
.type("image") \
.build()
response = await self.lark_api.im.v1.message_resource.aget(request)
if not response.success():
logger.error(f"无法下载飞书图片: {image_key}")
image_bytes = response.file.read()
image_base64 = base64.b64encode(image_bytes).decode()
abm.message.append(Image.fromBase64(image_base64))
for comp in abm.message:
if isinstance(comp, Plain):
abm.message_str += comp.text
abm.message_id = message.message_id
abm.raw_message = message
abm.sender = MessageMember(
user_id=event.event.sender.sender_id.open_id,
nickname=event.event.sender.sender_id.open_id[:8]
)
# 独立会话
if not self.unique_session:
if abm.type == MessageType.GROUP_MESSAGE:
abm.session_id = abm.group_id
else:
abm.session_id = abm.sender.user_id
else:
abm.session_id = abm.sender.user_id
logger.debug(abm)
await self.handle_msg(abm)
async def handle_msg(self, abm: AstrBotMessage):
event = LarkMessageEvent(
message_str=abm.message_str,
message_obj=abm,
platform_meta=self.meta(),
session_id=abm.session_id,
bot=self.lark_api
)
self._event_queue.put_nowait(event)
async def run(self):
# self.client.start()
await self.client._connect()
@@ -0,0 +1,96 @@
import json
import uuid
import lark_oapi as lark
from typing import List
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import Plain, Image as AstrBotImage, Record, At, Node, Music, Video
from astrbot.core.utils.io import file_to_base64, download_image_by_url
from lark_oapi.api.im.v1 import *
from astrbot import logger
class LarkMessageEvent(AstrMessageEvent):
def __init__(self, message_str, message_obj, platform_meta, session_id, bot: lark.Client):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.bot = bot
@staticmethod
async def _convert_to_lark(message: MessageChain, lark_client: lark.Client) -> List:
ret = []
_stage = []
for comp in message.chain:
if isinstance(comp, Plain):
_stage.append({
"tag": "md",
"text": comp.text
})
elif isinstance(comp, At):
_stage.append({
"tag": "at",
"user_id": comp.qq,
"style": []
})
elif isinstance(comp, AstrBotImage):
file_path = ""
if comp.file and comp.file.startswith("file:///"):
file_path = comp.file.replace('file:///', '')
elif comp.file and comp.file.startswith("http"):
image_file_path = await download_image_by_url(comp.file)
file_path = image_file_path
elif comp.file and comp.file.startswith("base64://"):
pass
else:
file_path = comp.file
request = CreateImageRequest.builder() \
.request_body( \
CreateImageRequestBody.builder() \
.image_type("message") \
.image(open(file_path, 'rb')) \
.build() \
) \
.build()
response = await lark_client.im.v1.image.acreate(request)
if not response.success():
logger.error(f"无法上传飞书图片({response.code}): {response.msg}")
image_key = response.data.image_key
print(image_key)
ret.append(_stage)
ret.append([{
"tag": "img",
"image_key": image_key
}])
_stage.clear()
else:
logger.warning(f"飞书 暂时不支持消息段: {comp.type}")
if _stage:
ret.append(_stage)
return ret
async def send(self, message: MessageChain):
res = await LarkMessageEvent._convert_to_lark(message, self.bot)
wrapped = {
"zh_cn": {
"title": "",
"content": res,
}
}
request = ReplyMessageRequest.builder() \
.message_id(self.message_obj.message_id) \
.request_body( \
ReplyMessageRequestBody.builder() \
.content(json.dumps(wrapped)) \
.msg_type("post") \
.uuid(str(uuid.uuid4())) \
.reply_in_thread(False) \
.build() \
) \
.build()
response = await self.bot.im.v1.message.areply(request)
if not response.success():
logger.error(f"回复飞书消息失败({response.code}): {response.msg}")
await super().send(message)
@@ -5,9 +5,10 @@ import botpy.types.message
from astrbot.core.utils.io import file_to_base64, download_image_by_url
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image, Reply
from astrbot.api.message_components import Plain, Image
from botpy import Client
from botpy.http import Route
from astrbot.api import logger
class QQOfficialMessageEvent(AstrMessageEvent):
@@ -29,18 +30,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
plain_text, image_base64, image_path = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer)
ref = None
for i in self.send_buffer.chain:
if isinstance(i, Reply):
try:
ref = self.message_obj.raw_message.message_reference
ref = botpy.types.message.Reference(
message_id=ref.message_id,
ignore_get_message_error=False
)
except BaseException as _:
pass
break
if not plain_text and not image_base64 and not image_path:
return
payload = {
'content': plain_text,
@@ -49,30 +40,22 @@ class QQOfficialMessageEvent(AstrMessageEvent):
match type(source):
case botpy.message.GroupMessage:
if ref:
payload['message_reference'] = ref
if image_base64:
media = await self.upload_group_and_c2c_image(image_base64, 1, group_openid=source.group_openid)
payload['media'] = media
payload['msg_type'] = 7
await self.bot.api.post_group_message(group_openid=source.group_openid, **payload)
case botpy.message.C2CMessage:
if ref:
payload['message_reference'] = ref
if image_base64:
media = await self.upload_group_and_c2c_image(image_base64, 1, openid=source.author.user_openid)
payload['media'] = media
payload['msg_type'] = 7
await self.bot.api.post_c2c_message(openid=source.author.user_openid, **payload)
case botpy.message.Message:
if ref:
payload['message_reference'] = ref
if image_path:
payload['file_image'] = image_path
await self.bot.api.post_message(channel_id=source.channel_id, **payload)
case botpy.message.DirectMessage:
if ref:
payload['message_reference'] = ref
if image_path:
payload['file_image'] = image_path
await self.bot.api.post_dms(guild_id=source.guild_id, **payload)
@@ -114,4 +97,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
else:
image_base64 = file_to_base64(i.file).replace("base64://", "")
image_file_path = i.file
else:
logger.debug(f"qq_official 忽略 {i.type}")
return plain_text, image_base64, image_file_path
@@ -1,3 +1,5 @@
from __future__ import annotations
import botpy
import logging
import time
@@ -28,25 +30,25 @@ class botClient(Client):
# 收到群消息
async def on_group_at_message_create(self, message: botpy.message.GroupMessage):
abm = self.platform._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE)
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE)
abm.session_id = abm.sender.user_id if self.platform.unique_session else message.group_openid
self._commit(abm)
# 收到频道消息
async def on_at_message_create(self, message: botpy.message.Message):
abm = self.platform._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE)
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE)
abm.session_id = abm.sender.user_id if self.platform.unique_session else message.channel_id
self._commit(abm)
# 收到私聊消息
async def on_direct_message_create(self, message: botpy.message.DirectMessage):
abm = self.platform._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE)
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE)
abm.session_id = abm.sender.user_id
self._commit(abm)
# 收到 C2C 消息
async def on_c2c_message_create(self, message: botpy.message.C2CMessage):
abm = self.platform._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE)
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE)
abm.session_id = abm.sender.user_id
self._commit(abm)
@@ -102,7 +104,8 @@ class QQOfficialPlatformAdapter(Platform):
"QQ 机器人官方 API 适配器",
)
def _parse_from_qqofficial(self, message: Union[botpy.message.Message, botpy.message.GroupMessage],
@staticmethod
def _parse_from_qqofficial(message: Union[botpy.message.Message, botpy.message.GroupMessage],
message_type: MessageType):
abm = AstrBotMessage()
abm.type = message_type
@@ -0,0 +1,99 @@
import botpy
import logging
import asyncio
import botpy.message
import botpy.types
import botpy.types.message
from botpy import Client
from astrbot.api.platform import Platform, AstrBotMessage, MessageType, PlatformMetadata
from astrbot.api.event import MessageChain
from astrbot.core.platform.astr_message_event import MessageSesion
from .qo_webhook_event import QQOfficialWebhookMessageEvent
from ...register import register_platform_adapter
from .qo_webhook_server import QQOfficialWebhook
from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter
# remove logger handler
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
# QQ 机器人官方框架
class botClient(Client):
def set_platform(self, platform: 'QQOfficialWebhookPlatformAdapter'):
self.platform = platform
# 收到群消息
async def on_group_at_message_create(self, message: botpy.message.GroupMessage):
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE)
abm.session_id = abm.sender.user_id if self.platform.unique_session else message.group_openid
self._commit(abm)
# 收到频道消息
async def on_at_message_create(self, message: botpy.message.Message):
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE)
abm.session_id = abm.sender.user_id if self.platform.unique_session else message.channel_id
self._commit(abm)
# 收到私聊消息
async def on_direct_message_create(self, message: botpy.message.DirectMessage):
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE)
abm.session_id = abm.sender.user_id
self._commit(abm)
# 收到 C2C 消息
async def on_c2c_message_create(self, message: botpy.message.C2CMessage):
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE)
abm.session_id = abm.sender.user_id
self._commit(abm)
def _commit(self, abm: AstrBotMessage):
self.platform.commit_event(QQOfficialWebhookMessageEvent(
abm.message_str,
abm,
self.platform.meta(),
abm.session_id,
self
))
@register_platform_adapter("qq_official_webhook", "QQ 机器人官方 API 适配器(Webhook)")
class QQOfficialWebhookPlatformAdapter(Platform):
def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None:
super().__init__(event_queue)
self.config = platform_config
self.appid = platform_config['appid']
self.secret = platform_config['secret']
self.unique_session = platform_settings['unique_session']
intents = botpy.Intents(
public_messages=True,
public_guild_messages=True,
direct_message=True
)
self.client = botClient(
intents=intents, # 已经无用
bot_log=False,
timeout=20,
)
self.client.set_platform(self)
async def send_by_session(self, session: MessageSesion, message_chain: MessageChain):
raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session")
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
"qq_official_webhook",
"QQ 机器人官方 API 适配器",
)
async def run(self):
self.webhook_helper = QQOfficialWebhook(
self.config,
self._event_queue,
self.client
)
await self.webhook_helper.initialize()
await self.webhook_helper.start_polling()
@@ -0,0 +1,18 @@
import botpy
import botpy.message
import botpy.types
import botpy.types.message
from astrbot.core.utils.io import file_to_base64, download_image_by_url
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image, Reply
from botpy import Client
from botpy.http import Route
from astrbot.api import logger
from ..qqofficial.qqofficial_message_event import QQOfficialMessageEvent
class QQOfficialWebhookMessageEvent(QQOfficialMessageEvent):
def __init__(self, message_str: str, message_obj: AstrBotMessage, platform_meta: PlatformMetadata, session_id: str, bot: Client):
super().__init__(message_str, message_obj, platform_meta, session_id, bot)
@@ -0,0 +1,108 @@
import aiohttp
import quart
import json
import logging
import asyncio
import typing
from botpy import BotAPI, BotHttp, Client, Token, BotWebSocket, ConnectionSession
from astrbot.api import logger
import traceback
from cryptography.hazmat.primitives.asymmetric import ed25519
# remove logger handler
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
class QQOfficialWebhook():
def __init__(self, config: dict, event_queue: asyncio.Queue, botpy_client: Client):
self.appid = config['appid']
self.secret = config['secret']
self.port = config.get("port", 6196)
if isinstance(self.port, str):
self.port = int(self.port)
self.http: BotHttp = BotHttp(timeout=300)
self.api: BotAPI = BotAPI(http=self.http)
self.token = Token(self.appid, self.secret)
self.server = quart.Quart(__name__)
self.server.add_url_rule('/astrbot-qo-webhook/callback', view_func=self.callback, methods=['POST'])
self.client = botpy_client
self.event_queue = event_queue
async def initialize(self):
logger.info(f"正在登录到 QQ 官方机器人...")
self.user = await self.http.login(self.token)
logger.info(f"已登录 QQ 官方机器人账号: {self.user}")
# 直接注入到 botpy 的 Client,移花接木!
self.client.api = self.api
self.client.http = self.http
async def bot_connect():
pass
self._connection = ConnectionSession(
max_async=1,
connect=bot_connect,
dispatch=self.client.ws_dispatch,
loop=asyncio.get_event_loop(),
api=self.api,
)
async def repeat_seed(self, bot_secret: str, target_size: int = 32) -> bytes:
seed = bot_secret
while len(seed) < target_size:
seed *= 2
return seed[:target_size].encode('utf-8')
async def webhook_validation(self, validation_payload: dict):
seed = await self.repeat_seed(self.secret)
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed)
msg = validation_payload.get("event_ts", "") + validation_payload.get("plain_token", "")
# sign
signature = private_key.sign(msg.encode()).hex()
response = {
"plain_token": validation_payload.get("plain_token"),
"signature": signature
}
return response
async def callback(self):
msg: dict = await quart.request.json
logger.debug(f"收到 qq_official_webhook 回调: {msg}")
event = msg.get("t")
opcode = msg.get("op")
data = msg.get("d")
if opcode == 13:
# validation
signed = await self.webhook_validation(data)
print(signed)
return signed
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
event = msg["t"].lower()
try:
func = self._connection.parser[event]
except KeyError:
logger.error("_parser unknown event %s.", event)
else:
func(msg)
return {"opcode": 12}
async def start_polling(self):
await self.server.run_task(
host='0.0.0.0',
port=self.port,
shutdown_trigger=self.shutdown_trigger_placeholder
)
async def shutdown_trigger_placeholder(self):
while not self.event_queue.closed:
await asyncio.sleep(1)
logger.info("qq_official_webhook 适配器已关闭。")
@@ -1,44 +0,0 @@
import random
import asyncio
from astrbot.core.utils.io import download_image_by_url
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image
from vchat import Core
class VChatPlatformEvent(AstrMessageEvent):
def __init__(self, message_str: str, message_obj: AstrBotMessage, platform_meta: PlatformMetadata, session_id: str, client: Core):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
@staticmethod
async def send_with_client(client: Core, message: MessageChain, user_name: str):
plain = ""
for comp in message.chain:
if isinstance(comp, Plain):
if message.is_split_:
await client.send_msg(comp.text, user_name)
else:
plain += comp.text
elif isinstance(comp, Image):
if comp.file and comp.file.startswith("file:///"):
file_path = comp.file.replace("file:///", "")
with open(file_path, "rb") as f:
await client.send_image(user_name, fd=f)
elif comp.file and comp.file.startswith("http"):
image_path = await download_image_by_url(comp.file)
with open(image_path, "rb") as f:
await client.send_image(user_name, fd=f)
else:
logger.error(f"不支持的 vchat(微信适配器) 消息类型: {comp}")
await asyncio.sleep(random.uniform(0.5, 1.5)) # 🤓
if plain:
await client.send_msg(plain, user_name)
async def send(self, message: MessageChain):
await VChatPlatformEvent.send_with_client(self.client, message, self.message_obj.raw_message.from_.username)
await super().send(message)
@@ -1,120 +0,0 @@
import sys
import time
import uuid
import asyncio
import os
from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, MessageType, PlatformMetadata
from astrbot.api.event import MessageChain
from astrbot.api.message_components import *
from astrbot.api import logger
from astrbot.core.platform.astr_message_event import MessageSesion
from .vchat_message_event import VChatPlatformEvent
from ...register import register_platform_adapter
from vchat import Core
from vchat import model
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
@register_platform_adapter("vchat", "基于 VChat 的 Wechat 适配器")
class VChatPlatformAdapter(Platform):
def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None:
super().__init__(event_queue)
self.config = platform_config
self.settingss = platform_settings
self.test_mode = os.environ.get('TEST_MODE', 'off') == 'on'
self.client_self_id = uuid.uuid4().hex[:8]
@override
async def send_by_session(self, session: MessageSesion, message_chain: MessageChain):
from_username = session.session_id.split('$$')[0]
await VChatPlatformEvent.send_with_client(self.client, message_chain, from_username)
await super().send_by_session(session, message_chain)
@override
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
"vchat",
"基于 VChat 的 Wechat 适配器",
)
@override
def run(self):
self.client = Core()
@self.client.msg_register(msg_types=model.ContentTypes.TEXT,
contact_type=model.ContactTypes.CHATROOM | model.ContactTypes.USER)
async def _(msg: model.Message):
if isinstance(msg.content, model.UselessContent):
return
if msg.create_time < self.start_time:
logger.debug(f"忽略旧消息: {msg}")
return
logger.debug(f"收到消息: {msg.todict()}")
abmsg = self.convert_message(msg)
# await self.handle_msg(abmsg) # 不能直接调用,否则会阻塞
asyncio.create_task(self.handle_msg(abmsg))
# TODO: 对齐微信服务器时间
self.start_time = int(time.time())
return self._run()
async def _run(self):
await self.client.init()
await self.client.auto_login(hot_reload=True, enable_cmd_qr=True)
await self.client.run()
def convert_message(self, msg: model.Message) -> AstrBotMessage:
# credits: https://github.com/z2z63/astrbot_plugin_vchat/blob/master/main.py#L49
assert isinstance(msg.content, model.TextContent)
amsg = AstrBotMessage()
amsg.message = [Plain(msg.content.content)]
amsg.self_id = self.client_self_id
if msg.content.is_at_me:
amsg.message.insert(0, At(qq=amsg.self_id))
sender = msg.chatroom_sender or msg.from_
amsg.sender = MessageMember(sender.username, sender.nickname)
if msg.content.is_at_me:
amsg.message_str = msg.content.content.split("\u2005")[1].strip()
else:
amsg.message_str = msg.content.content
amsg.message_id = msg.message_id
if isinstance(msg.from_, model.User):
amsg.type = MessageType.FRIEND_MESSAGE
elif isinstance(msg.from_, model.Chatroom):
amsg.type = MessageType.GROUP_MESSAGE
amsg.group_id = msg.from_.username
else:
logger.error(f"不支持的 Wechat 消息类型: {msg.from_}")
amsg.raw_message = msg
if self.settingss['unique_session']:
session_id = msg.from_.username + "$$" + msg.to.username
if msg.chatroom_sender is not None:
session_id += '$$' + msg.chatroom_sender.username
else:
session_id = msg.from_.username
amsg.session_id = session_id
return amsg
async def handle_msg(self, message: AstrBotMessage):
message_event = VChatPlatformEvent(
message_str=message.message_str,
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
client=self.client
)
logger.info(f"处理消息: {message_event}")
self.commit_event(message_event)
@@ -1,8 +1,10 @@
import os
import uuid
import base64
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import Plain, Image
from astrbot.core.utils.io import file_to_base64, download_image_by_url
from astrbot.core.utils.io import download_image_by_url
from astrbot.core import web_chat_back_queue
class WebChatMessageEvent(AstrMessageEvent):
@@ -30,6 +32,11 @@ class WebChatMessageEvent(AstrMessageEvent):
with open(path, "wb") as f:
with open(ph, "rb") as f2:
f.write(f2.read())
elif comp.file.startswith("base64://"):
base64_str = comp.file[9:]
image_data = base64.b64decode(base64_str)
with open(path, "wb") as f:
f.write(image_data)
elif comp.file and comp.file.startswith("http"):
await download_image_by_url(comp.file, path=path)
else:
@@ -37,5 +44,7 @@ class WebChatMessageEvent(AstrMessageEvent):
with open(comp.file, "rb") as f2:
f.write(f2.read())
web_chat_back_queue.put_nowait((f"[IMAGE]{filename}", cid))
else:
logger.debug(f"webchat 忽略: {comp.type}")
web_chat_back_queue.put_nowait(None)
await super().send(message)
+1 -1
View File
@@ -52,7 +52,7 @@ class ProviderRequest():
@dataclass
class LLMResponse:
role: str
'''角色'''
'''角色, assistant, tool, err'''
completion_text: str = ""
'''LLM 返回的文本'''
tools_call_args: List[Dict[str, any]] = field(default_factory=list)
@@ -102,6 +102,29 @@ class FuncCall:
)
return _l
def get_func_desc_anthropic_style(self) -> list:
"""
获得 Anthropic API 风格的**已经激活**的工具描述
"""
tools = []
for f in self.func_list:
if not f.active:
continue
# Convert internal format to Anthropic style
tool = {
"name": f.name,
"description": f.description,
"input_schema": {
"type": "object",
"properties": f.parameters.get("properties", {}),
# Keep the required field from the original parameters if it exists
"required": f.parameters.get("required", [])
}
}
tools.append(tool)
return tools
def get_func_desc_google_genai_style(self) -> Dict:
declarations = {}
tools = []
+148 -126
View File
@@ -1,11 +1,9 @@
import traceback
import uuid
from astrbot.core.config.astrbot_config import AstrBotConfig
from .provider import Provider, STTProvider, TTSProvider, Personality
from .entites import ProviderType
from typing import List
from astrbot.core.db import BaseDatabase
from collections import defaultdict
from .register import provider_cls_map, llm_tools
from astrbot.core import logger, sp
@@ -16,6 +14,14 @@ class ProviderManager():
self.provider_stt_settings: dict = config.get('provider_stt_settings', {})
self.provider_tts_settings: dict = config.get('provider_tts_settings', {})
self.persona_configs: list = config.get('persona', [])
self.astrbot_config = config
self.selected_provider_id = sp.get("curr_provider")
self.selected_stt_provider_id = self.provider_stt_settings.get("provider_id")
self.selected_tts_provider_id = self.provider_settings.get("provider_id")
self.provider_enabled = self.provider_settings.get("enable", False)
self.stt_enabled = self.provider_stt_settings.get("enable", False)
self.tts_enabled = self.provider_tts_settings.get("enable", False)
# 人格情景管理
# 目前没有拆成独立的模块
@@ -75,14 +81,15 @@ class ProviderManager():
_mood_imitation_dialogs_processed=""
)
self.personas.append(self.selected_default_persona)
self.provider_insts: List[Provider] = []
'''加载的 Provider 的实例'''
self.stt_provider_insts: List[STTProvider] = []
'''加载的 Speech To Text Provider 的实例'''
self.tts_provider_insts: List[TTSProvider] = []
'''加载的 Text To Speech Provider 的实例'''
self.inst_map = {}
'''Provider 实例映射. key: provider_id, value: Provider 实例'''
self.llm_tools = llm_tools
self.curr_provider_inst: Provider = None
'''当前使用的 Provider 实例'''
@@ -90,7 +97,6 @@ class ProviderManager():
'''当前使用的 Speech To Text Provider 实例'''
self.curr_tts_provider_inst: TTSProvider = None
'''当前使用的 Text To Speech Provider 实例'''
self.loaded_ids = defaultdict(bool)
self.db_helper = db_helper
# kdb(experimental)
@@ -99,141 +105,157 @@ class ProviderManager():
if kdb_cfg and len(kdb_cfg):
self.curr_kdb_name = list(kdb_cfg.keys())[0]
changed = False
for provider_cfg in self.providers_config:
if not provider_cfg['enable']:
continue
if provider_cfg['id'] in self.loaded_ids:
new_id = f"{provider_cfg['id']}_{str(uuid.uuid4())[:8]}"
logger.info(f"Provider ID 重复:{provider_cfg['id']}。已自动更改为 {new_id}")
provider_cfg['id'] = new_id
changed = True
self.loaded_ids[provider_cfg['id']] = True
try:
match provider_cfg['type']:
case "openai_chat_completion":
from .sources.openai_source import ProviderOpenAIOfficial as ProviderOpenAIOfficial
case "zhipu_chat_completion":
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "llm_tuner":
logger.info("加载 LLM Tuner 工具 ...")
from .sources.llmtuner_source import LLMTunerModelLoader as LLMTunerModelLoader
case "dify":
from .sources.dify_source import ProviderDify as ProviderDify
case "googlegenai_chat_completion":
from .sources.gemini_source import ProviderGoogleGenAI as ProviderGoogleGenAI
case "openai_whisper_api":
from .sources.whisper_api_source import ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI
case "openai_whisper_selfhost":
from .sources.whisper_selfhosted_source import ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost
case "openai_tts_api":
from .sources.openai_tts_api_source import ProviderOpenAITTSAPI as ProviderOpenAITTSAPI
case "fishaudio_tts_api":
from .sources.fishaudio_tts_api_source import ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI
except (ImportError, ModuleNotFoundError) as e:
logger.critical(f"加载 {provider_cfg['type']}({provider_cfg['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。")
continue
except Exception as e:
logger.critical(f"加载 {provider_cfg['type']}({provider_cfg['id']}) 提供商适配器失败:{e}。未知原因")
continue
if changed:
try:
config.save_config()
except Exception as e:
logger.warning(f"保存配置文件失败:{e}")
async def initialize(self):
selected_provider_id = sp.get("curr_provider")
selected_stt_provider_id = self.provider_stt_settings.get("provider_id")
selected_tts_provider_id = self.provider_settings.get("provider_id")
provider_enabled = self.provider_settings.get("enable", False)
stt_enabled = self.provider_stt_settings.get("enable", False)
tts_enabled = self.provider_tts_settings.get("enable", False)
for provider_config in self.providers_config:
if not provider_config['enable']:
continue
if provider_config['type'] not in provider_cls_map:
logger.error(f"未找到适用于 {provider_config['type']}({provider_config['id']}) 的提供商适配器,请检查是否已经安装或者名称填写错误。已跳过。")
continue
await self.load_provider(provider_config)
provider_metadata = provider_cls_map[provider_config['type']]
logger.info(f"尝试实例化 {provider_config['type']}({provider_config['id']}) 提供商适配器 ...")
try:
# 按任务实例化提供商
if provider_metadata.provider_type == ProviderType.SPEECH_TO_TEXT:
# STT 任务
inst = provider_metadata.cls_type(provider_config, self.provider_settings)
if getattr(inst, "initialize", None):
await inst.initialize()
self.stt_provider_insts.append(inst)
if selected_stt_provider_id == provider_config['id'] and stt_enabled:
self.curr_stt_provider_inst = inst
logger.info(f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前语音转文本提供商适配器。")
elif provider_metadata.provider_type == ProviderType.TEXT_TO_SPEECH:
# TTS 任务
inst = provider_metadata.cls_type(provider_config, self.provider_settings)
if getattr(inst, "initialize", None):
await inst.initialize()
self.tts_provider_insts.append(inst)
if selected_tts_provider_id == provider_config['id'] and tts_enabled:
self.curr_tts_provider_inst = inst
logger.info(f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前文本转语音提供商适配器。")
elif provider_metadata.provider_type == ProviderType.CHAT_COMPLETION:
# 文本生成任务
inst = provider_metadata.cls_type(
provider_config,
self.provider_settings,
self.db_helper,
self.provider_settings.get('persistant_history', True),
self.selected_default_persona
)
if getattr(inst, "initialize", None):
await inst.initialize()
self.provider_insts.append(inst)
if selected_provider_id == provider_config['id'] and provider_enabled:
self.curr_provider_inst = inst
logger.info(f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前提供商适配器。")
except Exception as e:
traceback.print_exc()
logger.error(f"实例化 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}")
if len(self.provider_insts) > 0 and not self.curr_provider_inst and provider_enabled:
self.curr_provider_inst = self.provider_insts[0]
if len(self.stt_provider_insts) > 0 and not self.curr_stt_provider_inst and stt_enabled:
self.curr_stt_provider_inst = self.stt_provider_insts[0]
if len(self.tts_provider_insts) > 0 and not self.curr_tts_provider_inst and tts_enabled:
self.curr_tts_provider_inst = self.tts_provider_insts[0]
if not self.curr_provider_inst:
logger.warning("未启用任何用于 文本生成 的提供商适配器。")
if stt_enabled and not self.curr_stt_provider_inst:
if self.stt_enabled and not self.curr_stt_provider_inst:
logger.warning("未启用任何用于 语音转文本 的提供商适配器。")
if tts_enabled and not self.curr_tts_provider_inst:
if self.tts_enabled and not self.curr_tts_provider_inst:
logger.warning("未启用任何用于 文本转语音 的提供商适配器。")
async def load_provider(self, provider_config: dict):
if not provider_config['enable']:
return
logger.info(f"载入 {provider_config['type']}({provider_config['id']}) 服务提供商适配器 ...")
# 动态导入
try:
match provider_config['type']:
case "openai_chat_completion":
from .sources.openai_source import ProviderOpenAIOfficial as ProviderOpenAIOfficial
case "zhipu_chat_completion":
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "anthropic_chat_completion":
from .sources.anthropic_source import ProviderAnthropic as ProviderAnthropic
case "llm_tuner":
logger.info("加载 LLM Tuner 工具 ...")
from .sources.llmtuner_source import LLMTunerModelLoader as LLMTunerModelLoader
case "dify":
from .sources.dify_source import ProviderDify as ProviderDify
case "dashscope":
from .sources.dashscope_source import ProviderDashscope as ProviderDashscope
case "googlegenai_chat_completion":
from .sources.gemini_source import ProviderGoogleGenAI as ProviderGoogleGenAI
case "openai_whisper_api":
from .sources.whisper_api_source import ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI
case "openai_whisper_selfhost":
from .sources.whisper_selfhosted_source import ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost
case "openai_tts_api":
from .sources.openai_tts_api_source import ProviderOpenAITTSAPI as ProviderOpenAITTSAPI
case "fishaudio_tts_api":
from .sources.fishaudio_tts_api_source import ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI
except (ImportError, ModuleNotFoundError) as e:
logger.critical(f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。")
return
except Exception as e:
logger.critical(f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。未知原因")
return
if provider_config['type'] not in provider_cls_map:
logger.error(f"未找到适用于 {provider_config['type']}({provider_config['id']}) 的提供商适配器,请检查是否已经安装或者名称填写错误。已跳过。")
return
provider_metadata = provider_cls_map[provider_config['type']]
try:
# 按任务实例化提供商
if provider_metadata.provider_type == ProviderType.SPEECH_TO_TEXT:
# STT 任务
inst = provider_metadata.cls_type(provider_config, self.provider_settings)
if getattr(inst, "initialize", None):
await inst.initialize()
self.stt_provider_insts.append(inst)
if self.selected_stt_provider_id == provider_config['id'] and self.stt_enabled:
self.curr_stt_provider_inst = inst
logger.info(f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前语音转文本提供商适配器。")
if not self.curr_stt_provider_inst and self.stt_enabled:
self.curr_stt_provider_inst = inst
elif provider_metadata.provider_type == ProviderType.TEXT_TO_SPEECH:
# TTS 任务
inst = provider_metadata.cls_type(provider_config, self.provider_settings)
if getattr(inst, "initialize", None):
await inst.initialize()
self.tts_provider_insts.append(inst)
if self.selected_tts_provider_id == provider_config['id'] and self.tts_enabled:
self.curr_tts_provider_inst = inst
logger.info(f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前文本转语音提供商适配器。")
if not self.curr_tts_provider_inst and self.tts_enabled:
self.curr_tts_provider_inst = inst
elif provider_metadata.provider_type == ProviderType.CHAT_COMPLETION:
# 文本生成任务
inst = provider_metadata.cls_type(
provider_config,
self.provider_settings,
self.db_helper,
self.provider_settings.get('persistant_history', True),
self.selected_default_persona
)
if getattr(inst, "initialize", None):
await inst.initialize()
self.provider_insts.append(inst)
if self.selected_provider_id == provider_config['id'] and self.provider_enabled:
self.curr_provider_inst = inst
logger.info(f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前提供商适配器。")
if not self.curr_provider_inst and self.provider_enabled:
self.curr_provider_inst = inst
self.inst_map[provider_config['id']] = inst
except Exception as e:
traceback.print_exc()
logger.error(f"实例化 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}")
async def reload(self, provider_config: dict):
await self.terminate_provider(provider_config['id'])
if provider_config['enable']:
await self.load_provider(provider_config)
# 和配置文件保持同步
config_ids = [provider['id'] for provider in self.providers_config]
for key in list(self.inst_map.keys()):
if key not in config_ids:
await self.terminate_provider(key)
if len(self.provider_insts) == 0:
self.curr_provider_inst = None
if len(self.stt_provider_insts) == 0:
self.curr_stt_provider_inst = None
if len(self.tts_provider_insts) == 0:
self.curr_tts_provider_inst = None
def get_insts(self):
return self.provider_insts
async def terminate_provider(self, provider_id: str):
if provider_id in self.inst_map:
logger.info(f"终止 {provider_id} 提供商适配器 ...")
if self.inst_map[provider_id] in self.provider_insts:
self.provider_insts.remove(self.inst_map[provider_id])
if self.inst_map[provider_id] in self.stt_provider_insts:
self.stt_provider_insts.remove(self.inst_map[provider_id])
if self.inst_map[provider_id] in self.tts_provider_insts:
self.tts_provider_insts.remove(self.inst_map[provider_id])
if getattr(self.inst_map[provider_id], 'terminate', None):
await self.inst_map[provider_id].terminate()
logger.info(f"{provider_id} 提供商适配器已终止。")
del self.inst_map[provider_id]
async def terminate(self):
for provider_inst in self.provider_insts:
if hasattr(provider_inst, "terminate"):
@@ -0,0 +1,189 @@
from typing import List
from mimetypes import guess_type
from anthropic import AsyncAnthropic
from anthropic.types import Message
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.db import BaseDatabase
from astrbot.api.provider import Provider, Personality
from astrbot import logger
from astrbot.core.provider.func_tool_manager import FuncCall
from ..register import register_provider_adapter
from astrbot.core.provider.entites import LLMResponse
from .openai_source import ProviderOpenAIOfficial
@register_provider_adapter("anthropic_chat_completion", "Anthropic Claude API 提供商适配器")
class ProviderAnthropic(ProviderOpenAIOfficial):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
db_helper: BaseDatabase,
persistant_history = True,
default_persona: Personality = None
) -> None:
# Skip OpenAI's __init__ and call Provider's __init__ directly
Provider.__init__(self, provider_config, provider_settings, persistant_history, db_helper, default_persona)
self.chosen_api_key = None
self.api_keys: List = provider_config.get("key", [])
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else None
self.base_url = provider_config.get("api_base", "https://api.anthropic.com")
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
self.client = AsyncAnthropic(
api_key=self.chosen_api_key,
timeout=self.timeout,
base_url=self.base_url
)
self.set_model(provider_config['model_config']['model'])
async def _query(self, payloads: dict, tools: FuncCall) -> LLMResponse:
if tools:
tool_list = tools.get_func_desc_anthropic_style()
if tool_list:
payloads['tools'] = tool_list
completion = await self.client.messages.create(
**payloads,
stream=False
)
assert isinstance(completion, Message)
logger.debug(f"completion: {completion}")
if len(completion.content) == 0:
raise Exception("API 返回的 completion 为空。")
# TODO: 如果进行函数调用,思维链被截断,用户可能需要思维链的内容
# 选最后一条消息,如果要进行函数调用,anthropic会先返回文本消息的思维链,然后再返回函数调用请求
content = completion.content[-1]
llm_response = LLMResponse("assistant")
if content.type == "text":
# text completion
completion_text = str(content.text).strip()
llm_response.completion_text = completion_text
# Anthropic每次只返回一个函数调用
if completion.stop_reason == "tool_use":
# tools call (function calling)
args_ls = []
func_name_ls = []
func_name_ls.append(content.name)
args_ls.append(content.input)
llm_response.role = "tool"
llm_response.tools_call_args = args_ls
llm_response.tools_call_name = func_name_ls
if not llm_response.completion_text and not llm_response.tools_call_args:
logger.error(f"API 返回的 completion 无法解析:{completion}")
raise Exception(f"API 返回的 completion 无法解析:{completion}")
llm_response.raw_completion = completion
return llm_response
async def text_chat(
self,
prompt: str,
session_id: str = None,
image_urls: List[str] = [],
func_tool: FuncCall = None,
contexts=[],
system_prompt=None,
**kwargs
) -> LLMResponse:
if not prompt:
prompt = "<image>"
new_record = await self.assemble_context(prompt, image_urls)
context_query = [*contexts, new_record]
for part in context_query:
if '_no_save' in part:
del part['_no_save']
model_config = self.provider_config.get("model_config", {})
payloads = {
"messages": context_query,
**model_config
}
# Anthropic has a different way of handling system prompts
if system_prompt:
payloads['system'] = system_prompt
llm_response = None
try:
llm_response = await self._query(payloads, func_tool)
except Exception as e:
if "maximum context length" in str(e):
retry_cnt = 20
while retry_cnt > 0:
logger.warning(f"上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}")
try:
await self.pop_record(context_query)
response = await self.client.messages.create(
messages=context_query,
**model_config
)
llm_response = LLMResponse("assistant")
llm_response.completion_text = response.content[0].text
llm_response.raw_completion = response
return llm_response
except Exception as e:
if "maximum context length" in str(e):
retry_cnt -= 1
else:
raise e
return LLMResponse("err", "err: 请尝试 /reset 清除会话记录。")
else:
logger.error(f"发生了错误。Provider 配置如下: {model_config}")
raise e
return llm_response
async def assemble_context(self, text: str, image_urls: List[str] = None):
'''组装上下文,支持文本和图片'''
if not image_urls:
return {"role": "user", "content": text}
content = []
content.append({"type": "text", "text": text})
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
image_data = await self.encode_image_bs64(image_path)
elif image_url.startswith("file:///"):
image_path = image_url.replace("file:///", "")
image_data = await self.encode_image_bs64(image_path)
else:
image_data = await self.encode_image_bs64(image_url)
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
# Get mime type for the image
mime_type, _ = guess_type(image_url)
if not mime_type:
mime_type = "image/jpeg" # Default to JPEG if can't determine
content.append({
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": image_data.split("base64,")[1] if "base64," in image_data else image_data
}
})
return {"role": "user", "content": content}
@@ -0,0 +1,128 @@
import asyncio
import functools
from typing import List
from .. import Provider, Personality
from ..entites import LLMResponse
from ..func_tool_manager import FuncCall
from astrbot.core.db import BaseDatabase
from ..register import register_provider_adapter
from .openai_source import ProviderOpenAIOfficial
from astrbot.core import logger, sp
from dashscope import Application
@register_provider_adapter("dashscope", "Dashscope APP 适配器。")
class ProviderDashscope(ProviderOpenAIOfficial):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
db_helper: BaseDatabase,
persistant_history=False,
default_persona: Personality = None,
) -> None:
Provider.__init__(
self,
provider_config,
provider_settings,
persistant_history,
db_helper,
default_persona,
)
self.api_key = provider_config.get("dashscope_api_key", "")
if not self.api_key:
raise Exception("阿里云百炼 API Key 不能为空。")
self.app_id = provider_config.get("dashscope_app_id", "")
if not self.app_id:
raise Exception("阿里云百炼 APP ID 不能为空。")
self.dashscope_app_type = provider_config.get("dashscope_app_type", "")
if not self.dashscope_app_type:
raise Exception("阿里云百炼 APP 类型不能为空。")
self.model_name = "dashscope"
self.variables: dict = provider_config.get("variables", {})
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
async def text_chat(
self,
prompt: str,
session_id: str = None,
image_urls: List[str] = [],
func_tool: FuncCall = None,
contexts: List = None,
system_prompt: str = None,
**kwargs,
) -> LLMResponse:
# 获得会话变量
payload_vars = self.variables.copy()
# 动态变量
session_vars = sp.get("session_variables", {})
session_var = session_vars.get(session_id, {})
payload_vars.update(session_var)
if self.dashscope_app_type in ["agent", "dialog-workflow"]:
# 支持多轮对话的
new_record = {"role": "user", "content": prompt}
if image_urls:
logger.warning("阿里云百炼暂不支持图片输入,将自动忽略图片内容。")
contexts_no_img = await self._remove_image_from_context(contexts)
context_query = [*contexts_no_img, new_record]
if system_prompt:
context_query.insert(0, {"role": "system", "content": system_prompt})
for part in context_query:
if "_no_save" in part:
del part["_no_save"]
# 调用阿里云百炼 API
partial = functools.partial(
Application.call,
app_id=self.app_id,
api_key=self.api_key,
messages=context_query,
biz_params=payload_vars or None,
)
response = await asyncio.get_event_loop().run_in_executor(None, partial)
else:
# 不支持多轮对话的
# 调用阿里云百炼 API
partial = functools.partial(
Application.call,
app_id=self.app_id,
promtp=prompt,
api_key=self.api_key,
biz_params=payload_vars or None,
)
response = await asyncio.get_event_loop().run_in_executor(None, partial)
logger.debug(f"dashscope resp: {response}")
if response.status_code != 200:
logger.error(
f"阿里云百炼请求失败: request_id={response.request_id}, code={response.status_code}, message={response.message}, 请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code"
)
return LLMResponse(
role="err",
completion_text=f"阿里云百炼请求失败: message={response.message} code={response.status_code}",
)
output_text = response.output.get("text", "")
return LLMResponse(role="assistant", completion_text=output_text)
async def forget(self, session_id):
return True
async def get_current_key(self):
return self.api_key
async def set_key(self, key):
raise Exception("阿里云百炼 适配器不支持设置 API Key。")
async def get_models(self):
return [self.get_model()]
async def get_human_readable_context(self, session_id, page, page_size):
raise Exception("暂不支持获得 阿里云百炼 的历史消息记录。")
async def terminate(self):
pass
+60 -47
View File
@@ -31,10 +31,15 @@ class ProviderDify(Provider):
raise Exception("Dify API 类型不能为空。")
self.model_name = "dify"
self.workflow_output_key = provider_config.get("dify_workflow_output_key", "astrbot_wf_output")
self.dify_query_input_key = provider_config.get("dify_query_input_key", "astrbot_text_query")
self.variables: dict = provider_config.get("variables", {})
if not self.dify_query_input_key:
self.dify_query_input_key = "astrbot_text_query"
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
self.conversation_ids = {}
'''记录当前 session id 的对话 ID'''
async def text_chat(
@@ -68,59 +73,67 @@ class ProviderDify(Provider):
logger.warning(f"未知的图片链接:{image_url},图片将忽略。")
# 获得会话变量
payload_vars = self.variables.copy()
# 动态变量
session_vars = sp.get("session_variables", {})
session_var = session_vars.get(session_id, {})
payload_vars.update(session_var)
try:
match self.api_type:
case "chat" | "agent":
async for chunk in self.api_client.chat_messages(
inputs={
**payload_vars,
},
query=prompt,
user=session_id,
conversation_id=conversation_id,
files=files_payload,
timeout=self.timeout
):
logger.debug(f"dify resp chunk: {chunk}")
if chunk['event'] == "message" or \
chunk['event'] == "agent_message":
result += chunk['answer']
if not conversation_id:
self.conversation_ids[session_id] = chunk['conversation_id']
conversation_id = chunk['conversation_id']
case "workflow":
async for chunk in self.api_client.workflow_run(
inputs={
self.dify_query_input_key: prompt,
"astrbot_session_id": session_id,
**payload_vars,
},
user=session_id,
files=files_payload,
timeout=self.timeout
):
match chunk['event']:
case "workflow_started":
logger.info(f"Dify 工作流(ID: {chunk['workflow_run_id']})开始运行。")
case "node_finished":
logger.debug(f"Dify 工作流节点(ID: {chunk['data']['node_id']} Title: {chunk['data'].get('title', '')})运行结束。")
case "workflow_finished":
logger.info(f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束。")
if chunk['data']['error']:
logger.error(f"Dify 工作流出现错误:{chunk['data']['error']}")
raise Exception(f"Dify 工作流出现错误:{chunk['data']['error']}")
if self.workflow_output_key not in chunk['data']['outputs']:
raise Exception(f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}")
result = chunk['data']['outputs'][self.workflow_output_key]
case _:
raise Exception(f"未知的 Dify API 类型:{self.api_type}")
except Exception as e:
logger.error(f"Dify 请求失败:{str(e)}")
return LLMResponse(role="err", completion_text=f"Dify 请求失败:{str(e)}")
match self.api_type:
case "chat" | "agent":
async for chunk in self.api_client.chat_messages(
inputs={
**session_var
},
query=prompt,
user=session_id,
conversation_id=conversation_id,
files=files_payload,
timeout=self.timeout
):
logger.debug(f"dify resp chunk: {chunk}")
if chunk['event'] == "message" or \
chunk['event'] == "agent_message":
result += chunk['answer']
if not conversation_id:
self.conversation_ids[session_id] = chunk['conversation_id']
conversation_id = chunk['conversation_id']
case "workflow":
async for chunk in self.api_client.workflow_run(
inputs={
"astrbot_text_query": prompt,
"astrbot_session_id": session_id,
**session_var
},
user=session_id,
files=files_payload,
timeout=self.timeout
):
match chunk['event']:
case "workflow_started":
logger.info(f"Dify 工作流(ID: {chunk['workflow_run_id']})开始运行。")
case "node_finished":
logger.debug(f"Dify 工作流节点(ID: {chunk['data']['node_id']} Title: {chunk['data'].get('title', '')})运行结束。")
case "workflow_finished":
logger.info(f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束。")
if chunk['data']['error']:
logger.error(f"Dify 工作流出现错误:{chunk['data']['error']}")
raise Exception(f"Dify 工作流出现错误:{chunk['data']['error']}")
if self.workflow_output_key not in chunk['data']['outputs']:
raise Exception(f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}")
result = chunk['data']['outputs'][self.workflow_output_key]
case _:
raise Exception(f"未知的 Dify API 类型:{self.api_type}")
return LLMResponse(role="assistant", completion_text=result)
async def forget(self, session_id):
self.conversation_ids.pop(session_id, None)
self.conversation_ids[session_id] = ""
return True
async def get_current_key(self):
+58 -30
View File
@@ -1,5 +1,6 @@
import base64
import aiohttp
import random
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.db import BaseDatabase
from astrbot.api.provider import Provider, Personality
@@ -106,6 +107,9 @@ class ProviderGoogleGenAI(Provider):
for message in payloads["messages"]:
if message["role"] == "user":
if isinstance(message["content"], str):
if not message['content']:
message['content'] = "<empty_content>"
google_genai_conversation.append({
"role": "user",
"parts": [{"text": message["content"]}]
@@ -115,6 +119,8 @@ class ProviderGoogleGenAI(Provider):
parts = []
for part in message["content"]:
if part["type"] == "text":
if not part["text"]:
part["text"] = "<empty_content>"
parts.append({"text": part["text"]})
elif part["type"] == "image_url":
parts.append({"inline_data": {
@@ -127,12 +133,13 @@ class ProviderGoogleGenAI(Provider):
})
elif message["role"] == "assistant":
if not message["content"]:
message["content"] = "<empty_content>"
google_genai_conversation.append({
"role": "model",
"parts": [{"text": message["content"]}]
})
logger.debug(f"google_genai_conversation: {google_genai_conversation}")
result = await self.client.generate_content(
@@ -187,33 +194,50 @@ class ProviderGoogleGenAI(Provider):
**model_config
}
llm_response = None
try:
llm_response = await self._query(payloads, func_tool)
except Exception as e:
if "maximum context length" in str(e):
retry_cnt = 20
while retry_cnt > 0:
logger.warning(f"请求失败:{e}。上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}")
try:
await self.pop_record(context_query)
llm_response = await self._query(payloads, func_tool)
break
except Exception as e:
if "maximum context length" in str(e):
retry_cnt -= 1
else:
raise e
if retry_cnt == 0:
llm_response = LLMResponse("err", "err: 请尝试 /reset 重置会话")
elif "Function calling is not enabled" in str(e):
logger.info(f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。")
if 'tools' in payloads:
del payloads['tools']
llm_response = await self._query(payloads, None)
else:
logger.error(f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}")
raise e
retry = 10
keys = self.api_keys.copy()
chosen_key = random.choice(keys)
for i in range(retry):
try:
self.client.api_key = chosen_key
llm_response = await self._query(payloads, func_tool)
break
except Exception as e:
if "maximum context length" in str(e):
retry_cnt = 20
while retry_cnt > 0:
logger.warning(f"请求失败:{e}。上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}")
try:
await self.pop_record(context_query)
llm_response = await self._query(payloads, func_tool)
break
except Exception as e:
if "maximum context length" in str(e):
retry_cnt -= 1
else:
raise e
if retry_cnt == 0:
llm_response = LLMResponse("err", "err: 请尝试 /reset 重置会话")
elif "Function calling is not enabled" in str(e):
logger.info(f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。")
if 'tools' in payloads:
del payloads['tools']
llm_response = await self._query(payloads, None)
break
elif "429" in str(e) or "API key not valid" in str(e):
keys.remove(chosen_key)
if len(keys) > 0:
chosen_key = random.choice(keys)
logger.info(f"检测到 Key 异常({str(e)}),正在尝试更换 API Key 重试... 当前 Key: {chosen_key[:12]}...")
continue
else:
logger.error(f"检测到 Key 异常({str(e)}),且已没有可用的 Key。 当前 Key: {chosen_key[:12]}...")
raise Exception("API 资源已耗尽,且没有可用的 Key 重试...")
else:
logger.error(f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}")
raise e
return llm_response
@@ -258,4 +282,8 @@ class ProviderGoogleGenAI(Provider):
with open(image_url, "rb") as f:
image_bs64 = base64.b64encode(f.read()).decode('utf-8')
return "data:image/jpeg;base64," + image_bs64
return ''
return ''
async def terminate(self):
await self.client.client.close()
logger.info("Google GenAI 适配器已终止。")
@@ -2,7 +2,7 @@ import base64
import json
import os
from openai import AsyncOpenAI, AsyncAzureOpenAI, NOT_GIVEN
from openai import AsyncOpenAI, AsyncAzureOpenAI
from openai.types.chat.chat_completion import ChatCompletion
from openai._exceptions import NotFoundError, UnprocessableEntityError
from astrbot.core.utils.io import download_image_by_url
@@ -48,14 +48,16 @@ class ProviderOpenAIOfficial(Provider):
base_url=provider_config.get("api_base", None),
timeout=self.timeout
)
self.set_model(provider_config['model_config']['model'])
model_config = provider_config.get("model_config", {})
model = model_config.get("model", "unknown")
self.set_model(model)
async def get_models(self):
try:
models_str = []
models = await self.client.models.list()
models = models.data
models = sorted(models.data, key=lambda x: x.id)
for model in models:
models_str.append(model.id)
return models_str
@@ -100,6 +102,9 @@ class ProviderOpenAIOfficial(Provider):
llm_response.role = "tool"
llm_response.tools_call_args = args_ls
llm_response.tools_call_name = func_name_ls
if choice.finish_reason == 'content_filter':
raise Exception("API 返回的 completion 由于内容安全过滤被拒绝(非 AstrBot)。")
if not llm_response.completion_text and not llm_response.tools_call_args:
logger.error(f"API 返回的 completion 无法解析:{completion}")
+72 -18
View File
@@ -1,19 +1,23 @@
import re
import inspect
from typing import List, Any, Type, Dict
from . import HandlerFilter
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.config import AstrBotConfig
from astrbot.core.utils.param_validation_mixin import ParameterValidationMixin
from .custom_filter import CustomFilter
from ..star_handler import StarHandlerMetadata
# 标准指令受到 wake_prefix 的制约。
class CommandFilter(HandlerFilter, ParameterValidationMixin):
class CommandFilter(HandlerFilter):
'''标准指令过滤器'''
def __init__(self, command_name: str, handler_md: StarHandlerMetadata = None):
def __init__(self, command_name: str, alias: set = None, handler_md: StarHandlerMetadata = None, parent_command_names: List[str] = [""]):
self.command_name = command_name
self.alias = alias if alias else set()
self.parent_command_names = parent_command_names
if handler_md:
self.init_handler_md(handler_md)
self.custom_filter_list: List[CustomFilter] = []
def print_types(self):
result = ""
@@ -22,6 +26,7 @@ class CommandFilter(HandlerFilter, ParameterValidationMixin):
result += f"{k}({v.__name__}),"
else:
result += f"{k}({type(v).__name__})={v},"
result = result.rstrip(",")
return result
def init_handler_md(self, handle_md: StarHandlerMetadata):
@@ -42,30 +47,79 @@ class CommandFilter(HandlerFilter, ParameterValidationMixin):
def get_handler_md(self) -> StarHandlerMetadata:
return self.handler_md
def add_custom_filter(self, custom_filter: CustomFilter):
self.custom_filter_list.append(custom_filter)
def custom_filter_ok(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
for custom_filter in self.custom_filter_list:
if not custom_filter.filter(event, cfg):
return False
return True
def validate_and_convert_params(self, params: List[Any], param_type: Dict[str, Type]) -> Dict[str, Any]:
'''将参数列表 params 根据 param_type 转换为参数字典。
'''
result = {}
for i, (param_name, param_type_or_default_val) in enumerate(param_type.items()):
if i >= len(params):
if isinstance(param_type_or_default_val, Type) or param_type_or_default_val is inspect.Parameter.empty:
# 是类型
raise ValueError(f"必要参数缺失。该指令完整参数: {self.print_types()}")
else:
# 是默认值
result[param_name] = param_type_or_default_val
else:
# 尝试强制转换
try:
if param_type_or_default_val is None:
if params[i].isdigit():
result[param_name] = int(params[i])
else:
result[param_name] = params[i]
elif isinstance(param_type_or_default_val, str):
# 如果 param_type_or_default_val 是字符串,直接赋值
result[param_name] = params[i]
elif isinstance(param_type_or_default_val, int):
result[param_name] = int(params[i])
elif isinstance(param_type_or_default_val, float):
result[param_name] = float(params[i])
else:
result[param_name] = param_type_or_default_val(params[i])
except ValueError:
raise ValueError(f"参数 {param_name} 类型错误。完整参数: {self.print_types()}")
return result
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
if not event.is_at_or_wake_command:
return False
if event.get_extra("parsing_command"):
message_str = event.get_extra("parsing_command").strip()
else:
message_str = event.get_message_str().strip()
# 分割为列表(每个参数之间可能会有多个空格)
ls = re.split(r"\s+", message_str)
if self.command_name != ls[0]:
if not self.custom_filter_ok(event, cfg):
return False
# if len(self.handler_params) == 0 and len(ls) > 1:
# # 一定程度避免 LLM 聊天时误判为指令
# return False
# params_str = message_str[len(self.command_name):].strip()
ls = ls[1:]
# 检查是否以指令开头
message_str = re.sub(r"\s+", " ", event.get_message_str().strip())
candidates = [self.command_name] + list(self.alias)
ok = False
for candidate in candidates:
for parent_command_name in self.parent_command_names:
if parent_command_name:
_full = f"{parent_command_name} {candidate}"
else:
_full = candidate
if message_str.startswith(f"{_full} ") or message_str == _full:
message_str = message_str[len(_full):].strip()
ok = True
break
if not ok:
return False
# 分割为列表
ls = message_str.split(" ")
# 去除空字符串
ls = [param for param in ls if param]
params = {}
try:
params = self.validate_and_convert_params(ls, self.handler_params)
except ValueError as e:
raise e
+76 -51
View File
@@ -6,72 +6,97 @@ from . import HandlerFilter
from .command import CommandFilter
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.config import AstrBotConfig
from .custom_filter import CustomFilter
from ..star_handler import StarHandlerMetadata
# 指令组受到 wake_prefix 的制约。
class CommandGroupFilter(HandlerFilter):
def __init__(self, group_name: str):
def __init__(self, group_name: str, alias: set = None, parent_group: CommandGroupFilter = None):
self.group_name = group_name
self.alias = alias if alias else set()
self.sub_command_filters: List[Union[CommandFilter, CommandGroupFilter]] = []
self.custom_filter_list: List[CustomFilter] = []
self.parent_group = parent_group
def add_sub_command_filter(self, sub_command_filter: Union[CommandFilter, CommandGroupFilter]):
self.sub_command_filters.append(sub_command_filter)
def add_custom_filter(self, custom_filter: CustomFilter):
self.custom_filter_list.append(custom_filter)
def get_complete_command_names(self) -> List[str]:
'''遍历父节点获取完整的指令名。
新版本 v3.4.29 采用预编译指令,不再从指令组递归遍历子指令,因此这个方法是返回包括别名在内的整个指令名列表。'''
parent_cmd_names = self.parent_group.get_complete_command_names() if self.parent_group else []
if not parent_cmd_names:
# 根节点
return [self.group_name] + list(self.alias)
result = []
candidates = [self.group_name] + list(self.alias)
for parent_cmd_name in parent_cmd_names:
for candidate in candidates:
result.append(parent_cmd_name + " " + candidate)
return result
# 以树的形式打印出来
def print_cmd_tree(self, sub_command_filters: List[Union[CommandFilter, CommandGroupFilter]], prefix: str = "") -> str:
def print_cmd_tree(self,
sub_command_filters: List[Union[CommandFilter, CommandGroupFilter]],
prefix: str = "",
event: AstrMessageEvent = None,
cfg: AstrBotConfig = None,
) -> str:
result = ""
for sub_filter in sub_command_filters:
if isinstance(sub_filter, CommandFilter):
cmd_th = sub_filter.print_types()
result += f"{prefix}├── {sub_filter.command_name}"
if cmd_th:
result += f" ({cmd_th})"
else:
result += " (无参数指令)"
result += "\n"
custom_filter_pass = True
if event and cfg:
custom_filter_pass = sub_filter.custom_filter_ok(event, cfg)
if custom_filter_pass:
cmd_th = sub_filter.print_types()
result += f"{prefix}├── {sub_filter.command_name}"
if cmd_th:
result += f" ({cmd_th})"
else:
result += " (无参数指令)"
if sub_filter.handler_md and sub_filter.handler_md.desc:
result += f": {sub_filter.handler_md.desc}"
result += "\n"
elif isinstance(sub_filter, CommandGroupFilter):
result += f"{prefix}├── {sub_filter.group_name}"
result += "\n"
result += sub_filter.print_cmd_tree(sub_filter.sub_command_filters, prefix+"")
custom_filter_pass = True
if event and cfg:
custom_filter_pass = sub_filter.custom_filter_ok(event, cfg)
if custom_filter_pass:
result += f"{prefix}├── {sub_filter.group_name}"
result += "\n"
result += sub_filter.print_cmd_tree(sub_filter.sub_command_filters, prefix+"", event=event, cfg=cfg)
return result
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> Tuple[bool, StarHandlerMetadata]:
def custom_filter_ok(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
for custom_filter in self.custom_filter_list:
if not custom_filter.filter(event, cfg):
return False
return True
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
if not event.is_at_or_wake_command:
return False, None
if event.get_extra("parsing_command"):
message_str = event.get_extra("parsing_command").strip()
else:
message_str = event.get_message_str().strip()
ls = re.split(r"\s+", message_str)
if ls[0] != self.group_name:
return False, None
# 改写 message_str
ls = ls[1:]
# event.message_str = " ".join(ls)
# event.message_str = event.message_str.strip()
parsing_command = " ".join(ls)
parsing_command = parsing_command.strip()
event.set_extra("parsing_command", parsing_command)
if parsing_command == "":
# 当前还是指令组
tree = self.group_name + "\n" + self.print_cmd_tree(self.sub_command_filters)
return False
# 判断当前指令组的自定义过滤器
if not self.custom_filter_ok(event, cfg):
return False
complete_command_names = self.get_complete_command_names()
if event.message_str.strip() in complete_command_names:
tree = self.group_name + "\n" + self.print_cmd_tree(self.sub_command_filters, event=event, cfg=cfg)
raise ValueError(f"指令组 {self.group_name} 未填写完全。这个指令组下有如下指令:\n"+tree)
child_command_handler_md = None
for sub_filter in self.sub_command_filters:
if isinstance(sub_filter, CommandFilter):
if sub_filter.filter(event, cfg):
child_command_handler_md = sub_filter.get_handler_md()
return True, child_command_handler_md
elif isinstance(sub_filter, CommandGroupFilter):
ok, handler = sub_filter.filter(event, cfg)
if ok:
child_command_handler_md = handler
return True, child_command_handler_md
tree = self.group_name + "\n" + self.print_cmd_tree(self.sub_command_filters)
raise ValueError(f"指令组 {self.group_name} 下没有找到对应的指令。这个指令组下有如下指令:\n"+tree)
# complete_command_names = [name + " " for name in complete_command_names]
# return event.message_str.startswith(tuple(complete_command_names))
return False
+53
View File
@@ -0,0 +1,53 @@
from abc import abstractmethod, ABCMeta
from . import HandlerFilter
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.config import AstrBotConfig
class CustomFilterMeta(ABCMeta):
def __and__(cls, other):
if not issubclass(other, CustomFilter):
raise TypeError("Operands must be subclasses of CustomFilter.")
return CustomFilterAnd(cls(), other())
def __or__(cls, other):
if not issubclass(other, CustomFilter):
raise TypeError("Operands must be subclasses of CustomFilter.")
return CustomFilterOr(cls(), other())
class CustomFilter(HandlerFilter, metaclass=CustomFilterMeta):
def __init__(self, raise_error: bool = True, **kwargs):
self.raise_error = raise_error
@abstractmethod
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
''' 一个用于重写的自定义Filter '''
raise NotImplementedError
def __or__(self, other):
return CustomFilterOr(self, other)
def __and__(self, other):
return CustomFilterAnd(self, other)
class CustomFilterOr(CustomFilter):
def __init__(self, filter1: CustomFilter, filter2: CustomFilter):
super().__init__()
if not isinstance(filter1, (CustomFilter, CustomFilterAnd, CustomFilterOr)):
raise ValueError("CustomFilter lass can only operate with other CustomFilter.")
self.filter1 = filter1
self.filter2 = filter2
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
return self.filter1.filter(event, cfg) or self.filter2.filter(event, cfg)
class CustomFilterAnd(CustomFilter):
def __init__(self, filter1: CustomFilter, filter2: CustomFilter):
super().__init__()
if not isinstance(filter1, (CustomFilter, CustomFilterAnd, CustomFilterOr)):
raise ValueError("CustomFilter lass can only operate with other CustomFilter.")
self.filter1 = filter1
self.filter2 = filter2
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
return self.filter1.filter(event, cfg) and self.filter2.filter(event, cfg)
+2
View File
@@ -6,6 +6,7 @@ from .star_handler import (
register_platform_adapter_type,
register_regex,
register_permission_type,
register_custom_filter,
register_on_llm_request,
register_on_llm_response,
register_llm_tool,
@@ -21,6 +22,7 @@ __all__ = [
'register_platform_adapter_type',
'register_regex',
'register_permission_type',
'register_custom_filter',
'register_on_llm_request',
'register_on_llm_response',
'register_llm_tool',
+91 -27
View File
@@ -7,6 +7,7 @@ from ..filter.command_group import CommandGroupFilter
from ..filter.event_message_type import EventMessageTypeFilter, EventMessageType
from ..filter.platform_adapter_type import PlatformAdapterTypeFilter, PlatformAdapterType
from ..filter.permission import PermissionTypeFilter, PermissionType
from ..filter.custom_filter import CustomFilterAnd, CustomFilterOr
from ..filter.regex import RegexFilter
from typing import Awaitable
from astrbot.core.provider.func_tool_manager import SUPPORTED_TYPES
@@ -50,21 +51,19 @@ def get_handler_or_create(
star_handlers_registry.append(md)
return md
def register_command(command_name: str = None, *args, **kwargs):
def register_command(command_name: str = None, sub_command: str = None, alias: set = None, **kwargs):
'''注册一个 Command.
'''
# print("command: ", command_name, args, kwargs)
new_command = None
add_to_event_filters = False
if isinstance(command_name, RegisteringCommandable):
# 子指令
new_command = CommandFilter(args[0], None)
parent_command_names = command_name.parent_group.get_complete_command_names()
new_command = CommandFilter(sub_command, alias, None, parent_command_names=parent_command_names)
command_name.parent_group.add_sub_command_filter(new_command)
else:
# 裸指令
new_command = CommandFilter(command_name, None)
new_command = CommandFilter(command_name, alias, None)
add_to_event_filters = True
def decorator(awaitable):
@@ -72,46 +71,96 @@ def register_command(command_name: str = None, *args, **kwargs):
kwargs['sub_command'] = True # 打一个标记,表示这是一个子指令,再 wakingstage 阶段这个 handler 将会直接被跳过(其父指令会接管)
handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent, **kwargs)
new_command.init_handler_md(handler_md)
if add_to_event_filters:
# 裸指令
handler_md.event_filters.append(new_command)
handler_md.event_filters.append(new_command)
return awaitable
return decorator
def register_command_group(command_group_name: str = None, *args, **kwargs):
def register_custom_filter(custom_type_filter, *args, **kwargs):
'''注册一个自定义的 CustomFilter
Args:
custom_type_filter: 在裸指令时为CustomFilter对象
在指令组时为父指令的RegisteringCommandable对象,即self或者command_group的返回
raise_error: 如果没有权限,是否抛出错误到消息平台,并且停止事件传播。默认为 True
'''
add_to_event_filters = False
raise_error = True
# 判断是否是指令组,指令组则添加到指令组的CommandGroupFilter对象中在waking_check的时候一起判断
if isinstance(custom_type_filter, RegisteringCommandable):
# 子指令, 此时函数为RegisteringCommandable对象的方法,首位参数为RegisteringCommandable对象的self。
parent_register_commandable = custom_type_filter
custom_filter = args[0]
if len(args) > 1:
raise_error = args[1]
else:
# 裸指令
add_to_event_filters = True
custom_filter = custom_type_filter
if args:
raise_error = args[0]
if not isinstance(custom_filter, (CustomFilterAnd, CustomFilterOr)):
custom_filter = custom_filter(raise_error)
def decorator(awaitable):
# 裸指令,子指令与指令组的区分,指令组会因为标记跳过wake。
if not add_to_event_filters and isinstance(awaitable, RegisteringCommandable) or \
(add_to_event_filters and isinstance(awaitable, RegisteringCommandable)):
# 指令组 与 根指令组,添加到本层的grouphandle中一起判断
awaitable.parent_group.add_custom_filter(custom_filter)
else:
handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent, **kwargs)
if not add_to_event_filters and not isinstance(awaitable, RegisteringCommandable):
# 底层子指令
handle_full_name = get_handler_full_name(awaitable)
for sub_handle in parent_register_commandable.parent_group.sub_command_filters:
# 所有符合fullname一致的子指令handle添加自定义过滤器。
# 不确定是否会有多个子指令有一样的fullname,比如一个方法添加多个command装饰器?
sub_handle_md = sub_handle.get_handler_md()
if sub_handle_md and sub_handle_md.handler_full_name == handle_full_name:
sub_handle.add_custom_filter(custom_filter)
else:
# 裸指令
handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent, **kwargs)
handler_md.event_filters.append(custom_filter)
return awaitable
return decorator
def register_command_group(
command_group_name: str = None, sub_command: str = None, alias: set = None, **kwargs
):
'''注册一个 CommandGroup
'''
# print("commandgroup: ", command_group_name,args, kwargs)
new_group = None
add_to_event_filters = False
if isinstance(command_group_name, RegisteringCommandable):
# 子指令组
new_group = CommandGroupFilter(args[0])
new_group = CommandGroupFilter(sub_command, alias, parent_group=command_group_name.parent_group)
command_group_name.parent_group.add_sub_command_filter(new_group)
else:
# 根指令组
new_group = CommandGroupFilter(command_group_name)
add_to_event_filters = True
new_group = CommandGroupFilter(command_group_name, alias)
def decorator(obj):
if add_to_event_filters:
# 根指令组
handler_md = get_handler_or_create(obj, EventType.AdapterMessageEvent, **kwargs)
handler_md.event_filters.append(new_group)
# 根指令组
handler_md = get_handler_or_create(obj, EventType.AdapterMessageEvent, **kwargs)
handler_md.event_filters.append(new_group)
return RegisteringCommandable(new_group)
return decorator
class RegisteringCommandable():
'''用于指令组级联注册'''
group = register_command_group
command = register_command
group: CommandGroupFilter = register_command_group
command: CommandFilter = register_command
custom_filter = register_custom_filter
def __init__(self, parent_group: CommandGroupFilter):
self.parent_group = parent_group
@@ -204,7 +253,7 @@ def register_llm_tool(name: str = None):
```
@llm_tool(name="get_weather") # 如果 name 不填,将使用函数名
async def get_weather(event: AstrMessageEvent, location: str) -> MessageEventResult:
async def get_weather(event: AstrMessageEvent, location: str):
\'\'\'获取天气信息。
Args:
@@ -214,7 +263,22 @@ def register_llm_tool(name: str = None):
```
可接受的参数类型有:string, number, object, array, boolean。
返回值:
- 返回 str:结果会被加入下一次 LLM 请求的 prompt 中,用于让 LLM 总结工具返回的结果
- 返回 None:结果不会被加入下一次 LLM 请求的 prompt 中。
可以使用 yield 发送消息、终止事件。
发送消息:请参考文档。
终止事件:
```
event.stop_event()
yield
```
'''
name_ = name
def decorator(awaitable: Awaitable):
+2 -2
View File
@@ -340,8 +340,8 @@ class PluginManager:
self.failed_plugin_info = fail_rec
return False, fail_rec
async def install_plugin(self, repo_url: str):
plugin_path = await self.updator.install(repo_url)
async def install_plugin(self, repo_url: str, proxy=""):
plugin_path = await self.updator.install(repo_url, proxy)
# reload the plugin
await self.reload()
return plugin_path
+2 -2
View File
@@ -15,10 +15,10 @@ class PluginUpdator(RepoZipUpdator):
def get_plugin_store_path(self) -> str:
return self.plugin_store_path
async def install(self, repo_url: str) -> str:
async def install(self, repo_url: str, proxy="") -> str:
repo_name = self.format_repo_name(repo_url)
plugin_path = os.path.join(self.plugin_store_path, repo_name)
await self.download_from_repo_url(plugin_path, repo_url)
await self.download_from_repo_url(plugin_path, repo_url, proxy)
self.unzip_file(plugin_path + ".zip", plugin_path)
return plugin_path
+8 -4
View File
@@ -32,9 +32,6 @@ class AstrBotUpdator(RepoZipUpdator):
pass
def _reboot(self, delay: int = 3):
if os.environ.get('TEST_MODE', 'off') == 'on':
logger.info("测试模式下不会重启。")
return
py = sys.executable
time.sleep(delay)
self.terminate_child_processes()
@@ -47,8 +44,11 @@ class AstrBotUpdator(RepoZipUpdator):
async def check_update(self, url: str, current_version: str) -> ReleaseInfo:
return await super().check_update(self.ASTRBOT_RELEASE_API, VERSION)
async def get_releases(self) -> list:
return await self.fetch_release_info(self.ASTRBOT_RELEASE_API)
async def update(self, reboot = False, latest = True, version = None):
async def update(self, reboot = False, latest = True, version = None, proxy = ""):
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
file_url = None
@@ -70,6 +70,10 @@ class AstrBotUpdator(RepoZipUpdator):
raise Exception("commit hash 长度不正确,应为 40")
logger.info(f"正在尝试更新到指定 commit: {version}")
file_url = "https://github.com/Soulter/AstrBot/archive/" + version + ".zip"
if proxy:
proxy = proxy.removesuffix("/")
file_url = f"{proxy}/{file_url}"
try:
await download_file(file_url, "temp.zip")
+52 -1
View File
@@ -119,4 +119,55 @@ class DifyAPIClient:
return await resp.json() # {"id": "xxx", ...}
async def close(self):
await self.session.close()
await self.session.close()
async def get_chat_convs(
self,
user: str,
limit: int = 20
):
# conversations. GET
url = f"{self.api_base}/conversations"
payload = {
"user": user,
"limit": limit,
}
async with self.session.get(
url, params=payload, headers=self.headers
) as resp:
return await resp.json()
async def delete_chat_conv(
self,
user: str,
conversation_id: str
):
# conversation. DELETE
url = f"{self.api_base}/conversations/{conversation_id}"
payload = {
"user": user,
}
async with self.session.delete(
url, json=payload, headers=self.headers
) as resp:
return await resp.json()
async def rename(
self,
conversation_id: str,
name: str,
user: str,
auto_generate: bool = False
):
# /conversations/:conversation_id/name
url = f"{self.api_base}/conversations/{conversation_id}/name"
payload = {
"user": user,
"name": name,
"auto_generate": auto_generate,
}
async with self.session.post(
url, json=payload, headers=self.headers
) as resp:
return await resp.json()
+11 -10
View File
@@ -7,6 +7,7 @@ import aiohttp
import base64
import zipfile
import uuid
import psutil
from typing import Union
from PIL import Image
@@ -160,17 +161,17 @@ def file_to_base64(file_path: str) -> str:
base64_str = base64.b64encode(data_bytes).decode()
return "base64://" + base64_str
def get_local_ip_addresses():
ip = ''
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
except BaseException:
pass
finally:
s.close()
return ip
net_interfaces = psutil.net_if_addrs()
network_ips = []
for interface, addrs in net_interfaces.items():
for addr in addrs:
if addr.family == socket.AF_INET: # 使用 socket.AF_INET 代替 psutil.AF_INET
network_ips.append(addr.address)
return network_ips
async def get_dashboard_version():
if os.path.exists("data/dist"):
@@ -1,36 +0,0 @@
import inspect
from typing import List, Dict, Any, Type
class ParameterValidationMixin:
def validate_and_convert_params(self, params: List[Any], param_type: Dict[str, Type]) -> Dict[str, Any]:
'''将参数列表 params 根据 param_type 转换为参数字典。
'''
result = {}
for i, (param_name, param_type_or_default_val) in enumerate(param_type.items()):
if i >= len(params):
if isinstance(param_type_or_default_val, Type) or param_type_or_default_val is inspect.Parameter.empty:
# 是类型
raise ValueError(f"参数 {param_name} 缺失")
else:
# 是默认值
result[param_name] = param_type_or_default_val
else:
# 尝试强制转换
try:
if param_type_or_default_val is None:
if params[i].isdigit():
result[param_name] = int(params[i])
else:
result[param_name] = params[i]
elif isinstance(param_type_or_default_val, str):
# 如果 param_type_or_default_val 是字符串,直接赋值
result[param_name] = params[i]
elif isinstance(param_type_or_default_val, int):
result[param_name] = int(params[i])
elif isinstance(param_type_or_default_val, float):
result[param_name] = float(params[i])
else:
result[param_name] = param_type_or_default_val(params[i])
except ValueError:
raise ValueError(f"参数 {param_name} 类型错误")
return result
+1 -1
View File
@@ -22,7 +22,7 @@ class HtmlRenderer:
@return: 图片 URL 或者文件路径,取决于 return_url 参数。
@example: 参见 https://astrbot.soulter.top 插件开发部分。
@example: 参见 https://astrbot.app 插件开发部分。
'''
local = locals()
local.pop('self')
+21 -15
View File
@@ -22,21 +22,27 @@ async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str:
async def wav_to_tencent_silk(wav_path: str, output_path: str) -> int:
'''返回 duration'''
import pysilk
with wave.open(wav_path, 'rb') as wav:
wav_data = wav.readframes(wav.getnframes())
wav_data = BytesIO(wav_data)
output_io = BytesIO()
pysilk.encode(wav_data, output_io, 24000, 24000)
output_io.seek(0)
try:
import pilk
except (ImportError, ModuleNotFoundError) as _:
raise Exception("pilk 模块未安装,请前往管理面板->控制台->安装pip库 安装 pilk 这个库")
# with wave.open(wav_path, 'rb') as wav:
# wav_data = wav.readframes(wav.getnframes())
# wav_data = BytesIO(wav_data)
# output_io = BytesIO()
# pysilk.encode(wav_data, output_io, 24000, 24000)
# output_io.seek(0)
# 在首字节添加 \x02,去除结尾的\xff\xff
silk_data = output_io.read()
silk_data_with_prefix = b'\x02' + silk_data[:-2]
# # 在首字节添加 \x02,去除结尾的\xff\xff
# silk_data = output_io.read()
# silk_data_with_prefix = b'\x02' + silk_data[:-2]
# return BytesIO(silk_data_with_prefix)
with open(output_path, "wb") as f:
f.write(silk_data_with_prefix)
# # return BytesIO(silk_data_with_prefix)
# with open(output_path, "wb") as f:
# f.write(silk_data_with_prefix)
return 0
# return 0
with wave.open(wav_path, 'rb') as wav:
rate = wav.getframerate()
duration = pilk.encode(wav_path, output_path, pcm_rate=rate, tencent=True)
return duration
+18 -21
View File
@@ -34,10 +34,19 @@ class RepoZipUpdator():
result = await response.json()
if not result:
return []
if latest:
ret = self.github_api_release_parser([result[0]])
else:
ret = self.github_api_release_parser(result)
# if latest:
# ret = self.github_api_release_parser([result[0]])
# else:
# ret = self.github_api_release_parser(result)
ret = []
for release in result:
ret.append({
"version": release['name'],
"published_at": release['published_at'],
"body": release['body'],
"tag_name": release['tag_name'],
"zipball_url": release['zipball_url']
})
except BaseException:
raise Exception("解析版本信息失败")
return ret
@@ -49,17 +58,10 @@ class RepoZipUpdator():
'''
ret = []
for release in releases:
version = release['name']
commit_hash = ''
# 规范是: v3.0.7.xxxxxx,其中xxxxxx为 commit hash
_t = version.split(".")
if len(_t) == 4:
commit_hash = _t[3]
ret.append({
"version": release['name'],
"published_at": release['published_at'],
"body": release['body'],
"commit_hash": commit_hash,
"tag_name": release['tag_name'],
"zipball_url": release['zipball_url']
})
@@ -100,7 +102,7 @@ class RepoZipUpdator():
body=update_data[0]['body']
)
async def download_from_repo_url(self, target_path: str, repo_url: str):
async def download_from_repo_url(self, target_path: str, repo_url: str, proxy=""):
repo_namespace = repo_url.split("/")[-2:]
author = repo_namespace[0]
repo = repo_namespace[1]
@@ -114,15 +116,10 @@ class RepoZipUpdator():
release_url = f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
else:
release_url = releases[0]['zipball_url']
# 镜像站点
match self.repo_mirror:
case 'https://github-mirror.us.kg/':
release_url = self.repo_mirror + release_url
case "https://ghp.ci/":
release_url = self.repo_mirror + release_url
case _:
pass
if proxy:
release_url = f"{proxy}/{release_url}"
logger.info(f"使用代理下载: {release_url}")
await download_file(release_url, target_path + ".zip")
+109 -6
View File
@@ -1,3 +1,5 @@
import typing
import traceback
from .route import Route, Response, RouteContext
from quart import request
from astrbot.core.config.default import CONFIG_METADATA_2, DEFAULT_VALUE_MAP
@@ -17,7 +19,7 @@ def try_cast(value: str, type_: str):
elif type_ == "float" and isinstance(value, int):
return float(value)
def validate_config(data, schema: dict, is_core: bool):
def validate_config(data, schema: dict, is_core: bool) -> typing.Tuple[typing.List[str], typing.Dict]:
errors = []
def validate(data, metadata=schema, path=""):
for key, meta in metadata.items():
@@ -60,22 +62,23 @@ def validate_config(data, schema: dict, is_core: bool):
group_meta = group.get("metadata")
if not group_meta:
continue
logger.info(f"验证配置: 组 {key} ...")
# logger.info(f"验证配置: 组 {key} ...")
validate(data, group_meta, path=f"{key}.")
else:
validate(data, schema)
return errors
return errors, data
def save_config(post_config: dict, config: AstrBotConfig, is_core: bool = False):
'''验证并保存配置'''
errors = None
try:
if is_core:
errors = validate_config(post_config, CONFIG_METADATA_2, is_core)
errors, post_config = validate_config(post_config, CONFIG_METADATA_2, is_core)
else:
errors = validate_config(post_config, config.schema, is_core)
errors, post_config = validate_config(post_config, config.schema, is_core)
except BaseException as e:
logger.error(traceback.format_exc())
logger.warning(f"验证配置时出现异常: {e}")
if errors:
raise ValueError(f"格式校验未通过: {errors}")
@@ -89,6 +92,14 @@ class ConfigRoute(Route):
'/config/get': ('GET', self.get_configs),
'/config/astrbot/update': ('POST', self.post_astrbot_configs),
'/config/plugin/update': ('POST', self.post_plugin_configs),
'/config/platform/new': ('POST', self.post_new_platform),
'/config/platform/update': ('POST', self.post_update_platform),
'/config/platform/delete': ('POST', self.post_delete_platform),
'/config/provider/new': ('POST', self.post_new_provider),
'/config/provider/update': ('POST', self.post_update_provider),
'/config/provider/delete': ('POST', self.post_delete_provider)
}
self.register_routes()
@@ -117,7 +128,99 @@ class ConfigRoute(Route):
return Response().ok(None, f"保存插件 {plugin_name} 成功~ 机器人正在重载配置。").__dict__
except Exception as e:
return Response().error(str(e)).__dict__
async def post_new_platform(self):
new_platform_config = await request.json
self.config['platform'].append(new_platform_config)
try:
save_config(self.config, self.config, is_core=True)
await self.core_lifecycle.platform_manager.load_platform(new_platform_config)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "新增平台配置成功~").__dict__
async def post_new_provider(self):
new_provider_config = await request.json
self.config['provider'].append(new_provider_config)
try:
save_config(self.config, self.config, is_core=True)
await self.core_lifecycle.provider_manager.load_provider(new_provider_config)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "新增服务提供商配置成功~").__dict__
async def post_update_platform(self):
update_platform_config = await request.json
platform_id = update_platform_config.get("id", None)
new_config = update_platform_config.get("config", None)
if not platform_id or not new_config:
return Response().error("参数错误").__dict__
for i, platform in enumerate(self.config['platform']):
if platform['id'] == platform_id:
self.config['platform'][i] = new_config
break
else:
return Response().error("未找到对应平台").__dict__
try:
await self._save_astrbot_configs(self.config)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "更新平台配置成功~").__dict__
async def post_update_provider(self):
update_provider_config = await request.json
provider_id = update_provider_config.get("id", None)
new_config = update_provider_config.get("config", None)
if not provider_id or not new_config:
return Response().error("参数错误").__dict__
for i, provider in enumerate(self.config['provider']):
if provider['id'] == provider_id:
self.config['provider'][i] = new_config
break
else:
return Response().error("未找到对应服务提供商").__dict__
try:
save_config(self.config, self.config, is_core=True)
await self.core_lifecycle.provider_manager.reload(new_config)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "更新成功,已经实时生效~").__dict__
async def post_delete_platform(self):
platform_id = await request.json
platform_id = platform_id.get("id")
for i, platform in enumerate(self.config['platform']):
if platform['id'] == platform_id:
del self.config['platform'][i]
break
else:
return Response().error("未找到对应平台").__dict__
try:
await self._save_astrbot_configs(self.config)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "删除平台配置成功~").__dict__
async def post_delete_provider(self):
provider_id = await request.json
provider_id = provider_id.get("id")
for i, provider in enumerate(self.config['provider']):
if provider['id'] == provider_id:
del self.config['provider'][i]
break
else:
return Response().error("未找到对应服务提供商").__dict__
try:
save_config(self.config, self.config, is_core=True)
await self.core_lifecycle.provider_manager.terminate_provider(provider_id)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "删除成功,已经实时生效~").__dict__
async def _get_astrbot_config(self):
config = self.config
+9 -4
View File
@@ -113,11 +113,17 @@ class PluginRoute(Route):
for filter in handler.event_filters: # 正常handler就只有 1~2 个 filter,因此这里时间复杂度不会太高
if isinstance(filter, CommandFilter):
info["type"] = "指令"
info["cmd"] = filter.command_name
info["cmd"] = f"{filter.parent_command_names[0]} {filter.command_name}"
info["cmd"] = info["cmd"].strip()
if self.core_lifecycle.astrbot_config['wake_prefix'] and len(self.core_lifecycle.astrbot_config['wake_prefix']) > 0:
info["cmd"] = f"{self.core_lifecycle.astrbot_config['wake_prefix'][0]}{info['cmd']}"
elif isinstance(filter, CommandGroupFilter):
info["type"] = "指令组"
info["cmd"] = filter.group_name
info["cmd"] = filter.get_complete_command_names()[0]
info["cmd"] = info["cmd"].strip()
info["sub_command"] = filter.print_cmd_tree(filter.sub_command_filters)
if self.core_lifecycle.astrbot_config['wake_prefix'] and len(self.core_lifecycle.astrbot_config['wake_prefix']) > 0:
info["cmd"] = f"{self.core_lifecycle.astrbot_config['wake_prefix'][0]}{info['cmd']}"
elif isinstance(filter, RegexFilter):
info["type"] = "正则匹配"
info["cmd"] = filter.regex_str
@@ -146,11 +152,10 @@ class PluginRoute(Route):
proxy: str = post_data.get("proxy", None)
if proxy:
proxy = proxy.removesuffix("/")
repo_url = f"{proxy}/{repo_url}"
try:
logger.info(f"正在安装插件 {repo_url}")
await self.plugin_manager.install_plugin(repo_url)
await self.plugin_manager.install_plugin(repo_url, proxy)
self.core_lifecycle.restart()
logger.info(f"安装插件 {repo_url} 成功。")
return Response().ok(None, "安装成功。").__dict__
+1 -1
View File
@@ -16,7 +16,7 @@ class StatRoute(Route):
'/stat/get': ('GET', self.get_stat),
'/stat/version': ('GET', self.get_version),
'/stat/start-time': ('GET', self.get_start_time),
'/stat/restart-core': ('GET', self.restart_core)
'/stat/restart-core': ('POST', self.restart_core)
}
self.db_helper = db_helper
self.register_routes()
+15 -1
View File
@@ -13,6 +13,7 @@ class UpdateRoute(Route):
super().__init__(context)
self.routes = {
'/update/check': ('GET', self.check_update),
'/update/releases': ('GET', self.get_releases),
'/update/do': ('POST', self.update_project),
'/update/dashboard': ('POST', self.update_dashboard),
'/update/pip-install': ('POST', self.install_pip_package)
@@ -46,6 +47,14 @@ class UpdateRoute(Route):
except Exception as e:
logger.warning(f"检查更新失败: {str(e)} (不影响除项目更新外的正常使用)")
return Response().error(e.__str__()).__dict__
async def get_releases(self):
try:
ret = await self.astrbot_updator.get_releases()
return Response().ok(ret).__dict__
except Exception as e:
logger.error(f"/api/update/releases: {traceback.format_exc()}")
return Response().error(e.__str__()).__dict__
async def update_project(self):
data = await request.json
@@ -56,8 +65,13 @@ class UpdateRoute(Route):
version = ''
else:
latest = False
proxy: str = data.get("proxy", None)
if proxy:
proxy = proxy.removesuffix("/")
try:
await self.astrbot_updator.update(latest=latest, version=version)
await self.astrbot_updator.update(latest=latest, version=version, proxy=proxy)
if latest:
try:
+72 -10
View File
@@ -2,6 +2,10 @@ import logging
import jwt
import asyncio
import os
import socket
import sys
import psutil
from astrbot.core.config.default import VERSION
from quart import Quart, request, jsonify, g
from quart.logging import default_handler
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -66,16 +70,74 @@ class AstrBotDashboard():
await asyncio.sleep(1)
logger.info("管理面板已关闭。")
def check_port_in_use(self, port: int) -> bool:
"""
跨平台检测端口是否被占用
"""
try:
# 创建 IPv4 TCP Socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 设置超时时间
sock.settimeout(2)
result = sock.connect_ex(('127.0.0.1', port))
sock.close()
# result 为 0 表示端口被占用
return result == 0
except Exception as e:
logger.warning(f"检查端口 {port} 时发生错误: {str(e)}")
# 如果出现异常,保守起见认为端口可能被占用
return True
def get_process_using_port(self, port: int) -> str:
"""获取占用端口的进程详细信息"""
try:
for conn in psutil.net_connections(kind='inet'):
if conn.laddr.port == port:
try:
process = psutil.Process(conn.pid)
# 获取详细信息
proc_info = [
f"进程名: {process.name()}",
f"PID: {process.pid}",
f"执行路径: {process.exe()}",
f"工作目录: {process.cwd()}",
f"启动命令: {' '.join(process.cmdline())}"
]
return "\n ".join(proc_info)
except (psutil.NoSuchProcess, psutil.AccessDenied) as e:
return f"无法获取进程详细信息(可能需要管理员权限): {str(e)}"
return "未找到占用进程"
except Exception as e:
return f"获取进程信息失败: {str(e)}"
def run(self):
ip_addr = get_local_ip_addresses()
logger.info(f"""
✨✨✨
AstrBot 管理面板已启动,可访问
try:
ip_addr = get_local_ip_addresses()
except Exception as e:
ip_addr = []
port = self.core_lifecycle.astrbot_config['dashboard'].get("port", 6185)
if isinstance(port, str):
port = int(port)
1. http://{ip_addr}:6185
2. http://localhost:6185
if self.check_port_in_use(port):
process_info = self.get_process_using_port(port)
logger.error(f"错误:端口 {port} 已被占用\n"
f"占用信息: \n {process_info}\n"
f"请确保:\n"
f"1. 没有其他 AstrBot 实例正在运行\n"
f"2. 端口 {port} 没有被其他程序占用\n"
f"3. 如需使用其他端口,请修改配置文件")
raise Exception(f"端口 {port} 已被占用")
display = f"\n ✨✨✨\n AstrBot v{VERSION} 管理面板已启动,可访问\n\n"
display += f" ➜ 本地: http://localhost:{port}\n"
for ip in ip_addr:
display += f" ➜ 网络: http://{ip}:{port}\n"
display += " ➜ 默认用户名和密码: astrbot\n ✨✨✨\n"
logger.info(display)
默认用户名和密码是 astrbot。
✨✨✨
""")
return self.app.run_task(host="0.0.0.0", port=6185, shutdown_trigger=self.shutdown_trigger_placeholder)
return self.app.run_task(host="0.0.0.0", port=port, shutdown_trigger=self.shutdown_trigger_placeholder)
+9
View File
@@ -0,0 +1,9 @@
# What's Changed
1. ✨ 新增: 支持接入飞书(Lark)。支持飞书文字、图片。
2. ✨ 新增: 添加月之暗面配置模板 #446
3. ✨ 新增: Gewechat 支持文件输出
4. 🐛 修复: 修复gewechat无法at人和发语音失败的问题 #447 #438
5. 🐛 修复: 修复qq在@和回复开启的情况下转发消息异常的问题
6. 🐛 修复: GitHub 加速镜像没有正确被应用
7. 🐛 优化: 平台将显示不受支持的消息段
+12
View File
@@ -0,0 +1,12 @@
# What's Changed
1. ✨ 新增: 支持 Webhook 方式接入 QQ 官方机器人接口
2. ✨ 新增: 支持完善的 Dify Chat 模式对话管理,包括 /new /switch /del /ls /reset 均已适配 Dify Chat 模式。
3. ✨ 新增: 支持基于对数函数的分段回复延时时间计算 #414
4. ✨ 新增: 支持设置管理面板的端口号
5. ✨ 新增: 支持对大模型的响应进行内容审查 #474
6. 🐛 修复: gewechat 不能发送主动消息 #402
7. 🐛 修复: dify Chat 模式无法重置会话 #469
8. 🐛 修复: ensure result is retrieved again to handle potential plugin chain replacements
9. 🐛 优化: 将 Gewechat 所有事件下发到流水线供插件开发
10. 🐛 修复: correct dashboard update tooltip typo by @Akuma-real
+14
View File
@@ -0,0 +1,14 @@
# What's Changed
1. ✨ 新增: 支持日语版本的 Readme by @eltociear
2. ✨ 新增: 主动回复支持白名单 #488
3. ⚡ 优化: 面板数据展示图表的时区问题 #460
4. ⚡ 优化: 针对 id 对模型号进行排序以适配 OneAPI 乱序情况 #384
5. ✨ 新增: 支持对大模型的响应进行内容审查 #474
6. 🐛 修复: 修复保存插件配置时没有检查类型合法性的问题
7. 🐛 修复: 尝试修复 Gemini empty text 相关报错
8. 🐛 修复: dify 不能正常使用 set/unset 指令定义动态变量 #482
9. 🐛 修复: 不能在 Webhook 模式下的 QQ 官方 API 私聊 #484
10. 🐛 修复: 在没有触发并且没通过安全审查的情况下仍然发送了未通过消息
11. 🐛 修复: /del 指令导致的相关异常
12. 🐛 修复: 在 Gewechat 中不能先写内容后 @ 机器人 #492
+12
View File
@@ -0,0 +1,12 @@
# What's Changed
1. ✨ 新增: 管理面板支持搜索插件
2. ✨ 新增: 支持传递 OneBot 的 notice, request 事件类型,如戳一戳,进退群请求等
3. ✨ 新增: 插件支持自定义过滤算子 by @AraragiEro
4. ✨ 新增: 添加命令和命令组的别名支持 by @Cvandia
4. ✨ 新增: 提供了一个方法以删除分段回复后的某些字符,如末尾的标点符号。 by @Soulter and @Nothingness-Void
5. ⚡ 优化: 优化了分段回复和回复时at,引用都打开时的一些体验性问题
7. 🐛 修复: 分段回复导致了不完全的非 LLM 输出 #503
8. 🐛 修复: 添加 no_proxy 环境变量以支持本地请求, 修复在代理状态下时的 502 错误当通过 LMStudio, Ollama 本地部署 LLM 时 #504 #514
9. 💡🐛 修复: 修复转发消息的字数阈值功能 #510
10. 💡🐛 修复: 修复 Dify 下无法主动回复的问题 #494
+14
View File
@@ -0,0 +1,14 @@
# What's Changed
1. ✨ 新增: gemini source 初步支持对 API Key 进行负载均衡请求 #534
2. ✨ 新增: 开启对话隔离的群聊以及私聊下,非 op 可以可以使用 /del 和 /reset #519
3. ✨ 新增: 事件钩子支持 yield 方式发送消息
4. ⚡ 优化: 查询模型列表时,可以显示当前使用的模型名称 #523
5. ⚡ 优化: 更换为预编译指令的方式处理指令组指令
6. 🐛 修复: resolve KeyError when current conversation is not in paginated list
7. 🐛 修复: 修复指令组的情况下,Permission Filter 对子指令失效的问题
8. 🐛 修复: 🐛 fix: 修复 reminder rm失败 #529
9. 🐛 修复: 🐛 fix: reminder 时区问题 #529
10. 🐛 修复: 修复 Dify 下无法主动回复的问题 #494
11. 🐛 修复: 添加代码执行器 Docker 宿主机绝对路径配置及相关功能以修复 Docker 下无法使用代码执行器的问题 #525
12. 🐛 修复: gewechat 微信群聊情况下可能导致 unknown 的问题 #537
+5
View File
@@ -0,0 +1,5 @@
# What's Changed
1. ‼️🐛 修复: 修复某些情况下导致插件报错 AttributeError 的问题 #549
2. ✨ 新增: add xAI template
3. 🐛 修复: 修复 dify 无法使用事件钩子的问题以及出现 GeneratorExit 的问题 #533 #264
+18
View File
@@ -0,0 +1,18 @@
# What's Changed
> 提示:改动范围较大
1. ✨ 新增: 添加对 Anthropic Claude 的支持 by @Rt39
2. ✨ 新增: 支持阿里云百炼应用(dashscope)智能体、工作流 #552 by @Soulter
3. ✨ 新增: 支持 AstrBot 更新使用 Github 加速地址 by @Fridemn
4. ✨ 新增: 适配多节点的转发消息,添加新的消息段 `Nodes`
5. ✨ 新增: 支持在管理面板重启(设置页)
6. ✨ 新增: 前端支持以列表展示正式版和开发版的列表
7. ✨ 新增: 支持插件禁止默认的llm调用(event.should_call_llm()#579
8. 🍺 重构: 支持更大范围的热重载以及管理面板将平台和提供商配置独立化 by @Soulter
9. ⚡ 优化: 启动时检查端口占用 by @Fridemn
10. ⚡ 优化: 添加控制台关闭自动滚动按钮 by @Fridemn
11. ⚡ 优化: 在聊天页面添加粘贴图片的快捷键提示 #557
12. 🐛 修复: 修复 webchat 未处理 base64 的问题 by @Raven95676
13. 🐛 修复: 修复 aiocqhttp_platform_adapter 文件相关判断逻辑 by @Raven95676
14. ‼️🐛 修复: 修复 gemini 请求时出现多次不支持函数工具调用最后 429 的问题
@@ -6,8 +6,8 @@
<div v-for="(index, key) in iterable" :key="key" style="margin-bottom: 0.5px;"
v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template">
<v-alert v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
style="margin-bottom: 16px" :text="metadata[metadataKey].items[key]?.hint"
:title="'💡 关于' + metadata[metadataKey].items[key]?.description" type="info" variant="tonal">
style="margin-bottom: 8px" :text="metadata[metadataKey].items[key]?.hint"
:title="'💡 ' + metadata[metadataKey].items[key]?.description" type="info" variant="tonal" color="primary">
</v-alert>
<div style="display: flex; align-items: center; justify-content: center; gap: 16px">
@@ -66,8 +66,8 @@
</div>
<div v-else>
<v-alert v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint"
style="margin-bottom: 16px" :text="metadata[metadataKey]?.hint"
:title="'💡 关于' + metadata[metadataKey]?.description" type="info" variant="tonal">
style="margin-bottom: 8px" :text="metadata[metadataKey]?.hint"
:title="'💡 ' + metadata[metadataKey]?.description" type="info" variant="tonal" color="primary">
</v-alert>
<div style="display: flex; align-items: center; justify-content: center; gap: 16px">
@@ -4,7 +4,7 @@ import { useCommonStore } from '@/stores/common';
<template>
<div id="term"
style="background-color: #1e1e1e; padding: 16px; border-radius: 8px; overflow-y:scroll">
style="background-color: #1e1e1e; padding: 16px; border-radius: 8px; overflow-y:auto">
</div>
</template>
@@ -13,6 +13,7 @@ export default {
name: 'ConsoleDisplayer',
data() {
return {
autoScroll: true, // 默认开启自动滚动
logColorAnsiMap: {
'\u001b[1;34m': 'color: #0000FF; font-weight: bold;', // bold_blue
'\u001b[1;36m': 'color: #00FFFF; font-weight: bold;', // bold_cyan
@@ -54,6 +55,9 @@ export default {
}
},
methods: {
toggleAutoScroll() {
this.autoScroll = !this.autoScroll;
},
printLog(log) {
// append 一个 span 标签到 termblock 的方式
let ele = document.getElementById('term')
@@ -66,11 +70,13 @@ export default {
break
}
}
span.style = style + 'display: block; font-size: 12px; font-family: Consolas, monospace;'
span.style = style + 'display: block; font-size: 12px; font-family: Consolas, monospace; white-space: pre-wrap;'
span.classList.add('fade-in')
span.innerText = log
ele.appendChild(span)
ele.scrollTop = ele.scrollHeight
if (this.autoScroll) {
ele.scrollTop = ele.scrollHeight
}
}
},
}
@@ -4,6 +4,7 @@ const props = defineProps({
link: String,
logo: String,
has_update: Boolean,
activated: Boolean,
});
const open = (link: string | undefined) => {
@@ -16,8 +17,9 @@ const open = (link: string | undefined) => {
<v-card-item style="padding: 10px 12px">
<div class="d-sm-flex align-center justify-space-between">
<img v-if="logo" :src="logo" alt="logo" style="width: 40px; height: 40px; margin-right: 8px;">
<v-card-title style="font-size: 16px;">{{ props.title }}</v-card-title>
<v-card-title style="font-size: 15px; max-width: 70%">{{ props.title }}</v-card-title>
<v-spacer></v-spacer>
<v-icon color="success" v-if="!activated">mdi-cancel</v-icon>
<v-icon color="success" v-if="has_update">mdi-arrow-up-bold</v-icon>
<v-btn size="small" text="Read" variant="flat" border @click="open(props.link)">帮助</v-btn>
</div>
@@ -1,7 +1,7 @@
<template>
<div class="list-config-item">
<h3>{{ label }}</h3>
<v-list dense style="background-color: transparent;max-height: 300px; overflow-y: scroll;" >
<v-list dense style="background-color: transparent;max-height: 300px; overflow-y: auto;" >
<v-list-item v-for="(item, index) in items" :key="index">
<v-list-item-content style="display: flex; justify-content: space-between;">
<v-list-item-title>
@@ -4,17 +4,6 @@
<v-card-title>正在等待 AstrBot 重启...</v-card-title>
<v-card-text>
<v-progress-linear indeterminate color="primary"></v-progress-linear>
<div style="margin-top: 16px;">
<div class="py-12 text-center" v-if="newStartTime != -1">
<v-icon class="mb-6" color="success" icon="mdi-check-circle-outline" size="128"></v-icon>
<p>重启成功</p>
</div>
<small v-if="startTime != -1" style="display: block;">当前实例标识{{ startTime }}</small>
<small v-if="newStartTime != -1" style="display: block;">检查到新实例{{ newStartTime }}即将自动刷新页面</small>
<small v-if="status" style="display: block;">{{ status }}</small>
<small style="display: block;">尝试次数{{ cnt }} / 60</small>
</div>
</v-card-text>
</v-card>
</v-dialog>
@@ -73,11 +62,9 @@ export default {
if (this.newStartTime !== this.startTime) {
this.newStartTime = newStartTime
console.log('wfr: restarted')
setTimeout(() => {
this.visible = false
// reload
window.location.reload()
}, 2000)
this.visible = false
// reload
window.location.reload()
}
return this.newStartTime
}
@@ -19,6 +19,18 @@ let botCurrVersion = ref('');
let dashboardHasNewVersion = ref(false);
let dashboardCurrentVersion = ref('');
let version = ref('');
let releases = ref([]);
let devCommits = ref([]); // 新增的 ref
let tab = ref(0);
let releasesHeader = [
{ title: '标签', key: 'tag_name' },
{ title: '发布时间', key: 'published_at' },
{ title: '内容', key: 'body' },
{ title: '源码地址', key: 'zipball_url' },
{ title: '操作', key: 'switch' }
];
const open = (link: string) => {
window.open(link, '_blank');
@@ -83,10 +95,46 @@ function checkUpdate() {
});
}
function getReleases() {
axios.get('/api/update/releases')
.then((res) => {
// releases.value = res.data.data;
// 更新 published_at 的时间为本地时间
releases.value = res.data.data.map((item: any) => {
item.published_at = new Date(item.published_at).toLocaleString();
return item;
})
})
.catch((err) => {
console.log(err);
});
}
function getDevCommits() {
fetch('https://api.github.com/repos/Soulter/AstrBot/commits', {
headers: {
'Host': 'api.github.com',
'Referer': 'https://api.github.com'
}
})
.then(response => response.json())
.then(data => {
devCommits.value = data.map((commit: any) => ({
sha: commit.sha,
date: new Date(commit.commit.author.date).toLocaleString(),
message: commit.commit.message
}));
})
.catch(err => {
console.log(err);
});
}
function switchVersion(version: string) {
updateStatus.value = '正在切换版本...';
axios.post('/api/update/do', {
version: version
version: version,
proxy: localStorage.getItem('selectedGitHubProxy') || ''
})
.then((res) => {
updateStatus.value = res.data.message;
@@ -150,10 +198,10 @@ commonStore.getStartTime();
</div>
<v-dialog v-model="updateStatusDialog" width="700">
<v-dialog v-model="updateStatusDialog" width="1000">
<template v-slot:activator="{ props }">
<v-btn @click="checkUpdate" class="text-primary mr-4" color="lightprimary" variant="flat" rounded="sm"
v-bind="props">
<v-btn @click="checkUpdate(); getReleases(); getDevCommits();" class="text-primary mr-4" color="lightprimary"
variant="flat" rounded="sm" v-bind="props">
更新 🔄
</v-btn>
</template>
@@ -163,40 +211,80 @@ commonStore.getStartTime();
</v-card-title>
<v-card-text>
<v-container>
<h3 class="mb-4">升级到项目最新版本</h3>
<small>当前版本 {{ botCurrVersion }}</small>
<div class="mb-4">
<small>会同时尝试更新机器人主程序和管理面板如果您正在使用 Docker 部署也可以重新拉取镜像或者使用 <a
href="https://containrrr.dev/watchtower/usage-overview/">watchtower</a> 来自动监控拉取</small>
<small>跳到旧版本或者切换到某个版本不会重新下载管理面板文件这可能会造成部分数据显示错误您可在 <a href="https://github.com/Soulter/AstrBot/releases">此处</a>
找到对应的面板文件 dist.zip解压后替换 data/dist 文件夹即可当然前端源代码在 dashboard 目录下你也可以自己使用 npm install npm build 构建</small>
</div>
<p>{{ updateStatus }}</p>
<v-btn class="mt-4 mb-4" @click="switchVersion('latest')" color="primary" style="border-radius: 10px;"
:disabled="!hasNewVersion">
更新到最新版本
<v-tabs v-model="tab">
<v-tab value="0">正式版</v-tab>
<v-tab value="1">开发版(master 分支)</v-tab>
</v-tabs>
<v-tabs-window v-model="tab">
<!-- 发行版 -->
<v-tabs-window-item key="0" v-show="tab == 0">
<small>当前版本 {{ botCurrVersion }}</small>
<p>{{ updateStatus }}</p>
<v-btn class="mt-4 mb-4" @click="switchVersion('latest')" color="primary" style="border-radius: 10px;"
:disabled="!hasNewVersion">
更新到最新版本
</v-btn>
<div class="mb-4">
<small>`更新到最新版本` 按钮会同时尝试更新机器人主程序和管理面板如果您正在使用 Docker 部署也可以重新拉取镜像或者使用 <a
href="https://containrrr.dev/watchtower/usage-overview/">watchtower</a> 来自动监控拉取</small>
</div>
<v-data-table :headers="releasesHeader" :items="releases" item-key="name">
<template v-slot:item.body="{ item }: { item: { body: string } }">
<v-tooltip :text="item.body">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" rounded="xl" variant="tonal" color="primary" size="small">查看</v-btn>
</template>
</v-tooltip>
</template>
<template v-slot:item.switch="{ item }: { item: { tag_name: string } }">
<v-btn @click="switchVersion(item.tag_name)" rounded="xl" variant="plain" color="primary">
切换
</v-btn>
</template>
</v-data-table>
</v-tabs-window-item>
<!-- 开发版 -->
<v-tabs-window-item key="1" v-show="tab == 1">
<div style="margin-top: 16px;">
<v-data-table
:headers="[{ title: 'SHA', key: 'sha' }, { title: '日期', key: 'date' }, { title: '信息', key: 'message' }, { title: '操作', key: 'switch' }]"
:items="devCommits" item-key="sha">
<template v-slot:item.switch="{ item }: { item: { sha: string } }">
<v-btn @click="switchVersion(item.sha)" rounded="xl" variant="plain" color="primary">
切换
</v-btn>
</template>
</v-data-table>
</div>
</v-tabs-window-item>
</v-tabs-window>
<h3 class="mb-4">手动输入版本号或 Commit SHA</h3>
<v-text-field label="输入版本号或 master 分支下的 commit hash。" v-model="version" required
variant="outlined"></v-text-field>
<div class="mb-4">
<small> v3.3.16 (不带 SHA) 42e5ec5d80b93b6bfe8b566754d45ffac4c3fe0b</small>
<br>
<a href="https://github.com/Soulter/AstrBot/commits/master"><small>查看 master 分支提交记录点击右边的 copy
即可复制</small></a>
</div>
<v-btn color="error" style="border-radius: 10px;" @click="switchVersion(version)">
确定切换
</v-btn>
<v-divider></v-divider>
<div style="margin-top: 16px;">
<h3 class="mb-4">切换到项目指定版本或指定提交</h3>
<div class="mb-4">
<small>跳到旧版本不会重新下载管理面板文件这可能会造成部分数据显示错误您可在 <a href="https://github.com/Soulter/AstrBot/releases">此处</a>
找到对应的面板文件 dist.zip解压后替换 data/dist 文件夹即可</small>
</div>
<v-text-field label="输入版本号或 master 分支下的 commit hash。" v-model="version" required
variant="outlined"></v-text-field>
<div class="mb-4">
<small> v3.3.16 (不带 SHA) 42e5ec5d80b93b6bfe8b566754d45ffac4c3fe0b</small>
<br>
<a href="https://github.com/Soulter/AstrBot/commits/master"><small>查看 master 分支提交记录点击右边的 copy
即可复制</small></a>
</div>
<v-btn color="error" style="border-radius: 10px;" @click="switchVersion(version)">
确定切换
</v-btn>
</div>
<v-divider></v-divider>
<div style="margin-top: 16px;">
<h3 class="mb-4">更新管理面板到最新版本</h3>
<h3 class="mb-4">单独更新管理面板到最新版本</h3>
<div class="mb-4">
<small>当前版本 {{ dashboardCurrentVersion }}</small>
<br>
@@ -267,4 +355,4 @@ commonStore.getStartTime();
</v-card>
</v-dialog>
</v-app-bar>
</template>
</template>
@@ -21,14 +21,14 @@ const sidebarMenu = shallowRef(sidebarItems);
</div>
<div style="position: absolute; bottom: 32px; width: 100%" class="text-center">
<v-list-item v-if="!customizer.mini_sidebar" href="https://astrbot.soulter.top/">
<v-list-item v-if="!customizer.mini_sidebar" href="https://astrbot.app/">
<v-btn variant="plain" size="small">
🤔 初次使用点击查看文档
</v-btn>
</v-list-item>
<small style="display: block;" v-if="buildVer">构建: {{ buildVer }}</small>
<small style="display: block;" v-else>构建: embedded</small>
<v-tooltip text="使用 /dashbord_update 指令更新管理面板">
<v-tooltip text="使用 /dashboard_update 指令更新管理面板">
<template v-slot:activator="{ props }">
<small v-bind="props" v-if="hasWebUIUpdate" style="display: block; margin-top: 4px;">面板有更新</small>
</template>
@@ -21,7 +21,17 @@ const sidebarItem: menu[] = [
to: '/dashboard/default'
},
{
title: '配置文件',
title: '消息平台',
icon: 'mdi-message-processing',
to: '/platforms',
},
{
title: '服务提供商',
icon: 'mdi-creation',
to: '/providers',
},
{
title: '配置',
icon: 'mdi-cog',
to: '/config',
},
+10 -1
View File
@@ -16,12 +16,21 @@ const MainRoutes = {
path: '/extension',
component: () => import('@/views/ExtensionPage.vue')
},
{
name: 'Platforms',
path: '/platforms',
component: () => import('@/views/PlatformPage.vue')
},
{
name: 'Providers',
path: '/providers',
component: () => import('@/views/ProviderPage.vue')
},
{
name: 'Configs',
path: '/config',
component: () => import('@/views/ConfigPage.vue')
},
{
name: 'Default',
path: '/dashboard/default',
+28 -5
View File
@@ -1,18 +1,41 @@
<template>
<v-card style="height: 100%;">
<v-card-text style="padding: 0; height: 100%;">
<v-card-text style="padding: 0; height: 100%; overflow-y: auto;">
<div
style="display: flex; justify-content: center; align-items: center; height: 100%; flex-direction: column;">
<div @click="selectedLogo = selectedLogo == 0 ? 1 : 0" style="height: 300px;">
<img v-if="selectedLogo == 0" width="300" src="@/assets/images/logo-waifu.png" alt="AstrBot Logo" class="fade-in">
<img v-if="selectedLogo == 1" width="300" src="@/assets/images/logo-normal.svg" alt="AstrBot Logo" class="fade-in">
<img v-if="selectedLogo == 0" width="300" src="@/assets/images/logo-waifu.png" alt="AstrBot Logo"
class="fade-in">
<img v-if="selectedLogo == 1" width="300" src="@/assets/images/logo-normal.svg" alt="AstrBot Logo"
class="fade-in">
</div>
<h1 class="mt-8">AstrBot</h1>
<span style="color: #777;" class="mt-4">By <a href="https://soulter.top">Soulter</a> And <a href="https://github.com/Soulter/AstrBot/graphs/contributors">AstrBot Contributors</a></span>
<span class="mt-2" style="color: #777;">A project out of interests and loves </span>
<v-btn class="text-primary mt-16" @click="open('https://github.com/Soulter/AstrBot')"
<span style="color: #777; margin-left: 32px; margin-right: 32px" class="mt-4">By <a
href="https://soulter.top">Soulter</a>, <a
href="https://github.com/Soulter/AstrBot/graphs/contributors">AstrBot Contributors</a>
and <a href="https://github.com/Soulter/AstrBot_Plugins_Collection/graphs/contributors">AstrBot
Plugin Authors</a>
</span>
<!-- Copy-paste in your Readme.md file -->
<img style="margin-top: 16px; width: 50%; max-width: 500px; margin-left: 32px; margin-right: 32px"
alt="Active Contributors of Soulter/AstrBot - Last 28 days"
src="https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=575865240&limit=365&image_size=auto&color_scheme=light">
<img style="margin-top: 16px; width: 50%; max-width: 500px; margin-left: 32px; margin-right: 32px"
alt="Active Contributors of Soulter/AstrBot - Last 28 days"
src="https://next.ossinsight.io/widgets/official/analyze-repo-stars-map/thumbnail.png?activity=stars&repo_id=575865240&image_size=auto&color_scheme=light
">
<!-- Made with [OSS Insight](https://ossinsight.io/) -->
<v-btn class="text-primary mt-8" @click="open('https://github.com/Soulter/AstrBot')"
color="lightprimary" variant="flat" rounded="sm">
Star 这个项目! 🌟
</v-btn>
+7 -1
View File
@@ -56,7 +56,7 @@ marked.setOptions({
<div style="margin-top: 8px; color: #aaa;">
<span>输入</span>
<span
style="background-color: #eee; padding-left: 4px; padding-right: 4px; margin: 2px; border-radius: 4px;">/help</span>
style="background-color: #eee; padding-left: 4px; padding-right: 4px; margin: 2px; border-radius: 4px;">help</span>
<span>获取帮助 😊</span>
</div>
<div style="margin-top: 8px; color: #aaa;">
@@ -65,6 +65,12 @@ marked.setOptions({
style="background-color: #eee; padding-left: 4px; padding-right: 4px; margin: 2px; border-radius: 4px;">K</span>
<span>开始语音 🎤</span>
</div>
<div style="margin-top: 8px; color: #aaa;">
<span>按</span>
<span
style="background-color: #eee; padding-left: 4px; padding-right: 4px; margin: 2px; border-radius: 4px;">Ctrl + V</span>
<span>粘贴图片 🏞️</span>
</div>
</div>
<div v-else style="max-height: 100%; padding: 16px; max-width: 700px; margin: 0 auto;">
+6 -1
View File
@@ -44,6 +44,11 @@ import config from '@/config';
</v-expansion-panel-title>
<v-expansion-panel-text v-if="metadata[key]['metadata'][key2]?.config_template">
<!-- 带有 config_template 的配置项 -->
<v-alert style="margin-top: 16px; margin-bottom: 16px" color="primary" variant="tonal" v-if="key2 === 'platform' || key2 === 'provider'">
😄 消息平台适配器和服务提供商的配置已经迁移至更方便的独立页面推荐前往左栏配置哦
</v-alert>
<v-tabs style="margin-top: 16px;" align-tabs="left" color="deep-purple-accent-4" v-model="config_template_tab">
<v-tab v-if="metadata[key]['metadata'][key2]?.tmpl_display_title" v-for="(item, index) in config_data[key2]" :key="index" :value="index">
{{ item[metadata[key]['metadata'][key2]?.tmpl_display_title] }}
@@ -91,7 +96,7 @@ import config from '@/config';
<div style="margin-left: 16px; padding-bottom: 16px">
<small>不了解配置请见 <a
href="https://astrbot.soulter.top/">官方文档</a>
href="https://astrbot.app/">官方文档</a>
<a
href="https://qm.qq.com/cgi-bin/qm/qr?k=EYGsuUTfe00_iOu9JTXS7_TEpMkXOvwv&jump_from=webapi&authKey=uUEMKCROfsseS+8IzqPjzV3y1tzy4AkykwTib2jNkOFdzezF9s9XknqnIaf3CDft">加群询问</a></small>
</div>
+43 -28
View File
@@ -9,34 +9,42 @@ import axios from 'axios';
<div
style="background-color: white; padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px; display: flex; flex-direction: row; align-items: center; justify-content: space-between;">
<h4>控制台</h4>
<v-dialog v-model="pipDialog" width="400">
<template v-slot:activator="{ props }">
<v-btn variant="plain" v-bind="props">安装 pip </v-btn>
</template>
<v-card>
<v-card-title>
<span class="text-h5">安装 Pip </span>
</v-card-title>
<v-card-text>
<v-text-field v-model="pipInstallPayload.package" label="*库名,如 llmtuner" variant="outlined"></v-text-field>
<v-text-field v-model="pipInstallPayload.mirror" label="镜像站链接(可选)" variant="outlined"></v-text-field>
<small>如果不填镜像站链接默认使用阿里云镜像https://mirrors.aliyun.com/pypi/simple/</small>
<div>
<small>{{ status }}</small>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="pipInstall" :loading="loading">
安装
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<div class="d-flex align-center">
<v-switch
v-model="autoScrollDisabled"
:label="autoScrollDisabled ? '自动滚动已关闭' : '自动滚动已开启'"
hide-details
density="compact"
style="margin-right: 16px;"
></v-switch>
<v-dialog v-model="pipDialog" width="400">
<template v-slot:activator="{ props }">
<v-btn variant="plain" v-bind="props">安装 pip </v-btn>
</template>
<v-card>
<v-card-title>
<span class="text-h5">安装 Pip </span>
</v-card-title>
<v-card-text>
<v-text-field v-model="pipInstallPayload.package" label="*库名,如 llmtuner" variant="outlined"></v-text-field>
<v-text-field v-model="pipInstallPayload.mirror" label="镜像站链接(可选)" variant="outlined"></v-text-field>
<small>如果不填镜像站链接默认使用阿里云镜像https://mirrors.aliyun.com/pypi/simple/</small>
<div>
<small>{{ status }}</small>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="pipInstall" :loading="loading">
安装
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</div>
<ConsoleDisplayer style="height: calc(100vh - 160px); " />
<ConsoleDisplayer ref="consoleDisplayer" style="height: calc(100vh - 160px); " />
</div>
</template>
<script>
@@ -47,6 +55,7 @@ export default {
},
data() {
return {
autoScrollDisabled: false,
pipDialog: false,
pipInstallPayload: {
package: '',
@@ -56,7 +65,13 @@ export default {
status: ''
}
},
watch: {
autoScrollDisabled(val) {
if (this.$refs.consoleDisplayer) {
this.$refs.consoleDisplayer.autoScroll = !val;
}
}
},
methods: {
pipInstall() {
this.loading = true;
+91 -50
View File
@@ -10,8 +10,8 @@ import { max } from 'date-fns';
<template>
<v-row>
<v-alert style="margin: 16px" text="1. 如果因为网络问题安装失败,点击设置页选择 GitHub 加速地址。或前往仓库下载压缩包然后本地上传。" title="💡提示"
type="info" color="primary" variant="tonal">
<v-alert style="margin: 16px" text="1. 如果因为网络问题安装失败,点击设置页选择 GitHub 加速地址。或前往仓库下载压缩包然后本地上传。" title="💡提示" type="info"
color="primary" variant="tonal">
</v-alert>
<v-col cols="12" md="12">
<div style="background-color: white; width: 100%; padding: 16px; border-radius: 10px;">
@@ -44,14 +44,14 @@ import { max } from 'date-fns';
</v-dialog>
</div>
</div>
</v-col>
</v-col>
<v-col cols="12" md="6" lg="3" v-for="extension in extension_data.data">
<ExtensionCard :key="extension.name" :title="extension.name" :link="extension.repo" :logo="extension?.logo"
:has_update="extension.has_update" style="margin-bottom: 4px;">
:has_update="extension.has_update" style="margin-bottom: 4px;" :activated="extension.activated">
<div style="min-height: 140px; max-height: 140px; overflow: auto;">
<div>
<span style="font-weight: bold ;">By @{{ extension.author }}</span>
<span> | 插件有 {{ extension.handlers.length }} 个行为</span>
<span> | {{ extension.handlers.length }} 个行为</span>
</div>
<span> 当前: <v-chip size="small" color="primary">{{ extension.version }}</v-chip>
<span v-if="extension.online_version">
@@ -82,51 +82,88 @@ import { max } from 'date-fns';
</div>
</ExtensionCard>
</v-col>
<v-col cols="12" md="12">
<div style="background-color: white; width: 100%; padding: 16px; border-radius: 10px;">
<div style="display: flex; align-items: center;">
<h3>🧩 插件市场</h3>
<small style="margin-left: 16px;">如无法显示请打开 <a
href="https://soulter.github.io/AstrBot_Plugins_Collection/plugins.json">链接</a> 复制想安装插件对应的 `repo`
链接然后点击右下角 + 号安装或打开链接下载压缩包安装</small>
<v-btn icon @click="isListView = !isListView" size="small" style="margin-left: auto;" variant="plain">
<v-icon>{{ isListView ? 'mdi-view-grid' : 'mdi-view-list' }}</v-icon>
</v-btn>
</div>
</div>
</v-col>
<v-col cols="12" md="12" v-if="announcement">
<v-banner color="success" lines="one" :text="announcement" :stacked="false">
</v-banner>
</v-col>
<template v-if="isListView">
<v-col cols="12" md="12">
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name">
<template v-slot:item.actions="{ item }">
<v-btn v-if="!item.installed" class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="extension_url = item.repo; newExtension()">安装</v-btn>
<v-btn v-else class="text-none mr-2" size="small" text="Read" variant="flat" border disabled>已安装</v-btn>
</template>
</v-data-table>
</v-col>
</template>
<template v-else>
<v-col cols="12" md="6" lg="3" v-for="plugin in pluginMarketData">
<ExtensionCard :key="plugin.name" :title="plugin.name" :link="plugin.repo" style="margin-bottom: 4px;">
<div style="min-height: 130px; max-height: 130px; overflow: hidden;">
<p style="font-weight: bold;">By @{{ plugin.author }}</p>
{{ plugin.desc }}
</div>
<div class="d-flex align-center gap-2">
<v-btn v-if="!plugin.installed" class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="extension_url = plugin.repo; newExtension()">安装</v-btn>
<v-btn v-else class="text-none mr-2" size="small" text="Read" variant="flat" border disabled>已安装</v-btn>
</div>
</ExtensionCard>
</v-col>
</template>
<v-col cols="12" md="12">
<v-card>
<v-card-title class="d-flex align-center pe-2">
🧩 插件市场
<v-btn icon size="small" style="margin-left: 8px" variant="plain">
<v-icon size="small">mdi-help</v-icon>
<v-tooltip activator="parent" location="start">
如无法显示请打开 <a href="https://soulter.github.io/AstrBot_Plugins_Collection/plugins.json">链接</a> 复制想安装插件对应的
`repo`
链接然后点击右下角 + 号安装或打开链接下载压缩包安装
</v-tooltip>
</v-btn>
<v-btn icon @click="isListView = !isListView" size="small" style="margin-left: auto;" variant="plain">
<v-icon>{{ isListView ? 'mdi-view-grid' : 'mdi-view-list' }}</v-icon>
</v-btn>
<v-spacer></v-spacer>
<v-text-field v-model="marketSearch" density="compact" label="Search" prepend-inner-icon="mdi-magnify"
variant="solo-filled" flat hide-details single-line></v-text-field>
</v-card-title>
<v-divider></v-divider>
<template v-if="isListView">
<v-col cols="12" md="12">
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name"
v-model:search="marketSearch" :filter-keys="['name']">
<template v-slot:item.name="{ item }">
<span v-if="item?.repo"><a :href="item?.repo" style="color: #000; text-decoration:none">{{ item.name }}</a></span>
<span v-else>{{ item.name}}</span>
</template>
<template v-slot:item.author="{ item }">
<span v-if="item?.social_link"><a :href="item?.social_link">{{ item.author}}</a></span>
<span v-else>{{ item.author}}</span>
</template>
<template v-slot:item.tags="{ item }">
<span v-if="item.tags.length === 0"></span>
<v-chip v-for="tag in item.tags" :key="tag" color="primary" size="small">{{ tag }}</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<v-btn v-if="!item.installed" class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="extension_url = item.repo; newExtension()">安装</v-btn>
<v-btn v-else class="text-none mr-2" size="small" text="Read" variant="flat" border disabled>已安装</v-btn>
</template>
</v-data-table>
</v-col>
</template>
<template v-else>
<v-row style="margin: 8px;">
<v-col cols="12" md="6" lg="3" v-for="plugin in pluginMarketData">
<ExtensionCard :key="plugin.name" :title="plugin.name" :link="plugin.repo" style="margin-bottom: 4px;">
<div style="min-height: 130px; max-height: 130px; overflow: hidden;">
<p style="font-weight: bold;">By @{{ plugin.author }}</p>
{{ plugin.desc }}
</div>
<div class="d-flex align-center gap-2">
<v-btn v-if="!plugin.installed" class="text-none mr-2" size="small" text="Read" variant="flat"
border @click="extension_url = plugin.repo; newExtension()">安装</v-btn>
<v-btn v-else class="text-none mr-2" size="small" text="Read" variant="flat" border
disabled>已安装</v-btn>
</div>
</ExtensionCard>
</v-col>
</v-row>
</template>
</v-card>
</v-col>
<v-col style="margin-bottom: 16px;" cols="12" md="12">
<small><a href="https://astrbot.app/dev/plugin.html">插件开发文档</a></small> |
@@ -314,7 +351,7 @@ export default {
"config": {}
},
upload_file: null,
pluginMarketData: {},
pluginMarketData: [],
loadingDialog: {
show: false,
title: "加载中...",
@@ -331,13 +368,15 @@ export default {
{ title: '具体类型', key: 'type' },
{ title: '触发方式', key: 'cmd' },
],
isListView: false,
isListView: true,
pluginMarketHeaders: [
{ title: '名称', value: 'name' },
{ title: '描述', value: 'desc' },
{ title: '作者', value: 'author' },
{ title: '操作', value: 'actions', sortable: false }
{ title: '名称', key: 'name', maxWidth: '150px' },
{ title: '描述', key: 'desc', maxWidth: '250px' },
{ title: '作者', key: 'author', maxWidth: '60px' },
{ title: '标签', key: 'tags', maxWidth: '60px' },
{ title: '操作', key: 'actions', sortable: false }
],
marketSearch: "",
alreadyCheckUpdate: false
}
},
@@ -562,6 +601,8 @@ export default {
"repo": res.data.data[key].repo,
"installed": false,
"version": res.data.data[key]?.version ? res.data.data[key].version : "未知",
"social_link": res.data.data[key]?.social_link,
"tags": res.data.data[key]?.tags ? res.data.data[key].tags : []
})
}
this.pluginMarketData = data;
+241
View File
@@ -0,0 +1,241 @@
<template>
<v-card style="height: 100%;">
<v-card-text style="padding: 32px; height: 100%;">
<v-menu>
<template v-slot:activator="{ props }">
<v-btn class="flex-grow-1" variant="tonal" @click="new_platform_dialog = true" size="large"
rounded="lg" v-bind="props" color="primary">
<template v-slot:default>
<v-icon>mdi-plus</v-icon>
新增平台适配器
</template>
</v-btn>
</template>
<v-list @update:selected="addFromDefaultConfigTmpl($event)">
<v-list-item
v-for="(item, index) in metadata['platform_group']['metadata']['platform'].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-row style="margin-top: 16px;">
<v-col v-for="(platform, index) in config_data['platform']" :key="index" cols="12" md="6" lg="3">
<v-card class="fade-in"
style="margin-bottom: 16px; min-height: 200px; display: flex; justify-content: space-between; flex-direction: column;">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h4">{{ platform.id }}</span>
<v-switch color="primary" hide-details density="compact" v-model="platform['enable']"
@update:modelValue="platformStatusChange(platform)"></v-switch>
</v-card-title>
<v-card-text>
<div>
<span style="font-size:12px">适配器类型: </span>
<v-chip size="small" color="primary" text>{{ platform.type }}</v-chip>
</div>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="error" text @click="deletePlatform(platform.id);">
删除
</v-btn>
<v-btn color="blue-darken-1" text
@click="updatingMode = true; showPlatformCfg = true; newSelectedPlatformConfig = platform; newSelectedPlatformName = platform.id">
配置
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-dialog v-model="showPlatformCfg" width="700">
<v-card>
<v-card-title>
<span class="text-h4">{{ newSelectedPlatformName }} 配置</span>
</v-card-title>
<v-card-text>
<AstrBotConfig :iterable="newSelectedPlatformConfig"
:metadata="metadata['platform_group']['metadata']" metadataKey="platform" />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="newPlatform" :loading="loading">
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-btn style="margin-top: 16px" class="flex-grow-1" variant="tonal" size="large" rounded="lg" color="gray" @click="showConsole = !showConsole">
<template v-slot:default>
<v-icon>mdi-console-line</v-icon>
{{ showConsole ? '隐藏' : '显示' }}日志
</template>
</v-btn>
<div v-if="showConsole" style="margin-top: 32px; ">
<ConsoleDisplayer style="background-color: #fff; height: 300px"></ConsoleDisplayer>
</div>
</v-card-text>
</v-card>
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
{{ save_message }}
</v-snackbar>
<WaitingForRestart ref="wfr"></WaitingForRestart>
</template>
<script>
import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
export default {
name: 'PlatformPage',
components: {
AstrBotConfig,
WaitingForRestart,
ConsoleDisplayer
},
data() {
return {
config_data: {},
fetched: false,
metadata: {},
showPlatformCfg: false,
newSelectedPlatformName: '',
newSelectedPlatformConfig: {},
updatingMode: false,
loading: false,
save_message_snack: false,
save_message: "",
save_message_success: "",
showConsole: false,
}
},
mounted() {
this.getConfig();
},
methods: {
getConfig() {
// 获取配置
axios.get('/api/config/get').then((res) => {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
},
addFromDefaultConfigTmpl(index) {
// 从默认配置模板中添加
console.log(index);
this.newSelectedPlatformName = index[0];
this.showPlatformCfg = true;
this.updatingMode = false;
this.newSelectedPlatformConfig = this.metadata['platform_group']['metadata']['platform'].config_template[index[0]];
},
newPlatform() {
// 新建或者更新平台
this.loading = true;
if (this.updatingMode) {
axios.post('/api/config/platform/update', {
id: this.newSelectedPlatformName,
config: this.newSelectedPlatformConfig
}).then((res) => {
this.loading = false;
this.showPlatformCfg = false;
this.getConfig();
this.$refs.wfr.check();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.loading = false;
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
this.updatingMode = false;
} else {
axios.post('/api/config/platform/new', this.newSelectedPlatformConfig).then((res) => {
this.loading = false;
this.showPlatformCfg = false;
this.getConfig();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.loading = false;
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
}
},
deletePlatform(platform_id) {
// 删除平台
axios.post('/api/config/platform/delete', { id: platform_id }).then((res) => {
this.getConfig();
this.$refs.wfr.check();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
},
platformStatusChange(platform) {
// 平台状态改变
axios.post('/api/config/platform/update', {
id: platform.id,
config: platform
}).then((res) => {
this.getConfig();
this.$refs.wfr.check();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
}
}
}
</script>
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fadeIn 0.2s ease-in-out;
}
</style>
+240
View File
@@ -0,0 +1,240 @@
<template>
<v-card style="height: 100%;">
<v-card-text style="padding: 32px; height: 100%;">
<v-menu>
<template v-slot:activator="{ props }">
<v-btn class="flex-grow-1" variant="tonal" @click="new_provider_dialog = true" size="large"
rounded="lg" v-bind="props" color="primary">
<template v-slot:default>
<v-icon>mdi-plus</v-icon>
新增服务提供商
</template>
</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-row style="margin-top: 16px;">
<v-col v-for="(provider, index) in config_data['provider']" :key="index" cols="12" md="6" lg="3">
<v-card class="fade-in" style="margin-bottom: 16px; min-height: 200px; display: flex; justify-content: space-between; flex-direction: column;">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h4">{{ provider.id }}</span>
<v-switch color="primary" hide-details density="compact" v-model="provider['enable']"
@update:modelValue="providerStatusChange(provider)"></v-switch>
</v-card-title>
<v-card-text>
<div>
<span style="font-size:12px">适配器类型: </span> <v-chip size="small" color="primary" text>{{ provider.type }}</v-chip>
</div>
<div v-if="provider?.api_base" style="margin-top: 8px;">
<span style="font-size:12px">API Base: </span> <v-chip size="small" color="primary" text>{{ provider?.api_base }}</v-chip>
</div>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="error" text @click="deleteprovider(provider.id);">
删除
</v-btn>
<v-btn color="blue-darken-1" text
@click="updatingMode = true; showproviderCfg = true; newSelectedproviderConfig = provider; newSelectedproviderName = provider.id">
配置
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-dialog v-model="showproviderCfg" width="700">
<v-card>
<v-card-title>
<span class="text-h4">{{ newSelectedproviderName }} 配置</span>
</v-card-title>
<v-card-text>
<AstrBotConfig :iterable="newSelectedproviderConfig"
:metadata="metadata['provider_group']['metadata']" metadataKey="provider" />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="newprovider" :loading="loading">
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-btn style="margin-top: 16px" class="flex-grow-1" variant="tonal" size="large" rounded="lg" color="gray" @click="showConsole = !showConsole">
<template v-slot:default>
<v-icon>mdi-console-line</v-icon>
{{ showConsole ? '隐藏' : '显示' }}日志
</template>
</v-btn>
<div v-if="showConsole" style="margin-top: 32px; ">
<ConsoleDisplayer style="background-color: #fff; height: 300px"></ConsoleDisplayer>
</div>
</v-card-text>
</v-card>
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
{{ save_message }}
</v-snackbar>
<WaitingForRestart ref="wfr"></WaitingForRestart>
</template>
<script>
import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
export default {
name: 'ProviderPage',
components: {
AstrBotConfig,
WaitingForRestart,
ConsoleDisplayer
},
data() {
return {
config_data: {},
fetched: false,
metadata: {},
showproviderCfg: false,
newSelectedproviderName: '',
newSelectedproviderConfig: {},
updatingMode: false,
loading: false,
save_message_snack: false,
save_message: "",
save_message_success: "",
showConsole: false,
}
},
mounted() {
this.getConfig();
},
methods: {
getConfig() {
// 获取配置
axios.get('/api/config/get').then((res) => {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
}).catch((err) => {
save_message = err;
save_message_snack = true;
save_message_success = "error";
});
},
addFromDefaultConfigTmpl(index) {
// 从默认配置模板中添加
console.log(index);
this.newSelectedproviderName = index[0];
this.showproviderCfg = true;
this.updatingMode = false;
this.newSelectedproviderConfig = this.metadata['provider_group']['metadata']['provider'].config_template[index[0]];
},
newprovider() {
// 新建或者更新平台
this.loading = true;
if (this.updatingMode) {
axios.post('/api/config/provider/update', {
id: this.newSelectedproviderName,
config: this.newSelectedproviderConfig
}).then((res) => {
this.loading = false;
this.showproviderCfg = false;
this.getConfig();
// this.$refs.wfr.check();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.loading = false;
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
this.updatingMode = false;
} else {
axios.post('/api/config/provider/new', this.newSelectedproviderConfig).then((res) => {
this.loading = false;
this.showproviderCfg = false;
this.getConfig();
}).catch((err) => {
this.loading = false;
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
}
},
deleteprovider(provider_id) {
// 删除平台
axios.post('/api/config/provider/delete', { id: provider_id }).then((res) => {
this.getConfig();
// this.$refs.wfr.check();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
},
providerStatusChange(provider) {
// 平台状态改变
axios.post('/api/config/provider/update', {
id: provider.id,
config: provider
}).then((res) => {
this.getConfig();
// this.$refs.wfr.check();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
}
}
}
</script>
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fadeIn 0.2s ease-in-out;
}
</style>
+22 -2
View File
@@ -5,23 +5,39 @@
<v-list lines="two">
<v-list-subheader>网络</v-list-subheader>
<v-list-item subtitle="设置下载插件时所用的 GitHub 加速地址。这在中国大陆的网络环境有效。可以自定义,输入结果实时生效" title="GitHub 加速地址">
<v-list-item subtitle="设置下载插件或者更新 AstrBot 时所用的 GitHub 加速地址。这在中国大陆的网络环境有效。可以自定义,输入结果实时生效" title="GitHub 加速地址">
<v-combobox variant="outlined" style="width: 100%; margin-top: 16px;" v-model="selectedGitHubProxy" :items="githubProxies"
label="选择 GitHub 加速地址">
</v-combobox>
</v-list-item>
<v-list-subheader>系统</v-list-subheader>
<v-list-item subtitle="重启 AstrBot" title="重启">
<v-btn style="margin-top: 16px;" color="error" @click="restartAstrBot">重启</v-btn>
</v-list-item>
</v-list>
</div>
<WaitingForRestart ref="wfr"></WaitingForRestart>
</template>
<script>
import axios from 'axios';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
export default {
components: {
WaitingForRestart,
},
data() {
return {
githubProxies: [
@@ -35,7 +51,11 @@ export default {
}
},
methods: {
restartAstrBot() {
axios.post('/api/stat/restart-core').then(() => {
this.$refs.wfr.check();
})
}
},
mounted() {
this.selectedGitHubProxy = localStorage.getItem('selectedGitHubProxy') || "";
@@ -72,6 +72,11 @@ export default {
type: 'datetime',
title: {
text: '时间'
},
labels: {
formatter: function (value) {
return new Date(value).toLocaleString();
}
}
},
yaxis: {
+7 -4
View File
@@ -32,6 +32,7 @@ class LongTermMemory:
self.ar_method = self.active_reply["method"]
self.ar_possibility = self.active_reply["possibility_reply"]
self.ar_prompt = self.active_reply.get("prompt", "")
self.ar_whitelist = self.active_reply.get("whitelist", [])
# self.put_history_to_prompt = self.config["put_history_to_prompt"]
@@ -67,6 +68,12 @@ class LongTermMemory:
if event.is_at_or_wake_command:
# if the message is a command, let it pass
return False
if self.ar_whitelist and (
event.unified_msg_origin not in self.ar_whitelist
and (event.get_group_id() and event.get_group_id() not in self.ar_whitelist)
):
return False
match self.ar_method:
case "possibility_reply":
@@ -118,10 +125,6 @@ class LongTermMemory:
else:
req.system_prompt += "You are now in a chatroom. The chat history is as follows: \n"
req.system_prompt += chats_str
if self.image_caption:
req.system_prompt += (
"The images sent by the members are displayed in text form above."
)
async def after_req_llm(self, event: AstrMessageEvent):
if event.unified_msg_origin not in self.session_chats:
+182 -64
View File
@@ -6,8 +6,8 @@ import astrbot.api.star as star
import astrbot.api.event.filter as filter
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.api import sp
from astrbot.api.platform import MessageType
from astrbot.api.provider import Personality, ProviderRequest, LLMResponse
from astrbot.api.provider import ProviderRequest
from astrbot.core.provider.sources.dify_source import ProviderDify
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
from astrbot.core.star.star_handler import star_handlers_registry, StarHandlerMetadata
from astrbot.core.star.star import star_map
@@ -39,7 +39,7 @@ class Main(star.Star):
async def _query_astrbot_notice(self):
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get("https://astrbot.soulter.top/notice.json", timeout=2) as resp:
async with session.get("https://astrbot.app/notice.json", timeout=2) as resp:
return (await resp.json())["notice"]
except BaseException:
return ""
@@ -74,8 +74,8 @@ AstrBot 指令:
/model: 模型列表
/ls: 对话列表
/new: 创建新对话
/switch: 切换对话
/rename: 重命名对话
/switch 序号: 切换对话
/rename 新名字: 重命名当前对话
/del: 删除当前会话对话(op)
/reset: 重置 LLM 会话(op)
/history: 当前对话的对话记录
@@ -83,9 +83,6 @@ AstrBot 指令:
/tool ls: 函数工具
/key: API Key(op)
/websearch: 网页搜索
[其他]
/set 变量名 值: 为会话定义变量(Dify 工作流输入)
{notice}"""
event.set_result(MessageEventResult().message(msg).use_t2i(False))
@@ -96,6 +93,7 @@ AstrBot 指令:
@tool.command("ls")
async def tool_ls(self, event: AstrMessageEvent):
'''查看函数工具列表'''
tm = self.context.get_llm_tool_manager()
msg = "函数工具:\n"
for tool in tm.func_list:
@@ -107,6 +105,7 @@ AstrBot 指令:
@tool.command("on")
async def tool_on(self, event: AstrMessageEvent, tool_name: str):
'''启用一个函数工具'''
if self.context.activate_llm_tool(tool_name):
event.set_result(MessageEventResult().message(f"激活工具 {tool_name} 成功。"))
else:
@@ -114,6 +113,7 @@ AstrBot 指令:
@tool.command("off")
async def tool_off(self, event: AstrMessageEvent, tool_name: str):
'''停用一个函数工具'''
if self.context.deactivate_llm_tool(tool_name):
event.set_result(MessageEventResult().message(f"停用工具 {tool_name} 成功。"))
else:
@@ -121,6 +121,7 @@ AstrBot 指令:
@tool.command("off_all")
async def tool_all_off(self, event: AstrMessageEvent):
'''停用所有函数工具'''
tm = self.context.get_llm_tool_manager()
for tool in tm.func_list:
self.context.deactivate_llm_tool(tool.name)
@@ -128,6 +129,7 @@ AstrBot 指令:
@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():
@@ -189,6 +191,7 @@ AstrBot 指令:
@filter.command("t2i")
async def t2i(self, event: AstrMessageEvent):
'''开关文本转图片'''
config = self.context.get_config()
if config['t2i']:
config['t2i'] = False
@@ -201,6 +204,7 @@ AstrBot 指令:
@filter.command("tts")
async def tts(self, event: AstrMessageEvent):
'''开关文本转语音'''
config = self.context.get_config()
if config['provider_tts_settings']['enable']:
config['provider_tts_settings']['enable'] = False
@@ -213,6 +217,7 @@ AstrBot 指令:
@filter.command("sid")
async def sid(self, event: AstrMessageEvent):
'''获取会话 ID 和 管理员 ID'''
sid = event.unified_msg_origin
user_id = str(event.get_sender_id())
ret = f"""SID: {sid} 此 ID 可用于设置会话白名单。/wl <SID> 添加白名单, /dwl <SID> 删除白名单。
@@ -222,6 +227,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("op")
async def op(self, event: AstrMessageEvent, admin_id: str):
'''授权管理员。op <admin_id>'''
self.context.get_config()['admins_id'].append(admin_id)
self.context.get_config().save_config()
event.set_result(MessageEventResult().message("授权成功。"))
@@ -229,6 +235,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("deop")
async def deop(self, event: AstrMessageEvent, admin_id: str):
'''取消授权管理员。deop <admin_id>'''
try:
self.context.get_config()['admins_id'].remove(admin_id)
self.context.get_config().save_config()
@@ -340,14 +347,26 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
else:
event.set_result(MessageEventResult().message("无效的参数。"))
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("reset")
async def reset(self, message: AstrMessageEvent):
'''重置 LLM 会话'''
is_unique_session = self.context.get_config()['platform_settings']['unique_session']
if message.get_group_id() and not is_unique_session and message.role != "admin":
# 群聊,没开独立会话,发送人不是管理员
message.set_result(MessageEventResult().message(f"会话处于群聊,并且未开启独立会话,并且您 (ID {message.get_sender_id()}) 不是管理员,因此没有权限重置当前对话。"))
return
if not self.context.get_using_provider():
message.set_result(MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"))
return
provider = self.context.get_using_provider()
if provider and provider.meta().type == 'dify':
assert isinstance(provider, ProviderDify)
await provider.forget(message.unified_msg_origin)
message.set_result(MessageEventResult().message("已重置当前 Dify 会话,新聊天将更换到新的会话。"))
return
cid = await self.context.conversation_manager.get_curr_conversation_id(message.unified_msg_origin)
if not cid:
@@ -385,6 +404,10 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
for model in models:
ret += f"\n{i}. {model}"
i += 1
curr_model = self.context.get_using_provider().get_model() or ""
ret += f"\n当前模型: [{curr_model}]"
ret += "\nTips: 使用 /model <模型名/编号>,即可实时更换模型。如目标模型不存在于上表,请输入模型名。"
message.set_result(MessageEventResult().message(ret).use_t2i(False))
else:
@@ -410,7 +433,6 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
message.set_result(
MessageEventResult().message(f"切换模型到 {self.context.get_using_provider().get_model()}"))
@filter.command("history")
async def his(self, message: AstrMessageEvent, page: int = 1):
'''查看对话记录'''
@@ -422,7 +444,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
session_curr_cid = await self.context.conversation_manager.get_curr_conversation_id(message.unified_msg_origin)
if not session_curr_cid:
message.set_result(MessageEventResult().message("当前未处于对话状态,请 /switch 切换或者 /new 创建。"))
message.set_result(MessageEventResult().message("当前未处于对话状态,请 /switch 序号 切换或者 /new 创建。"))
return
contexts, total_pages = await self.context.conversation_manager.get_human_readable_context(
@@ -447,33 +469,65 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.command("ls")
async def convs(self, message: AstrMessageEvent, page: int = 1):
'''查看对话列表'''
provider = self.context.get_using_provider()
if provider and provider.meta().type == 'dify':
"""原有的Dify处理逻辑保持不变"""
ret = "Dify 对话列表:\n"
assert isinstance(provider, ProviderDify)
data = await provider.api_client.get_chat_convs(message.unified_msg_origin)
idx = 1
for conv in data['data']:
ts_h = datetime.datetime.fromtimestamp(conv['updated_at']).strftime('%m-%d %H:%M')
ret += f"{idx}. {conv['name']}({conv['id'][:4]})\n 上次更新:{ts_h}\n"
idx += 1
if idx == 1:
ret += "没有找到任何对话。"
dify_cid = provider.conversation_ids.get(message.unified_msg_origin, None)
ret += f"\n\n用户: {message.unified_msg_origin}\n当前对话: {dify_cid}\n使用 /switch <序号> 切换对话。"
message.set_result(MessageEventResult().message(ret))
return
size_per_page = 6
conversations = await self.context.conversation_manager.get_conversations(message.unified_msg_origin)
total_pages = len(conversations) // size_per_page
if len(conversations) % size_per_page != 0:
total_pages += 1
conversations = conversations[(page-1)*size_per_page:page*size_per_page]
"""获取所有对话列表"""
conversations_all = await self.context.conversation_manager.get_conversations(message.unified_msg_origin)
"""计算总页数"""
total_pages = (len(conversations_all) + size_per_page - 1) // size_per_page
"""确保页码有效"""
page = max(1, min(page, total_pages))
"""分页处理"""
start_idx = (page - 1) * size_per_page
end_idx = start_idx + size_per_page
conversations_paged = conversations_all[start_idx:end_idx]
ret = "对话列表:\n---\n"
global_index = (page - 1) * size_per_page + 1
"""全局序号从当前页的第一个开始"""
global_index = start_idx + 1
"""生成所有对话的标题字典"""
_titles = {}
for conv in conversations:
for conv in conversations_all:
persona_id = conv.persona_id
if not persona_id and not persona_id == "[%None]":
if not persona_id or persona_id == "[%None]":
persona_id = self.context.provider_manager.selected_default_persona['name']
title = conv.title if conv.title else "新对话"
_titles[conv.cid] = title
"""遍历分页后的对话生成列表显示"""
for conv in conversations_paged:
persona_id = conv.persona_id
if not persona_id or persona_id == "[%None]":
persona_id = self.context.provider_manager.selected_default_persona['name']
title = _titles.get(conv.cid, "新对话")
ret += f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_id}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
global_index += 1
ret += "---\n"
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(message.unified_msg_origin)
if curr_cid:
ret += f"\n当前对话: {_titles[curr_cid]}({curr_cid[:4]})"
"""从所有对话的标题字典中获取标题"""
title = _titles.get(curr_cid, "新对话")
ret += f"\n当前对话: {title}({curr_cid[:4]})"
else:
ret += "\n当前对话: 无"
@@ -482,21 +536,54 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
ret += "\n会话隔离粒度: 个人"
else:
ret += "\n会话隔离粒度: 群聊"
ret += f"\n{page} 页 | 共 {total_pages}"
ret += "\n*输入 /ls 2 跳转到第 2 页"
message.set_result(MessageEventResult().message(ret).use_t2i(False))
return
@filter.command("new")
async def new_conv(self, message: AstrMessageEvent):
'''创建新对话'''
provider = self.context.get_using_provider()
if provider and provider.meta().type == 'dify':
assert isinstance(provider, ProviderDify)
await provider.forget(message.unified_msg_origin)
message.set_result(MessageEventResult().message("成功,下次聊天将是新对话。"))
return
cid = await self.context.conversation_manager.new_conversation(message.unified_msg_origin)
message.set_result(MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。"))
@filter.command("switch")
async def switch_conv(self, message: AstrMessageEvent, index: int):
async def switch_conv(self, message: AstrMessageEvent, index: int = None):
'''通过 /ls 前面的序号切换对话'''
provider = self.context.get_using_provider()
if provider and provider.meta().type == 'dify':
assert isinstance(provider, ProviderDify)
data = await provider.api_client.get_chat_convs(message.unified_msg_origin)
if not data['data']:
message.set_result(MessageEventResult().message("未找到任何对话。"))
return
selected_conv = None
if index is not None:
try:
selected_conv = data['data'][index-1]
except IndexError:
message.set_result(MessageEventResult().message("对话序号错误,请使用 /ls 查看"))
return
else:
selected_conv = data['data'][0]
ret = f"Dify 切换到对话: {selected_conv['name']}({selected_conv['id'][:4]})。"
provider.conversation_ids[message.unified_msg_origin] = selected_conv['id']
message.set_result(MessageEventResult().message(ret))
return
if index is None:
message.set_result(MessageEventResult().message("请输入对话序号。/switch 对话序号。/ls 查看对话 /new 新建对话"))
return
conversations = await self.context.conversation_manager.get_conversations(message.unified_msg_origin)
if index > len(conversations) or index < 1:
message.set_result(MessageEventResult().message("对话序号错误,请使用 /ls 查看"))
@@ -509,23 +596,47 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.command("rename")
async def rename_conv(self, message: AstrMessageEvent, new_name: str):
'''重命名对话'''
provider = self.context.get_using_provider()
if provider and provider.meta().type == 'dify':
assert isinstance(provider, ProviderDify)
cid = provider.conversation_ids.get(message.unified_msg_origin, None)
if not cid:
message.set_result(MessageEventResult().message("未找到当前对话。"))
return
await provider.api_client.rename(cid, new_name, message.unified_msg_origin)
message.set_result(MessageEventResult().message("重命名对话成功。"))
return
await self.context.conversation_manager.update_conversation_title(message.unified_msg_origin, new_name)
message.set_result(MessageEventResult().message("重命名对话成功。"))
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("del")
async def del_conv(self, message: AstrMessageEvent):
'''删除当前对话'''
is_unique_session = self.context.get_config()['platform_settings']['unique_session']
if message.get_group_id() and not is_unique_session and message.role != "admin":
# 群聊,没开独立会话,发送人不是管理员
message.set_result(MessageEventResult().message(f"会话处于群聊,并且未开启独立会话,并且您 (ID {message.get_sender_id()}) 不是管理员,因此没有权限删除当前对话。"))
return
provider = self.context.get_using_provider()
if provider and provider.meta().type == 'dify':
assert isinstance(provider, ProviderDify)
await provider.api_client.delete_chat_conv(message.unified_msg_origin)
provider.conversation_ids.pop(message.unified_msg_origin, None)
message.set_result(MessageEventResult().message("删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"))
return
session_curr_cid = await self.context.conversation_manager.get_curr_conversation_id(message.unified_msg_origin)
if not session_curr_cid:
message.set_result(MessageEventResult().message("当前未处于对话状态,请 /switch 切换或 /new 创建。"))
message.set_result(MessageEventResult().message("当前未处于对话状态,请 /switch 序号 切换或 /new 创建。"))
return
await self.context.conversation_manager.delete_conversation(message.unified_msg_origin, session_curr_cid)
message.set_result(MessageEventResult().message("删除当前对话成功。"))
message.set_result(MessageEventResult().message("删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"))
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("key")
async def key(self, message: AstrMessageEvent, index: int=None):
@@ -637,31 +748,32 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.command("set")
async def set_variable(self, event: AstrMessageEvent, key: str, value: str):
session_id = event.get_session_id()
# session_id = event.get_session_id()
uid = event.unified_msg_origin
session_vars = sp.get("session_variables", {})
session_var = session_vars.get(session_id, {})
session_var = session_vars.get(uid, {})
session_var[key] = value
session_vars[session_id] = session_var
session_vars[uid] = session_var
sp.put("session_variables", session_vars)
yield event.plain_result(f"会话 {session_id} 变量 {key} 存储成功。使用 /unset 移除。")
yield event.plain_result(f"会话 {uid} 变量 {key} 存储成功。使用 /unset 移除。")
@filter.command("unset")
async def unset_variable(self, event: AstrMessageEvent, key: str):
session_id = event.get_session_id()
uid = event.unified_msg_origin
session_vars = sp.get("session_variables", {})
session_var = session_vars.get(session_id, {})
session_var = session_vars.get(uid, {})
if key not in session_var:
yield event.plain_result("没有那个变量名。格式 /unset 变量名。")
else:
del session_var[key]
sp.put("session_variables", session_vars)
yield event.plain_result(f"会话 {session_id} 变量 {key} 移除成功。")
yield event.plain_result(f"会话 {uid} 变量 {key} 移除成功。")
@filter.command("gewe_logout")
async def gewe_logout(self, event: AstrMessageEvent):
@@ -670,9 +782,16 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
if platform.meta().name == "gewechat":
yield event.plain_result("正在登出 gewechat")
await platform.logout()
yield event.plain_result("已登出 gewechat")
yield event.plain_result("已登出 gewechat,请重启 AstrBot")
return
@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:
f.write(code)
yield event.plain_result("验证码已保存。")
@filter.platform_adapter_type(filter.PlatformAdapterType.ALL)
async def on_message(self, event: AstrMessageEvent):
@@ -695,18 +814,30 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
logger.error("未找到任何 LLM 提供商。请先配置。无法主动回复")
return
try:
session_curr_cid = await self.context.conversation_manager.get_curr_conversation_id(event.unified_msg_origin)
if not session_curr_cid:
logger.error("当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /switch 切换或者 /new 创建一个会话。")
return
conv = await self.context.conversation_manager.get_conversation(
event.unified_msg_origin,
session_curr_cid
)
history = json.loads(conv.history)
conv = None
history = []
if provider.meta().type != 'dify':
# Dify 自己有维护对话,不需要 bot 端维护。
session_curr_cid = await self.context.conversation_manager.get_curr_conversation_id(event.unified_msg_origin)
if not session_curr_cid:
logger.error("当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /switch 序号 切换或者 /new 创建一个会话。")
return
conv = await self.context.conversation_manager.get_conversation(
event.unified_msg_origin,
session_curr_cid
)
history = []
if conv:
history = json.loads(conv.history)
else:
assert isinstance(provider, ProviderDify)
cid = provider.conversation_ids.get(event.unified_msg_origin, None)
if cid is None:
logger.error("[Dify] 当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /switch 序号 切换或者 /new 创建一个会话。")
return
prompt = self.ltm.ar_prompt
if not prompt:
prompt = event.message_str
@@ -836,20 +967,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
# @filter.command_group("kdb")
# def kdb(self):
# pass
# @kdb.command("on")
# async def on_kdb(self, event: AstrMessageEvent):
# self.kdb_enabled = True
# curr_kdb_name = self.context.provider_manager.curr_kdb_name
# if not curr_kdb_name:
# yield event.plain_result("未载入任何知识库")
# else:
# yield event.plain_result(f"知识库已打开。当前载入的知识库: {curr_kdb_name}")
# @kdb.command("off")
# async def off_kdb(self, event: AstrMessageEvent):
# self.kdb_enabled = False
# yield event.plain_result("知识库已关闭")
# @filter.on_llm_request()
# async def on_llm_response(self, event: AstrMessageEvent, req: ProviderRequest):
@@ -860,4 +978,4 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
# if results:
# req.system_prompt += "\nHere are documents that related to user's query: \n"
# for result in results:
# req.system_prompt += f"- {result}\n"
# req.system_prompt += f"- {result}\n"7
+37 -7
View File
@@ -85,7 +85,8 @@ DEFAULT_CONFIG = {
"sandbox": {
"image": "soulter/astrbot-code-interpreter-sandbox",
"docker_mirror": "", # cjie.eu.org
}
},
"docker_host_astrbot_abs_path": ""
}
PATH = "data/config/python_interpreter.json"
@@ -95,8 +96,14 @@ class Main(star.Star):
def __init__(self, context: star.Context) -> None:
self.context = context
self.curr_dir = os.path.dirname(os.path.abspath(__file__))
self.workplace_path = os.path.join(self.curr_dir, "workplace")
self.shared_path = os.path.join(self.curr_dir, "shared")
self.shared_path = os.path.join("data", "py_interpreter_shared")
if not os.path.exists(self.shared_path):
# 复制 api.py 到 shared 目录
os.makedirs(self.shared_path, exist_ok=True)
shared_api_file = os.path.join(self.curr_dir, "shared", "api.py")
shutil.copy(shared_api_file, self.shared_path)
self.workplace_path = os.path.join("data", "py_interpreter_workplace")
os.makedirs(self.workplace_path, exist_ok=True)
self.user_file_msg_buffer = defaultdict(list)
@@ -195,7 +202,16 @@ class Main(star.Star):
@filter.command_group("pi")
def pi(self):
pass
@pi.command("absdir")
async def pi_absdir(self, event: AstrMessageEvent, path: str = ""):
'''设置 Docker 宿主机绝对路径'''
if not path:
yield event.plain_result(f"当前 Docker 宿主机绝对路径: {self.config.get('docker_host_astrbot_abs_path', '')}")
else:
self.config["docker_host_astrbot_abs_path"] = path
self._save_config()
yield event.plain_result(f"设置 Docker 宿主机绝对路径成功: {path}")
@pi.command("mirror")
async def pi_mirror(self, event: AstrMessageEvent, url: str = ""):
@@ -305,6 +321,20 @@ class Main(star.Star):
yield event.plain_result(f"使用沙箱执行代码中,请稍等...(尝试次数: {i+1}/{n})")
self.docker_host_astrbot_abs_path = self.config.get("docker_host_astrbot_abs_path", "")
if self.docker_host_astrbot_abs_path:
host_shared = os.path.join(self.docker_host_astrbot_abs_path, self.shared_path)
host_output = os.path.join(self.docker_host_astrbot_abs_path, output_path)
host_workplace = os.path.join(self.docker_host_astrbot_abs_path, workplace_path)
else:
host_shared = os.path.abspath(self.shared_path)
host_output = os.path.abspath(output_path)
host_workplace = os.path.abspath(workplace_path)
logger.debug(f"host_shared: {host_shared}, host_output: {host_output}, host_workplace: {host_workplace}")
container = await docker.containers.run({
"Image": image_name,
"Cmd": ["python", "exec.py"],
@@ -312,9 +342,9 @@ class Main(star.Star):
"NanoCPUs": 1000000000,
"HostConfig": {
"Binds": [
f"{self.shared_path}:/astrbot_sandbox/shared:ro",
f"{output_path}:/astrbot_sandbox/output:rw",
f"{workplace_path}:/astrbot_sandbox:rw",
f"{host_shared}:/astrbot_sandbox/shared:ro",
f"{host_output}:/astrbot_sandbox/output:rw",
f"{host_workplace}:/astrbot_sandbox:rw",
]
},
"Env": [
+13 -5
View File
@@ -13,13 +13,13 @@ class Main(star.Star):
'''使用 LLM 待办提醒。只需对 LLM 说想要提醒的事情和时间即可。比如:`之后每天这个时候都提醒我做多邻国`'''
def __init__(self, context: star.Context) -> None:
self.context = context
self.scheduler = AsyncIOScheduler()
self.scheduler = AsyncIOScheduler(timezone='Asia/Shanghai')
# set and load config
if not os.path.exists("data/astrbot-reminder.json"):
with open("data/astrbot-reminder.json", "w") as f:
with open("data/astrbot-reminder.json", "w", encoding='utf-8') as f:
f.write("{}")
with open("data/astrbot-reminder.json", "r") as f:
with open("data/astrbot-reminder.json", "r", encoding='utf-8') as f:
self.reminder_data = json.load(f)
self._init_scheduler()
@@ -64,7 +64,7 @@ class Main(star.Star):
async def _save_data(self):
'''Save the reminder data.'''
with open("data/astrbot-reminder.json", "w") as f:
with open("data/astrbot-reminder.json", "w", encoding='utf-8') as f:
json.dump(self.reminder_data, f, ensure_ascii=False)
def _parse_cron_expr(self, cron_expr: str):
@@ -79,7 +79,7 @@ class Main(star.Star):
@llm_tool("reminder")
async def reminder_tool(self, event: AstrMessageEvent, text: str=None, datetime_str: str = None, cron_expression: str = None, human_readable_cron: str = None):
'''Call this function when user ask for setting a reminder.
'''Call this function when user is asking for setting a reminder.
Args:
text(string): Must Required. The content of the reminder.
@@ -175,10 +175,18 @@ class Main(star.Star):
else:
reminder = reminders.pop(index - 1)
job_id = reminder.get("id")
# self.reminder_data[event.unified_msg_origin] = reminder
users_reminders = self.reminder_data.get(event.unified_msg_origin, [])
for i, r in enumerate(users_reminders):
if r.get("id") == job_id:
users_reminders.pop(i)
try:
self.scheduler.remove_job(job_id)
except Exception as e:
logger.error(f"Remove job error: {e}")
yield event.plain_result(f"成功移除对应的待办事项。删除定时任务失败: {str(e)} 可能需要重启 AstrBot 以取消该提醒任务。")
await self._save_data()
yield event.plain_result("成功删除待办事项:\n" + reminder["text"])
+2 -2
View File
@@ -75,10 +75,10 @@ class Main(star.Star):
@llm_tool("web_search")
async def search_from_search_engine(self, event: AstrMessageEvent, query: str) -> str:
'''Search the web for answers to the user's query
'''搜索网络以回答用户的问题。当用户需要搜索网络以获取即时性的信息时调用此工具。
Args:
query(string): A search query which will be used to fetch the most relevant snippets regarding the user's query
query(string): 和用户的问题最相关的搜索关键词用于在 Google 上搜索
'''
logger.info("web_searcher - search_from_search_engine: " + query)
results = []
+8 -1
View File
@@ -1,6 +1,7 @@
pydantic~=2.10.3
aiohttp
openai
anthropic
qq-botpy
chardet~=5.1.0
Pillow
@@ -17,4 +18,10 @@ apscheduler
docstring_parser
aiodocker
silk-python
ormsgpack
psutil>=5.8.0
lark-oapi
ormsgpack
cryptography
dashscope