Compare commits

...

284 Commits

Author SHA1 Message Date
Soulter 23ee5e81c9 🐛 fix: 修复 LLM 响应后事件钩子无法生效的问题 2025-03-26 17:56:55 +08:00
Soulter 483f55e4b1 Update README.md 2025-03-26 16:16:03 +08:00
Soulter 1bb1bc2553 🐛 fix: remove error logging for empty result and refresh extensions after upload 2025-03-26 15:43:56 +08:00
Soulter a4e4e36f94 📦 release: v3.5.0 2025-03-26 15:30:09 +08:00
Soulter 6849415812 Merge pull request #996 from zhx8702/fix-star-manager
fix: install_plugin_from_file 方法load传参数改为文件名
2025-03-26 15:26:53 +08:00
zhx 86f6cb038e fix: install_plugin_from_file 方法load传参数改为文件名 2025-03-26 15:06:33 +08:00
Soulter 7480a1d6ce feat: 支持通过指令下载插件 /plugin get 2025-03-26 14:33:45 +08:00
Soulter 3cd10117dd 🎈 perf: 优化新版本时的信息显示 2025-03-26 14:14:01 +08:00
Soulter 0caf19d390 Merge pull request #937 from advent259141/master
将对只有一个 @ 的消息内容的处理改成调用llm回复
2025-03-26 13:54:43 +08:00
Soulter 9c9ab50d1a 🎈 perf: 优化 WebUI 对话数据库中文历史检索 2025-03-26 13:50:11 +08:00
Soulter d4bcb8174e 🐛 fix: 修复部分可能形成 SQL 注入的风险 2025-03-26 13:41:18 +08:00
Soulter aca18fab0f feat: 优化配置文件中的提示信息,增强可读性 2025-03-26 00:56:51 +08:00
Soulter 691de01b79 feat: 支持设置最多携带对话数量 2025-03-26 00:46:15 +08:00
Soulter 3383f15142 Merge pull request #988 from Soulter/NiceAir/master
 feat: Update UI elements and improve layout in various components
2025-03-25 23:17:11 +08:00
Soulter 84c1593889 feat: Update UI elements and improve layout in various components 2025-03-25 21:52:15 +08:00
Soulter 3c80fa1e33 Update README.md 2025-03-25 21:31:23 +08:00
Soulter 06b16a1deb Merge pull request #983 from Soulter/feat-conversation-webui-mgr
 支持 WebUI 对话管理
2025-03-25 21:26:00 +08:00
Soulter 4c4246fb09 Merge pull request #982 from NiceAir/master
添加对gewe的表情包、引用消息、视频的支持
2025-03-25 21:25:00 +08:00
Soulter 364be1e9f6 🐛 fix: Handle missing defusedxml dependency for Gewechat message parsing 2025-03-25 21:21:38 +08:00
NiceAir f959ed71aa feat: Gewechat 支持表情包、引用消息、视频
Co-authored-by: Soulter <905617992@qq.com>
2025-03-25 21:00:12 +08:00
Soulter 125fc3a622 feat: 支持 WebUI 对话管理 2025-03-25 19:44:46 +08:00
Soulter 6b9e785db3 Merge pull request #968 from Soulter/pre-commit-ci-update-config
🎈 pre-commit autoupdate
2025-03-25 15:03:39 +08:00
Soulter 25d34e9a43 Merge pull request #974 from zhx8702/feat-webui-add-search-keys
feat: 插件市场列表卡片过滤条件提出变量保持一致
2025-03-25 15:03:09 +08:00
Soulter 457d4aa1dc Merge pull request #976 from Raven95676/master
Improves Telegram adapter termination
2025-03-25 15:01:04 +08:00
Raven95676 ff0c0992ff Improves Telegram adapter termination 2025-03-25 14:46:20 +08:00
Soulter d379e012c4 🐛 fix: telegram /start issue #751 2025-03-25 14:03:46 +08:00
zhx 151fff26fd feat: 插件市场列表卡片过滤条件提出变量保持一致 2025-03-25 13:50:16 +08:00
Soulter 3d0d561215 Update compose.yml 2025-03-25 13:24:37 +08:00
Soulter 22d586ed7b Update compose.yml 2025-03-25 13:24:19 +08:00
Soulter 6dc19b29e8 🐛 fix: remove redundant validation call in config validation function #901 2025-03-25 12:56:48 +08:00
Soulter 50975a87d4 🐛 fix: handle message sending failures with error logging 2025-03-25 12:34:43 +08:00
Soulter ce721d9f0f 🐛 fix: platform adapter server blocks ctrl+c 2025-03-25 11:31:46 +08:00
Soulter 20510a33f7 feat: improve pyproject and use uv as package mgr 2025-03-25 11:07:20 +08:00
pre-commit-ci[bot] 3abd9c8763 🎈 pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.0 → v0.11.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.0...v0.11.2)
2025-03-24 17:08:12 +00:00
Soulter e9eff7420b feat: 更加完善和美观的 本地 Markdown 渲染 2025-03-25 00:56:19 +08:00
Soulter 64c250c9d8 🎈perf: 优化可能的 conversation 为 None 的问题 2025-03-25 00:06:25 +08:00
Soulter 8047f82bfd 🎈perf: 优化删除插件目录的逻辑,抛出异常细节;完善 mcp 未安装时的提示 2025-03-24 23:07:56 +08:00
Soulter af6467fb3d Merge pull request #962 from zhx8702/feat-webui-add-double-confirm
feat: 删除插件添加二次确认,插件列表添加非空判断
2025-03-24 23:01:43 +08:00
zhx 3ff1664aec feat: 删除多余代码 2025-03-24 20:27:05 +08:00
zhx 34ea2b44b8 Merge remote-tracking branch 'upstream/master' into feat-webui-add-double-confirm 2025-03-24 19:42:47 +08:00
Soulter 6c8d851109 Merge pull request #955 from Raven95676/master
Telegram适配器消息处理功能增强
2025-03-24 18:10:51 +08:00
Soulter d678299a74 Merge branch 'master' into master 2025-03-24 18:10:27 +08:00
Soulter 7aed0db2b6 Merge pull request #951 from IGCrystal/master
fix: fix SSLCertVerificationError
2025-03-24 18:05:49 +08:00
Soulter 0355524345 Merge branch 'master' into master 2025-03-24 17:58:00 +08:00
Soulter 0a43e4672e style: format codes 2025-03-24 17:57:28 +08:00
zhx 71e0ccdfec feat: 删除插件添加二次确认,插件列表添加非空判断 2025-03-24 16:41:54 +08:00
冰苷晶 1df33ac3c8 fix: fix error 2025-03-24 13:28:14 +08:00
pre-commit-ci[bot] 7334090ac1 🎈 auto fixes by pre-commit hooks 2025-03-24 05:20:37 +00:00
冰苷晶 6b0f044198 fix: fix other errors 2025-03-24 13:20:05 +08:00
pre-commit-ci[bot] ddf54c9cf8 🎈 auto fixes by pre-commit hooks 2025-03-24 04:32:21 +00:00
IGCrystal 7c64e184e2 Merge branch 'Soulter:master' into master 2025-03-24 12:32:16 +08:00
渡鸦95676 a904db033c Merge branch 'Soulter:master' into master 2025-03-24 12:19:17 +08:00
渡鸦95676 b234856b02 Remove unused variable
移除以通过ruff检查
在Ubuntu24.04LTS中,移除未见对现有功能的影响
2025-03-24 11:36:46 +08:00
Soulter 89d51d2afc 🎈 perf: config UI 2025-03-24 11:36:38 +08:00
Soulter 37cb9678e9 Merge pull request #826 from XuYingJie-cmd/master
新增了关于gewe发送视频的功能
2025-03-24 11:25:24 +08:00
pre-commit-ci[bot] 0500ff333a 🎈 auto fixes by pre-commit hooks 2025-03-24 02:50:28 +00:00
Raven95676 08528510ef Fix incorrect handling of reply messages within topics 2025-03-24 10:41:33 +08:00
Raven95676 ddbd03dc1e Adds sticker handling in Telegram adapter 2025-03-24 10:40:20 +08:00
Soulter ade87f378a 🎈 perf: UI 优化 2025-03-24 00:32:40 +08:00
冰苷晶 4db14b905f fix: fix error 2025-03-23 23:40:06 +08:00
pre-commit-ci[bot] b669b31451 🎈 auto fixes by pre-commit hooks 2025-03-23 15:07:22 +00:00
冰苷晶 1cb2b62f81 fix: fix error 2025-03-23 23:02:34 +08:00
Soulter e5828713cf 🎈 perf: improve ChatPage and ConfigPage UI 2025-03-23 22:57:02 +08:00
冰苷晶 d10cb84068 fix: fix SSLCertVerificationError 2025-03-23 22:55:07 +08:00
Soulter 4222f8516f Merge pull request #844 from AraragiEro/mcp_adapt
支持 MCP 服务并优化函数调用流程
2025-03-23 22:35:35 +08:00
Soulter 7f998c7611 chore: remove useless print output 2025-03-23 22:28:00 +08:00
Soulter db46000337 🎨 style: format codes 2025-03-23 22:22:11 +08:00
Soulter 1aac8d8041 feat: 适配完整的 function-calling 流程 2025-03-23 22:21:47 +08:00
Soulter c59c8e05f7 🐛 fix: tools result 2025-03-23 17:03:18 +08:00
Soulter 4942d0a629 feat: 在工具使用页面添加函数调用信息提示和链接功能 2025-03-23 17:00:38 +08:00
Soulter 873b7715f4 🎈 perf: 优化 MCP Client 异步 Event 管理 2025-03-23 16:51:28 +08:00
pre-commit-ci[bot] 98e7ed6920 🎈 auto fixes by pre-commit hooks 2025-03-23 08:34:05 +00:00
Soulter 046f5e645e feat: 完善 MCP 管理和实现 WebUI MCP 相关的页面 2025-03-23 16:33:44 +08:00
pre-commit-ci[bot] f5e5a7094c 🎈 auto fixes by pre-commit hooks 2025-03-23 06:39:13 +00:00
Gao Jinzhe 154125fee6 Add files via upload 2025-03-23 14:35:44 +08:00
pre-commit-ci[bot] 9f8e960ebe 🎈 auto fixes by pre-commit hooks 2025-03-23 03:31:20 +00:00
Soulter 4179b0be0a chore: 优化注解格式和 requirements.txt 2025-03-23 11:31:10 +08:00
Soulter 28bafa38db Merge branch 'master' into mcp_adapt 2025-03-23 11:01:44 +08:00
Soulter b07552565e Merge pull request #926 from Soulter/perf-graceful-shutdown
支持所有消息平台的优雅退出
2025-03-23 10:56:56 +08:00
Soulter c4427471d2 🎨 style: format codes 2025-03-23 00:25:26 +08:00
Soulter 08f81c6784 🐛 fix: 修复图片没有被存储到上下文中的问题 2025-03-23 00:23:42 +08:00
Soulter a471e98aca 🐛 fix: Telegram 下无法识别图片描述(Caption) #910 2025-03-23 00:23:01 +08:00
Soulter 75a8fcc8a0 🐛 fix: 修复 Telegram 下非默认群组话题引用消息异常 #906 2025-03-22 23:39:21 +08:00
Soulter 46ef76c168 feat: 支持消息平台的热重载 2025-03-22 19:54:54 +08:00
Soulter 66637446c9 Merge remote-tracking branch 'origin/master' into perf-graceful-shutdown 2025-03-22 19:26:35 +08:00
Soulter 21efeb888a Merge pull request #904 from LunarMeal/master
新增了newgroup指令
2025-03-22 19:18:06 +08:00
Soulter a4ee8b5322 Merge remote-tracking branch 'origin/master' into LunarMeal/master 2025-03-22 19:17:12 +08:00
Soulter 36519ac47e 🐛 fix: groupnew 设置为管理员指令 2025-03-22 19:14:58 +08:00
Soulter 3f514fceca 🎨 style: format codes 2025-03-22 19:07:47 +08:00
pre-commit-ci[bot] c2249fdfac 🎈 auto fixes by pre-commit hooks 2025-03-22 11:06:42 +00:00
Soulter c610719a44 feat: 为各平台适配器支持优雅关闭 2025-03-22 19:02:49 +08:00
Soulter 36a6c2461a 🐛 fix: 修复 Telegram Topic 群组下LLM 上下文及主动消息混乱的问题 #908 2025-03-22 18:15:43 +08:00
Soulter c29f22c39e Update PLUGIN_PUBLISH.yml 2025-03-22 15:51:35 +08:00
Soulter 30d3062944 🎈 perf: 优化钉钉在配置错误之后堵塞整个线程的问题 #885
a.k.a 帮钉钉擦屁股
2025-03-22 15:44:42 +08:00
Soulter 69ba75abf4 Update README.md 2025-03-22 01:26:03 +08:00
Soulter e4d486fec5 docs: 宝塔面板部署方式 2025-03-22 00:42:04 +08:00
Soulter f242144dcf 更新 README.md 2025-03-21 19:21:35 +08:00
Soulter 02dee2d664 🎈 perf: add error handling for missing pyffmpeg library in video sending functionality 2025-03-21 16:51:23 +08:00
Soulter a3dd2c3069 Merge remote-tracking branch 'origin/master' into XuYingJie-cmd/master 2025-03-21 16:49:15 +08:00
Soulter a23425e8aa Merge pull request #781 from Moyuyanli/master
添加gewe的群相关操作
2025-03-21 16:31:10 +08:00
Moyuyanli be79ddc9a3 fix:去掉跟post_text功能相同的接口方法 2025-03-21 16:24:31 +08:00
Soulter 7d71015e8c Update README.md 2025-03-21 16:12:25 +08:00
Soulter ad54549b51 Update README.md 2025-03-21 15:58:40 +08:00
Soulter 6cf032a164 Update compose.yml 2025-03-21 11:06:22 +08:00
Soulter 6390d796ac Update compose.yml 2025-03-21 11:05:44 +08:00
Soulter 98b8411905 Update compose.yml 2025-03-21 10:53:09 +08:00
LunarMeal ddf1029afa Merge branch 'master' of https://github.com/LunarMeal/AstrBot 2025-03-20 22:53:29 +08:00
LunarMeal 1effbc5cc9 fix 2025-03-20 22:53:21 +08:00
pre-commit-ci[bot] 414b645e9f 🎈 auto fixes by pre-commit hooks 2025-03-20 14:42:37 +00:00
LunarMeal 398c76f496 新增了newgroup指令 2025-03-20 22:39:49 +08:00
Soulter 1bc456dd95 🎈 perf: 改善一些术语描述 2025-03-20 20:31:36 +08:00
Soulter 2e8421884e Merge pull request #864 from Soulter/pre-commit-ci-update-config
🎈 pre-commit autoupdate
2025-03-20 20:23:45 +08:00
Soulter 70d9b193ac 🐛 fix: 修复私聊下 get_group 的一些问题 2025-03-20 20:18:20 +08:00
Moyuyanli b49c11004a fix:还原回原来的依赖信息 2025-03-20 19:57:35 +08:00
Soulter 34843eea90 🎨 style: format codes 2025-03-20 18:07:24 +08:00
pre-commit-ci[bot] 2d6d7f31e8 🎈 auto fixes by pre-commit hooks 2025-03-20 10:06:11 +00:00
Soulter 7a24cbff1c feat: 支持 aiocqhttp 适配器下的获取群消息 2025-03-20 18:05:44 +08:00
pre-commit-ci[bot] 1e7eb2cf1c 🎈 auto fixes by pre-commit hooks 2025-03-20 09:21:32 +00:00
Soulter 361256e016 chore: 添加了一些 gewechat client 的注释 2025-03-20 17:20:32 +08:00
Soulter 8838dbd003 🎨 style: format codes 2025-03-20 16:54:27 +08:00
pre-commit-ci[bot] 13a95e1f2b 🎈 auto fixes by pre-commit hooks 2025-03-20 08:42:40 +00:00
Soulter 1aaa451a3e Merge branch 'master' into Moyuyanli/master 2025-03-20 16:42:13 +08:00
Soulter cbba81e54d 🐛 fix: 无法接收图片 aiocqhttp 2025-03-20 16:03:41 +08:00
Soulter 370868dfac 🎈 perf: 消息平台和配置提供商配置页中,自动更新旧的配置,添加新的配置项 2025-03-20 13:22:49 +08:00
Soulter 77f692aae2 🎈 perf: 配置项显示优化 2025-03-20 13:17:27 +08:00
Soulter 9318e205ea feat: 阿里云百炼应用支持 RAG 应用 #878 2025-03-20 13:17:06 +08:00
Soulter ebcc717c19 🎈 perf: Dify 下支持更多类型的图片输入及提高代码复用性 #893
🐛 fix: 修复飞书下无法进行图片输入的问题
2025-03-20 11:21:45 +08:00
Soulter 4c16b564ee 🎈 perf: 忽略微信团队消息 #859 2025-03-19 01:09:01 +08:00
Soulter e2283d1453 🐛 fix: 修复 dify 下某些修改了 LLM 响应的插件可能不生效的问题 #876 2025-03-19 01:05:28 +08:00
Soulter d891801c5a v3.4.39 2025-03-18 22:43:35 +08:00
Soulter de75386944 🎈 perf: 登录后检查默认密码和弹出修改警告 2025-03-18 22:41:33 +08:00
Soulter 82dc37de50 style: format codes 2025-03-18 22:21:47 +08:00
Soulter b6fa7f62dc chore: 添加安全提示信息 2025-03-18 22:18:01 +08:00
Soulter f9e0a95c5e chore: 默认地址改回 0.0.0.0 2025-03-18 22:15:22 +08:00
pre-commit-ci[bot] b2c6e12647 🎈 auto fixes by pre-commit hooks 2025-03-17 17:10:06 +00:00
pre-commit-ci[bot] caffb83780 🎈 pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.10 → v0.11.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.10...v0.11.0)
2025-03-17 17:09:59 +00:00
Soulter 8882cb5479 v3.4.38 2025-03-18 00:54:51 +08:00
Soulter 75dace2dee 🎈 perf: 优化配置页的显示 2025-03-18 00:16:47 +08:00
Soulter ad6487d042 🐛 fix: 修复部分指令可能造成的配置类型问题 2025-03-17 23:44:04 +08:00
Soulter a91604e8ab Merge pull request #853 from IGCrystal/master
🎈 perf: 优化了iframe窗口,新增跳转按钮
2025-03-17 23:25:26 +08:00
Soulter c364f7c643 🎈 perf: Dify 下当只有图片输入时的默认 prompt #837 2025-03-17 23:17:07 +08:00
Soulter 53435ba184 🐛 fix: 修复 model_config 中自定义的配置项(如温度)类型自动变回 string #854 2025-03-17 23:11:57 +08:00
Soulter 25f8d5519b 🐛 fix: LLOnebot 合并消息转发错误 #842 2025-03-17 22:42:48 +08:00
Moyuyanli 2e4fef6c66 feat:添加消息记录器 2025-03-17 16:02:55 +08:00
冰苷晶 80b2b7dc00 🎈 perf: 优化了iframe窗口 2025-03-16 21:35:30 +08:00
Alero 8585cd8e21 修复codecheck 2025-03-15 20:26:17 +08:00
Alero 9fa2a7eeea 修复codecheck 2025-03-15 20:24:36 +08:00
pre-commit-ci[bot] 2d1f74228d 🎈 auto fixes by pre-commit hooks 2025-03-15 12:10:17 +00:00
Alero 3d6f7aa0e1 修复codecheck 2025-03-15 20:09:49 +08:00
pre-commit-ci[bot] 3dea60366a 🎈 auto fixes by pre-commit hooks 2025-03-15 11:54:09 +00:00
Alero d4d9a1df4c feat:新增MCP服务支持并优化工具调用逻辑
引入MCP客户端支持,增加mcp_server.json配置样例,完善工具描述生成及调用逻辑以支持MCP服务工具功能。同时调整相关逻辑以区分本地工具与MCP工具的调用方式,提升扩展性和灵活性。
2025-03-15 19:47:06 +08:00
Soulter 7d6975fd31 Merge pull request #832 from IGCrystal/master
🎈 perf: 优化iframe窗口,加入了关闭按钮
2025-03-15 14:25:16 +08:00
IGCrystal 08be52ed17 Merge branch 'Soulter:master' into master 2025-03-15 12:05:27 +08:00
邹永赫 682a7700c2 Merge pull request #835 from zouyonghe/master
修改注册函数工具时的打印信息
2025-03-15 12:20:32 +09:00
pre-commit-ci[bot] 9d87009216 🎈 auto fixes by pre-commit hooks 2025-03-15 03:16:51 +00:00
邹永赫 ef86838f62 修改注册函数工具时的打印信息 2025-03-15 12:15:05 +09:00
Soulter 35468233f8 🎈 perf: supports for customizing webui host, wecom webhook server host, qq official webhook server host #821 2025-03-15 01:21:36 +08:00
Soulter 26e229867d 🐛fix: 可能的QQ平台回复消息带有末尾空白的问题 #822 2025-03-15 00:57:17 +08:00
Soulter 3a1578b3c6 feat: 支持 Dify 文件、图片、视频、音频输出。#819 2025-03-15 00:51:32 +08:00
冰苷晶 d5e3d2cbbc 🎈 perf: 优化iframe窗口,加入了关闭按钮 2025-03-14 20:23:15 +08:00
Moyuyanli c095248176 Merge remote-tracking branch 'origin/master' 2025-03-14 18:30:42 +08:00
Moyuyanli 44601c8954 fix:修复gewe的ModContacts消息类型 2025-03-14 18:30:27 +08:00
Soulter 135dbb8f07 style: clean codes 2025-03-14 18:02:00 +08:00
pre-commit-ci[bot] c95682a0c7 🎈 auto fixes by pre-commit hooks 2025-03-14 09:11:21 +00:00
Moyuyanli d177b9f7fa feat:添加主动添加好友事件 2025-03-14 17:11:10 +08:00
徐英杰 9b57615d94 新增了关于gewe发送视频的功能 2025-03-14 16:19:41 +08:00
Soulter c03f3eacd1 Update README.md 2025-03-13 23:03:36 +08:00
Soulter a26e395932 Merge pull request #817 from Soulter/feat-parse-reply
[Feature] 添加了 LLM 对消息平台引用回复内容的感知
2025-03-13 21:06:44 +08:00
Soulter 0870b87c96 🐛 fix: 获取引用消息失败时没有将引用消息段加入消息链 2025-03-13 20:59:52 +08:00
Soulter b52a44a7dd 🎨 stype: format codes 2025-03-13 20:44:08 +08:00
Soulter 0a290aafef Merge pull request #815 from diudiu62/perf-gewechat
微信有未处理的消息类型,导致控制台打印太多的日志
2025-03-13 20:39:39 +08:00
Soulter 9014d4c410 🎨 style: format codes 2025-03-13 20:36:41 +08:00
pre-commit-ci[bot] 60e58b4f5f 🎈 auto fixes by pre-commit hooks 2025-03-13 09:52:03 +00:00
Soulter 620e74a6aa Merge branch 'master' into feat-parse-reply 2025-03-13 17:51:12 +08:00
Soulter efa287ed35 feat: 支持 LLM 对引用消息的感知 #783 2025-03-13 17:40:28 +08:00
Soulter a24eb9d9b0 🏗 refactor: clean up AstrBotConfig component markup for improved readability 2025-03-13 17:02:58 +08:00
Soulter bd3dab8aae 🐛 fix: 插件管理的插件简介太长 “帮助”“操作”图标不显示 #790 2025-03-13 17:02:58 +08:00
Soulter 4fe1ebaa5b 🏗 refactor: improve styling and layout of AstrBotConfig component for enhanced readability 2025-03-13 17:02:58 +08:00
Soulter c5e944744b 🏗 refactor: enhance ConfigPage layout and styling for better user experience 2025-03-13 17:02:58 +08:00
Soulter 0c396181f7 🏗 refactor: 配置页样式重写 2025-03-13 17:02:58 +08:00
Soulter 0034474219 🐛 fix: sent message to wrong topic in topic group #801 2025-03-13 17:02:58 +08:00
shuiping233 8136ad8287 修复命令参数报错信息无法发送至qq官方机器人平台的bug 2025-03-13 17:02:58 +08:00
Soulter 681940d466 🐛 fix: 修复重载插件时函数工具可能多次家在的问题 2025-03-13 17:02:58 +08:00
Soulter 16488506e8 🐛 fix: 修复部分情况下文件无法上传到 Telegram 群组的问题 #601 2025-03-13 17:02:58 +08:00
邹永赫 122fccc041 修复无法发送非嵌套的转发消息的问题 2025-03-13 17:02:58 +08:00
邹永赫 9d0ad35403 支持嵌套转发,里层包含多条信息 2025-03-13 17:02:58 +08:00
邹永赫 f9ec97e026 支持嵌套转发 2025-03-13 17:02:58 +08:00
Soulter 95495a2647 🏗 refactor: clean up AstrBotConfig component markup for improved readability 2025-03-13 16:40:59 +08:00
Soulter e3310a605c 🐛 fix: 插件管理的插件简介太长 “帮助”“操作”图标不显示 #790 2025-03-13 16:36:35 +08:00
Soulter b55719bf28 🏗 refactor: improve styling and layout of AstrBotConfig component for enhanced readability 2025-03-13 15:59:20 +08:00
diudiu62 b957b51279 已知消息类型,没有业务处理,只是避免控制台打印太多的日志 2025-03-13 15:55:22 +08:00
Soulter 90bcfab369 🏗 refactor: enhance ConfigPage layout and styling for better user experience 2025-03-13 15:44:52 +08:00
Soulter f8a8e30641 🏗 refactor: 配置页样式重写 2025-03-13 15:37:53 +08:00
Soulter 25cb98e7a7 🐛 fix: sent message to wrong topic in topic group #801 2025-03-13 13:02:22 +08:00
Soulter 03e1bb7cf9 Merge pull request #807 from shuiping233/fix-#806
修复命令参数报错信息无法发送至qq官方机器人平台的bug
2025-03-13 10:05:24 +08:00
Soulter 85dbb24f3a 🐛 fix: 修复重载插件时函数工具可能多次家在的问题 2025-03-12 23:37:24 +08:00
shuiping233 d817635782 修复命令参数报错信息无法发送至qq官方机器人平台的bug 2025-03-12 18:09:25 +08:00
Soulter 2f4f237810 🐛 fix: 修复部分情况下文件无法上传到 Telegram 群组的问题 #601 2025-03-12 14:14:45 +08:00
邹永赫 5ac94d810f Merge pull request #794 from zouyonghe/dev/nested-forward
修复无法发送非嵌套的转发消息的问题
2025-03-12 12:01:33 +09:00
邹永赫 39dc46dc25 修复无法发送非嵌套的转发消息的问题 2025-03-12 11:59:53 +09:00
邹永赫 0d9cf725f7 Merge pull request #792 from zouyonghe/dev/nested-forward
支持嵌套转发,里层包含多条信息
2025-03-12 11:17:16 +09:00
邹永赫 e55dbead5b 支持嵌套转发,里层包含多条信息 2025-03-12 11:14:54 +09:00
邹永赫 7d046e5b30 Merge pull request #788 from zouyonghe/dev/nested-forward
支持嵌套转发
2025-03-12 08:50:50 +09:00
邹永赫 8b4693cf66 支持嵌套转发 2025-03-12 08:39:54 +09:00
Soulter a1172c9a82 feat: 支持解析回复消息 #783 2025-03-11 23:27:10 +08:00
Soulter 1ed2bd33f0 🐛 fix: 修复插件更新时显示未知更新的问题 2025-03-11 22:38:25 +08:00
Soulter 4c159bd0ba Merge pull request #785 from shuiping233/fix-qq-offical-image-upload-issue
修复了使用Image.fromBytes等包装的图片消息链无法通过qq官方机器人适配器发送的bug
2025-03-11 22:10:27 +08:00
Soulter 050654b2a9 🐛 fix: 修复 QQ 官方机器人适配器下发送base64图片消息段报错的问题。
Co-authored-by: shuiping233 <1944680304@qq.com>
2025-03-11 22:08:13 +08:00
Soulter 61b261e1b2 Merge pull request #780 from beat4ocean/master
fix: 修复gewechat平台用户本人发消息触发消息回复的bug
2025-03-11 21:55:44 +08:00
shuiping233 017b010206 修复了使用Image.fromBytes等包装的图片消息链无法通过qq官方机器人适配器发送的bug 2025-03-11 21:17:08 +08:00
pre-commit-ci[bot] 00f5189f58 🎈 auto fixes by pre-commit hooks 2025-03-11 09:16:43 +00:00
Moyuyanli 4a8309ed1f style:idea默认格式化了部分代码
feat:添加根据消息事件获取群信息的接口
2025-03-11 17:10:55 +08:00
Moyuyanli 76cfc31a1d feat:添加 Group 类型 2025-03-11 17:10:04 +08:00
Moyuyanli d9ec434699 feat:gewe的client添加 添加好友接口
feat:gewe的client添加 获取群信息/群成员接口
feat:gewe的client添加 添加群成员为好友接口
2025-03-11 17:08:33 +08:00
Soulter 239f3c40be 🎈 perf: 优化 WebUI 边栏宽度 2025-03-11 16:11:34 +08:00
Soulter 09c8c6e670 🐛 fix: 修复 aiocqhttp 下可能的设置管理员无效的问题 2025-03-11 15:52:30 +08:00
beat4ocean 7e4ad01c94 Merge branch 'Soulter:master' into master 2025-03-11 15:52:23 +08:00
beat4ocean ed98e269ef Merge remote-tracking branch 'origin/master' 2025-03-11 15:48:44 +08:00
beat4ocean b47d63334f fix: 修复gewechat平台用户本人发消息触发消息回复的bug 2025-03-11 15:48:28 +08:00
Soulter 5e2a3a5aea fix: 修复部分情况下 EdgeTTS 无法使用的问题
Co-authored-by: 需要哦 <2687427560@qq.com>
2025-03-11 15:29:51 +08:00
Soulter 1a7eb21fc7 Revert "🐛 fix: 修复 gewechat 部分场景下下载图片报错 #700"
This reverts commit c38fa77ce6.
2025-03-11 14:54:41 +08:00
Soulter 834a51cdc9 🐛 fix: 修复 OpenAI TTS API TypeError 报错 #755 2025-03-11 14:30:59 +08:00
Soulter 1b69d99c06 🐛 fix: 修复更新插件后插件重载不完全的问题 2025-03-11 14:20:24 +08:00
Soulter ad189933c6 Merge pull request #775 from roeseth/master
update compose.yml to mount system time and tz
2025-03-11 12:49:38 +08:00
Soulter 9d86ff32de Merge pull request #774 from Soulter/pre-commit-ci-update-config
🎈 pre-commit autoupdate
2025-03-11 11:40:57 +08:00
Soulter 278bb57a58 Merge pull request #772 from beat4ocean/master
fix: 修复个人微信非第一次登陆情况,已记录gewechat的appid失效设备不存在导致无法重新登陆个人微信的bug
2025-03-11 11:40:07 +08:00
pre-commit-ci[bot] 0ba494e0ba 🎈 auto fixes by pre-commit hooks 2025-03-11 02:11:25 +00:00
roeseth 8b247054bb update compose.yml to mount system time and tz 2025-03-10 19:07:45 -07:00
pre-commit-ci[bot] 7c5c8e4e0d 🎈 auto fixes by pre-commit hooks 2025-03-11 00:55:01 +00:00
beat4ocean ad106a27f3 Merge branch 'Soulter:master' into master 2025-03-11 08:54:55 +08:00
beat4ocean 9d6f61b49e fix: 修复非第一次登陆情况,已记录的gewechat的appid失效设备不存在导致无法重新登陆的bug 2025-03-11 08:48:37 +08:00
pre-commit-ci[bot] 02368954a0 🎈 auto fixes by pre-commit hooks 2025-03-10 17:09:25 +00:00
pre-commit-ci[bot] b477a35a01 🎈 pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.9 → v0.9.10](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.9...v0.9.10)
2025-03-10 17:09:18 +00:00
Soulter 16622887de perf: 在调用插件异常时更完整的报错信息 2025-03-11 00:47:37 +08:00
Soulter 9059d1fb17 feat: 支持在对话隔离情况下可以将群聊加入白名单 #746 2025-03-11 00:34:29 +08:00
Soulter df2b008d82 Merge pull request #744 from roeseth/fix-local-timezone
Use system local time zone instead of hardcoded UTC+8
2025-03-11 00:21:43 +08:00
Soulter 0da871efd0 chore: 日志完善 2025-03-10 23:58:42 +08:00
Soulter 1c55349f81 fix: 钉钉 webui 文档 2025-03-10 23:58:42 +08:00
Soulter 9309fa1e81 修复fishaudio默认baseurl不可用的问题 2025-03-10 01:32:26 +08:00
Soulter 5996189f91 Update README.md 2025-03-09 22:25:45 +08:00
Soulter bd2b984bfb v3.4.37 2025-03-09 22:14:23 +08:00
pre-commit-ci[bot] 194409a117 🎈 auto fixes by pre-commit hooks 2025-03-09 13:23:52 +00:00
roeseth 27978b216d use system local timezone instead of hardcoded UTC+8 2025-03-09 06:18:53 -07:00
Soulter c38fa77ce6 🐛 fix: 修复 gewechat 部分场景下下载图片报错 #700 2025-03-09 18:10:38 +08:00
Soulter 3eb49f7422 feat: 支持设置私聊是否需要唤醒前缀唤醒 #735 2025-03-09 18:03:23 +08:00
Soulter 1989d615d2 🌈 style: format codes 2025-03-09 17:48:59 +08:00
Soulter 239412d265 feat: 支持接入钉钉 #643 2025-03-09 17:47:51 +08:00
Soulter 375a419a9e Merge pull request #732 from xiewoc/master
Update aiocqhttp_platform_adapter.py
2025-03-09 12:36:48 +08:00
Soulter 875c8ab424 ci: upate astrbot webui build cis 2025-03-09 11:31:10 +08:00
Soulter c9bfc810ce ci: upload astrbot webui build ci 2025-03-09 11:26:10 +08:00
Soulter 46ecb16949 🐛 fix: 无法正常保存插件的 list 类型配置 #737 2025-03-09 11:12:24 +08:00
Soulter f6dc16f17b style: format codes 2025-03-08 20:55:25 +08:00
Soulter 4eef42f730 refactor: 移除未使用的 defineEmits 导入 2025-03-08 20:53:43 +08:00
Soulter 8612d9a771 docs: update changelogs 2025-03-08 20:37:46 +08:00
Soulter 0caff054f5 feat: 会话控制器支持自定义会话ID算子 2025-03-08 20:29:42 +08:00
Soulter 4aa91ad599 feat: 支持当消息只有@bot时,下一条发送人的消息直接唤醒机器人 2025-03-08 19:55:24 +08:00
Soulter 7a0864f5c2 feat: 推荐插件页面 2025-03-08 18:58:50 +08:00
Soulter 73dc0dfcf6 perf: 插件市场支持显示插件 logo 2025-03-08 17:31:08 +08:00
Soulter 1ff9a69339 chore: plugin logo 2025-03-08 17:23:25 +08:00
Soulter 179eb5d847 feat: 优化了插件卡片的 UI,插件卡片支持显示 logo 2025-03-08 17:13:36 +08:00
Soulter 52c868828c perf: 插件更新、保存配置均支持热重载 2025-03-08 15:22:56 +08:00
Soulter 7eea4615b6 perf: 优化了日志显示 2025-03-08 15:22:22 +08:00
Soulter d9b351df1a fix: 修复主动人格情况下人格失效的问题 #719 #712 2025-03-08 14:14:14 +08:00
pre-commit-ci[bot] d6a785b645 🎈 auto fixes by pre-commit hooks 2025-03-08 04:33:19 +00:00
xiewoc 79db828a01 Update aiocqhttp_platform_adapter.py 2025-03-08 12:30:49 +08:00
Soulter a5ffb0f8dc perf: 安装/更新插件后直接热重载而不重启;更新 plugin 指令 2025-03-08 00:20:48 +08:00
Soulter 9492fcde74 perf: 完善了插件的启用和禁用的生命周期管理 2025-03-07 23:44:07 +08:00
Soulter d2456ce4cd Update README.md 2025-03-07 10:52:09 +08:00
Soulter 7de27abc8d 🐛 fix: Telegram适配器使用代理地址无法获取图片 #723 2025-03-07 09:05:00 +08:00
Soulter d8155bc8eb 🐛 fix: Telegram适配器使用代理地址无法获取图片 #723 2025-03-07 00:42:15 +08:00
Soulter cf08e52a92 style: cleanup 2025-03-06 23:52:15 +08:00
Soulter 768398b991 feat: 支持 gewechat 图片等更多类型的主动消息 #710 2025-03-06 22:26:58 +08:00
Soulter 24c20a19f1 feat: 支持插件会话控制 API 2025-03-06 22:13:14 +08:00
Soulter 8fbcbcd4c0 🐛 fix: webchat cannot send active image message #710 2025-03-05 22:34:37 +08:00
Soulter e0da5bb943 chore: delete some files for project safety 2025-03-05 19:05:50 +08:00
Soulter 36fbc4fb82 Update README.md 2025-03-05 18:55:40 +08:00
Soulter cb11051f42 Update README.md 2025-03-05 17:56:23 +08:00
Soulter a824781d14 Update README.md 2025-03-05 17:55:06 +08:00
Soulter 600a2c6748 🐛 fix: context.get_platform() error 2025-03-05 13:28:55 +08:00
Soulter 77df64bfb5 🐛 fix: 修复插件在带了 __del__ 之后无法被禁用和重载的问题 2025-03-05 11:33:01 +08:00
Soulter 2d6e54903c Update README.md 2025-03-05 00:58:44 +08:00
Soulter baa2b83df9 🐛 fix: telegram cannot handle /start #620 2025-03-05 00:40:38 +08:00
Soulter 1ff02446af 🐛 fix: 404 error after installing plugins 2025-03-04 23:39:01 +08:00
Soulter b58c6ba762 feat: add template of lmstudio #691 2025-03-04 23:38:33 +08:00
126 changed files with 12804 additions and 2712 deletions
+5 -4
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
欢迎发布插件到插件市场!
欢迎发布插件到插件市场!请确保您的插件经过**完整的**测试。
- type: textarea
attributes:
@@ -22,9 +22,10 @@ body:
插件名:
插件作者:
插件简介:
标签: (可选)
社交链接: (可选, 将会在插件市场作者名称上作为可点击的链接)
description: 必填。请以列表的字段按顺序将插件名、插件作者、插件简介放在这里。
支持的消息平台:(必填,如 QQ、微信、飞书)
标签:(可选)
社交链接:(可选, 将会在插件市场作者名称上作为可点击的链接)
description: 必填。请以列表的字段按顺序将插件名、插件作者、插件简介放在这里。如果您不知道支持哪些消息平台,请填写测试过的消息平台。
- type: checkboxes
attributes:
+31
View File
@@ -0,0 +1,31 @@
name: AstrBot Dashboard CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: npm install, build
run: |
cd dashboard
npm install
npm run build
- name: Inject Commit SHA
id: get_sha
run: |
echo "COMMIT_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV
mkdir -p dashboard/dist/assets
echo $COMMIT_SHA > dashboard/dist/assets/version
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: dist-without-markdown
path: |
dashboard/dist
!dist/**/*.md
+4
View File
@@ -1,6 +1,8 @@
__pycache__
botpy.log
.vscode
.venv*
.idea
data_v2.db
data_v3.db
configs/session
@@ -26,3 +28,5 @@ venv/*
packages/python_interpreter/workplace
.venv/*
.conda/
.idea
pytest.ini
+1 -1
View File
@@ -7,7 +7,7 @@ ci:
autoupdate_commit_msg: ":balloon: pre-commit autoupdate"
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.9
rev: v0.11.2
hooks:
- id: ruff
- id: ruff-format
+27 -28
View File
@@ -10,13 +10,12 @@ _✨ 易上手的多平台 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>
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="Static Badge" src="https://img.shields.io/badge/QQ群-630166526-purple"></a>
[![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)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/Soulter/AstrBot?style=for-the-badge&color=76bad9)](https://github.com/Soulter/AstrBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&color=76bad9" 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?style=for-the-badge&color=76bad9"/></a>
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="Static Badge" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
[![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e.svg?style=for-the-badge&color=76bad9)](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%B4%BB%E8%B7%83%E9%87%8F&cacheSeconds=60&style=for-the-badge&color=3b618e)
<a href="https://github.com/Soulter/AstrBot/blob/master/README_en.md">English</a>
<a href="https://github.com/Soulter/AstrBot/blob/master/README_ja.md">日本語</a>
@@ -26,14 +25,20 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用的插件系统和完善的大语言模型(LLM)接入功能的聊天机器人及开发框架。
[![star](https://gitcode.com/Soulter/AstrBot/star/badge.svg?style=for-the-badge)](https://gitcode.com/Soulter/AstrBot)
<!-- [![codecov](https://img.shields.io/codecov/c/github/soulter/astrbot?style=for-the-badge)](https://codecov.io/gh/Soulter/AstrBot)
-->
## ✨ 主要功能
1. **大语言模型对话**。支持各种大语言模型,包括 OpenAI API、Google Gemini、Llama、Deepseek、ChatGLM 等,支持接入本地部署的大模型,通过 Ollama、LLMTuner。具有多轮对话、人格情境、多模态能力,支持图片理解、语音转文字(Whisper)。
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,可在面板上与大模型对话
6. **高稳定性、高模块化**。基于事件总线和流水线的架构设计,高度模块化,低耦合
2. **支持 MCP**。AstrBot 现已支持接入 MCP 服务器
3. **多消息平台接入**。支持接入 QQ(OneBot)、QQ 频道、微信(Gewechat)、飞书、Telegram。后续将支持钉钉、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核
4. **Agent**原生支持部分 Agent 能力,如代码执行器、自然语言待办、网页搜索。对接 [Dify 平台](https://astrbot.app/others/dify.html),便捷接入 Dify 智能助手、知识库和 Dify 工作流
5. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,极简开发。已支持安装多个插件
6. **可视化管理面板**。支持可视化修改配置、插件管理、日志查看等功能,降低配置难度。集成 WebChat,可在面板上与大模型对话
7. **高稳定性、高模块化**。基于事件总线和流水线的架构设计,高度模块化,低耦合。
> [!TIP]
> 管理面板在线体验 Demo: [https://demo.astrbot.app/](https://demo.astrbot.app/)
@@ -48,22 +53,25 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
#### Windows 一键安装器部署
需要电脑上安装有 Python>3.10)。请参阅官方文档 [使用 Windows 一键安装器部署 AstrBot](https://astrbot.app/deploy/astrbot/windows.html) 。
请参阅官方文档 [使用 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)
请参阅官方文档 [宝塔面板部署](https://astrbot.app/deploy/astrbot/btpanel.html) 。
#### CasaOS 部署
社区贡献的部署方式。
请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/casaos.html) 。
请参阅官方文档 [CasaOS 部署](https://astrbot.app/deploy/astrbot/casaos.html) 。
#### 手动部署
请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
#### Replit 部署
[![Run on Repl.it](https://repl.it/badge/github/Soulter/AstrBot)](https://repl.it/github/Soulter/AstrBot)
## ⚡ 消息平台支持情况
@@ -74,7 +82,8 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
| 微信(个人号) | ✔ | 微信个人号私聊、群聊 | 文字、图片、语音 |
| [Telegram](https://github.com/Soulter/astrbot_plugin_telegram) | ✔ | 私聊、群聊 | 文字、图片 |
| [微信(企业微信)](https://github.com/Soulter/astrbot_plugin_wecom) | ✔ | 私聊 | 文字、图片、语音 |
| 飞书 | ✔ | 群聊 | 文字、图片 |
| 飞书 | ✔ | 私聊、群聊 | 文字、图片 |
| 钉钉 | ✔ | 私聊、群聊 | 文字、图片 |
| 微信对话开放平台 | 🚧 | 计划内 | - |
| Discord | 🚧 | 计划内 | - |
| WhatsApp | 🚧 | 计划内 | - |
@@ -96,6 +105,7 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
| Whisper | ✔ | 语音转文本 | 支持 API、本地部署 |
| SenseVoice | ✔ | 语音转文本 | 本地部署 |
| OpenAI TTS API | ✔ | 文本转语音 | |
| GSVI | ✔ | 文本转语音 | GPT-Sovits-Inference |
| Fishaudio | ✔ | 文本转语音 | GPT-Sovits 作者参与的项目 |
| Edge-TTS | ✔ | 文本转语音 | Edge 浏览器的免费 TTS |
@@ -174,16 +184,5 @@ _✨ 内置 Web Chat,在线与机器人交互 ✨_
2. The deployment of WeChat (personal account) utilizes [Gewechat](https://github.com/Devo919/Gewechat) service. AstrBot only guarantees connectivity with Gewechat and recommends using a WeChat account that is not frequently used. In the event of account risk control, the author of this project shall not bear any responsibility.
3. Please ensure compliance with local laws and regulations when using this project.
<!-- ## ✨ ATRI [Beta 测试]
该功能作为插件载入。插件仓库地址:[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
-->
_私は、高性能ですから!_
+2
View File
@@ -5,6 +5,7 @@ from astrbot.core.platform import (
MessageMember,
MessageType,
PlatformMetadata,
Group,
)
from astrbot.core.platform.register import register_platform_adapter
@@ -18,4 +19,5 @@ __all__ = [
"MessageType",
"PlatformMetadata",
"register_platform_adapter",
"Group",
]
+7
View File
@@ -0,0 +1,7 @@
from astrbot.core.utils.session_waiter import (
SessionWaiter,
SessionController,
session_waiter,
)
__all__ = ["SessionWaiter", "SessionController", "session_waiter"]
+94 -17
View File
@@ -2,7 +2,7 @@
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
"""
VERSION = "3.4.35"
VERSION = "3.5.0"
DB_PATH = "data/data_v3.db"
# 默认配置
@@ -36,6 +36,8 @@ DEFAULT_CONFIG = {
"content_cleanup_rule": "",
},
"no_permission_reply": True,
"empty_mention_waiting": True,
"friend_message_needs_wake_prefix": False,
},
"provider": [],
"provider_settings": {
@@ -47,6 +49,7 @@ DEFAULT_CONFIG = {
"datetime_system_prompt": True,
"default_personality": "default",
"prompt_prefix": "",
"max_context_length": -1,
},
"provider_stt_settings": {
"enable": False,
@@ -78,17 +81,19 @@ DEFAULT_CONFIG = {
"admins_id": ["astrbot"],
"t2i": False,
"t2i_word_threshold": 150,
"t2i_strategy": "remote",
"t2i_endpoint": "",
"http_proxy": "",
"dashboard": {
"enable": True,
"username": "astrbot",
"password": "77b90590a8945a7d36c963981a307dc9",
"host": "0.0.0.0",
"port": 6185,
},
"platform": [],
"wake_prefix": ["/"],
"log_level": "INFO",
"t2i_endpoint": "",
"pip_install_arg": "",
"plugin_repo_mirror": "",
"knowledge_db": {},
@@ -120,6 +125,7 @@ CONFIG_METADATA_2 = {
"enable": False,
"appid": "",
"secret": "",
"callback_server_host": "0.0.0.0",
"port": 6196,
},
"aiocqhttp(OneBotv11)": {
@@ -144,10 +150,11 @@ CONFIG_METADATA_2 = {
"enable": False,
"corpid": "",
"secret": "",
"port": 6195,
"token": "",
"encoding_aes_key": "",
"api_base_url": "https://qyapi.weixin.qq.com/cgi-bin/",
"callback_server_host": "0.0.0.0",
"port": 6195,
},
"lark(飞书)": {
"id": "lark",
@@ -158,6 +165,13 @@ CONFIG_METADATA_2 = {
"app_secret": "",
"domain": "https://open.feishu.cn",
},
"dingtalk(钉钉)": {
"id": "dingtalk",
"type": "dingtalk",
"enable": False,
"client_id": "",
"client_secret": "",
},
"telegram": {
"id": "telegram",
"type": "telegram",
@@ -165,6 +179,7 @@ CONFIG_METADATA_2 = {
"telegram_token": "your_bot_token",
"start_message": "Hello, I'm AstrBot!",
"telegram_api_base_url": "https://api.telegram.org/bot",
"telegram_file_base_url": "https://api.telegram.org/file/bot",
},
},
"items": {
@@ -210,7 +225,7 @@ CONFIG_METADATA_2 = {
"hint": "启用后,机器人可以接收到频道的私聊消息。",
},
"ws_reverse_host": {
"description": "反向 Websocket 主机地址",
"description": "反向 Websocket 主机地址(AstrBot 为服务器端)",
"type": "string",
"hint": "aiocqhttp 适配器的反向 Websocket 服务器 IP 地址,不包含端口号。",
},
@@ -256,6 +271,16 @@ CONFIG_METADATA_2 = {
"type": "bool",
"hint": "启用后,当用户没有权限执行某个操作时,机器人会回复一条消息。",
},
"empty_mention_waiting": {
"description": "只 @ 机器人是否触发等待回复",
"type": "bool",
"hint": "启用后,当消息内容只有 @ 机器人时,会触发等待回复,在 60 秒内的该用户的任意一条消息均会唤醒机器人。这在某些平台不支持 @ 和语音/图片等消息同时发送时特别有用。",
},
"friend_message_needs_wake_prefix": {
"description": "私聊消息是否需要唤醒前缀",
"type": "bool",
"hint": "启用后,私聊消息需要唤醒前缀才会被处理,同群聊一样。",
},
"segmented_reply": {
"description": "分段回复",
"type": "object",
@@ -322,7 +347,7 @@ CONFIG_METADATA_2 = {
"type": "list",
"items": {"type": "string"},
"obvious_hint": True,
"hint": "AstrBot 只处理填写的 ID 发来的消息事件为空时不启用白名单过滤。可使用 /sid 指令获取在某个平台上的会话 ID。也可在 AstrBot 日志内获取会话 ID,当一条消息没通过白名单时,会输出 INFO 级别的日志。会话 ID 类似 aiocqhttp:GroupMessage:547540978。管理员可使用 /wl 添加白名单",
"hint": "只处理填写的 ID 发来的消息事件为空时不启用。可使用 /sid 指令获取在平台上的会话 ID(类似 abc:GroupMessage:123)。管理员可使用 /wl 添加白名单",
},
"id_whitelist_log": {
"description": "打印白名单日志",
@@ -465,6 +490,16 @@ CONFIG_METADATA_2 = {
"model": "llama3.1-8b",
},
},
"LM_Studio": {
"id": "lm_studio",
"type": "openai_chat_completion",
"enable": True,
"key": ["lmstudio"],
"api_base": "http://localhost:1234/v1",
"model_config": {
"model": "llama-3.1-8b",
},
},
"Gemini(OpenAI兼容)": {
"id": "gemini_default",
"type": "openai_chat_completion",
@@ -548,7 +583,7 @@ CONFIG_METADATA_2 = {
"dify_api_type": "chat",
"dify_api_key": "",
"dify_api_base": "https://api.dify.ai/v1",
"dify_workflow_output_key": "",
"dify_workflow_output_key": "astrbot_wf_output",
"dify_query_input_key": "astrbot_text_query",
"variables": {},
"timeout": 60,
@@ -560,6 +595,11 @@ CONFIG_METADATA_2 = {
"dashscope_app_type": "agent",
"dashscope_api_key": "",
"dashscope_app_id": "",
"rag_options": {
"pipeline_ids": [],
"file_ids": [],
"output_reference": False,
},
"variables": {},
"timeout": 60,
},
@@ -626,12 +666,36 @@ CONFIG_METADATA_2 = {
"type": "fishaudio_tts_api",
"enable": False,
"api_key": "",
"api_base": "https://api.fish-audio.cn/v1",
"api_base": "https://api.fish.audio/v1",
"fishaudio-tts-character": "可莉",
"timeout": "20",
},
},
"items": {
"rag_options": {
"description": "RAG 选项",
"type": "object",
"hint": "检索知识库设置, 非必填。仅 Agent 应用类型支持(智能体应用, 包括 RAG 应用)",
"items": {
"pipeline_ids": {
"description": "知识库 ID 列表",
"type": "list",
"items": {"type": "string"},
"hint": "对指定知识库内所有文档进行检索, 前往 https://bailian.console.aliyun.com/ 数据应用->知识索引创建和获取 ID。",
},
"file_ids": {
"description": "非结构化文档 ID, 传入该参数将对指定非结构化文档进行检索。",
"type": "list",
"items": {"type": "string"},
"hint": "对指定非结构化文档进行检索。前往 https://bailian.console.aliyun.com/ 数据管理创建和获取 ID。",
},
"output_reference": {
"description": "是否输出知识库/文档的引用",
"type": "bool",
"hint": "在每次回答尾部加上引用源。默认为 False。",
},
},
},
"sensevoice_hint": {
"description": "部署SenseVoice",
"type": "string",
@@ -648,12 +712,14 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "modelscope 上的模型名称。默认:iic/SenseVoiceSmall。",
},
# "variables": {
# "description": "工作流固定输入变量",
# "type": "object",
# "obvious_hint": True,
# "hint": "可选。工作流固定输入变量,将会作为工作流的输入。也可以在对话时使用 /set 指令动态设置变量。如果变量名冲突,优先使用动态设置的变量。",
# },
"variables": {
"description": "工作流固定输入变量",
"type": "object",
"obvious_hint": True,
"items": {},
"hint": "可选。工作流固定输入变量,将会作为工作流的输入。也可以在对话时使用 /set 指令动态设置变量。如果变量名冲突,优先使用动态设置的变量。",
"invisible": True,
},
# "fastgpt_app_type": {
# "description": "应用类型",
# "type": "string",
@@ -664,7 +730,7 @@ CONFIG_METADATA_2 = {
"dashscope_app_type": {
"description": "应用类型",
"type": "string",
"hint": "阿里云百炼应用的应用类型。",
"hint": "百炼应用的应用类型。",
"options": [
"agent",
"agent-arrange",
@@ -844,6 +910,11 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "添加之后,会在每次对话的 Prompt 前加上此文本。",
},
"max_context_length": {
"description": "最多携带对话数量(条)",
"type": "int",
"hint": "超出这个数量时将丢弃最旧的部分,用户和AI的一轮聊天记为 1 条。-1 表示不限制,默认为不限制。",
},
},
},
"persona": {
@@ -937,10 +1008,10 @@ CONFIG_METADATA_2 = {
"hint": "群聊消息最大数量。超过此数量后,会自动清除旧消息。",
},
"image_caption": {
"description": "启用图像转述(需模型支持)",
"description": "群聊图像转述(需模型支持)",
"type": "bool",
"obvious_hint": True,
"hint": "启用后,当接收到图片消息时,会使用模型先将图片转述为文字再进行后续处理。推荐使用 gpt-4o-mini 模型",
"hint": "用模型将群聊中的图片消息转述为文字,推荐 gpt-4o-mini 模型。和机器人的唤醒聊天中的图片消息仍然会直接作为上下文输入",
},
"image_caption_provider_id": {
"description": "图像转述提供商 ID",
@@ -1030,10 +1101,16 @@ CONFIG_METADATA_2 = {
"hint": "控制台输出日志的级别。",
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
},
"t2i_strategy": {
"description": "文本转图像渲染源",
"type": "string",
"hint": "文本转图像策略。`remote` 为使用远程基于 HTML 的渲染服务,`local` 为使用 PIL 本地渲染。当使用 local 时,将 ttf 字体命名为 'font.ttf' 放在 data/ 目录下可自定义字体。",
"options": ["remote", "local"],
},
"t2i_endpoint": {
"description": "文本转图像服务接口",
"type": "string",
"hint": "为空时使用 AstrBot API 服务",
"hint": "当 t2i_strategy 为 remote 时生效。为空时使用 AstrBot API 服务",
},
"pip_install_arg": {
"description": "pip 安装参数",
+8 -4
View File
@@ -40,7 +40,6 @@ class AstrBotCoreLifecycle:
else:
logger.setLevel(self.astrbot_config["log_level"])
self.event_queue = Queue()
self.event_queue.closed = False
self.provider_manager = ProviderManager(self.astrbot_config, self.db)
@@ -81,6 +80,8 @@ class AstrBotCoreLifecycle:
await self.platform_manager.initialize()
"""根据配置实例化各个平台适配器"""
self.dashboard_shutdown_event = asyncio.Event()
def _load(self):
event_bus_task = asyncio.create_task(
self.event_bus.dispatch(), name="event_bus"
@@ -129,11 +130,12 @@ class AstrBotCoreLifecycle:
await asyncio.gather(*self.curr_tasks, return_exceptions=True)
async def stop(self):
self.event_queue.closed = True
for task in self.curr_tasks:
task.cancel()
await self.provider_manager.terminate()
await self.platform_manager.terminate()
self.dashboard_shutdown_event.set()
for task in self.curr_tasks:
try:
@@ -143,8 +145,10 @@ class AstrBotCoreLifecycle:
except Exception as e:
logger.error(f"任务 {task.get_name()} 发生错误: {e}")
def restart(self):
self.event_queue.closed = True
async def restart(self):
await self.provider_manager.terminate()
await self.platform_manager.terminate()
self.dashboard_shutdown_event.set()
threading.Thread(
target=self.astrbot_updator._reboot, name="restart", daemon=True
).start()
+43 -1
View File
@@ -1,6 +1,6 @@
import abc
from dataclasses import dataclass
from typing import List
from typing import List, Dict, Any, Tuple
from astrbot.core.db.po import Stats, LLMHistory, ATRIVision, Conversation
@@ -117,3 +117,45 @@ class BaseDatabase(abc.ABC):
def update_conversation_persona_id(self, user_id: str, cid: str, persona_id: str):
"""更新 Conversation Persona ID"""
raise NotImplementedError
@abc.abstractmethod
def get_all_conversations(
self, page: int = 1, page_size: int = 20
) -> Tuple[List[Dict[str, Any]], int]:
"""获取所有对话,支持分页
Args:
page: 页码,从1开始
page_size: 每页数量
Returns:
Tuple[List[Dict[str, Any]], int]: 返回一个元组,包含对话列表和总对话数
"""
raise NotImplementedError
@abc.abstractmethod
def get_filtered_conversations(
self,
page: int = 1,
page_size: int = 20,
platforms: List[str] = None,
message_types: List[str] = None,
search_query: str = None,
exclude_ids: List[str] = None,
exclude_platforms: List[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
"""获取筛选后的对话列表
Args:
page: 页码
page_size: 每页数量
platforms: 平台筛选列表
message_types: 消息类型筛选列表
search_query: 搜索关键词
exclude_ids: 排除的用户ID列表
exclude_platforms: 排除的平台列表
Returns:
Tuple[List[Dict[str, Any]], int]: 返回一个元组,包含对话列表和总对话数
"""
raise NotImplementedError
+192 -18
View File
@@ -3,7 +3,7 @@ import os
import time
from astrbot.core.db.po import Platform, Stats, LLMHistory, ATRIVision, Conversation
from . import BaseDatabase
from typing import Tuple
from typing import Tuple, List, Dict, Any
class SQLiteDatabase(BaseDatabase):
@@ -128,24 +128,23 @@ class SQLiteDatabase(BaseDatabase):
except sqlite3.ProgrammingError:
c = self._get_conn(self.db_path).cursor()
where_clause = ""
if session_id or provider_type:
where_clause += " WHERE "
has = False
if session_id:
where_clause += f"session_id = '{session_id}'"
has = True
if provider_type:
if has:
where_clause += " AND "
where_clause += f"provider_type = '{provider_type}'"
conditions = []
params = []
if session_id:
conditions.append("session_id = ?")
params.append(session_id)
if provider_type:
conditions.append("provider_type = ?")
params.append(provider_type)
sql = "SELECT * FROM llm_history"
if conditions:
sql += " WHERE " + " AND ".join(conditions)
c.execute(sql, params)
c.execute(
"""
SELECT * FROM llm_history
"""
+ where_clause
)
res = c.fetchall()
histories = []
for row in res:
@@ -389,3 +388,178 @@ class SQLiteDatabase(BaseDatabase):
if res:
return ATRIVision(*res)
return None
def get_all_conversations(
self, page: int = 1, page_size: int = 20
) -> Tuple[List[Dict[str, Any]], int]:
"""获取所有对话,支持分页,按更新时间降序排序"""
try:
c = self.conn.cursor()
except sqlite3.ProgrammingError:
c = self._get_conn(self.db_path).cursor()
try:
# 获取总记录数
c.execute("""
SELECT COUNT(*) FROM webchat_conversation
""")
total_count = c.fetchone()[0]
# 计算偏移量
offset = (page - 1) * page_size
# 获取分页数据,按更新时间降序排序
c.execute(
"""
SELECT user_id, cid, created_at, updated_at, title, persona_id
FROM webchat_conversation
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
""",
(page_size, offset),
)
rows = c.fetchall()
conversations = []
for row in rows:
user_id, cid, created_at, updated_at, title, persona_id = row
# 确保 cid 是字符串类型且至少有8个字符,否则使用一个默认值
safe_cid = str(cid) if cid else "unknown"
display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid
conversations.append(
{
"user_id": user_id or "",
"cid": safe_cid,
"title": title or f"对话 {display_cid}",
"persona_id": persona_id or "",
"created_at": created_at or 0,
"updated_at": updated_at or 0,
}
)
return conversations, total_count
except Exception as _:
# 返回空列表和0,确保即使出错也有有效的返回值
return [], 0
finally:
c.close()
def get_filtered_conversations(
self,
page: int = 1,
page_size: int = 20,
platforms: List[str] = None,
message_types: List[str] = None,
search_query: str = None,
exclude_ids: List[str] = None,
exclude_platforms: List[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
"""获取筛选后的对话列表"""
try:
c = self.conn.cursor()
except sqlite3.ProgrammingError:
c = self._get_conn(self.db_path).cursor()
try:
# 构建查询条件
where_clauses = []
params = []
# 平台筛选
if platforms and len(platforms) > 0:
platform_conditions = []
for platform in platforms:
platform_conditions.append("user_id LIKE ?")
params.append(f"{platform}:%")
if platform_conditions:
where_clauses.append(f"({' OR '.join(platform_conditions)})")
# 消息类型筛选
if message_types and len(message_types) > 0:
message_type_conditions = []
for msg_type in message_types:
message_type_conditions.append("user_id LIKE ?")
params.append(f"%:{msg_type}:%")
if message_type_conditions:
where_clauses.append(f"({' OR '.join(message_type_conditions)})")
# 搜索关键词
if search_query:
search_query = search_query.encode("unicode_escape").decode("utf-8")
where_clauses.append(
"(title LIKE ? OR user_id LIKE ? OR cid LIKE ? OR history LIKE ?)"
)
search_param = f"%{search_query}%"
params.extend([search_param, search_param, search_param, search_param])
# 排除特定用户ID
if exclude_ids and len(exclude_ids) > 0:
for exclude_id in exclude_ids:
where_clauses.append("user_id NOT LIKE ?")
params.append(f"{exclude_id}%")
# 排除特定平台
if exclude_platforms and len(exclude_platforms) > 0:
for exclude_platform in exclude_platforms:
where_clauses.append("user_id NOT LIKE ?")
params.append(f"{exclude_platform}:%")
# 构建完整的 WHERE 子句
where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# 构建计数查询
count_sql = f"SELECT COUNT(*) FROM webchat_conversation{where_sql}"
# 获取总记录数
c.execute(count_sql, params)
total_count = c.fetchone()[0]
# 计算偏移量
offset = (page - 1) * page_size
# 构建分页数据查询
data_sql = f"""
SELECT user_id, cid, created_at, updated_at, title, persona_id
FROM webchat_conversation
{where_sql}
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
"""
query_params = params + [page_size, offset]
# 获取分页数据
c.execute(data_sql, query_params)
rows = c.fetchall()
conversations = []
for row in rows:
user_id, cid, created_at, updated_at, title, persona_id = row
# 确保 cid 是字符串类型,否则使用一个默认值
safe_cid = str(cid) if cid else "unknown"
display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid
conversations.append(
{
"user_id": user_id or "",
"cid": safe_cid,
"title": title or f"对话 {display_cid}",
"persona_id": persona_id or "",
"created_at": created_at or 0,
"updated_at": updated_at or 0,
}
)
return conversations, total_count
except Exception as _:
# 返回空列表和0,确保即使出错也有有效的返回值
return [], 0
finally:
c.close()
+5 -3
View File
@@ -38,11 +38,13 @@ CREATE TABLE IF NOT EXISTS atri_vision(
);
CREATE TABLE IF NOT EXISTS webchat_conversation(
user_id TEXT,
cid TEXT,
user_id TEXT, -- 会话 id
cid TEXT, -- 对话 id
history TEXT,
created_at INTEGER,
updated_at INTEGER,
title TEXT,
persona_id TEXT
);
);
PRAGMA encoding = 'UTF-8';
@@ -2,17 +2,16 @@ import asyncio
import traceback
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from .server import AstrBotDashboard
from astrbot.core.db import BaseDatabase
from astrbot.core import LogBroker
from astrbot.dashboard.server import AstrBotDashboard
class AstrBotDashBoardLifecycle:
class InitialLoader:
def __init__(self, db: BaseDatabase, log_broker: LogBroker):
self.db = db
self.logger = logger
self.log_broker = log_broker
self.dashboard_server = None
async def start(self):
core_lifecycle = AstrBotCoreLifecycle(self.log_broker, self.db)
@@ -25,7 +24,9 @@ class AstrBotDashBoardLifecycle:
logger.critical(traceback.format_exc())
logger.critical(f"😭 初始化 AstrBot 失败:{e} !!!")
self.dashboard_server = AstrBotDashboard(core_lifecycle, self.db)
self.dashboard_server = AstrBotDashboard(
core_lifecycle, self.db, core_lifecycle.dashboard_shutdown_event
)
task = asyncio.gather(core_task, self.dashboard_server.run())
try:
+58 -2
View File
@@ -1,6 +1,7 @@
import logging
import colorlog
import asyncio
import os
from collections import deque
from asyncio import Queue
from typing import List
@@ -17,6 +18,31 @@ log_color_config = {
}
def is_plugin_path(pathname):
"""
检查文件路径是否来自插件目录
"""
if not pathname:
return False
norm_path = os.path.normpath(pathname)
return ("data/plugins" in norm_path) or ("packages/" in norm_path)
def get_short_level_name(level_name):
"""
将日志级别名称转换为四个字母的缩写
"""
level_map = {
"DEBUG": "DBUG",
"INFO": "INFO",
"WARNING": "WARN",
"ERROR": "ERRO",
"CRITICAL": "CRIT",
}
return level_map.get(level_name, level_name[:4].upper())
class LogBroker:
def __init__(self):
self.log_cache = deque(maxlen=CACHED_SIZE)
@@ -62,12 +88,41 @@ class LogManager:
return logger
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_formatter = colorlog.ColoredFormatter(
fmt="%(log_color)s [%(asctime)s] [%(levelname)-5s] [%(filename)s:%(lineno)d]: %(message)s %(reset)s",
fmt="%(log_color)s [%(asctime)s] %(plugin_tag)s [%(short_levelname)-4s] [%(filename)s:%(lineno)d]: %(message)s %(reset)s",
datefmt="%H:%M:%S",
log_colors=log_color_config,
)
class PluginFilter(logging.Filter):
def filter(self, record):
record.plugin_tag = (
"[Plug]" if is_plugin_path(record.pathname) else "[Core]"
)
return True
class FileNameFilter(logging.Filter):
# 获取这个文件和父文件夹的名字:<folder>.<file> 并且去除 .py
def filter(self, record):
dirname = os.path.dirname(record.pathname)
record.filename = (
os.path.basename(dirname)
+ "."
+ os.path.basename(record.pathname).replace(".py", "")
)
return True
class LevelNameFilter(logging.Filter):
# 添加短日志级别名称
def filter(self, record):
record.short_levelname = get_short_level_name(record.levelname)
return True
console_handler.setFormatter(console_formatter)
logger.addFilter(PluginFilter())
logger.addFilter(FileNameFilter())
logger.addFilter(LevelNameFilter()) # 添加级别名称过滤器
logger.setLevel(logging.DEBUG)
logger.addHandler(console_handler)
@@ -80,9 +135,10 @@ class LogManager:
if logger.handlers:
handler.setFormatter(logger.handlers[0].formatter)
else:
# 为队列处理器设置相同格式的formatter
handler.setFormatter(
logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
"[%(asctime)s] [%(short_levelname)s] %(plugin_tag)s[%(filename)s:%(lineno)d]: %(message)s"
)
)
logger.addHandler(handler)
+136 -11
View File
@@ -25,9 +25,11 @@ SOFTWARE.
import base64
import json
import os
import uuid
import typing as T
from enum import Enum
from pydantic.v1 import BaseModel
from astrbot.core.utils.io import download_image_by_url, file_to_base64
class ComponentType(Enum):
@@ -59,6 +61,8 @@ class ComponentType(Enum):
TTS = "TTS"
Unknown = "Unknown"
WechatEmoji = "WechatEmoji" # Wechat 下的 emoji 表情包
class BaseMessageComponent(BaseModel):
type: ComponentType
@@ -146,6 +150,51 @@ class Record(BaseMessageComponent):
return Record(file=url, **_)
raise Exception("not a valid url")
async def convert_to_file_path(self) -> str:
"""将这个语音统一转换为本地文件路径。这个方法避免了手动判断语音数据类型,直接返回语音数据的本地路径(如果是网络 URL, 则会自动进行下载)。
Returns:
str: 语音的本地路径,以绝对路径表示。
"""
if self.file and self.file.startswith("file:///"):
file_path = self.file[8:]
return file_path
elif self.file and self.file.startswith("http"):
file_path = await download_image_by_url(self.file)
return os.path.abspath(file_path)
elif self.file and self.file.startswith("base64://"):
bs64_data = self.file.removeprefix("base64://")
image_bytes = base64.b64decode(bs64_data)
file_path = f"data/temp/{uuid.uuid4()}.jpg"
with open(file_path, "wb") as f:
f.write(image_bytes)
return os.path.abspath(file_path)
elif os.path.exists(self.file):
file_path = self.file
return os.path.abspath(file_path)
else:
raise Exception(f"not a valid file: {self.file}")
async def convert_to_base64(self) -> str:
"""将语音统一转换为 base64 编码。这个方法避免了手动判断语音数据类型,直接返回语音数据的 base64 编码。
Returns:
str: 语音的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
"""
# convert to base64
if self.file and self.file.startswith("file:///"):
bs64_data = file_to_base64(self.file[8:])
elif self.file and self.file.startswith("http"):
file_path = await download_image_by_url(self.file)
bs64_data = file_to_base64(file_path)
elif self.file and self.file.startswith("base64://"):
bs64_data = self.file
elif os.path.exists(self.file):
bs64_data = file_to_base64(self.file)
else:
raise Exception(f"not a valid file: {self.file}")
return bs64_data
class Video(BaseMessageComponent):
type: ComponentType = "Video"
@@ -279,10 +328,6 @@ class Image(BaseMessageComponent):
file_unique: T.Optional[str] = "" # 某些平台可能有图片缓存的唯一标识
def __init__(self, file: T.Optional[str], **_):
# for k in _.keys():
# if (k == "_type" and _[k] not in ["flash", "show", None]) or \
# (k == "c" and _[k] not in [2, 3]):
# logger.warn(f"Protocol: {k}={_[k]} doesn't match values")
super().__init__(file=file, **_)
@staticmethod
@@ -307,14 +352,77 @@ class Image(BaseMessageComponent):
def fromIO(IO):
return Image.fromBytes(IO.read())
async def convert_to_file_path(self) -> str:
"""将这个图片统一转换为本地文件路径。这个方法避免了手动判断图片数据类型,直接返回图片数据的本地路径(如果是网络 URL, 则会自动进行下载)。
Returns:
str: 图片的本地路径,以绝对路径表示。
"""
url = self.url if self.url else self.file
if url and url.startswith("file:///"):
image_file_path = url[8:]
return image_file_path
elif url and url.startswith("http"):
image_file_path = await download_image_by_url(url)
return os.path.abspath(image_file_path)
elif url and url.startswith("base64://"):
bs64_data = url.removeprefix("base64://")
image_bytes = base64.b64decode(bs64_data)
image_file_path = f"data/temp/{uuid.uuid4()}.jpg"
with open(image_file_path, "wb") as f:
f.write(image_bytes)
return os.path.abspath(image_file_path)
elif os.path.exists(url):
image_file_path = url
return os.path.abspath(image_file_path)
else:
raise Exception(f"not a valid file: {url}")
async def convert_to_base64(self) -> str:
"""将这个图片统一转换为 base64 编码。这个方法避免了手动判断图片数据类型,直接返回图片数据的 base64 编码。
Returns:
str: 图片的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
"""
# convert to base64
url = self.url if self.url else self.file
if url and url.startswith("file:///"):
bs64_data = file_to_base64(url[8:])
elif url and url.startswith("http"):
image_file_path = await download_image_by_url(url)
bs64_data = file_to_base64(image_file_path)
elif url and url.startswith("base64://"):
bs64_data = url
elif os.path.exists(url):
bs64_data = file_to_base64(url)
else:
raise Exception(f"not a valid file: {url}")
return bs64_data
class Reply(BaseMessageComponent):
type: ComponentType = "Reply"
id: T.Union[str, int]
text: T.Optional[str] = ""
qq: T.Optional[int] = 0
"""所引用的消息 ID"""
chain: T.Optional[T.List["BaseMessageComponent"]] = []
"""引用的消息段列表"""
sender_id: T.Optional[int] | T.Optional[str] = 0
"""引用的消息发送者 ID"""
sender_nickname: T.Optional[str] = ""
"""引用的消息发送者昵称"""
time: T.Optional[int] = 0
"""引用的消息发送时间"""
message_str: T.Optional[str] = ""
"""解析后的纯文本消息字符串"""
sender_str: T.Optional[str] = ""
"""被引用的消息纯文本"""
text: T.Optional[str] = ""
"""deprecated"""
qq: T.Optional[int] = 0
"""deprecated"""
seq: T.Optional[int] = 0
"""deprecated"""
def __init__(self, **_):
super().__init__(**_)
@@ -353,16 +461,22 @@ class Node(BaseMessageComponent):
id: T.Optional[int] = 0 # 忽略
name: T.Optional[str] = "" # qq昵称
uin: T.Optional[int] = 0 # qq号
content: T.Optional[T.Union[str, list]] = "" # 子消息段列表
content: T.Optional[T.Union[str, list, dict]] = "" # 子消息段列表
seq: T.Optional[T.Union[str, list]] = "" # 忽略
time: T.Optional[int] = 0
def __init__(self, content: T.Union[str, list], **_):
def __init__(self, content: T.Union[str, list, dict, "Node", T.List["Node"]], **_):
if isinstance(content, list):
_content = ""
for chain in content:
_content += chain.toString()
_content = None
if all(isinstance(item, Node) for item in content):
_content = [node.toDict() for node in content]
else:
_content = ""
for chain in content:
_content += chain.toString()
content = _content
elif isinstance(content, Node):
content = content.toDict()
super().__init__(content=content, **_)
def toString(self):
@@ -449,6 +563,16 @@ class File(BaseMessageComponent):
super().__init__(name=name, file=file)
class WechatEmoji(BaseMessageComponent):
type: ComponentType = "WechatEmoji"
md5: T.Optional[str] = ""
md5_len: T.Optional[int] = 0
cdnurl: T.Optional[str] = ""
def __init__(self, **_):
super().__init__(**_)
ComponentTypes = {
"plain": Plain,
"text": Plain,
@@ -477,4 +601,5 @@ ComponentTypes = {
"tts": TTS,
"unknown": Unknown,
"file": File,
"WechatEmoji": WechatEmoji,
}
+4 -4
View File
@@ -77,6 +77,10 @@ class MessageChain:
self.use_t2i_ = use_t2i
return self
def get_plain_text(self) -> str:
"""获取纯文本消息。这个方法将获取 chain 中所有 Plain 组件的文本并拼接成一条消息。空格分隔。"""
return " ".join([comp.text for comp in self.chain if isinstance(comp, Plain)])
class EventResultType(enum.Enum):
"""用于描述事件处理的结果类型。
@@ -147,9 +151,5 @@ class MessageEventResult(MessageChain):
"""是否为 LLM 结果。"""
return self.result_content_type == ResultContentType.LLM_RESULT
def get_plain_text(self) -> str:
"""获取纯文本消息。这个方法将获取所有 Plain 组件的文本并拼接成一条消息。空格分隔。"""
return " ".join([comp.text for comp in self.chain if isinstance(comp, Plain)])
CommandResult = MessageEventResult
@@ -1,7 +1,4 @@
import re
import os
import json
import base64
from . import ContentSafetyStrategy
@@ -11,13 +8,13 @@ class KeywordsStrategy(ContentSafetyStrategy):
if extra_keywords is None:
extra_keywords = []
self.keywords.extend(extra_keywords)
keywords_path = os.path.join(os.path.dirname(__file__), "unfit_words")
# keywords_path = os.path.join(os.path.dirname(__file__), "unfit_words")
# internal keywords
if os.path.exists(keywords_path):
with open(keywords_path, "r", encoding="utf-8") as f:
self.keywords.extend(
json.loads(base64.b64decode(f.read()).decode("utf-8"))["keywords"]
)
# if os.path.exists(keywords_path):
# with open(keywords_path, "r", encoding="utf-8") as f:
# self.keywords.extend(
# json.loads(base64.b64decode(f.read()).decode("utf-8"))["keywords"]
# )
def check(self, content: str) -> bool:
for keyword in self.keywords:
@@ -1 +0,0 @@
ewogICAgImtleXdvcmRzIjogWwogICAgICAgICLkuaDov5HlubMiLAogICAgICAgICLog6HplKbmtpsiLAogICAgICAgICLmsZ/ms73msJEiLAogICAgICAgICLmuKnlrrblrp0iLAogICAgICAgICLmnY7lhYvlvLoiLAogICAgICAgICLmnY7plb/mmKUiLAogICAgICAgICLmr5vms73kuJwiLAogICAgICAgICLpgpPlsI/lubMiLAogICAgICAgICLlkajmganmnaUiLAogICAgICAgICLnpL7kvJrkuLvkuYkiLAogICAgICAgICLlhbHkuqflhZoiLAogICAgICAgICLlhbHkuqfkuLvkuYkiLAogICAgICAgICLlpKfpmYblrpjmlrkiLAogICAgICAgICLljJfkuqzmlL/mnYMiLAogICAgICAgICLkuK3ljY7luJ3lm70iLAogICAgICAgICLkuK3lm73mlL/lupwiLAogICAgICAgICLlhbHni5ciLAogICAgICAgICLlha3lm5vkuovku7YiLAogICAgICAgICLlpKnlronpl6giLAogICAgICAgICLlha3lm5siLAogICAgICAgICLmlL/msrvlsYDluLjlp5QiLAogICAgICAgICLlrabmva4iLAogICAgICAgICLlhavkuZ0iLAogICAgICAgICLkuozljYHlpKciLAogICAgICAgICLmsJHov5vlhZoiLAogICAgICAgICLlj7Dni6wiLAogICAgICAgICLlj7Dmub7ni6znq4siLAogICAgICAgICLlj7Dmub7lm70iLAogICAgICAgICLlm73msJHlhZoiLAogICAgICAgICLlj7Dmub7msJHlm70iLAogICAgICAgICLkuK3ljY7msJHlm70iLAogICAgICAgICJwb3JuaHViIiwKICAgICAgICAiUG9ybmh1YiIsCiAgICAgICAgIuS9nOeIsSIsCiAgICAgICAgIuWBmueIsSIsCiAgICAgICAgIuaAp+S6pCIsCiAgICAgICAgIuiHquaFsCIsCiAgICAgICAgIumYtOiMjiIsCiAgICAgICAgIua3q+WmhyIsCiAgICAgICAgIuiCm+S6pCIsCiAgICAgICAgIuS6pOmFjSIsCiAgICAgICAgIuaAp+WFs+ezuyIsCiAgICAgICAgIuaAp+a0u+WKqCIsCiAgICAgICAgIuiJsuaDhSIsCiAgICAgICAgIuiJsuWbviIsCiAgICAgICAgIuijuOS9kyIsCiAgICAgICAgIuWwj+eptCIsCiAgICAgICAgIua3q+iNoSIsCiAgICAgICAgIuaAp+eIsSIsCiAgICAgICAgIua4r+eLrCIsCiAgICAgICAgIuazlei9ruWKnyIsCiAgICAgICAgIuWFreWbmyIKICAgIF0KfQ==
@@ -3,6 +3,7 @@
"""
import traceback
import asyncio
import json
from typing import Union, AsyncGenerator
from ...context import PipelineContext
@@ -15,7 +16,13 @@ from astrbot.core.message.message_event_result import (
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, LLMResponse
from astrbot.core.provider.entites import (
ProviderRequest,
LLMResponse,
ToolCallMessageSegment,
AssistantMessageSegment,
ToolCallsResult,
)
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star import star_map
@@ -27,6 +34,9 @@ class LLMRequestSubStage(Stage):
self.provider_wake_prefix = ctx.astrbot_config["provider_settings"][
"wake_prefix"
] # str
self.max_context_length = ctx.astrbot_config["provider_settings"][
"max_context_length"
] # int
for bwp in self.bot_wake_prefixs:
if self.provider_wake_prefix.startswith(bwp):
@@ -63,21 +73,28 @@ class LLMRequestSubStage(Stage):
req.func_tool = self.ctx.plugin_manager.context.get_llm_tool_manager()
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)
image_path = await comp.convert_to_file_path()
req.image_urls.append(image_path)
# 获取对话上下文
conversation_id = await self.conv_manager.get_curr_conversation_id(
event.unified_msg_origin
)
req.session_id = event.unified_msg_origin
if not conversation_id:
conversation_id = await self.conv_manager.new_conversation(
event.unified_msg_origin
)
req.session_id = event.unified_msg_origin
conversation = await self.conv_manager.get_conversation(
event.unified_msg_origin, conversation_id
)
if not conversation:
conversation_id = await self.conv_manager.new_conversation(
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)
@@ -109,100 +126,59 @@ class LLMRequestSubStage(Stage):
if isinstance(req.contexts, str):
req.contexts = json.loads(req.contexts)
# max context length
if (
self.max_context_length != -1 # -1 为不限制
and len(req.contexts) // 2 > self.max_context_length
):
logger.debug("上下文长度超过限制,将截断。")
req.contexts = req.contexts[-self.max_context_length * 2 :]
try:
logger.debug(f"提供商请求 Payload: {req}")
if _nested:
req.func_tool = None # 暂时不支持递归工具调用
llm_response = await provider.text_chat(**req.__dict__) # 请求 LLM
need_loop = True
while need_loop:
need_loop = False
logger.debug(f"提供商请求 Payload: {req}")
llm_response = await provider.text_chat(**req.__dict__) # 请求 LLM
# 执行 LLM 响应后的事件钩子。
handlers = star_handlers_registry.get_handlers_by_event_type(
EventType.OnLLMResponseEvent
# 执行 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())
if event.is_stopped():
logger.info(
f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。"
)
return
async for result in self._handle_llm_response(event, req, llm_response):
if isinstance(result, ProviderRequest):
# 有函数工具调用并且返回了结果,我们需要再次请求 LLM
req = result
need_loop = True
else:
yield
asyncio.create_task(
Metric.upload(
llm_tick=1,
model_name=provider.get_model(),
provider_type=provider.meta().type,
)
)
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())
if event.is_stopped():
logger.info(
f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。"
)
return
# 保存到历史记录
await self._save_to_history(event, req, llm_response)
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)
)
elif llm_response.role == "err":
event.set_result(
MessageEventResult().message(
f"AstrBot 请求失败。\n错误信息: {llm_response.completion_text}"
)
)
elif llm_response.role == "tool":
# function calling
function_calling_result = {}
logger.info(
f"触发 {len(llm_response.tools_call_name)} 个函数调用: {llm_response.tools_call_name}"
)
for func_tool_name, func_tool_args in zip(
llm_response.tools_call_name, llm_response.tools_call_args
):
func_tool = req.func_tool.get_func(func_tool_name)
logger.info(
f"调用工具函数:{func_tool_name},参数:{func_tool_args}"
)
try:
# 尝试调用工具函数
wrapper = self._call_handler(
self.ctx, event, func_tool.handler, **func_tool_args
)
async for resp in wrapper:
if resp is not None: # 有 return 返回
function_calling_result[func_tool_name] = resp
else:
yield # 有生成器返回
event.clear_result() # 清除上一个 handler 的结果
except BaseException as e:
logger.warning(traceback.format_exc())
function_calling_result[func_tool_name] = (
"When calling the function, an error occurred: " + str(e)
)
if function_calling_result:
# 工具返回 LLM 资源。比如 RAG、网页 得到的相关结果等。
# 我们重新执行一遍这个 stage
req.func_tool = None # 暂时不支持递归工具调用
extra_prompt = "\n\nSystem executed some external tools for this task and here are the results:\n"
for tool_name, tool_result in function_calling_result.items():
extra_prompt += (
f"Tool: {tool_name}\nTool Result: {tool_result}\n"
)
req.prompt += extra_prompt
async for _ in self.process(event, _nested=True):
yield
else:
if llm_response.completion_text:
event.set_result(
MessageEventResult().message(llm_response.completion_text)
)
except BaseException as e:
logger.error(traceback.format_exc())
event.set_result(
@@ -212,6 +188,116 @@ class LLMRequestSubStage(Stage):
)
return
async def _handle_llm_response(
self, event: AstrMessageEvent, req: ProviderRequest, llm_response: LLMResponse
) -> AsyncGenerator[None, None]:
"""处理 LLM 响应。
Returns:
bool: 是否需要继续调用 LLM
Yields:
Iterator[bool]: 将 event 交付给下一个 stage
"""
if llm_response.role == "assistant":
# text completion
if llm_response.result_chain:
event.set_result(
MessageEventResult(
chain=llm_response.result_chain.chain
).set_result_content_type(ResultContentType.LLM_RESULT)
)
else:
event.set_result(
MessageEventResult()
.message(llm_response.completion_text)
.set_result_content_type(ResultContentType.LLM_RESULT)
)
elif llm_response.role == "err":
event.set_result(
MessageEventResult().message(
f"AstrBot 请求失败。\n错误信息: {llm_response.completion_text}"
)
)
elif llm_response.role == "tool":
# function calling
tool_call_result: list[ToolCallMessageSegment] = []
logger.info(
f"触发 {len(llm_response.tools_call_name)} 个函数调用: {llm_response.tools_call_name}"
)
for func_tool_name, func_tool_args, func_tool_id in zip(
llm_response.tools_call_name,
llm_response.tools_call_args,
llm_response.tools_call_ids,
):
try:
func_tool = req.func_tool.get_func(func_tool_name)
if func_tool.origin == "mcp":
logger.info(
f"从 MCP 服务 {func_tool.mcp_server_name} 调用工具函数:{func_tool.name},参数:{func_tool_args}"
)
client = req.func_tool.mcp_client_dict[
func_tool.mcp_server_name
]
res = await client.session.call_tool(
func_tool.name, func_tool_args
)
if res:
# TODO content的类型可能包括list[TextContent | ImageContent | EmbeddedResource],这里只处理了TextContent。
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=res.content[0].text,
)
)
else:
logger.info(
f"调用工具函数:{func_tool_name},参数:{func_tool_args}"
)
# 尝试调用工具函数
wrapper = self._call_handler(
self.ctx, event, func_tool.handler, **func_tool_args
)
async for resp in wrapper:
if resp is not None: # 有 return 返回
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=resp,
)
)
else:
yield # 有生成器返回
event.clear_result() # 清除上一个 handler 的结果
except BaseException as e:
logger.warning(traceback.format_exc())
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=f"error: {str(e)}",
)
)
if tool_call_result:
# 函数调用结果
req.func_tool = None # 暂时不支持递归工具调用
assistant_msg_seg = AssistantMessageSegment(
role="assistant", tool_calls=llm_response.to_openai_tool_calls()
)
# 在多轮 Tool 调用的情况下,这里始终保持最新的 Tool 调用结果,减少上下文长度。
req.tool_calls_result = ToolCallsResult(
tool_calls_info=assistant_msg_seg,
tool_calls_result=tool_call_result,
)
yield req # 再次执行 LLM 请求
else:
if llm_response.completion_text:
event.set_result(
MessageEventResult().message(llm_response.completion_text)
)
async def _save_to_history(
self, event: AstrMessageEvent, req: ProviderRequest, llm_response: LLMResponse
):
@@ -221,8 +307,12 @@ class LLMRequestSubStage(Stage):
if llm_response.role == "assistant":
# 文本回复
contexts = req.contexts
new_record = {"role": "user", "content": req.prompt}
contexts.append(new_record)
contexts.append(await req.assemble_context())
# tool calls result
if req.tool_calls_result:
contexts.extend(req.tool_calls_result.to_openai_messages())
contexts.append(
{"role": "assistant", "content": llm_response.completion_text}
)
+9 -2
View File
@@ -103,9 +103,16 @@ class RespondStage(Stage):
for comp in result.chain:
i = await self._calc_comp_interval(comp)
await asyncio.sleep(i)
await event.send(MessageChain([*decorated_comps, comp]))
try:
await event.send(MessageChain([*decorated_comps, comp]))
except Exception as e:
logger.error(f"发送消息失败: {e} chain: {result.chain}")
break
else:
await event.send(result)
try:
await event.send(result)
except Exception as e:
logger.error(f"发送消息失败: {e} chain: {result.chain}")
await event._post_send()
logger.info(
f"AstrBot -> {event.get_sender_name()}/{event.get_sender_id()}: {event._outline_chain(result.chain)}"
@@ -31,6 +31,8 @@ class ResultDecorateStage(Stage):
self.t2i_word_threshold = 50
except BaseException:
self.t2i_word_threshold = 150
self.t2i_strategy = ctx.astrbot_config["t2i_strategy"]
self.t2i_use_network = self.t2i_strategy == "remote"
self.forward_threshold = ctx.astrbot_config["platform_settings"][
"forward_threshold"
@@ -192,7 +194,9 @@ class ResultDecorateStage(Stage):
if plain_str and len(plain_str) > self.t2i_word_threshold:
render_start = time.time()
try:
url = await html_renderer.render_t2i(plain_str, return_url=True)
url = await html_renderer.render_t2i(
plain_str, return_url=True, use_network=self.t2i_use_network
)
except BaseException:
logger.error("文本转图片失败,使用文本发送。")
return
@@ -201,7 +205,10 @@ class ResultDecorateStage(Stage):
"文本转图片耗时超过了 3 秒,如果觉得很慢可以使用 /t2i 关闭文本转图片模式。"
)
if url:
result.chain = [Image.fromURL(url)]
if url.startswith("http"):
result.chain = [Image.fromURL(url)]
else:
result.chain = [Image.fromFileSystem(url)]
# 触发转发消息
has_forwarded = False
+19 -11
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import abc
import inspect
import traceback
from astrbot.api import logger
from typing import List, AsyncGenerator, Union, Awaitable
from astrbot.core.platform.astr_message_event import AstrMessageEvent
@@ -43,25 +44,32 @@ class Stage(abc.ABC):
"""调用 Handler。"""
# 判断 handler 是否是类方法(通过装饰器注册的没有 __self__ 属性)
ready_to_call = None
trace_ = None
try:
ready_to_call = handler(event, *args, **kwargs)
except TypeError as e:
except TypeError as _:
# 向下兼容
logger.debug(str(e))
trace_ = traceback.format_exc()
ready_to_call = handler(event, ctx.plugin_manager.context, *args, **kwargs)
if isinstance(ready_to_call, AsyncGenerator):
_has_yielded = False
async for ret in ready_to_call:
# 如果处理函数是生成器,返回值只能是 MessageEventResult 或者 None(无返回值)
_has_yielded = True
if isinstance(ret, (MessageEventResult, CommandResult)):
event.set_result(ret)
try:
async for ret in ready_to_call:
# 如果处理函数是生成器,返回值只能是 MessageEventResult 或者 None(无返回值)
_has_yielded = True
if isinstance(ret, (MessageEventResult, CommandResult)):
event.set_result(ret)
yield
else:
yield ret
if not _has_yielded:
yield
else:
yield ret
if not _has_yielded:
yield
except Exception as e:
logger.error(f"Previous Error: {trace_}")
raise e
elif inspect.iscoroutine(ready_to_call):
# 如果只是一个 coroutine
ret = await ready_to_call
+8 -2
View File
@@ -25,6 +25,10 @@ class WakingCheckStage(Stage):
self.no_permission_reply = self.ctx.astrbot_config["platform_settings"].get(
"no_permission_reply", True
)
# 私聊是否需要 wake_prefix 才能唤醒机器人
self.friend_message_needs_wake_prefix = self.ctx.astrbot_config[
"platform_settings"
].get("friend_message_needs_wake_prefix", False)
async def process(
self, event: AstrMessageEvent
@@ -32,7 +36,7 @@ class WakingCheckStage(Stage):
# 设置 sender 身份
event.message_str = event.message_str.strip()
for admin_id in self.ctx.astrbot_config["admins_id"]:
if event.get_sender_id() == admin_id:
if str(event.get_sender_id()) == admin_id:
event.role = "admin"
break
@@ -68,7 +72,7 @@ class WakingCheckStage(Stage):
event.is_at_or_wake_command = True
break
# 检查是否是私聊
if event.is_private_chat():
if event.is_private_chat() and not self.friend_message_needs_wake_prefix:
is_wake = True
event.is_wake = True
event.is_at_or_wake_command = True
@@ -102,6 +106,7 @@ class WakingCheckStage(Stage):
f"插件 {star_map[handler.handler_module_path].name}: {e}"
)
)
await event._post_send()
event.stop_event()
passed = False
break
@@ -113,6 +118,7 @@ class WakingCheckStage(Stage):
f"ID {event.get_sender_id()} 权限不足。通过 /sid 获取 ID 并请管理员添加。"
)
)
await event._post_send()
event.stop_event()
return
@@ -51,7 +51,10 @@ class WhitelistCheckStage(Stage):
and event.get_message_type() == MessageType.FRIEND_MESSAGE
):
return
if event.unified_msg_origin not in self.whitelist:
if (
event.unified_msg_origin not in self.whitelist
and event.get_group_id() not in self.whitelist
):
if self.wl_log:
logger.info(
f"会话 ID {event.unified_msg_origin} 不在会话白名单中,已终止事件传播。请在配置文件中添加该会话 ID 到白名单。"
+2 -1
View File
@@ -1,7 +1,7 @@
from .platform import Platform
from .astr_message_event import AstrMessageEvent
from .platform_metadata import PlatformMetadata
from .astrbot_message import AstrBotMessage, MessageMember, MessageType
from .astrbot_message import AstrBotMessage, MessageMember, MessageType, Group
__all__ = [
"Platform",
@@ -10,4 +10,5 @@ __all__ = [
"AstrBotMessage",
"MessageMember",
"MessageType",
"Group",
]
+40 -14
View File
@@ -1,10 +1,9 @@
import abc
import asyncio
from dataclasses import dataclass
from .astrbot_message import AstrBotMessage
from .platform_metadata import PlatformMetadata
from astrbot.core.message.message_event_result import MessageEventResult, MessageChain
from astrbot.core.platform.message_type import MessageType
from typing import List, Union
from typing import List, Union, Optional
from astrbot.core.db.po import Conversation
from astrbot.core.message.components import (
Plain,
Image,
@@ -13,10 +12,14 @@ from astrbot.core.message.components import (
At,
AtAll,
Forward,
Reply,
)
from astrbot.core.utils.metrics import Metric
from astrbot.core.message.message_event_result import MessageEventResult, MessageChain
from astrbot.core.platform.message_type import MessageType
from astrbot.core.provider.entites import ProviderRequest
from astrbot.core.db.po import Conversation
from astrbot.core.utils.metrics import Metric
from .astrbot_message import AstrBotMessage, Group
from .platform_metadata import PlatformMetadata
@dataclass
@@ -100,8 +103,15 @@ class AstrMessageEvent(abc.ABC):
elif isinstance(i, Forward):
# 转发消息
outline += "[转发消息]"
elif isinstance(i, Reply):
# 引用回复
if i.message_str:
outline += f"[引用消息({i.sender_nickname}: {i.message_str})]"
else:
outline += "[引用消息]"
else:
outline += f"[{i.type}]"
outline += " "
return outline
def get_message_outline(self) -> str:
@@ -192,13 +202,6 @@ class AstrMessageEvent(abc.ABC):
"""
return self.role == "admin"
async def send(self, message: MessageChain):
"""
发送消息到消息平台。
"""
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
self._has_send_oper = True
async def _pre_send(self):
"""调度器会在执行 send() 前调用该方法"""
@@ -360,3 +363,26 @@ class AstrMessageEvent(abc.ABC):
system_prompt=system_prompt,
conversation=conversation,
)
"""平台适配器"""
async def send(self, message: MessageChain):
"""发送消息到消息平台。
Args:
message (MessageChain): 消息链,具体使用方式请参考文档。
"""
asyncio.create_task(
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
)
self._has_send_oper = True
async def get_group(self, group_id: str = None, **kwargs) -> Optional[Group]:
"""获取一个群聊的数据, 如果不填写 group_id: 如果是私聊消息,返回 None。如果是群聊消息,返回当前群聊的数据。
适配情况:
- gewechat
- aiocqhttp(OneBotv11)
"""
...
+35
View File
@@ -10,6 +10,41 @@ class MessageMember:
user_id: str # 发送者id
nickname: str = None
def __str__(self):
# 使用 f-string 来构建返回的字符串表示形式
return (
f"User ID: {self.user_id},"
f"Nickname: {self.nickname if self.nickname else 'N/A'}"
)
@dataclass
class Group:
group_id: str
"""群号"""
group_name: str = None
"""群名称"""
group_avatar: str = None
"""群头像"""
group_owner: str = None
"""群主 id"""
group_admins: List[str] = None
"""群管理员 id"""
members: List[MessageMember] = None
"""所有群成员"""
def __str__(self):
# 使用 f-string 来构建返回的字符串表示形式
return (
f"Group ID: {self.group_id}\n"
f"Name: {self.group_name if self.group_name else 'N/A'}\n"
f"Avatar: {self.group_avatar if self.group_avatar else 'N/A'}\n"
f"Owner ID: {self.group_owner if self.group_owner else 'N/A'}\n"
f"Admin IDs: {self.group_admins if self.group_admins else 'N/A'}\n"
f"Members Len: {len(self.members) if self.members else 0}\n"
f"First Member: {self.members[0] if self.members else 'N/A'}\n"
)
class AstrBotMessage:
"""
+44 -32
View File
@@ -64,6 +64,10 @@ class PlatformManager:
)
case "lark":
from .sources.lark.lark_adapter import LarkPlatformAdapter # noqa: F401
case "dingtalk":
from .sources.dingtalk.dingtalk_adapter import (
DingtalkPlatformAdapter, # noqa: F401
)
case "telegram":
from .sources.telegram.tg_adapter import TelegramPlatformAdapter # noqa: F401
case "wecom":
@@ -81,14 +85,18 @@ class PlatformManager:
)
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
inst: Platform = cls_type(platform_config, self.settings, self.event_queue)
self._inst_map[platform_config["id"]] = {
"inst": inst,
"client_id": inst.client_self_id,
}
self.platform_insts.append(inst)
asyncio.create_task(
self._task_wrapper(
asyncio.create_task(
inst.run(), name=platform_config["id"] + "_platform"
inst.run(),
name=f"platform_{platform_config['type']}_{platform_config['id']}",
)
)
)
@@ -105,38 +113,42 @@ class PlatformManager:
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.terminate_platform(platform_config["id"])
if platform_config["enable"]:
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} 平台适配器。")
# 和配置文件保持同步
config_ids = [provider["id"] for provider in self.platforms_config]
for key in list(self._inst_map.keys()):
if key not in config_ids:
await self.terminate_platform(key)
# 再启动新的实例
await self.load_platform(platform_config)
async def terminate_platform(self, platform_id: str):
if platform_id in self._inst_map:
logger.info(f"正在尝试终止 {platform_id} 平台适配器 ...")
# client_id = self._inst_map.pop(platform_id, None)
info = self._inst_map.pop(platform_id, None)
client_id = info["client_id"]
inst = info["inst"]
try:
self.platform_insts.remove(
next(
inst
for inst in self.platform_insts
if inst.client_self_id == client_id
)
)
except Exception:
logger.warning(f"可能未完全移除 {platform_id} 平台适配器")
if getattr(inst, "terminate", None):
await inst.terminate()
async def terminate(self):
for inst in self.platform_insts:
if getattr(inst, "terminate", None):
await inst.terminate()
def get_insts(self):
return self.platform_insts
+3 -1
View File
@@ -1,4 +1,5 @@
import abc
import uuid
from typing import Awaitable, Any
from asyncio import Queue
from .platform_metadata import PlatformMetadata
@@ -13,6 +14,7 @@ class Platform(abc.ABC):
super().__init__()
# 维护了消息平台的事件队列,EventBus 会从这里取出事件并处理。
self._event_queue = event_queue
self.client_self_id = uuid.uuid4().hex
@abc.abstractmethod
def run(self) -> Awaitable[Any]:
@@ -25,7 +27,7 @@ class Platform(abc.ABC):
"""
终止一个平台的运行实例。
"""
pass
...
@abc.abstractmethod
def meta(self) -> PlatformMetadata:
@@ -1,9 +1,9 @@
import asyncio
import typing
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import Group, MessageMember
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
class AiocqhttpMessageEvent(AstrMessageEvent):
@@ -21,20 +21,12 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
d = segment.toDict()
if isinstance(segment, Plain):
d["type"] = "text"
d["data"]["text"] = segment.text.strip()
elif isinstance(segment, (Image, Record)):
# convert to base64
if segment.file and segment.file.startswith("file:///"):
bs64_data = file_to_base64(segment.file[8:])
image_file_path = segment.file[8:]
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)
bs64 = await segment.convert_to_base64()
d["data"] = {
"file": bs64_data,
"file": bs64,
}
elif isinstance(segment, At):
d["data"] = {
@@ -55,8 +47,13 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
if send_one_by_one:
for seg in message.chain:
if isinstance(seg, Nodes):
# 带有多个节点的合并转发消息
if isinstance(seg, (Node, Nodes)):
# 合并转发消息
if isinstance(seg, Node):
nodes = Nodes([seg])
seg = nodes
payload = seg.toDict()
if self.get_group_id():
payload["group_id"] = self.get_group_id()
@@ -78,3 +75,46 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
await self.bot.send(self.message_obj.raw_message, ret)
await super().send(message)
async def get_group(self, group_id=None, **kwargs):
if isinstance(group_id, str) and group_id.isdigit():
group_id = int(group_id)
elif self.get_group_id():
group_id = int(self.get_group_id())
else:
return None
info: dict = await self.bot.call_action(
"get_group_info",
group_id=group_id,
)
members: typing.List[typing.Dict] = await self.bot.call_action(
"get_group_member_list",
group_id=group_id,
)
owner_id = None
admin_ids = []
for member in members:
if member["role"] == "owner":
owner_id = member["user_id"]
if member["role"] == "admin":
admin_ids.append(member["user_id"])
group = Group(
group_id=str(group_id),
group_name=info.get("group_name"),
group_avatar="",
group_admins=admin_ids,
group_owner=str(owner_id),
members=[
MessageMember(
user_id=member["user_id"],
nickname=member.get("nickname") or member.get("card"),
)
for member in members
],
)
return group
@@ -43,8 +43,6 @@ class AiocqhttpAdapter(Platform):
"适用于 OneBot 标准的消息平台适配器,支持反向 WebSockets。",
)
self.stop = False
self.bot = CQHttp(
use_ws_reverse=True, import_name="aiocqhttp", api_timeout_sec=180
)
@@ -140,7 +138,7 @@ class AiocqhttpAdapter(Platform):
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)
str(abm.sender.user_id) + "_" + str(event.group_id)
) # 也保留群组 id
else:
abm.session_id = (
@@ -160,8 +158,14 @@ class AiocqhttpAdapter(Platform):
return abm
async def _convert_handle_message_event(self, event: Event) -> AstrBotMessage:
"""OneBot V11 消息类事件"""
async def _convert_handle_message_event(
self, event: Event, get_reply=True
) -> AstrBotMessage:
"""OneBot V11 消息类事件
@param event: 事件对象
@param get_reply: 是否获取回复消息。这个参数是为了防止多个回复嵌套。
"""
abm = AstrBotMessage()
abm.self_id = str(event.self_id)
abm.sender = MessageMember(
@@ -240,6 +244,36 @@ class AiocqhttpAdapter(Platform):
except BaseException as e:
logger.error(f"获取文件失败: {e},此消息段将被忽略。")
elif t == "reply":
if not get_reply:
a = ComponentTypes[t](**m["data"]) # noqa: F405
abm.message.append(a)
else:
try:
reply_event_data = await self.bot.call_action(
action="get_msg",
message_id=int(m["data"]["id"]),
)
abm_reply = await self._convert_handle_message_event(
Event.from_payload(reply_event_data), get_reply=False
)
reply_seg = Reply(
id=abm_reply.message_id,
chain=abm_reply.message,
sender_id=abm_reply.sender.user_id,
sender_nickname=abm_reply.sender.nickname,
time=abm_reply.timestamp,
message_str=abm_reply.message_str,
text=abm_reply.message_str, # for compatibility
qq=abm_reply.sender.user_id, # for compatibility
)
abm.message.append(reply_seg)
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)
@@ -267,22 +301,19 @@ class AiocqhttpAdapter(Platform):
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
logging.getLogger("aiocqhttp").setLevel(logging.ERROR)
self.shutdown_event = asyncio.Event()
return coro
async def terminate(self):
self.stop = True
await asyncio.sleep(1)
self.shutdown_event.set()
async def shutdown_trigger_placeholder(self):
await self.shutdown_event.wait()
logger.info("aiocqhttp 适配器已被优雅地关闭")
def meta(self) -> PlatformMetadata:
return self.metadata
async def shutdown_trigger_placeholder(self):
# TODO: use asyncio.Event
while not self._event_queue.closed and not self.stop: # noqa: ASYNC110
await asyncio.sleep(1)
logger.info("aiocqhttp 适配器已关闭。")
async def handle_msg(self, message: AstrBotMessage):
message_event = AiocqhttpMessageEvent(
message_str=message.message_str,
@@ -0,0 +1,227 @@
import asyncio
import uuid
import aiohttp
import dingtalk_stream
import threading
from astrbot.api.platform import (
Platform,
AstrBotMessage,
MessageMember,
MessageType,
PlatformMetadata,
)
from astrbot.api.event import MessageChain
from astrbot.api.message_components import Image, Plain, At
from astrbot.core.platform.astr_message_event import MessageSesion
from .dingtalk_event import DingtalkMessageEvent
from ...register import register_platform_adapter
from astrbot import logger
from dingtalk_stream import AckMessage
from astrbot.core.utils.io import download_file
class MyEventHandler(dingtalk_stream.EventHandler):
async def process(self, event: dingtalk_stream.EventMessage):
print(
"2",
event.headers.event_type,
event.headers.event_id,
event.headers.event_born_time,
event.data,
)
return AckMessage.STATUS_OK, "OK"
@register_platform_adapter("dingtalk", "钉钉机器人官方 API 适配器")
class DingtalkPlatformAdapter(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.client_id = platform_config["client_id"]
self.client_secret = platform_config["client_secret"]
class AstrCallbackClient(dingtalk_stream.ChatbotHandler):
async def process(self_, message: dingtalk_stream.CallbackMessage):
logger.debug(f"dingtalk: {message.data}")
im = dingtalk_stream.ChatbotMessage.from_dict(message.data)
abm = await self.convert_msg(im)
await self.handle_msg(abm)
return AckMessage.STATUS_OK, "OK"
self.client = AstrCallbackClient()
credential = dingtalk_stream.Credential(self.client_id, self.client_secret)
client = dingtalk_stream.DingTalkStreamClient(credential, logger=logger)
client.register_all_event_handler(MyEventHandler())
client.register_callback_handler(
dingtalk_stream.ChatbotMessage.TOPIC, self.client
)
self.client_ = client # 用于 websockets 的 client
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
raise NotImplementedError("钉钉机器人适配器不支持 send_by_session")
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
"dingtalk",
"钉钉机器人官方 API 适配器",
)
async def convert_msg(
self, message: dingtalk_stream.ChatbotMessage
) -> AstrBotMessage:
abm = AstrBotMessage()
abm.message = []
abm.message_str = ""
abm.timestamp = int(message.create_at / 1000)
abm.type = (
MessageType.GROUP_MESSAGE
if message.conversation_type == "2"
else MessageType.FRIEND_MESSAGE
)
abm.sender = MessageMember(
user_id=message.sender_id, nickname=message.sender_nick
)
abm.self_id = message.chatbot_user_id
abm.message_id = message.message_id
abm.raw_message = message
if abm.type == MessageType.GROUP_MESSAGE:
if message.is_in_at_list:
abm.message.append(At(qq=abm.self_id))
abm.group_id = message.conversation_id
if self.unique_session:
abm.session_id = abm.sender.user_id
else:
abm.session_id = abm.group_id
else:
abm.session_id = abm.sender.user_id
message_type: str = message.message_type
match message_type:
case "text":
abm.message_str = message.text.content.strip()
abm.message.append(Plain(abm.message_str))
case "richText":
rtc: dingtalk_stream.RichTextContent = message.rich_text_content
contents: list[dict] = rtc.rich_text_list
for content in contents:
plains = ""
if "text" in content:
plains += content["text"]
abm.message.append(Plain(plains))
elif "type" in content and content["type"] == "picture":
f_path = await self.download_ding_file(
content["downloadCode"],
message.robot_code,
"jpg",
)
abm.message.append(Image.fromFileSystem(f_path))
case "audio":
pass
return abm # 别忘了返回转换后的消息对象
async def download_ding_file(
self, download_code: str, robot_code: str, ext: str
) -> str:
"""下载钉钉文件
:param access_token: 钉钉机器人的 access_token
:param download_code: 下载码
:param robot_code: 机器人码
:param ext: 文件后缀
:return: 文件路径
"""
access_token = await self.get_access_token()
headers = {
"x-acs-dingtalk-access-token": access_token,
}
payload = {
"downloadCode": download_code,
"robotCode": robot_code,
}
f_path = f"data/dingtalk_file_{uuid.uuid4()}.{ext}"
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.dingtalk.com/v1.0/robot/messageFiles/download",
headers=headers,
json=payload,
) as resp:
if resp.status != 200:
logger.error(
f"下载钉钉文件失败: {resp.status}, {await resp.text()}"
)
return None
resp_data = await resp.json()
download_url = resp_data["data"]["downloadUrl"]
await download_file(download_url, f_path)
return f_path
async def get_access_token(self) -> str:
payload = {
"appKey": self.client_id,
"appSecret": self.client_secret,
}
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.dingtalk.com/v1.0/oauth2/accessToken",
json=payload,
) as resp:
if resp.status != 200:
logger.error(
f"获取钉钉机器人 access_token 失败: {resp.status}, {await resp.text()}"
)
return None
return (await resp.json())["data"]["accessToken"]
async def handle_msg(self, abm: AstrBotMessage):
event = DingtalkMessageEvent(
message_str=abm.message_str,
message_obj=abm,
platform_meta=self.meta(),
session_id=abm.session_id,
client=self.client,
)
self._event_queue.put_nowait(event)
async def run(self):
# await self.client_.start()
# 钉钉的 SDK 并没有实现真正的异步,start() 里面有堵塞方法。
def start_client(loop: asyncio.AbstractEventLoop):
try:
self._shutdown_event = threading.Event()
task = loop.create_task(self.client_.start())
self._shutdown_event.wait()
if task.done():
task.result()
except Exception as e:
if "Graceful shutdown" in str(e):
logger.info("钉钉适配器已被优雅地关闭")
return
logger.error(f"钉钉机器人启动失败: {e}")
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, start_client, loop)
async def terminate(self):
def monkey_patch_close():
raise Exception("Graceful shutdown")
self.client_.open_connection = monkey_patch_close
await self.client_.websocket.close(code=1000, reason="Graceful shutdown")
self._shutdown_event.set()
def get_client(self):
return self.client
@@ -0,0 +1,58 @@
import asyncio
import dingtalk_stream
import astrbot.api.message_components as Comp
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot import logger
class DingtalkMessageEvent(AstrMessageEvent):
def __init__(
self,
message_str,
message_obj,
platform_meta,
session_id,
client: dingtalk_stream.ChatbotHandler,
):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
async def send_with_client(
self, client: dingtalk_stream.ChatbotHandler, message: MessageChain
):
for segment in message.chain:
if isinstance(segment, Comp.Plain):
segment.text = segment.text.strip()
await asyncio.get_event_loop().run_in_executor(
None, client.reply_text, segment.text, self.message_obj.raw_message
)
elif isinstance(segment, Comp.Image):
markdown_str = ""
if segment.file and segment.file.startswith("file:///"):
logger.warning(
"dingtalk only support url image, not: " + segment.file
)
continue
elif segment.file and segment.file.startswith("http"):
markdown_str += f"![image]({segment.file})\n\n"
elif segment.file and segment.file.startswith("base64://"):
logger.warning("dingtalk only support url image, not base64")
continue
else:
logger.warning(
"dingtalk only support url image, not: " + segment.file
)
continue
ret = await asyncio.get_event_loop().run_in_executor(
None,
client.reply_markdown,
"😄",
markdown_str,
self.message_obj.raw_message,
)
logger.debug(f"send image: {ret}")
async def send(self, message: MessageChain):
await self.send_with_client(self.client, message)
await super().send(message)
+318 -48
View File
@@ -1,17 +1,26 @@
import threading
import asyncio
import aiohttp
import quart
import base64
import datetime
import re
import os
import re
import threading
import aiohttp
import anyio
from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType
from astrbot.api.message_components import Plain, Image, At, Record
import quart
from astrbot.api import logger, sp
from .downloader import GeweDownloader
from astrbot.api.message_components import Plain, Image, At, Record, Video
from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType
from astrbot.core.utils.io import download_image_by_url
from .downloader import GeweDownloader
try:
from .xml_data_parser import GeweDataParser
except (ImportError, ModuleNotFoundError) as e:
logger.warning(
f"警告: 可能未安装 defusedxml 依赖库,将导致无法解析微信的 表情包、引用 类型的消息: {str(e)}"
)
class SimpleGewechatClient:
@@ -51,11 +60,11 @@ class SimpleGewechatClient:
self.server = quart.Quart(__name__)
self.server.add_url_rule(
"/astrbot-gewechat/callback", view_func=self.callback, methods=["POST"]
"/astrbot-gewechat/callback", view_func=self._callback, methods=["POST"]
)
self.server.add_url_rule(
"/astrbot-gewechat/file/<file_id>",
view_func=self.handle_file,
view_func=self._handle_file,
methods=["GET"],
)
@@ -70,9 +79,10 @@ class SimpleGewechatClient:
self.userrealnames = {}
self.stop = False
self.shutdown_event = asyncio.Event()
async def get_token_id(self):
"""获取 Gewechat Token。"""
async with aiohttp.ClientSession() as session:
async with session.post(f"{self.base_url}/tools/getTokenId") as resp:
json_blob = await resp.json()
@@ -87,6 +97,15 @@ class SimpleGewechatClient:
type_name = data["type_name"]
else:
raise Exception("无法识别的消息类型")
# 以下没有业务处理,只是避免控制台打印太多的日志
if type_name == "ModContacts":
logger.info("gewechat下发:ModContacts消息通知。")
return
if type_name == "DelContacts":
logger.info("gewechat下发:DelContacts消息通知。")
return
if type_name == "Offline":
logger.critical("收到 gewechat 下线通知。")
return
@@ -147,6 +166,11 @@ class SimpleGewechatClient:
abm.type = MessageType.FRIEND_MESSAGE
user_id = from_user_name
# 检查消息是否由自己发送,若是则忽略
if user_id == abm.self_id:
logger.info("忽略自己发送的消息")
return None
abm.message = []
if at_me:
abm.message.insert(0, At(qq=abm.self_id))
@@ -178,6 +202,11 @@ class SimpleGewechatClient:
abm.sender = MessageMember(user_id, user_real_name)
abm.raw_message = d
abm.message_str = ""
if user_id == "weixin":
# 忽略微信团队消息
return
# 不同消息类型
match d["MsgType"]:
case 1:
@@ -195,18 +224,42 @@ class SimpleGewechatClient:
case 34:
# 语音消息
# data = await self.multimedia_downloader.download_voice(
# self.appid,
# content,
# abm.message_id
# )
# print(data)
if "ImgBuf" in d and "buffer" in d["ImgBuf"]:
voice_data = base64.b64decode(d["ImgBuf"]["buffer"])
file_path = f"data/temp/gewe_voice_{abm.message_id}.silk"
async with await anyio.open_file(file_path, "wb") as f:
await f.write(voice_data)
abm.message.append(Record(file=file_path, url=file_path))
# 以下已知消息类型,没有业务处理,只是避免控制台打印太多的日志
case 37: # 好友申请
logger.info("消息类型(37):好友申请")
case 42: # 名片
logger.info("消息类型(42):名片")
case 43: # 视频
video = Video(file="", cover=content)
abm.message.append(video)
case 47: # emoji
data_parser = GeweDataParser(content, abm.group_id == "")
emoji = data_parser.parse_emoji()
abm.message.append(emoji)
case 48: # 地理位置
logger.info("消息类型(48):地理位置")
case 49: # 公众号/文件/小程序/引用/转账/红包/视频号/群聊邀请
data_parser = GeweDataParser(content, abm.group_id == "")
abm_data = data_parser.parse_mutil_49()
if abm_data:
abm.message.append(abm_data)
case 51: # 帐号消息同步?
logger.info("消息类型(51):帐号消息同步?")
case 10000: # 被踢出群聊/更换群主/修改群名称
logger.info("消息类型(10000):被踢出群聊/更换群主/修改群名称")
case 10002: # 撤回/拍一拍/成员邀请/被移出群聊/解散群聊/群公告/群待办
logger.info(
"消息类型(10002):撤回/拍一拍/成员邀请/被移出群聊/解散群聊/群公告/群待办"
)
case _:
logger.info(f"未实现的消息类型: {d['MsgType']}")
abm.raw_message = d
@@ -214,7 +267,7 @@ class SimpleGewechatClient:
logger.debug(f"abm: {abm}")
return abm
async def callback(self):
async def _callback(self):
data = await quart.request.json
logger.debug(f"收到 gewechat 回调: {data}")
@@ -236,7 +289,7 @@ class SimpleGewechatClient:
return quart.jsonify({"r": "AstrBot ACK"})
async def handle_file(self, file_id):
async def _handle_file(self, file_id):
file_path = f"data/temp/{file_id}"
return await quart.send_file(file_path)
@@ -262,17 +315,14 @@ class SimpleGewechatClient:
await self.server.run_task(
host="0.0.0.0",
port=self.port,
shutdown_trigger=self.shutdown_trigger_placeholder,
shutdown_trigger=self.shutdown_trigger,
)
async def shutdown_trigger_placeholder(self):
# TODO: use asyncio.Event
while not self.event_queue.closed and not self.stop: # noqa: ASYNC110
await asyncio.sleep(1)
logger.info("gewechat 适配器已关闭。")
async def shutdown_trigger(self):
await self.shutdown_event.wait()
async def check_online(self, appid: str):
# /login/checkOnline
"""检查 APPID 对应的设备是否在线。"""
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/login/checkOnline",
@@ -283,6 +333,7 @@ class SimpleGewechatClient:
return json_blob["data"]
async def logout(self):
"""登出 gewechat。"""
if self.appid:
online = await self.check_online(self.appid)
if online:
@@ -296,6 +347,7 @@ class SimpleGewechatClient:
logger.info(f"登出结果: {json_blob}")
async def login(self):
"""登录 gewechat。一般来说插件用不到这个方法。"""
if self.token is None:
await self.get_token_id()
@@ -304,32 +356,49 @@ class SimpleGewechatClient:
)
if self.appid:
online = await self.check_online(self.appid)
if online:
logger.info(f"APPID: {self.appid} 已在线")
return
try:
online = await self.check_online(self.appid)
if online:
logger.info(f"APPID: {self.appid} 已在线")
return
except Exception as e:
logger.error(f"检查在线状态失败: {e}")
sp.put(f"gewechat-appid-{self.nickname}", "")
self.appid = None
payload = {"appId": self.appid}
if self.appid:
logger.info(f"使用 APPID: {self.appid}, {self.nickname}")
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/login/getLoginQrCode",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
if json_blob["ret"] != 200:
raise Exception(f"获取二维码失败: {json_blob}")
qr_data = json_blob["data"]["qrData"]
qr_uuid = json_blob["data"]["uuid"]
appid = json_blob["data"]["appId"]
logger.info(f"APPID: {appid}")
logger.warning(
f"请打开该网址,然后使用微信扫描二维码登录: https://api.cl2wm.cn/api/qrcode/code?text={qr_data}"
)
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/login/getLoginQrCode",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
if json_blob["ret"] != 200:
error_msg = json_blob.get("data", {}).get("msg", "")
if "设备不存在" in error_msg:
logger.error(
f"检测到无效的appid: {self.appid},将清除并重新登录。"
)
sp.put(f"gewechat-appid-{self.nickname}", "")
self.appid = None
return await self.login()
else:
raise Exception(f"获取二维码失败: {json_blob}")
qr_data = json_blob["data"]["qrData"]
qr_uuid = json_blob["data"]["uuid"]
appid = json_blob["data"]["appId"]
logger.info(f"APPID: {appid}")
logger.warning(
f"请打开该网址,然后使用微信扫描二维码登录: https://api.cl2wm.cn/api/qrcode/code?text={qr_data}"
)
except Exception as e:
raise e
# 执行登录
retry_cnt = 64
@@ -390,9 +459,18 @@ class SimpleGewechatClient:
self.appid = appid
logger.info(f"已保存 APPID: {appid}")
"""API"""
"""API 部分。Gewechat 的 API 文档请参考: https://apifox.com/apidoc/shared/69ba62ca-cb7d-437e-85e4-6f3d3df271b1
"""
async def get_chatroom_member_list(self, chatroom_wxid: str):
async def get_chatroom_member_list(self, chatroom_wxid: str) -> dict:
"""获取群成员列表。
Args:
chatroom_wxid (str): 微信群聊的id。可以通过 event.get_group_id() 获取。
Returns:
dict: 返回群成员列表字典。其中键为 memberList 的值为群成员列表。
"""
payload = {"appId": self.appid, "chatroomId": chatroom_wxid}
async with aiohttp.ClientSession() as session:
@@ -405,6 +483,7 @@ class SimpleGewechatClient:
return json_blob["data"]
async def post_text(self, to_wxid, content: str, ats: str = ""):
"""发送纯文本消息"""
payload = {
"appId": self.appid,
"toWxid": to_wxid,
@@ -421,6 +500,7 @@ class SimpleGewechatClient:
logger.debug(f"发送消息结果: {json_blob}")
async def post_image(self, to_wxid, image_url: str):
"""发送图片消息"""
payload = {
"appId": self.appid,
"toWxid": to_wxid,
@@ -434,7 +514,79 @@ class SimpleGewechatClient:
json_blob = await resp.json()
logger.debug(f"发送图片结果: {json_blob}")
async def post_emoji(self, to_wxid, emoji_md5, emoji_size, cdnurl=""):
"""发送emoji消息"""
payload = {
"appId": self.appid,
"toWxid": to_wxid,
"emojiMd5": emoji_md5,
"emojiSize": emoji_size,
}
# 优先表情包,若拿不到表情包的md5,就用当作图片发
try:
if emoji_md5 != "" and emoji_size != "":
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/message/postEmoji",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
logger.info(
f"发送emoji消息结果: {json_blob.get('msg', '操作失败')}"
)
else:
await self.post_image(to_wxid, cdnurl)
except Exception as e:
logger.error(e)
async def post_video(
self, to_wxid, video_url: str, thumb_url: str, video_duration: int
):
payload = {
"appId": self.appid,
"toWxid": to_wxid,
"videoUrl": video_url,
"thumbUrl": thumb_url,
"videoDuration": video_duration,
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/message/postVideo", headers=self.headers, json=payload
) as resp:
json_blob = await resp.json()
logger.debug(f"发送视频结果: {json_blob}")
async def forward_video(self, to_wxid, cnd_xml: str):
"""转发视频
Args:
to_wxid (str): 发送给谁
cnd_xml (str): 视频消息的cdn信息
"""
payload = {
"appId": self.appid,
"toWxid": to_wxid,
"xml": cnd_xml,
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/message/forwardVideo",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
logger.debug(f"转发视频结果: {json_blob}")
async def post_voice(self, to_wxid, voice_url: str, voice_duration: int):
"""发送语音信息
Args:
voice_url (str): 语音文件的网络链接
voice_duration (int): 语音时长,毫秒
"""
payload = {
"appId": self.appid,
"toWxid": to_wxid,
@@ -449,9 +601,16 @@ class SimpleGewechatClient:
f"{self.base_url}/message/postVoice", headers=self.headers, json=payload
) as resp:
json_blob = await resp.json()
logger.debug(f"发送语音结果: {json_blob}")
logger.info(f"发送语音结果: {json_blob.get('msg', '操作失败')}")
async def post_file(self, to_wxid, file_url: str, file_name: str):
"""发送文件
Args:
to_wxid (string): 微信ID
file_url (str): 文件的网络链接
file_name (str): 文件名
"""
payload = {
"appId": self.appid,
"toWxid": to_wxid,
@@ -465,3 +624,114 @@ class SimpleGewechatClient:
) as resp:
json_blob = await resp.json()
logger.debug(f"发送文件结果: {json_blob}")
async def add_friend(self, v3: str, v4: str, content: str):
"""申请添加好友"""
payload = {
"appId": self.appid,
"scene": 3,
"content": content,
"v4": v4,
"v3": v3,
"option": 2,
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/contacts/addContacts",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
logger.debug(f"申请添加好友结果: {json_blob}")
return json_blob
async def get_group(self, group_id: str):
payload = {
"appId": self.appid,
"chatroomId": group_id,
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/group/getChatroomInfo",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
logger.debug(f"获取群信息结果: {json_blob}")
return json_blob
async def get_group_member(self, group_id: str):
payload = {
"appId": self.appid,
"chatroomId": group_id,
}
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()
logger.debug(f"获取群信息结果: {json_blob}")
return json_blob
async def accept_group_invite(self, url: str):
"""同意进群"""
payload = {"appId": self.appid, "url": url}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/group/agreeJoinRoom",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
logger.debug(f"获取群信息结果: {json_blob}")
return json_blob
async def add_group_member_to_friend(
self, group_id: str, to_wxid: str, content: str
):
payload = {
"appId": self.appid,
"chatroomId": group_id,
"content": content,
"memberWxid": to_wxid,
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/group/addGroupMemberAsFriend",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
logger.debug(f"获取群信息结果: {json_blob}")
return json_blob
async def get_user_or_group_info(self, *ids):
"""
获取用户或群组信息。
:param ids: 可变数量的 wxid 参数
"""
wxids_str = list(ids)
payload = {
"appId": self.appid,
"wxids": wxids_str, # 使用逗号分隔的字符串
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/contacts/getDetailInfo",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
logger.debug(f"获取群信息结果: {json_blob}")
return json_blob
@@ -39,3 +39,17 @@ class GeweDownloader:
continue
raise Exception("无法下载图片")
async def download_emoji_md5(self, app_id, emoji_md5):
"""下载emoji"""
try:
payload = {"appId": app_id, "emojiMd5": emoji_md5}
# gewe 计划中的接口,暂时没有实现。返回代码404
data = await self._post_json(
self.base_url, "/message/downloadEmojiMd5", payload
)
json_blob = json.loads(data)
return json_blob
except BaseException as e:
logger.error(f"gewe download emoji: {e}")
@@ -2,12 +2,21 @@ 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.io import save_temp_img, 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, At, File
from astrbot.api.platform import AstrBotMessage, PlatformMetadata, Group, MessageMember
from astrbot.api.message_components import (
Plain,
Image,
Record,
At,
File,
Video,
WechatEmoji as Emoji,
)
from .client import SimpleGewechatClient
@@ -37,12 +46,9 @@ class GewechatPlatformEvent(AstrMessageEvent):
self.client = client
@staticmethod
async def send_with_client(message: MessageChain, user_name: str):
pass
async def send(self, message: MessageChain):
to_wxid = self.message_obj.raw_message.get("to_wxid", None)
async def send_with_client(
message: MessageChain, to_wxid: str, client: SimpleGewechatClient
):
if not to_wxid:
logger.error("无法获取到 to_wxid。")
return
@@ -70,56 +76,94 @@ class GewechatPlatformEvent(AstrMessageEvent):
payload["content"] = text
payload["ats"] = ats
has_at = True
await self.client.post_text(**payload)
await client.post_text(**payload)
elif isinstance(comp, Image):
img_url = comp.file
img_path = ""
if img_url.startswith("file:///"):
img_path = img_url[8:]
elif comp.file and comp.file.startswith("http"):
img_path = await download_image_by_url(comp.file)
else:
img_path = img_url
img_path = await comp.convert_to_file_path()
# 检查 record_path 是否在 data/temp 目录中, record_path 可能是绝对路径
# 检查 record_path 是否在 data/temp 目录中
temp_directory = os.path.abspath("data/temp")
img_path = os.path.abspath(img_path)
if os.path.commonpath([temp_directory, img_path]) != temp_directory:
with open(img_path, "rb") as f:
img_path = save_temp_img(f.read())
file_id = os.path.basename(img_path)
img_url = f"{self.client.file_server_url}/{file_id}"
img_url = f"{client.file_server_url}/{file_id}"
logger.debug(f"gewe callback img url: {img_url}")
await self.client.post_image(to_wxid, img_url)
await client.post_image(to_wxid, img_url)
elif isinstance(comp, Video):
if comp.cover != "":
await client.forward_video(to_wxid, comp.cover)
else:
try:
from pyffmpeg import FFmpeg
except (ImportError, ModuleNotFoundError):
logger.error(
"需要安装 pyffmpeg 库才能发送视频: pip install pyffmpeg"
)
raise ModuleNotFoundError(
"需要安装 pyffmpeg 库才能发送视频: pip install pyffmpeg"
)
video_url = comp.file
# 根据 url 下载视频
video_filename = f"{uuid.uuid4()}.mp4"
video_path = f"data/temp/{video_filename}"
await download_file(video_url, video_path)
# 获取视频第一帧
thumb_path = f"data/temp/{uuid.uuid4()}.jpg"
try:
ff = FFmpeg()
command = f'-i "{video_path}" -ss 0 -vframes 1 "{thumb_path}"'
ff.options(command)
thumb_file_id = os.path.basename(thumb_path)
thumb_url = f"{client.file_server_url}/{thumb_file_id}"
except Exception as e:
logger.error(f"获取视频第一帧失败: {e}")
# 获取视频时长
try:
from pyffmpeg import FFprobe
# 创建 FFprobe 实例
ffprobe = FFprobe(video_url)
# 获取时长字符串
duration_str = ffprobe.duration
# 处理时长字符串
video_duration = float(duration_str.replace(":", ""))
except Exception as e:
logger.error(f"获取时长失败: {e}")
video_duration = 10
file_id = os.path.basename(video_path)
video_url = f"{client.file_server_url}/{file_id}"
await client.post_video(
to_wxid, video_url, thumb_url, video_duration
)
# 删除临时视频和缩略图文件
if os.path.exists(video_path):
os.remove(video_path)
if os.path.exists(thumb_path):
os.remove(thumb_path)
elif isinstance(comp, Record):
# 默认已经存在 data/temp 中
record_url = comp.file
record_path = ""
if record_url.startswith("file:///"):
record_path = record_url[8:]
elif record_url.startswith("http"):
await download_file(record_url, f"data/temp/{uuid.uuid4()}.wav")
else:
record_path = record_url
record_path = await comp.convert_to_file_path()
silk_path = f"data/temp/{uuid.uuid4()}.silk"
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)}")
)
await client.post_text(to_wxid, 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}"
record_url = f"{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)
await client.post_voice(to_wxid, record_url, duration * 1000)
elif isinstance(comp, File):
file_path = comp.file
file_name = comp.name
@@ -131,12 +175,44 @@ class GewechatPlatformEvent(AstrMessageEvent):
file_path = file_path
file_id = os.path.basename(file_path)
file_url = f"{self.client.file_server_url}/{file_id}"
file_url = f"{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)
await client.post_file(to_wxid, file_url, file_id)
elif isinstance(comp, Emoji):
await client.post_emoji(to_wxid, comp.md5, comp.md5_len, comp.cdnurl)
elif isinstance(comp, At):
pass
else:
logger.debug(f"gewechat 忽略: {comp.type}")
async def send(self, message: MessageChain):
to_wxid = self.message_obj.raw_message.get("to_wxid", None)
await GewechatPlatformEvent.send_with_client(message, to_wxid, self.client)
await super().send(message)
async def get_group(self, group_id=None, **kwargs):
# 确定有效的 group_id
if group_id is None:
group_id = self.get_group_id()
if not group_id:
return None
res = await self.client.get_group(group_id)
data: dict = res["data"]
if not data["chatroomId"]:
return None
members = [
MessageMember(user_id=member["wxid"], nickname=member["nickName"])
for member in data.get("memberList", [])
]
return Group(
group_id=data["chatroomId"],
group_name=data.get("nickName"),
group_avatar=data.get("smallHeadImgUrl"),
group_owner=data.get("chatRoomOwner"),
members=members,
)
@@ -4,12 +4,11 @@ import os
from astrbot.api.platform import Platform, AstrBotMessage, MessageType, PlatformMetadata
from astrbot.api.event import MessageChain
from astrbot.api import logger
from astrbot.core.platform.astr_message_event import MessageSesion
from ...register import register_platform_adapter
from .gewechat_event import GewechatPlatformEvent
from .client import SimpleGewechatClient
from astrbot.core.message.components import Plain
from astrbot import logger
if sys.version_info >= (3, 12):
from typing import override
@@ -45,14 +44,16 @@ class GewechatPlatformAdapter(Platform):
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
to_wxid = session.session_id
if not to_wxid:
logger.error("无法获取到 to_wxid。")
return
session_id = session.session_id
if "#" in session_id:
# unique session
to_wxid = session_id.split("#")[1]
else:
to_wxid = session_id
for comp in message_chain.chain:
if isinstance(comp, Plain):
await self.client.post_text(to_wxid, comp.text)
await GewechatPlatformEvent.send_with_client(
message_chain, to_wxid, self.client
)
await super().send_by_session(session, message_chain)
@@ -64,8 +65,9 @@ class GewechatPlatformAdapter(Platform):
)
async def terminate(self):
self.client.stop = True
await asyncio.sleep(1)
self.client.shutdown_event.set()
await self.client.server.shutdown()
logger.info("Gewechat 适配器已被优雅地关闭。")
async def logout(self):
await self.client.logout()
@@ -81,7 +83,7 @@ class GewechatPlatformAdapter(Platform):
async def handle_msg(self, message: AstrBotMessage):
if message.type == MessageType.GROUP_MESSAGE:
if self.settingss["unique_session"]:
message.session_id = message.sender.user_id + "_" + message.group_id
message.session_id = message.sender.user_id + "#" + message.group_id
message_event = GewechatPlatformEvent(
message_str=message.message_str,
@@ -0,0 +1,78 @@
from defusedxml import ElementTree as eT
from astrbot.api import logger
from astrbot.api.message_components import WechatEmoji as Emoji, Reply, Plain
class GeweDataParser:
def __init__(self, data, is_private_chat):
self.data = data
self.is_private_chat = is_private_chat
def _format_to_xml(self):
return eT.fromstring(self.data)
def parse_mutil_49(self):
appmsg_type = self._format_to_xml().find(".//appmsg/type")
if appmsg_type is None:
return
match appmsg_type.text:
case "57":
return self.parse_reply()
def parse_emoji(self) -> Emoji | None:
try:
emoji_element = self._format_to_xml().find(".//emoji")
# 提取 md5 和 len 属性
if emoji_element is not None:
md5_value = emoji_element.get("md5")
emoji_size = emoji_element.get("len")
cdnurl = emoji_element.get("cdnurl")
return Emoji(md5=md5_value, md5_len=emoji_size, cdnurl=cdnurl)
except Exception as e:
logger.error(f"gewechat: parse_emoji failed, {e}")
def parse_reply(self) -> Reply | None:
try:
replied_id = -1
replied_uid = 0
replied_nickname = ""
replied_content = ""
content = ""
root = self._format_to_xml()
refermsg = root.find(".//refermsg")
if refermsg is not None:
# 被引用的信息
svrid = refermsg.find("svrid")
fromusr = refermsg.find("fromusr")
displayname = refermsg.find("displayname")
refermsg_content = refermsg.find("content")
if svrid is not None:
replied_id = svrid.text
if fromusr is not None:
replied_uid = fromusr.text
if displayname is not None:
replied_nickname = displayname.text
if refermsg_content is not None:
replied_content = refermsg_content.text
# 提取引用者说的内容
title = root.find(".//appmsg/title")
if title is not None:
content = title.text
r = Reply(
id=replied_id,
chain=[Plain(content)],
sender_id=replied_uid,
sender_nickname=replied_nickname,
sender_str=replied_content,
message_str=content,
)
return r
except Exception as e:
logger.error(f"gewechat: parse_reply failed, {e}")
@@ -2,6 +2,7 @@ import base64
import asyncio
import json
import re
import astrbot.api.message_components as Comp
from astrbot.api.platform import (
Platform,
@@ -11,7 +12,6 @@ from astrbot.api.platform import (
PlatformMetadata,
)
from astrbot.api.event import MessageChain
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
@@ -66,7 +66,7 @@ class LarkPlatformAdapter(Platform):
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session")
raise NotImplementedError("Lark 适配器不支持 send_by_session")
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
@@ -92,7 +92,7 @@ class LarkPlatformAdapter(Platform):
at_list = {}
if message.mentions:
for m in message.mentions:
at_list[m.key] = At(qq=m.id.open_id, name=m.name)
at_list[m.key] = Comp.At(qq=m.id.open_id, name=m.name)
if m.name == self.bot_name:
abm.self_id = m.id.open_id
@@ -111,7 +111,7 @@ class LarkPlatformAdapter(Platform):
if s in at_list:
abm.message.append(at_list[s])
else:
abm.message.append(Plain(parts[i].strip()))
abm.message.append(Comp.Plain(parts[i].strip()))
elif message.message_type == "post":
_ls = []
@@ -132,7 +132,7 @@ class LarkPlatformAdapter(Platform):
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()))
abm.message.append(Comp.Plain(comp["text"].strip()))
elif comp["tag"] == "img":
image_key = comp["image_key"]
request = (
@@ -147,10 +147,10 @@ class LarkPlatformAdapter(Platform):
logger.error(f"无法下载飞书图片: {image_key}")
image_bytes = response.file.read()
image_base64 = base64.b64encode(image_bytes).decode()
abm.message.append(Image.fromBase64(image_base64))
abm.message.append(Comp.Image.fromBase64(image_base64))
for comp in abm.message:
if isinstance(comp, Plain):
if isinstance(comp, Comp.Plain):
abm.message_str += comp.text
abm.message_id = message.message_id
abm.raw_message = message
@@ -185,5 +185,9 @@ class LarkPlatformAdapter(Platform):
# self.client.start()
await self.client._connect()
async def terminate(self):
await self.client._disconnect()
logger.info("飞书(Lark) 适配器已被优雅地关闭")
def get_client(self) -> lark.Client:
return self.client
@@ -122,16 +122,16 @@ class QQOfficialMessageEvent(AstrMessageEvent):
plain_text += i.text
elif isinstance(i, Image) and not image_base64:
if i.file and i.file.startswith("file:///"):
image_base64 = file_to_base64(i.file[8:]).replace("base64://", "")
image_base64 = file_to_base64(i.file[8:])
image_file_path = i.file[8:]
elif i.file and i.file.startswith("http"):
image_file_path = await download_image_by_url(i.file)
image_base64 = file_to_base64(image_file_path).replace(
"base64://", ""
)
image_base64 = file_to_base64(image_file_path)
elif i.file and i.file.startswith("base64://"):
image_base64 = i.file
else:
image_base64 = file_to_base64(i.file).replace("base64://", "")
image_file_path = i.file
image_base64 = file_to_base64(i.file)
image_base64 = image_base64.removeprefix("base64://")
else:
logger.debug(f"qq_official 忽略 {i.type}")
return plain_text, image_base64, image_file_path
@@ -17,6 +17,7 @@ from astrbot.api.platform import (
MessageType,
PlatformMetadata,
)
from astrbot import logger
from astrbot.api.event import MessageChain
from typing import Union, List
from astrbot.api.message_components import Image, Plain, At
@@ -204,3 +205,7 @@ class QQOfficialPlatformAdapter(Platform):
def get_client(self) -> botClient:
return self.client
async def terminate(self):
await self.client.close()
logger.info("QQ 官方机器人接口 适配器已被优雅地关闭")
@@ -13,6 +13,7 @@ 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
from astrbot import logger
# remove logger handler
for handler in logging.root.handlers[:]:
@@ -111,3 +112,9 @@ class QQOfficialWebhookPlatformAdapter(Platform):
def get_client(self) -> botClient:
return self.client
async def terminate(self):
self.webhook_helper.shutdown_event.set()
await self.client.close()
await self.webhook_helper.server.shutdown()
logger.info("QQ 机器人官方 API 适配器已经被优雅地关闭")
@@ -15,6 +15,7 @@ class QQOfficialWebhook:
self.appid = config["appid"]
self.secret = config["secret"]
self.port = config.get("port", 6196)
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
if isinstance(self.port, str):
self.port = int(self.port)
@@ -29,6 +30,7 @@ class QQOfficialWebhook:
)
self.client = botpy_client
self.event_queue = event_queue
self.shutdown_event = asyncio.Event()
async def initialize(self):
logger.info("正在登录到 QQ 官方机器人...")
@@ -95,13 +97,14 @@ class QQOfficialWebhook:
return {"opcode": 12}
async def start_polling(self):
logger.info(
f"将在 {self.callback_server_host}:{self.port} 端口启动 QQ 官方机器人 webhook 适配器。"
)
await self.server.run_task(
host="0.0.0.0",
host=self.callback_server_host,
port=self.port,
shutdown_trigger=self.shutdown_trigger_placeholder,
shutdown_trigger=self.shutdown_trigger,
)
async def shutdown_trigger_placeholder(self):
while not self.event_queue.closed: # noqa: ASYNC110
await asyncio.sleep(1)
logger.info("qq_official_webhook 适配器已关闭。")
async def shutdown_trigger(self):
await self.shutdown_event.wait()
@@ -1,6 +1,7 @@
import sys
import uuid
import asyncio
import astrbot.api.message_components as Comp
from astrbot.api.platform import (
Platform,
@@ -10,14 +11,6 @@ from astrbot.api.platform import (
MessageType,
)
from astrbot.api.event import MessageChain
from astrbot.api.message_components import (
Plain,
Image,
Record,
File as AstrBotFile,
Video,
At,
)
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.api.platform import register_platform_adapter
@@ -50,18 +43,29 @@ class TelegramPlatformAdapter(Platform):
)
if not base_url:
base_url = "https://api.telegram.org/bot"
file_base_url = self.config.get(
"telegram_file_base_url", "https://api.telegram.org/file/bot"
)
if not file_base_url:
file_base_url = "https://api.telegram.org/file/bot"
self.base_url = base_url
self.application = (
ApplicationBuilder()
.token(self.config["telegram_token"])
.base_url(base_url)
.base_file_url(file_base_url)
.build()
)
message_handler = TelegramMessageHandler(
filters=filters.ALL, # receive all messages
callback=self.convert_message,
callback=self.message_handler,
)
self.application.add_handler(message_handler)
self.client = self.application.bot
logger.debug(f"Telegram base url: {self.client.base_url}")
@override
async def send_by_session(
@@ -93,70 +97,135 @@ class TelegramPlatformAdapter(Platform):
chat_id=update.effective_chat.id, text=self.config["start_message"]
)
async def message_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
logger.debug(f"Telegram message: {update.message}")
abm = await self.convert_message(update, context)
if abm:
await self.handle_msg(abm)
async def convert_message(
self, update: Update, context: ContextTypes.DEFAULT_TYPE
self, update: Update, context: ContextTypes.DEFAULT_TYPE, get_reply=True
) -> AstrBotMessage:
"""转换 Telegram 的消息对象为 AstrBotMessage 对象。
@param update: Telegram 的 Update 对象。
@param context: Telegram 的 Context 对象。
@param get_reply: 是否获取回复消息。这个参数是为了防止多个回复嵌套。
"""
message = AstrBotMessage()
message.session_id = str(update.message.chat.id)
# 获得是群聊还是私聊
if update.effective_chat.type == ChatType.PRIVATE:
if update.message.chat.type == ChatType.PRIVATE:
message.type = MessageType.FRIEND_MESSAGE
else:
message.type = MessageType.GROUP_MESSAGE
message.group_id = update.effective_chat.id
message.group_id = str(update.message.chat.id)
if update.message.message_thread_id:
# Topic Group
message.group_id += "#" + str(update.message.message_thread_id)
message.session_id = message.group_id
message.message_id = str(update.message.message_id)
message.session_id = str(update.effective_chat.id)
message.sender = MessageMember(
str(update.effective_user.id), update.effective_user.username
str(update.message.from_user.id), update.message.from_user.username
)
message.self_id = str(context.bot.username)
message.raw_message = update
message.message_str = ""
message.message = []
logger.debug(f"Telegram message: {update.message}")
if update.message.reply_to_message and not (
update.message.is_topic_message
and update.message.message_thread_id
== update.message.reply_to_message.message_id
):
# 获取回复消息
reply_update = Update(
update_id=1,
message=update.message.reply_to_message,
)
reply_abm = await self.convert_message(reply_update, context, False)
message.message.append(
Comp.Reply(
id=reply_abm.message_id,
chain=reply_abm.message,
sender_id=reply_abm.sender.user_id,
sender_nickname=reply_abm.sender.nickname,
time=reply_abm.timestamp,
message_str=reply_abm.message_str,
text=reply_abm.message_str,
qq=reply_abm.sender.user_id,
)
)
if update.message.text:
# 处理文本消息
plain_text = update.message.text
if update.message.entities:
for entity in update.message.entities:
if entity.type == "mention":
name = plain_text[entity.offset+1 : entity.offset + entity.length]
message.message.append(At(qq=name, name=name))
name = plain_text[
entity.offset + 1 : entity.offset + entity.length
]
message.message.append(Comp.At(qq=name, name=name))
plain_text = (
plain_text[: entity.offset]
+ plain_text[entity.offset + entity.length :]
)
message.message.append(Plain(plain_text))
if plain_text:
message.message.append(Comp.Plain(plain_text))
message.message_str = plain_text
if message.message_str.strip() == "/start":
await self.start(update, context)
return
elif update.message.voice:
file = await update.message.voice.get_file()
message.message = [
Record(file=file.file_path, url=file.file_path),
Comp.Record(file=file.file_path, url=file.file_path),
]
elif update.message.photo:
photo = update.message.photo[-1] # get the largest photo
file = await photo.get_file()
message.message.append(Image(file=file.file_path, url=file.file_path))
message.message.append(Comp.Image(file=file.file_path, url=file.file_path))
if update.message.caption:
message.message_str = update.message.caption
message.message.append(Comp.Plain(message.message_str))
if update.message.caption_entities:
for entity in update.message.caption_entities:
if entity.type == "mention":
name = message.message_str[
entity.offset + 1 : entity.offset + entity.length
]
message.message.append(Comp.At(qq=name, name=name))
elif update.message.sticker:
# 将sticker当作图片处理
file = await update.message.sticker.get_file()
message.message.append(Comp.Image(file=file.file_path, url=file.file_path))
if update.message.sticker.emoji:
sticker_text = f"Sticker: {update.message.sticker.emoji}"
message.message_str = sticker_text
message.message.append(Comp.Plain(sticker_text))
elif update.message.document:
file = await update.message.document.get_file()
message.message = [
AstrBotFile(
file=file.file_path, name=update.message.document.file_name
),
Comp.File(file=file.file_path, name=update.message.document.file_name),
]
elif update.message.video:
file = await update.message.video.get_file()
message.message = [
Video(file=file.file_path, path=file.file_path),
Comp.Video(file=file.file_path, path=file.file_path),
]
await self.handle_msg(message)
return message
async def handle_msg(self, message: AstrBotMessage):
message_event = TelegramPlatformEvent(
@@ -170,3 +239,15 @@ class TelegramPlatformAdapter(Platform):
def get_client(self) -> ExtBot:
return self.client
async def terminate(self):
try:
await self.application.stop()
# 保险起见先判断是否存在updater对象
if self.application.updater is not None:
await self.application.updater.stop()
logger.info("Telegram 适配器已被优雅地关闭")
except Exception as e:
logger.error(f"Telegram 适配器关闭时出错: {e}")
@@ -2,6 +2,7 @@ from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata, MessageType
from astrbot.api.message_components import Plain, Image, Reply, At, File, Record
from telegram.ext import ExtBot
from astrbot.core.utils.io import download_file
class TelegramPlatformEvent(AstrMessageEvent):
@@ -31,12 +32,18 @@ class TelegramPlatformEvent(AstrMessageEvent):
at_user_id = i.name
at_flag = False
message_thread_id = None
if "#" in user_name:
# it's a supergroup chat with message_thread_id
user_name, message_thread_id = user_name.split("#")
for i in message.chain:
payload = {
"chat_id": user_name,
}
if has_reply:
payload["reply_to_message_id"] = reply_message_id
if message_thread_id:
payload["message_thread_id"] = message_thread_id
if isinstance(i, Plain):
if at_user_id and not at_flag:
@@ -44,23 +51,18 @@ class TelegramPlatformEvent(AstrMessageEvent):
at_flag = True
await client.send_message(text=i.text, **payload)
elif isinstance(i, Image):
if i.path:
image_path = i.path
else:
image_path = i.file
if image_path.startswith("base64://"):
import base64
base64_data = image_path[9:]
image_bytes = base64.b64decode(base64_data)
await client.send_photo(photo=image_bytes, **payload)
else:
await client.send_photo(photo=image_path, **payload)
image_path = await i.convert_to_file_path()
await client.send_photo(photo=image_path, **payload)
elif isinstance(i, File):
if i.file.startswith("https://"):
path = "data/temp/" + i.name
await download_file(i.file, path)
i.file = path
await client.send_document(document=i.file, filename=i.name, **payload)
elif isinstance(i, Record):
await client.send_voice(voice=i.file, **payload)
path = await i.convert_to_file_path()
await client.send_voice(voice=path, **payload)
async def send(self, message: MessageChain):
if self.get_message_type() == MessageType.GROUP_MESSAGE:
@@ -13,7 +13,7 @@ from astrbot.core.platform import (
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.message.components import Plain, Image, Record # noqa: F403
from astrbot import logger
from astrbot.core import web_chat_queue, web_chat_back_queue
from astrbot.core import web_chat_queue
from .webchat_event import WebChatMessageEvent
from astrbot.core.platform.astr_message_event import MessageSesion
from ...register import register_platform_adapter
@@ -50,14 +50,7 @@ class WebChatAdapter(Platform):
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
# abm.session_id = f"webchat!{username}!{cid}"
plain = ""
cid = session.session_id.split("!")[-1]
for comp in message_chain.chain:
if isinstance(comp, Plain):
plain += comp.text
web_chat_back_queue.put_nowait((plain, cid))
await WebChatMessageEvent._send(message_chain, session.session_id)
await super().send_by_session(session, message_chain)
async def convert_message(self, data: tuple) -> AstrBotMessage:
@@ -126,3 +119,7 @@ class WebChatAdapter(Platform):
)
self.commit_event(message_event)
async def terminate(self):
# Do nothing
pass
@@ -7,19 +7,21 @@ from astrbot.api.message_components import Plain, Image
from astrbot.core.utils.io import download_image_by_url
from astrbot.core import web_chat_back_queue
imgs_dir = "data/webchat/imgs"
class WebChatMessageEvent(AstrMessageEvent):
def __init__(self, message_str, message_obj, platform_meta, session_id):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.imgs_dir = "data/webchat/imgs"
os.makedirs(self.imgs_dir, exist_ok=True)
os.makedirs(imgs_dir, exist_ok=True)
async def send(self, message: MessageChain):
@staticmethod
async def _send(message: MessageChain, session_id: str):
if not message:
web_chat_back_queue.put_nowait(None)
return
cid = self.session_id.split("!")[-1]
cid = session_id.split("!")[-1]
for comp in message.chain:
if isinstance(comp, Plain):
@@ -27,7 +29,7 @@ class WebChatMessageEvent(AstrMessageEvent):
elif isinstance(comp, Image):
# save image to local
filename = str(uuid.uuid4()) + ".jpg"
path = os.path.join(self.imgs_dir, filename)
path = os.path.join(imgs_dir, filename)
if comp.file and comp.file.startswith("file:///"):
ph = comp.file[8:]
with open(path, "wb") as f:
@@ -48,4 +50,7 @@ class WebChatMessageEvent(AstrMessageEvent):
else:
logger.debug(f"webchat 忽略: {comp.type}")
web_chat_back_queue.put_nowait(None)
async def send(self, message: MessageChain):
await WebChatMessageEvent._send(message, session_id=self.session_id)
await super().send(message)
@@ -34,6 +34,7 @@ class WecomServer:
def __init__(self, event_queue: asyncio.Queue, config: dict):
self.server = quart.Quart(__name__)
self.port = int(config.get("port"))
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
self.server.add_url_rule(
"/callback/command", view_func=self.verify, methods=["GET"]
)
@@ -49,6 +50,7 @@ class WecomServer:
)
self.callback = None
self.shutdown_event = asyncio.Event()
async def verify(self):
logger.info(f"验证请求有效性: {quart.request.args}")
@@ -86,17 +88,17 @@ class WecomServer:
return "success"
async def start_polling(self):
logger.info(f"将在 0.0.0.0:{self.port} 端口启动 企业微信 适配器。")
logger.info(
f"将在 {self.callback_server_host}:{self.port} 端口启动 企业微信 适配器。"
)
await self.server.run_task(
host="0.0.0.0",
host=self.callback_server_host,
port=self.port,
shutdown_trigger=self.shutdown_trigger_placeholder,
shutdown_trigger=self.shutdown_trigger,
)
async def shutdown_trigger_placeholder(self):
while not self.event_queue.closed: # noqa: ASYNC110
await asyncio.sleep(1)
logger.info("企业微信 适配器已关闭。")
async def shutdown_trigger(self):
await self.shutdown_event.wait()
@register_platform_adapter("wecom", "wecom 适配器")
@@ -232,3 +234,8 @@ class WecomPlatformAdapter(Platform):
def get_client(self) -> WeChatClient:
return self.client
async def terminate(self):
self.server.shutdown_event.set()
await self.server.server.shutdown()
logger.info("企业微信 适配器已被优雅地关闭")
@@ -3,7 +3,6 @@ from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image, Record
from wechatpy.enterprise import WeChatClient
from astrbot.core.utils.io import download_image_by_url, download_file
from astrbot.api import logger
@@ -43,14 +42,7 @@ class WecomPlatformEvent(AstrMessageEvent):
message_obj.self_id, message_obj.session_id, comp.text
)
elif isinstance(comp, Image):
img_url = comp.file
img_path = ""
if img_url.startswith("file:///"):
img_path = img_url[8:]
elif comp.file and comp.file.startswith("http"):
img_path = await download_image_by_url(comp.file)
else:
img_path = img_url
img_path = await comp.convert_to_file_path()
with open(img_path, "rb") as f:
try:
@@ -68,16 +60,7 @@ class WecomPlatformEvent(AstrMessageEvent):
response["media_id"],
)
elif isinstance(comp, Record):
record_url = comp.file
record_path = ""
if record_url.startswith("file:///"):
record_path = record_url[8:]
elif record_url.startswith("http"):
await download_file(record_url, f"data/temp/{uuid.uuid4()}.wav")
else:
record_path = record_url
record_path = await comp.convert_to_file_path()
# 转成amr
record_path_amr = f"data/temp/{uuid.uuid4()}.amr"
pydub.AudioSegment.from_wav(record_path).export(
+206 -4
View File
@@ -1,9 +1,18 @@
import enum
import base64
import json
from astrbot.core.utils.io import download_image_by_url
from astrbot import logger
from dataclasses import dataclass, field
from typing import List, Dict, Type
from .func_tool_manager import FuncCall
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_message_tool_call import (
ChatCompletionMessageToolCall,
)
from astrbot.core.db.po import Conversation
from astrbot.core.message.message_event_result import MessageChain
import astrbot.core.message.components as Comp
class ProviderType(enum.Enum):
@@ -27,6 +36,58 @@ class ProviderMetaData:
"""显示在 WebUI 配置页中的提供商名称,如空则是 type"""
@dataclass
class ToolCallMessageSegment:
"""OpenAI 格式的上下文中 role 为 tool 的消息段。参考: https://platform.openai.com/docs/guides/function-calling"""
tool_call_id: str
content: str
role: str = "tool"
def to_dict(self):
return {
"tool_call_id": self.tool_call_id,
"content": self.content,
"role": self.role,
}
@dataclass
class AssistantMessageSegment:
"""OpenAI 格式的上下文中 role 为 assistant 的消息段。参考: https://platform.openai.com/docs/guides/function-calling"""
content: str = None
tool_calls: List[ChatCompletionMessageToolCall | Dict] = None
role: str = "assistant"
def to_dict(self):
ret = {
"role": self.role,
}
if self.content:
ret["content"] = self.content
elif self.tool_calls:
ret["tool_calls"] = self.tool_calls
return ret
@dataclass
class ToolCallsResult:
"""工具调用结果"""
tool_calls_info: AssistantMessageSegment
"""函数调用的信息"""
tool_calls_result: List[ToolCallMessageSegment]
"""函数调用的结果"""
def to_openai_messages(self) -> List[Dict]:
ret = [
self.tool_calls_info.to_dict(),
*[item.to_dict() for item in self.tool_calls_result],
]
return ret
@dataclass
class ProviderRequest:
prompt: str
@@ -36,7 +97,7 @@ class ProviderRequest:
image_urls: List[str] = None
"""图片 URL 列表"""
func_tool: FuncCall = None
"""工具"""
"""可用的函数工具"""
contexts: List = None
"""上下文。格式与 openai 的上下文格式一致:
参考 https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages
@@ -45,23 +106,164 @@ class ProviderRequest:
"""系统提示词"""
conversation: Conversation = None
tool_calls_result: ToolCallsResult = None
"""附加的上次请求后工具调用的结果。参考: https://platform.openai.com/docs/guides/function-calling#handling-function-calls"""
def __repr__(self):
return f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, image_urls={self.image_urls}, func_tool={self.func_tool}, contexts={self.contexts}, system_prompt={self.system_prompt})"
return f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, image_urls={self.image_urls}, func_tool={self.func_tool}, contexts={self._print_friendly_context()}, system_prompt={self.system_prompt.strip()}, tool_calls_result={self.tool_calls_result})"
def __str__(self):
return self.__repr__()
def _print_friendly_context(self):
"""打印友好的消息上下文。将 image_url 的值替换为 <Image>"""
if not self.contexts:
return f"prompt: {self.prompt}, image_count: {len(self.image_urls or [])}"
result_parts = []
for ctx in self.contexts:
role = ctx.get("role", "unknown")
content = ctx.get("content", "")
if isinstance(content, str):
result_parts.append(f"{role}: {content}")
elif isinstance(content, list):
msg_parts = []
image_count = 0
for item in content:
item_type = item.get("type", "")
if item_type == "text":
msg_parts.append(item.get("text", ""))
elif item_type == "image_url":
image_count += 1
if image_count > 0:
if msg_parts:
msg_parts.append(f"[+{image_count} images]")
else:
msg_parts.append(f"[{image_count} images]")
result_parts.append(f"{role}: {''.join(msg_parts)}")
return result_parts
async def assemble_context(self) -> Dict:
"""将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。"""
if self.image_urls:
user_content = {
"role": "user",
"content": [{"type": "text", "text": self.prompt}],
}
for image_url in self.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
user_content["content"].append(
{"type": "image_url", "image_url": {"url": image_data}}
)
return user_content
else:
return {"role": "user", "content": self.prompt}
async def _encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64"""
if image_url.startswith("base64://"):
return image_url.replace("base64://", "data:image/jpeg;base64,")
with open(image_url, "rb") as f:
image_bs64 = base64.b64encode(f.read()).decode("utf-8")
return "data:image/jpeg;base64," + image_bs64
return ""
@dataclass
class LLMResponse:
role: str
"""角色, assistant, tool, err"""
completion_text: str = ""
"""LLM 返回的文本"""
result_chain: MessageChain = None
"""返回的消息链"""
tools_call_args: List[Dict[str, any]] = field(default_factory=list)
"""工具调用参数"""
tools_call_name: List[str] = field(default_factory=list)
"""工具调用名称"""
tools_call_ids: List[str] = field(default_factory=list)
"""工具调用 ID"""
raw_completion: ChatCompletion = None
_new_record: Dict[str, any] = None
_completion_text: str = ""
def __init__(
self,
role: str,
completion_text: str = "",
result_chain: MessageChain = None,
tools_call_args: List[Dict[str, any]] = [],
tools_call_name: List[str] = [],
tools_call_ids: List[str] = [],
raw_completion: ChatCompletion = None,
_new_record: Dict[str, any] = None,
):
"""初始化 LLMResponse
Args:
role (str): 角色, assistant, tool, err
completion_text (str, optional): 返回的结果文本,已经过时,推荐使用 result_chain. Defaults to "".
result_chain (MessageChain, optional): 返回的消息链. Defaults to None.
tools_call_args (List[Dict[str, any]], optional): 工具调用参数. Defaults to None.
tools_call_name (List[str], optional): 工具调用名称. Defaults to None.
raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None.
"""
self.role = role
self.completion_text = completion_text
self.result_chain = result_chain
self.tools_call_args = tools_call_args
self.tools_call_name = tools_call_name
self.tools_call_ids = tools_call_ids
self.raw_completion = raw_completion
self._new_record = _new_record
@property
def completion_text(self):
if self.result_chain:
return self.result_chain.get_plain_text()
return self._completion_text
@completion_text.setter
def completion_text(self, value):
if self.result_chain:
self.result_chain.chain = [
comp
for comp in self.result_chain.chain
if not isinstance(comp, Comp.Plain)
] # 清空 Plain 组件
self.result_chain.chain.insert(0, Comp.Plain(value))
else:
self._completion_text = value
def to_openai_tool_calls(self) -> List[Dict]:
"""将工具调用信息转换为 OpenAI 格式"""
ret = []
for idx, tool_call_arg in enumerate(self.tools_call_args):
ret.append(
{
"id": self.tools_call_ids[idx],
"function": {
"name": self.tools_call_name[idx],
"arguments": json.dumps(tool_call_arg),
},
"type": "function",
}
)
return ret
+288 -23
View File
@@ -1,7 +1,30 @@
from __future__ import annotations
import json
import textwrap
from typing import Dict, List, Awaitable
import os
import asyncio
import copy
from typing import Dict, List, Awaitable, Literal, Any
from dataclasses import dataclass
from typing import Optional
from contextlib import AsyncExitStack
from astrbot import logger
try:
import mcp
except (ModuleNotFoundError, ImportError):
logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。")
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
SUPPORTED_TYPES = [
"string",
"number",
"object",
"array",
"boolean",
] # json schema 支持的数据类型
@dataclass
@@ -13,25 +36,101 @@ class FuncTool:
name: str
parameters: Dict
description: str
handler: Awaitable
handler_module_path: str = None # 必须要保留这个,handler 在初始化会被 functools.partial 包装,导致 handler 的 __module__ 为 functools
handler: Awaitable = None
"""处理函数, 当 origin 为 mcp 时,这个为空"""
handler_module_path: str = None
"""处理函数的模块路径,当 origin 为 mcp 时,这个为空
必须要保留这个字段, handler 在初始化会被 functools.partial 包装,导致 handler 的 __module__ 为 functools
"""
active: bool = True
"""是否激活"""
origin: Literal["local", "mcp"] = "local"
"""函数工具的来源, local 为本地函数工具, mcp 为 MCP 服务"""
SUPPORTED_TYPES = [
"string",
"number",
"object",
"array",
"boolean",
] # json schema 支持的数据类型
# MCP 相关字段
mcp_server_name: str = None
"""MCP 服务名称,当 origin 为 mcp 时有效"""
mcp_client: MCPClient = None
"""MCP 客户端,当 origin 为 mcp 时有效"""
def __repr__(self):
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description}, active={self.active}, origin={self.origin})"
async def execute(self, **args) -> Any:
"""执行函数调用"""
if self.origin == "local":
if not self.handler:
raise Exception(f"Local function {self.name} has no handler")
return await self.handler(**args)
elif self.origin == "mcp":
if not self.mcp_client or not self.mcp_client.session:
raise Exception(f"MCP client for {self.name} is not available")
# 使用name属性而不是额外的mcp_tool_name
if ":" in self.name:
# 如果名字是格式为 mcp:server:tool_name,提取实际的工具名
actual_tool_name = self.name.split(":")[-1]
return await self.mcp_client.session.call_tool(actual_tool_name, args)
else:
return await self.mcp_client.session.call_tool(self.name, args)
else:
raise Exception(f"Unknown function origin: {self.origin}")
class MCPClient:
def __init__(self):
# Initialize session and client objects
self.session: Optional[mcp.ClientSession] = None
self.exit_stack = AsyncExitStack()
self.name = None
self.active: bool = True
self.tools: List[mcp.Tool] = []
async def connect_to_server(self, mcp_server_config: dict):
"""Connect to an MCP server
Args:
mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server
"""
cfg = mcp_server_config.copy()
cfg.pop("active", None)
server_params = mcp.StdioServerParameters(
**cfg,
)
stdio_transport = await self.exit_stack.enter_async_context(
mcp.stdio_client(server_params)
)
self.stdio, self.write = stdio_transport
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(self.stdio, self.write)
)
await self.session.initialize()
async def list_tools_and_save(self) -> mcp.ListToolsResult:
"""List all tools from the server and save them to self.tools"""
response = await self.session.list_tools()
logger.debug(f"MCP server {self.name} list tools response: {response}")
self.tools = response.tools
return response
async def cleanup(self):
"""Clean up resources"""
await self.exit_stack.aclose()
class FuncCall:
def __init__(self) -> None:
self.func_list: List[FuncTool] = []
"""内部加载的 func tools"""
self.mcp_client_dict: Dict[str, MCPClient] = {}
"""MCP 服务列表"""
self.mcp_service_queue = asyncio.Queue()
"""用于外部控制 MCP 服务的启停"""
self.mcp_client_event: Dict[str, asyncio.Event] = {}
def empty(self) -> bool:
return len(self.func_list) == 0
@@ -43,14 +142,16 @@ class FuncCall:
desc: str,
handler: Awaitable,
) -> None:
"""
为函数调用(function-calling / tools-use)添加工具。
"""添加函数调用工具
@param name: 函数名
@param func_args: 函数参数列表,格式为 [{"type": "string", "name": "arg_name", "description": "arg_description"}, ...]
@param desc: 函数描述
@param func_obj: 处理函数
"""
# check if the tool has been added before
self.remove_func(name)
params = {
"type": "object", # hard-coded here
"properties": {},
@@ -67,13 +168,14 @@ class FuncCall:
handler=handler,
)
self.func_list.append(_func)
logger.info(f"添加函数调用工具: {name}")
def remove_func(self, name: str) -> None:
"""
删除一个函数调用工具。
"""
for i, f in enumerate(self.func_list):
if f["name"] == name:
if f.name == name:
self.func_list.pop(i)
break
@@ -83,11 +185,166 @@ class FuncCall:
return f
return None
async def _init_mcp_clients(self) -> None:
"""从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下:
```
{
"mcpServers": {
"weather": {
"command": "uv",
"args": [
"--directory",
"/ABSOLUTE/PATH/TO/PARENT/FOLDER/weather",
"run",
"weather.py"
]
}
}
...
}
```
"""
current_dir = os.path.dirname(os.path.abspath(__file__))
data_dir = os.path.abspath(os.path.join(current_dir, "../../../data"))
mcp_json_file = os.path.join(data_dir, "mcp_server.json")
if not os.path.exists(mcp_json_file):
# 配置文件不存在错误处理
with open(mcp_json_file, "w", encoding="utf-8") as f:
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
logger.info(f"未找到 MCP 服务配置文件,已创建默认配置文件 {mcp_json_file}")
return
mcp_server_json_obj: Dict[str, Dict] = json.load(
open(mcp_json_file, "r", encoding="utf-8")
)["mcpServers"]
for name in mcp_server_json_obj.keys():
cfg = mcp_server_json_obj[name]
if cfg.get("active", True):
event = asyncio.Event()
asyncio.create_task(
self._init_mcp_client_task_wrapper(name, cfg, event)
)
self.mcp_client_event[name] = event
async def mcp_service_selector(self):
"""为了避免在不同异步任务中控制 MCP 服务导致的报错,整个项目统一通过这个 Task 来控制
使用 self.mcp_service_queue.put_nowait() 来控制 MCP 服务的启停,数据格式如下:
{"type": "init"} 初始化所有MCP客户端
{"type": "init", "name": "mcp_server_name", "cfg": {...}} 初始化指定的MCP客户端
{"type": "terminate"} 终止所有MCP客户端
{"type": "terminate", "name": "mcp_server_name"} 终止指定的MCP客户端
"""
while True:
data = await self.mcp_service_queue.get()
if data["type"] == "init":
if "name" in data:
event = asyncio.Event()
asyncio.create_task(
self._init_mcp_client_task_wrapper(
data["name"], data["cfg"], event
)
)
self.mcp_client_event[data["name"]] = event
else:
await self._init_mcp_clients()
elif data["type"] == "terminate":
if "name" in data:
# await self._terminate_mcp_client(data["name"])
if data["name"] in self.mcp_client_event:
self.mcp_client_event[data["name"]].set()
self.mcp_client_event.pop(data["name"], None)
else:
for name in self.mcp_client_dict.keys():
# await self._terminate_mcp_client(name)
# self.mcp_client_event[name].set()
if name in self.mcp_client_event:
self.mcp_client_event[name].set()
self.mcp_client_event.pop(name, None)
async def _init_mcp_client_task_wrapper(
self, name: str, cfg: dict, event: asyncio.Event
) -> None:
"""初始化 MCP 客户端的包装函数,用于捕获异常"""
try:
await self._init_mcp_client(name, cfg)
await event.wait()
logger.info(f"收到 MCP 客户端 {name} 终止信号")
await self._terminate_mcp_client(name)
except Exception as e:
logger.error(f"初始化 MCP 客户端 {name} 失败: {e}")
async def _init_mcp_client(self, name: str, config: dict) -> None:
"""初始化单个MCP客户端"""
try:
# 先清理之前的客户端,如果存在
if name in self.mcp_client_dict:
await self._terminate_mcp_client(name)
mcp_client = MCPClient()
mcp_client.name = name
await mcp_client.connect_to_server(config)
tools_res = await mcp_client.list_tools_and_save()
tool_names = [tool.name for tool in tools_res.tools]
self.mcp_client_dict[name] = mcp_client
# 移除该MCP服务之前的工具(如有)
self.func_list = [
f
for f in self.func_list
if not (f.origin == "mcp" and f.mcp_server_name == name)
]
# 将 MCP 工具转换为 FuncTool 并添加到 func_list
for tool in mcp_client.tools:
func_tool = FuncTool(
name=tool.name,
parameters=tool.inputSchema,
description=tool.description,
origin="mcp",
mcp_server_name=name,
mcp_client=mcp_client,
)
self.func_list.append(func_tool)
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
return True
except Exception as e:
logger.error(f"初始化 MCP 客户端 {name} 失败: {e}")
# 发生错误时确保客户端被清理
if name in self.mcp_client_dict:
await self._terminate_mcp_client(name)
return False
async def _terminate_mcp_client(self, name: str) -> None:
"""关闭并清理MCP客户端"""
if name in self.mcp_client_dict:
try:
# 关闭MCP连接
await self.mcp_client_dict[name].cleanup()
del self.mcp_client_dict[name]
except Exception as e:
logger.info(f"清空 MCP 客户端资源 {name}: {e}")
# 移除关联的FuncTool
self.func_list = [
f
for f in self.func_list
if not (f.origin == "mcp" and f.mcp_server_name == name)
]
logger.info(f"已关闭 MCP 服务 {name}")
def get_func_desc_openai_style(self) -> list:
"""
获得 OpenAI API 风格的**已经激活**的工具描述
"""
_l = []
# 处理所有工具(包括本地和MCP工具)
for f in self.func_list:
if not f.active:
continue
@@ -137,7 +394,13 @@ class FuncCall:
# 检查并添加非空的properties参数
params = f.parameters if isinstance(f.parameters, dict) else {}
params = copy.deepcopy(params)
if params.get("properties", {}):
properties = params["properties"]
for key, value in properties.items():
if "default" in value:
del value["default"]
params["properties"] = properties
func_declaration["parameters"] = params
tools.append(func_declaration)
@@ -153,9 +416,9 @@ class FuncCall:
continue
_l.append(
{
"name": f["name"],
"parameters": f["parameters"],
"description": f["description"],
"name": f.name,
"parameters": f.parameters,
"description": f.description,
}
)
func_definition = json.dumps(_l, ensure_ascii=False)
@@ -205,14 +468,11 @@ class FuncCall:
func_name = tool["name"]
args = tool["args"]
# 调用函数
tool_callable = None
for func in self.func_list:
if func.name == func_name:
tool_callable = func.star_handler_metadata.handler
break
if not tool_callable:
func_tool = self.get_func(func_name)
if not func_tool:
raise Exception(f"Request function {func_name} not found.")
ret = await tool_callable(**args)
ret = await func_tool.execute(**args)
if ret:
tool_call_result.append(str(ret))
return tool_call_result, True
@@ -222,3 +482,8 @@ class FuncCall:
def __repr__(self):
return str(self.func_list)
async def terminate(self):
for name in self.mcp_client_dict.keys():
await self._terminate_mcp_client(name)
logger.debug(f"清理 MCP 客户端 {name} 资源")
+10 -2
View File
@@ -1,4 +1,5 @@
import traceback
import asyncio
from astrbot.core.config.astrbot_config import AstrBotConfig
from .provider import Provider, STTProvider, TTSProvider, Personality
from .entites import ProviderType
@@ -127,14 +128,19 @@ class ProviderManager:
if self.tts_enabled and not self.curr_tts_provider_inst:
logger.warning("未启用任何用于 文本转语音 的提供商适配器。")
# 初始化 MCP Client 连接
asyncio.create_task(
self.llm_tools.mcp_service_selector(), name="mcp-service-handler"
)
self.llm_tools.mcp_service_queue.put_nowait({"type": "init"})
async def load_provider(self, provider_config: dict):
if not provider_config["enable"]:
return
logger.info(
f"载入 {provider_config['type']}({provider_config['id']}) 服务提供商适配器 ..."
f"载入 {provider_config['type']}({provider_config['id']}) 服务提供商 ..."
)
logger.debug(f"Provider Config: {provider_config}")
# 动态导入
try:
@@ -340,3 +346,5 @@ class ProviderManager:
for provider_inst in self.provider_insts:
if hasattr(provider_inst, "terminate"):
await provider_inst.terminate()
# 清理 MCP Client 连接
await self.llm_tools.mcp_service_queue.put({"type": "terminate"})
+3 -1
View File
@@ -3,7 +3,7 @@ from typing import List
from astrbot.core.db import BaseDatabase
from typing import TypedDict
from astrbot.core.provider.func_tool_manager import FuncCall
from astrbot.core.provider.entites import LLMResponse
from astrbot.core.provider.entites import LLMResponse, ToolCallsResult
from dataclasses import dataclass
@@ -90,6 +90,7 @@ class Provider(AbstractProvider):
func_tool: FuncCall = None,
contexts: List = None,
system_prompt: str = None,
tool_calls_result: ToolCallsResult = None,
**kwargs,
) -> LLMResponse:
"""获得 LLM 的文本对话结果。会使用当前的模型进行对话。
@@ -100,6 +101,7 @@ class Provider(AbstractProvider):
image_urls: 图片 URL 列表
tools: Function-calling 工具
contexts: 上下文
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
kwargs: 其他参数
Notes:
@@ -10,7 +10,7 @@ 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 astrbot.core.provider.entites import LLMResponse, ToolCallsResult
from .openai_source import ProviderOpenAIOfficial
@@ -79,11 +79,14 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
# tools call (function calling)
args_ls = []
func_name_ls = []
tool_use_ids = []
func_name_ls.append(content.name)
args_ls.append(content.input)
tool_use_ids.append(content.id)
llm_response.role = "tool"
llm_response.tools_call_args = args_ls
llm_response.tools_call_name = func_name_ls
llm_response.tools_call_ids = tool_use_ids
if not llm_response.completion_text and not llm_response.tools_call_args:
logger.error(f"API 返回的 completion 无法解析:{completion}")
@@ -101,6 +104,7 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
func_tool: FuncCall = None,
contexts=[],
system_prompt=None,
tool_calls_result: ToolCallsResult = None,
**kwargs,
) -> LLMResponse:
if not prompt:
@@ -113,6 +117,10 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
if "_no_save" in part:
del part["_no_save"]
if tool_calls_result:
# 暂时这样写。
prompt += f"Here are the related results via using tools: {str(tool_calls_result.tool_calls_result)}"
model_config = self.provider_config.get("model_config", {})
payloads = {"messages": context_query, **model_config}
@@ -1,3 +1,4 @@
import re
import asyncio
import functools
from typing import List
@@ -40,11 +41,24 @@ class ProviderDashscope(ProviderOpenAIOfficial):
raise Exception("阿里云百炼 APP 类型不能为空。")
self.model_name = "dashscope"
self.variables: dict = provider_config.get("variables", {})
self.rag_options: dict = provider_config.get("rag_options", {})
self.output_reference = self.rag_options.get("output_reference", False)
self.rag_options = self.rag_options.copy()
self.rag_options.pop("output_reference", None)
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
def has_rag_options(self):
if (
self.rag_options
and self.rag_options.get("pipeline_ids", None)
and self.rag_options.get("file_ids", None)
):
return True
return False
async def text_chat(
self,
prompt: str,
@@ -62,7 +76,10 @@ class ProviderDashscope(ProviderOpenAIOfficial):
session_var = session_vars.get(session_id, {})
payload_vars.update(session_var)
if self.dashscope_app_type in ["agent", "dialog-workflow"]:
if (
self.dashscope_app_type in ["agent", "dialog-workflow"]
and self.has_rag_options()
):
# 支持多轮对话的
new_record = {"role": "user", "content": prompt}
if image_urls:
@@ -86,12 +103,17 @@ class ProviderDashscope(ProviderOpenAIOfficial):
else:
# 不支持多轮对话的
# 调用阿里云百炼 API
payload = {
"app_id": self.app_id,
"prompt": prompt,
"api_key": self.api_key,
"biz_params": payload_vars or None,
}
if self.rag_options:
payload["rag_options"] = self.rag_options
partial = functools.partial(
Application.call,
app_id=self.app_id,
promtp=prompt,
api_key=self.api_key,
biz_params=payload_vars or None,
**payload,
)
response = await asyncio.get_event_loop().run_in_executor(None, partial)
@@ -107,6 +129,14 @@ class ProviderDashscope(ProviderOpenAIOfficial):
)
output_text = response.output.get("text", "")
# RAG 引用脚标格式化
output_text = re.sub(r"<ref>\[(\d+)\]</ref>", r"[\1]", output_text)
if self.output_reference and response.output.get("doc_references", None):
ref_str = ""
for ref in response.output.get("doc_references", []):
ref_str += f"{ref['index_id']}. {ref['title']}\n"
output_text += f"\n\n回答来源:\n{ref_str}"
return LLMResponse(role="assistant", completion_text=output_text)
async def forget(self, session_id):
+83 -27
View File
@@ -1,3 +1,5 @@
import astrbot.core.message.components as Comp
from typing import List
from .. import Provider, Personality
from ..entites import LLMResponse
@@ -5,8 +7,9 @@ from ..func_tool_manager import FuncCall
from astrbot.core.db import BaseDatabase
from ..register import register_provider_adapter
from astrbot.core.utils.dify_api_client import DifyAPIClient
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.io import download_image_by_url, download_file
from astrbot.core import logger, sp
from astrbot.core.message.message_event_result import MessageChain
@register_provider_adapter("dify", "Dify APP 适配器。")
@@ -30,7 +33,6 @@ class ProviderDify(Provider):
if not self.api_key:
raise Exception("Dify API Key 不能为空。")
api_base = provider_config.get("dify_api_base", "https://api.dify.ai/v1")
self.api_client = DifyAPIClient(self.api_key, api_base)
self.api_type = provider_config.get("dify_api_type", "")
if not self.api_type:
raise Exception("Dify API 类型不能为空。")
@@ -41,15 +43,19 @@ class ProviderDify(Provider):
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"
if not self.workflow_output_key:
self.workflow_output_key = "astrbot_wf_output"
self.variables: dict = provider_config.get("variables", {})
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
self.conversation_ids = {}
"""记录当前 session id 的对话 ID"""
self.api_client = DifyAPIClient(self.api_key, api_base)
async def text_chat(
self,
prompt: str,
@@ -65,26 +71,27 @@ class ProviderDify(Provider):
files_payload = []
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
file_response = await self.api_client.file_upload(
image_path, user=session_id
image_path = (
await download_image_by_url(image_url)
if image_url.startswith("http")
else image_url
)
file_response = await self.api_client.file_upload(
image_path, user=session_id
)
logger.debug(f"Dify 上传图片响应:{file_response}")
if "id" not in file_response:
logger.warning(
f"上传图片后得到未知的 Dify 响应:{file_response},图片将忽略。"
)
if "id" not in file_response:
logger.warning(
f"上传图片后得到未知的 Dify 响应:{file_response},图片将忽略。"
)
continue
files_payload.append(
{
"type": "image",
"transfer_method": "local_file",
"upload_file_id": file_response["id"],
}
)
else:
# TODO: 处理更多情况
logger.warning(f"未知的图片链接:{image_url},图片将忽略。")
continue
files_payload.append(
{
"type": "image",
"transfer_method": "local_file",
"upload_file_id": file_response["id"],
}
)
# 获得会话变量
payload_vars = self.variables.copy()
@@ -96,6 +103,9 @@ class ProviderDify(Provider):
try:
match self.api_type:
case "chat" | "agent":
if not prompt:
prompt = "请描述这张图片。"
async for chunk in self.api_client.chat_messages(
inputs={
**payload_vars,
@@ -148,8 +158,9 @@ class ProviderDify(Provider):
)
case "workflow_finished":
logger.info(
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束"
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束"
)
logger.debug(f"Dify 工作流结果:{chunk}")
if chunk["data"]["error"]:
logger.error(
f"Dify 工作流出现错误:{chunk['data']['error']}"
@@ -164,9 +175,7 @@ class ProviderDify(Provider):
raise Exception(
f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}"
)
result = chunk["data"]["outputs"][
self.workflow_output_key
]
result = chunk
case _:
raise Exception(f"未知的 Dify API 类型:{self.api_type}")
except Exception as e:
@@ -176,7 +185,54 @@ class ProviderDify(Provider):
if not result:
logger.warning("Dify 请求结果为空,请查看 Debug 日志。")
return LLMResponse(role="assistant", completion_text=result)
chain = await self.parse_dify_result(result)
return LLMResponse(role="assistant", result_chain=chain)
async def parse_dify_result(self, chunk: dict | str) -> MessageChain:
if isinstance(chunk, str):
# Chat
return MessageChain(chain=[Comp.Plain(chunk)])
async def parse_file(item: dict) -> Comp:
match item["type"]:
case "image":
return Comp.Image(file=item["url"], url=item["url"])
case "audio":
# 仅支持 wav
path = f"data/temp/{item['filename']}.wav"
await download_file(item["url"], path)
return Comp.Image(file=item["url"], url=item["url"])
case "video":
return Comp.Video(file=item["url"])
case _:
return Comp.File(name=item["filename"], file=item["url"])
output = chunk["data"]["outputs"][self.workflow_output_key]
chains = []
if isinstance(output, str):
# 纯文本输出
chains.append(Comp.Plain(output))
elif isinstance(output, list):
# 主要适配 Dify 的 HTTP 请求结点的多模态输出
for item in output:
# handle Array[File]
if (
not isinstance(item, dict)
or item.get("dify_model_identity", "") != "__dify__file__"
):
chains.append(Comp.Plain(str(output)))
break
else:
chains.append(Comp.Plain(str(output)))
# scan file
files = chunk["data"].get("files", [])
for item in files:
comp = await parse_file(item)
chains.append(comp)
return MessageChain(chain=chains)
async def forget(self, session_id):
self.conversation_ids[session_id] = ""
@@ -57,23 +57,30 @@ class ProviderEdgeTTS(TTSProvider):
# 使用ffmpeg将MP3转换为标准WAV格式
_ = await asyncio.create_subprocess_exec(
[
"ffmpeg",
"-y", # 覆盖输出文件
"-i",
mp3_path, # 输入文件
"-acodec",
"pcm_s16le", # 16位PCM编码
"-ar",
"24000", # 采样率24kHz (适合微信语音)
"-ac",
"1", # 单声道
wav_path, # 输出文件
],
capture_output=True,
check=True,
"ffmpeg",
"-y", # 覆盖输出文件
"-i",
mp3_path, # 输入文件
"-acodec",
"pcm_s16le", # 16位PCM编码
"-ar",
"24000", # 采样率24kHz (适合微信语音)
"-ac",
"1", # 单声道
"-af",
"apad=pad_dur=2", # 确保输出时长准确
"-fflags",
"+genpts", # 强制生成时间戳
"-hide_banner", # 隐藏版本信息
wav_path, # 输出文件
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# 等待进程完成并获取输出
stdout, stderr = await _.communicate()
logger.info(f"[EdgeTTS] FFmpeg 标准输出: {stdout.decode().strip()}")
logger.debug(f"FFmpeg错误输出: {stderr.decode().strip()}")
logger.info(f"[EdgeTTS] 返回值(0代表成功): {_.returncode}")
os.remove(mp3_path)
if os.path.exists(wav_path) and os.path.getsize(wav_path) > 0:
return wav_path
@@ -82,13 +89,15 @@ class ProviderEdgeTTS(TTSProvider):
raise RuntimeError("生成的WAV文件不存在或为空")
except subprocess.CalledProcessError as e:
logger.error(f"FFmpeg转换失败: {e.stderr.decode() if e.stderr else str(e)}")
logger.error(
f"FFmpeg 转换失败: {e.stderr.decode() if e.stderr else str(e)}"
)
try:
if os.path.exists(mp3_path):
os.remove(mp3_path)
except Exception:
pass
raise RuntimeError(f"FFmpeg转换失败: {str(e)}")
raise RuntimeError(f"FFmpeg 转换失败: {str(e)}")
except Exception as e:
logger.error(f"音频生成失败: {str(e)}")
+43 -4
View File
@@ -1,5 +1,6 @@
import base64
import aiohttp
import json
import random
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.db import BaseDatabase
@@ -146,11 +147,41 @@ class ProviderGoogleGenAI(Provider):
google_genai_conversation.append({"role": "user", "parts": parts})
elif message["role"] == "assistant":
if not message["content"]:
message["content"] = "<empty_content>"
google_genai_conversation.append(
{"role": "model", "parts": [{"text": message["content"]}]}
if "content" in message:
if not message["content"]:
message["content"] = "<empty_content>"
google_genai_conversation.append(
{"role": "model", "parts": [{"text": message["content"]}]}
)
elif "tool_calls" in message:
# tool calls in the last turn
parts = []
for tool_call in message["tool_calls"]:
parts.append(
{
"functionCall": {
"name": tool_call["function"]["name"],
"args": json.loads(
tool_call["function"]["arguments"]
),
}
}
)
google_genai_conversation.append({"role": "model", "parts": parts})
elif message["role"] == "tool":
parts = []
parts.append(
{
"functionResponse": {
"name": message["tool_call_id"],
"response": {
"name": message["tool_call_id"],
"content": message["content"],
},
}
}
)
google_genai_conversation.append({"role": "user", "parts": parts})
logger.debug(f"google_genai_conversation: {google_genai_conversation}")
@@ -174,6 +205,9 @@ class ProviderGoogleGenAI(Provider):
llm_response.role = "tool"
llm_response.tools_call_args.append(candidate["functionCall"]["args"])
llm_response.tools_call_name.append(candidate["functionCall"]["name"])
llm_response.tools_call_ids.append(
candidate["functionCall"]["name"]
) # 没有 tool id
llm_response.completion_text = llm_response.completion_text.strip()
return llm_response
@@ -186,6 +220,7 @@ class ProviderGoogleGenAI(Provider):
func_tool: FuncCall = None,
contexts=[],
system_prompt=None,
tool_calls_result=None,
**kwargs,
) -> LLMResponse:
new_record = await self.assemble_context(prompt, image_urls)
@@ -198,6 +233,10 @@ class ProviderGoogleGenAI(Provider):
if "_no_save" in part:
del part["_no_save"]
# tool calls result
if tool_calls_result:
context_query.extend(tool_calls_result.to_openai_messages())
model_config = self.provider_config.get("model_config", {})
model_config["model"] = self.get_model()
+11 -4
View File
@@ -120,15 +120,18 @@ class ProviderOpenAIOfficial(Provider):
# tools call (function calling)
args_ls = []
func_name_ls = []
tool_call_ids = []
for tool_call in choice.message.tool_calls:
for tool in tools.func_list:
if tool.name == tool_call.function.name:
args = json.loads(tool_call.function.arguments)
args_ls.append(args)
func_name_ls.append(tool_call.function.name)
tool_call_ids.append(tool_call.id)
llm_response.role = "tool"
llm_response.tools_call_args = args_ls
llm_response.tools_call_name = func_name_ls
llm_response.tools_call_ids = tool_call_ids
if choice.finish_reason == "content_filter":
raise Exception(
@@ -151,6 +154,7 @@ class ProviderOpenAIOfficial(Provider):
func_tool: FuncCall = None,
contexts=[],
system_prompt=None,
tool_calls_result=None,
**kwargs,
) -> LLMResponse:
new_record = await self.assemble_context(prompt, image_urls)
@@ -162,10 +166,15 @@ class ProviderOpenAIOfficial(Provider):
if "_no_save" in part:
del part["_no_save"]
# tool calls result
if tool_calls_result:
context_query.extend(tool_calls_result.to_openai_messages())
model_config = self.provider_config.get("model_config", {})
model_config["model"] = self.get_model()
payloads = {"messages": context_query, **model_config}
llm_response = None
try:
llm_response = await self._query(payloads, func_tool)
@@ -275,10 +284,8 @@ class ProviderOpenAIOfficial(Provider):
def set_key(self, key):
self.client.api_key = key
async def assemble_context(self, text: str, image_urls: List[str] = None):
"""
组装上下文。
"""
async def assemble_context(self, text: str, image_urls: List[str] = None) -> dict:
"""组装成符合 OpenAI 格式的 role 为 user 的消息段"""
if image_urls:
user_content = {"role": "user", "content": [{"type": "text", "text": text}]}
for image_url in image_urls:
@@ -18,10 +18,14 @@ class ProviderOpenAITTSAPI(TTSProvider):
self.chosen_api_key = provider_config.get("api_key", "")
self.voice = provider_config.get("openai-tts-voice", "alloy")
timeout = provider_config.get("timeout", NOT_GIVEN)
if isinstance(timeout, str):
timeout = int(timeout)
self.client = AsyncOpenAI(
api_key=self.chosen_api_key,
base_url=provider_config.get("api_base", None),
timeout=provider_config.get("timeout", NOT_GIVEN),
timeout=timeout,
)
self.set_model(provider_config.get("model", None))
+6 -2
View File
@@ -183,11 +183,15 @@ class Context:
获取指定类型的平台适配器。
"""
for platform in self.platform_manager.platform_insts:
name = platform.meta().name
if isinstance(platform_type, str):
if platform.meta().name == platform_type:
if name == platform_type:
return platform
else:
if platform.meta().name == ADAPTER_NAME_2_TYPE[platform_type]:
if (
name in ADAPTER_NAME_2_TYPE
and ADAPTER_NAME_2_TYPE[name] & platform_type
):
return platform
async def send_message(
+3 -4
View File
@@ -15,7 +15,6 @@ from ..filter.regex import RegexFilter
from typing import Awaitable
from astrbot.core.provider.func_tool_manager import SUPPORTED_TYPES
from astrbot.core.provider.register import llm_tools
from astrbot.core import logger
def get_handler_full_name(awaitable: Awaitable) -> str:
@@ -359,9 +358,9 @@ def register_llm_tool(name: str = None):
}
)
md = get_handler_or_create(awaitable, EventType.OnCallingFuncToolEvent)
llm_tools.add_func(llm_tool_name, args, docstring.description, md.handler)
logger.debug(f"LLM 函数工具 {llm_tool_name} 已注册")
llm_tools.add_func(
llm_tool_name, args, docstring.description.strip(), md.handler
)
return awaitable
return decorator
+2
View File
@@ -14,6 +14,8 @@ star_map: Dict[str, StarMetadata] = {}
class StarMetadata:
"""
插件的元数据。
当 activated 为 False 时,star_cls 可能为 None,请不要在插件未激活时调用 star_cls 的方法。
"""
name: str
+88 -41
View File
@@ -1,3 +1,7 @@
"""
插件的重载、启停、安装、卸载等操作。
"""
import inspect
import functools
import os
@@ -75,7 +79,7 @@ class PluginManager:
elif os.path.exists(os.path.join(path, d, d + ".py")):
module_str = d
else:
print(f"插件 {d} 未找到 main.py 或者 {d}.py,跳过。")
logger.info(f"插件 {d} 未找到 main.py 或者 {d}.py,跳过。")
continue
if os.path.exists(os.path.join(path, d, "main.py")) or os.path.exists(
os.path.join(path, d, d + ".py")
@@ -164,7 +168,6 @@ class PluginManager:
async def reload(self, specified_plugin_name=None):
"""扫描并加载所有的插件 当 specified_module_path 指定时,重载指定插件"""
specified_module_path = None
if specified_plugin_name:
for smd in star_registry:
@@ -184,6 +187,8 @@ class PluginManager:
f"插件 {smd.name} 未被正常终止: {str(e)}, 可能会导致该插件运行不正常。"
)
await self._unbind_plugin(smd.name, smd.module_path)
star_handlers_registry.clear()
star_map.clear()
star_registry.clear()
@@ -203,10 +208,17 @@ class PluginManager:
)
await self._unbind_plugin(smd.name, specified_module_path)
try:
del sys.modules[specified_module_path]
except KeyError:
logger.warning(f"模块 {specified_module_path} 未载入")
return await self.load(specified_module_path)
async def load(self, specified_module_path=None, specified_dir_name=None):
"""载入插件。
当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。
"""
inactivated_plugins: list = sp.get("inactivated_plugins", [])
inactivated_llm_tools: list = sp.get("inactivated_llm_tools", [])
alter_cmd = sp.get("alter_cmd", {})
plugin_modules = self._get_plugin_modules()
if plugin_modules is None:
@@ -214,11 +226,6 @@ class PluginManager:
fail_rec = ""
inactivated_plugins: list = sp.get("inactivated_plugins", [])
inactivated_llm_tools: list = sp.get("inactivated_llm_tools", [])
alter_cmd = sp.get("alter_cmd", {})
# 导入插件模块,并尝试实例化插件类
for plugin_module in plugin_modules:
try:
@@ -232,8 +239,11 @@ class PluginManager:
path = "data.plugins." if not reserved else "packages."
path += root_dir_name + "." + module_str
# 检查是否需要载入指定的插件
if specified_module_path and path != specified_module_path:
continue
if specified_dir_name and root_dir_name != specified_dir_name:
continue
logger.info(f"正在载入插件 {root_dir_name} ...")
@@ -287,18 +297,24 @@ class PluginManager:
except Exception:
pass
if plugin_config:
metadata.config = plugin_config
try:
metadata.star_cls = metadata.star_cls_type(
context=self.context, config=plugin_config
)
except TypeError as _:
if path not in inactivated_plugins:
# 只有没有禁用插件时才实例化插件类
if plugin_config:
metadata.config = plugin_config
try:
metadata.star_cls = metadata.star_cls_type(
context=self.context, config=plugin_config
)
except TypeError as _:
metadata.star_cls = metadata.star_cls_type(
context=self.context
)
else:
metadata.star_cls = metadata.star_cls_type(
context=self.context
)
else:
metadata.star_cls = metadata.star_cls_type(context=self.context)
logger.info(f"插件 {metadata.name} 已被禁用。")
metadata.module = module
metadata.root_dir_name = root_dir_name
@@ -316,7 +332,10 @@ class PluginManager:
)
# 绑定 llm_tool handler
for func_tool in llm_tools.func_list:
if func_tool.handler.__module__ == metadata.module_path:
if (
func_tool.handler
and func_tool.handler.__module__ == metadata.module_path
):
func_tool.handler_module_path = metadata.module_path
func_tool.handler = functools.partial(
func_tool.handler, metadata.star_cls
@@ -331,19 +350,23 @@ class PluginManager:
)
classes = self._get_classes(module)
if plugin_config:
try:
obj = getattr(module, classes[0])(
context=self.context, config=plugin_config
) # 实例化插件类
except TypeError as _:
if path not in inactivated_plugins:
# 只有没有禁用插件时才实例化插件类
if plugin_config:
try:
obj = getattr(module, classes[0])(
context=self.context, config=plugin_config
) # 实例化插件类
except TypeError as _:
obj = getattr(module, classes[0])(
context=self.context
) # 实例化插件类
else:
obj = getattr(module, classes[0])(
context=self.context
) # 实例化插件类
else:
obj = getattr(module, classes[0])(
context=self.context
) # 实例化插件类
logger.info(f"插件 {metadata.name} 已被禁用。")
metadata = None
metadata = self._load_plugin_metadata(
@@ -426,7 +449,8 @@ class PluginManager:
async def install_plugin(self, repo_url: str, proxy=""):
plugin_path = await self.updator.install(repo_url, proxy)
# reload the plugin
await self.reload()
dir_name = os.path.basename(plugin_path)
await self.load(specified_dir_name=dir_name)
return plugin_path
async def uninstall_plugin(self, plugin_name: str):
@@ -450,9 +474,11 @@ class PluginManager:
# 从 star_registry 和 star_map 中删除
await self._unbind_plugin(plugin_name, plugin.module_path)
if not remove_dir(os.path.join(ppath, root_dir_name)):
try:
remove_dir(os.path.join(ppath, root_dir_name))
except Exception as e:
raise Exception(
"移除插件成功,但是删除插件文件夹失败。您可以手动删除该文件夹,位于 addons/plugins/ 下。"
f"移除插件成功,但是删除插件文件夹失败: {str(e)}。您可以手动删除该文件夹,位于 addons/plugins/ 下。"
)
async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str):
@@ -464,7 +490,9 @@ class PluginManager:
for handler in star_handlers_registry.get_handlers_by_module_name(
plugin_module_path
):
logger.debug(f"unbind handler {handler.handler_name} from {plugin_name}")
logger.info(
f"移除了插件 {plugin_name} 的处理函数 {handler.handler_name} ({len(star_handlers_registry)})"
)
star_handlers_registry.remove(handler)
keys_to_delete = [
k
@@ -472,9 +500,15 @@ class PluginManager:
if k.startswith(plugin_module_path)
]
for k in keys_to_delete:
v = star_handlers_registry.star_handlers_map[k]
logger.debug(f"unbind handler {v.handler_name} from {plugin_name} (map)")
del star_handlers_registry.star_handlers_map[k]
try:
del star_handlers_registry.star_handlers_map[k]
except KeyError:
pass
try:
del sys.modules[plugin_module_path]
except KeyError:
logger.warning(f"模块 {plugin_module_path} 未载入")
async def update_plugin(self, plugin_name: str, proxy=""):
"""升级一个插件"""
@@ -485,7 +519,7 @@ class PluginManager:
raise Exception("该插件是 AstrBot 保留插件,无法更新。")
await self.updator.update(plugin, proxy=proxy)
await self.reload()
await self.reload(plugin_name)
async def turn_off_plugin(self, plugin_name: str):
"""
@@ -526,8 +560,15 @@ class PluginManager:
"""终止插件,调用插件的 terminate() 和 __del__() 方法"""
logging.info(f"正在终止插件 {star_metadata.name} ...")
if not star_metadata.activated:
# 说明之前已经被禁用了
logger.debug(f"插件 {star_metadata.name} 未被激活,不需要终止,跳过。")
return
if hasattr(star_metadata.star_cls, "__del__"):
asyncio.get_event_loop().run_in_executor(star_metadata.star_cls.__del__)
asyncio.get_event_loop().run_in_executor(
None, star_metadata.star_cls.__del__
)
else:
await star_metadata.star_cls.terminate()
@@ -541,12 +582,17 @@ class PluginManager:
# 启用插件启用的 llm_tool
for func_tool in llm_tools.func_list:
if func_tool.handler_module_path == plugin.module_path:
if (
func_tool.handler_module_path == plugin.module_path
and func_tool.name in inactivated_llm_tools
):
inactivated_llm_tools.remove(func_tool.name)
func_tool.active = True
sp.put("inactivated_llm_tools", inactivated_llm_tools)
plugin.activated = True
await self.reload(plugin_name)
# plugin.activated = True
async def install_plugin_from_file(self, zip_file_path: str):
dir_name = os.path.basename(zip_file_path).replace(".zip", "")
@@ -559,4 +605,5 @@ class PluginManager:
os.remove(zip_file_path)
except BaseException as e:
logger.warning(f"删除插件压缩包失败: {str(e)}")
await self.reload()
# await self.reload()
await self.load(specified_dir_name=dir_name)
+1 -1
View File
@@ -8,7 +8,7 @@ class DifyAPIClient:
def __init__(self, api_key: str, api_base: str = "https://api.dify.ai/v1"):
self.api_key = api_key
self.api_base = api_base
self.session = ClientSession()
self.session = ClientSession(trust_env=True)
self.headers = {
"Authorization": f"Bearer {self.api_key}",
}
+20 -9
View File
@@ -8,6 +8,9 @@ import base64
import zipfile
import uuid
import psutil
import certifi
from typing import Union
from PIL import Image
@@ -17,24 +20,20 @@ def on_error(func, path, exc_info):
"""
a callback of the rmtree function.
"""
print(f"remove {path} failed.")
import stat
if not os.access(path, os.W_OK):
os.chmod(path, stat.S_IWUSR)
func(path)
else:
raise
raise exc_info[1]
def remove_dir(file_path) -> bool:
if not os.path.exists(file_path):
return True
try:
shutil.rmtree(file_path, onerror=on_error)
return True
except BaseException:
return False
shutil.rmtree(file_path, onerror=on_error)
return True
def port_checker(port: int, host: str = "localhost"):
@@ -81,7 +80,13 @@ async def download_image_by_url(
下载图片, 返回 path
"""
try:
async with aiohttp.ClientSession(trust_env=True) as session:
ssl_context = ssl.create_default_context(
cafile=certifi.where()
) # 使用 certifi 提供的 CA 证书
connector = aiohttp.TCPConnector(ssl=ssl_context) # 使用 certifi 的根证书
async with aiohttp.ClientSession(
trust_env=True, connector=connector
) as session:
if post:
async with session.post(url, json=post_data) as resp:
if not path:
@@ -118,7 +123,13 @@ async def download_file(url: str, path: str, show_progress: bool = False):
从指定 url 下载文件到指定路径 path
"""
try:
async with aiohttp.ClientSession(trust_env=True) as session:
ssl_context = ssl.create_default_context(
cafile=certifi.where()
) # 使用 certifi 提供的 CA 证书
connector = aiohttp.TCPConnector(ssl=ssl_context)
async with aiohttp.ClientSession(
trust_env=True, connector=connector
) as session:
async with session.get(url, timeout=1800) as resp:
if resp.status != 200:
raise Exception(f"下载文件失败: {resp.status}")
+198
View File
@@ -0,0 +1,198 @@
"""
会话控制
"""
import abc
import asyncio
import time
import functools
import copy
import astrbot.core.message.components as Comp
from typing import Dict, Any, Callable, Awaitable, List
from astrbot.core.platform import AstrMessageEvent
USER_SESSIONS: Dict[str, "SessionWaiter"] = {} # 存储 SessionWaiter 实例
FILTERS: List["SessionFilter"] = [] # 存储 SessionFilter 实例
class SessionController:
"""
控制一个 Session 是否已经结束
"""
def __init__(self):
self.future = asyncio.Future()
self.current_event: asyncio.Event = None
"""当前正在等待的所用的异步事件"""
self.ts: float = None
"""上次保持(keep)开始时的时间"""
self.timeout: float | int = None
"""上次保持(keep)开始时的超时时间"""
self.history_chains: List[List[Comp.BaseMessageComponent]] = []
def stop(self, error: Exception = None):
"""立即结束这个会话"""
if not self.future.done():
if error:
self.future.set_exception(error)
else:
self.future.set_result(None)
def keep(self, timeout: float | int = 0, reset_timeout=False):
"""保持这个会话
Args:
timeout (float): 必填会话超时时间
reset_timeout 设置为 True , 代表重置超时时间, timeout 必须 > 0, 如果 <= 0 则立即结束会话
reset_timeout 设置为 False , 代表继续维持原来的超时时间, timeout = 原来剩余的timeout + timeout (可以 < 0)
"""
new_ts = time.time()
if reset_timeout:
if timeout <= 0:
self.stop()
return
else:
left_timeout = self.timeout - (new_ts - self.ts)
timeout = left_timeout + timeout
if timeout <= 0:
self.stop()
return
if self.current_event and not self.current_event.is_set():
self.current_event.set() # 通知上一个 keep 结束
new_event = asyncio.Event()
self.ts = new_ts
self.current_event = new_event
self.timeout = timeout
asyncio.create_task(self._holding(new_event, timeout)) # 开始新的 keep
async def _holding(self, event: asyncio.Event, timeout: int):
"""等待事件结束或超时"""
try:
await asyncio.wait_for(event.wait(), timeout)
except asyncio.TimeoutError:
if not self.future.done():
self.future.set_exception(TimeoutError("等待超时"))
except asyncio.CancelledError:
pass # 避免报错
# finally:
def get_history_chains(self) -> List[List[Comp.BaseMessageComponent]]:
"""获取历史消息链"""
return self.history_chains
class SessionFilter:
"""如何界定一个会话"""
@abc.abstractmethod
def filter(self, event: AstrMessageEvent) -> str:
"""根据事件返回一个会话标识符"""
pass
class DefaultSessionFilter(SessionFilter):
def filter(self, event: AstrMessageEvent) -> str:
"""默认实现,返回发送者的 ID 作为会话标识符"""
return event.get_sender_id()
class SessionWaiter:
def __init__(
self,
session_filter: SessionFilter,
session_id: str,
record_history_chains: bool,
):
self.session_id = session_id
self.session_filter = session_filter
self.handler: Callable[[str], Awaitable[Any]] | None = None # 处理函数
self.session_controller = SessionController()
self.record_history_chains = record_history_chains
"""是否记录历史消息链"""
self._lock = asyncio.Lock()
"""需要保证一个 session 同时只有一个 trigger"""
async def register_wait(
self, handler: Callable[[str], Awaitable[Any]], timeout: int = 30
) -> Any:
"""等待外部输入并处理"""
self.handler = handler
USER_SESSIONS[self.session_id] = self
# 开始一个会话保持事件
self.session_controller.keep(timeout, reset_timeout=True)
try:
return await self.session_controller.future
except Exception as e:
self._cleanup(e)
raise e
finally:
self._cleanup()
def _cleanup(self, error: Exception = None):
"""清理会话"""
USER_SESSIONS.pop(self.session_id, None)
try:
FILTERS.remove(self.session_filter)
except ValueError:
pass
self.session_controller.stop(error)
@classmethod
async def trigger(cls, session_id: str, event: AstrMessageEvent):
"""外部输入触发会话处理"""
session = USER_SESSIONS.get(session_id, None)
if not session or session.session_controller.future.done():
return
async with session._lock:
if not session.session_controller.future.done():
if session.record_history_chains:
session.session_controller.history_chains.append(
[copy.deepcopy(comp) for comp in event.get_messages()]
)
try:
# TODO: 这里使用 create_task,跟踪 task,防止超时后这里 handler 仍然在执行
await session.handler(session.session_controller, event)
except Exception as e:
session.session_controller.stop(e)
def session_waiter(timeout: int = 30, record_history_chains: bool = False):
"""
装饰器自动将函数注册为 SessionWaiter 处理函数并等待外部输入触发执行
:param timeout: 超时时间
:param record_history_chain: 是否自动记录历史消息链可以通过 controller.get_history_chains() 获取深拷贝
"""
def decorator(func: Callable[[str], Awaitable[Any]]):
@functools.wraps(func)
async def wrapper(
event: AstrMessageEvent,
session_filter: SessionFilter = None,
*args,
**kwargs,
):
if not session_filter:
session_filter = DefaultSessionFilter()
if not isinstance(session_filter, SessionFilter):
raise ValueError("session_filter 必须是 SessionFilter")
session_id = session_filter.filter(event)
FILTERS.append(session_filter)
waiter = SessionWaiter(session_filter, session_id, record_history_chains)
return await waiter.register_wait(func, timeout)
return wrapper
return decorator
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -1,5 +1,7 @@
import aiohttp
import os
import ssl
import certifi
from . import RenderStrategy
from astrbot.core.config import VERSION
@@ -46,7 +48,11 @@ class NetworkRenderStrategy(RenderStrategy):
},
}
if return_url:
async with aiohttp.ClientSession(trust_env=True) as session:
ssl_context = ssl.create_default_context(cafile=certifi.where())
connector = aiohttp.TCPConnector(ssl=ssl_context)
async with aiohttp.ClientSession(
trust_env=True, connector=connector
) as session:
async with session.post(
f"{self.BASE_RENDER_URL}/generate", json=post_data
) as resp:
+23 -3
View File
@@ -2,6 +2,10 @@ import aiohttp
import os
import zipfile
import shutil
import ssl
import certifi
from astrbot.core.utils.io import on_error, download_file
from astrbot.core import logger
@@ -19,7 +23,7 @@ class ReleaseInfo:
self.body = body
def __str__(self) -> str:
return f"新版本: {self.version}, 发布于: {self.published_at}, 详细内容: {self.body}"
return f"\n{self.body}\n\n版本: {self.version} | 发布于: {self.published_at}"
class RepoZipUpdator:
@@ -33,8 +37,23 @@ class RepoZipUpdator:
返回一个列表每个元素是一个字典包含版本号发布时间更新内容commit hash等信息
"""
try:
async with aiohttp.ClientSession(trust_env=True) as session:
ssl_context = ssl.create_default_context(
cafile=certifi.where()
) # 新增:创建基于 certifi 的 SSL 上下文
connector = aiohttp.TCPConnector(
ssl=ssl_context
) # 新增:使用 TCPConnector 指定 SSL 上下文
async with aiohttp.ClientSession(
trust_env=True, connector=connector
) as session:
async with session.get(url) as response:
# 检查 HTTP 状态码
if response.status != 200:
text = await response.text()
logger.error(
f"请求 {url} 失败,状态码: {response.status}, 内容: {text}"
)
raise Exception(f"请求失败,状态码: {response.status}")
result = await response.json()
if not result:
return []
@@ -53,7 +72,8 @@ class RepoZipUpdator:
"zipball_url": release["zipball_url"],
}
)
except BaseException:
except Exception as e:
logger.error(f"解析版本信息时发生异常: {e}")
raise Exception("解析版本信息失败")
return ret
-3
View File
@@ -1,3 +0,0 @@
from .dashboard_lifecycle import AstrBotDashBoardLifecycle
__all__ = ["AstrBotDashBoardLifecycle"]
+4
View File
@@ -6,6 +6,8 @@ from .stat import StatRoute
from .log import LogRoute
from .static_file import StaticFileRoute
from .chat import ChatRoute
from .tools import ToolsRoute # 导入新的ToolsRoute
from .conversation import ConversationRoute
__all__ = [
@@ -17,4 +19,6 @@ __all__ = [
"LogRoute",
"StaticFileRoute",
"ChatRoute",
"ToolsRoute", # 添加新的ToolsRoute
"ConversationRoute",
]
+13 -1
View File
@@ -3,6 +3,7 @@ import datetime
from .route import Route, Response, RouteContext
from quart import request
from astrbot.core import WEBUI_SK
from astrbot import logger
class AuthRoute(Route):
@@ -19,9 +20,20 @@ class AuthRoute(Route):
password = self.config["dashboard"]["password"]
post_data = await request.json
if post_data["username"] == username and post_data["password"] == password:
change_pwd_hint = False
if username == "astrbot" and password == "77b90590a8945a7d36c963981a307dc9":
change_pwd_hint = True
logger.warning("为了保证安全,请尽快修改默认密码。")
return (
Response()
.ok({"token": self.generate_jwt(username), "username": username})
.ok(
{
"token": self.generate_jwt(username),
"username": username,
"change_pwd_hint": change_pwd_hint,
}
)
.__dict__
)
else:
+41 -11
View File
@@ -29,17 +29,38 @@ def validate_config(
) -> typing.Tuple[typing.List[str], typing.Dict]:
errors = []
def validate(data, metadata=schema, path=""):
for key, meta in metadata.items():
if key not in data:
def validate(data: dict, metadata: dict = schema, path=""):
for key, value in data.items():
if key not in metadata:
# 无 schema 的配置项,执行类型猜测
if isinstance(value, str):
if value.isdigit():
data[key] = int(value)
elif value.replace(".", "", 1).isdigit():
data[key] = float(value)
elif value == "true":
data[key] = True
elif value == "false":
data[key] = False
continue
value = data[key]
meta = metadata[key]
# null 转换
if value is None:
data[key] = DEFAULT_VALUE_MAP[meta["type"]]
continue
# 递归验证
if meta["type"] == "list" and isinstance(value, list):
if meta["type"] == "list" and not isinstance(value, list):
errors.append(
f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}"
)
elif (
meta["type"] == "list"
and isinstance(value, list)
and value
and "items" in meta
and isinstance(value[0], dict)
):
# 当前仅针对 list[dict] 的情况进行类型校验,以适配 AstrBot 中 platform、provider 的配置
for item in value:
validate(item, meta["items"], path=f"{path}{key}.")
elif meta["type"] == "object" and isinstance(value, dict):
@@ -75,7 +96,6 @@ def validate_config(
errors.append(
f"错误的类型 {path}{key}: 期望是 dict, 得到了 {type(value).__name__}"
)
validate(value, meta["items"], path=f"{path}{key}.")
if is_core:
for key, group in schema.items():
@@ -103,6 +123,7 @@ def save_config(post_config: dict, config: AstrBotConfig, is_core: bool = False)
except BaseException as e:
logger.error(traceback.format_exc())
logger.warning(f"验证配置时出现异常: {e}")
raise ValueError(f"验证配置时出现异常: {e}")
if errors:
raise ValueError(f"格式校验未通过: {errors}")
config.save_config(post_config)
@@ -124,6 +145,7 @@ class ConfigRoute(Route):
"/config/provider/new": ("POST", self.post_new_provider),
"/config/provider/update": ("POST", self.post_update_provider),
"/config/provider/delete": ("POST", self.post_delete_provider),
"/config/llmtools": ("GET", self.get_llm_tools),
}
self.register_routes()
@@ -149,9 +171,10 @@ class ConfigRoute(Route):
plugin_name = request.args.get("plugin_name", "unknown")
try:
await self._save_plugin_configs(post_configs, plugin_name)
await self.core_lifecycle.plugin_manager.reload(plugin_name)
return (
Response()
.ok(None, f"保存插件 {plugin_name} 成功~ 机器人正在重载配置")
.ok(None, f"保存插件 {plugin_name} 成功~ 机器人正在重载插件")
.__dict__
)
except Exception as e:
@@ -196,7 +219,8 @@ class ConfigRoute(Route):
return Response().error("未找到对应平台").__dict__
try:
await self._save_astrbot_configs(self.config)
save_config(self.config, self.config, is_core=True)
await self.core_lifecycle.platform_manager.reload(new_config)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "更新平台配置成功~").__dict__
@@ -232,7 +256,8 @@ class ConfigRoute(Route):
else:
return Response().error("未找到对应平台").__dict__
try:
await self._save_astrbot_configs(self.config)
save_config(self.config, self.config, is_core=True)
await self.core_lifecycle.platform_manager.terminate_platform(platform_id)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "删除平台配置成功~").__dict__
@@ -253,6 +278,12 @@ class ConfigRoute(Route):
return Response().error(str(e)).__dict__
return Response().ok(None, "删除成功,已经实时生效~").__dict__
async def get_llm_tools(self):
"""获取函数调用工具。包含了本地加载的以及 MCP 服务的工具"""
tool_mgr = self.core_lifecycle.provider_manager.llm_tools
tools = tool_mgr.get_func_desc_openai_style()
return Response().ok(tools).__dict__
async def _get_astrbot_config(self):
config = self.config
@@ -298,7 +329,7 @@ class ConfigRoute(Route):
async def _save_astrbot_configs(self, post_configs: dict):
try:
save_config(post_configs, self.config, is_core=True)
self.core_lifecycle.restart()
await self.core_lifecycle.restart()
except Exception as e:
raise e
@@ -315,6 +346,5 @@ class ConfigRoute(Route):
try:
save_config(post_configs, md.config)
self.core_lifecycle.restart()
except Exception as e:
raise e
+215
View File
@@ -0,0 +1,215 @@
import traceback
import json
from .route import Route, Response, RouteContext
from astrbot.core import logger
from quart import request
from astrbot.core.db import BaseDatabase
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
class ConversationRoute(Route):
def __init__(
self,
context: RouteContext,
db_helper: BaseDatabase,
core_lifecycle: AstrBotCoreLifecycle,
) -> None:
super().__init__(context)
self.routes = {
"/conversation/list": ("GET", self.list_conversations),
"/conversation/detail": (
"POST",
self.get_conv_detail,
),
"/conversation/update": ("POST", self.upd_conv),
"/conversation/delete": ("POST", self.del_conv),
"/conversation/update_history": (
"POST",
self.update_history,
),
}
self.db_helper = db_helper
self.register_routes()
async def list_conversations(self):
"""获取对话列表,支持分页、排序和筛选"""
try:
# 获取分页参数
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 20, type=int)
# 获取筛选参数
platforms = request.args.get("platforms", "")
message_types = request.args.get("message_types", "")
search_query = request.args.get("search", "")
exclude_ids = request.args.get("exclude_ids", "")
exclude_platforms = request.args.get("exclude_platforms", "")
# 转换为列表
platform_list = platforms.split(",") if platforms else []
message_type_list = message_types.split(",") if message_types else []
exclude_id_list = exclude_ids.split(",") if exclude_ids else []
exclude_platform_list = (
exclude_platforms.split(",") if exclude_platforms else []
)
# 限制页面大小,防止请求过大数据
if page < 1:
page = 1
if page_size < 1:
page_size = 20
if page_size > 100:
page_size = 100
# 使用数据库的分页方法获取会话列表和总数,传入筛选条件
try:
conversations, total_count = self.db_helper.get_filtered_conversations(
page=page,
page_size=page_size,
platforms=platform_list,
message_types=message_type_list,
search_query=search_query,
exclude_ids=exclude_id_list,
exclude_platforms=exclude_platform_list,
)
except Exception as e:
logger.error(f"数据库查询出错: {str(e)}\n{traceback.format_exc()}")
return Response().error(f"数据库查询出错: {str(e)}").__dict__
# 计算总页数
total_pages = (
(total_count + page_size - 1) // page_size if total_count > 0 else 1
)
result = {
"conversations": conversations,
"pagination": {
"page": page,
"page_size": page_size,
"total": total_count,
"total_pages": total_pages,
},
}
return Response().ok(result).__dict__
except Exception as e:
error_msg = f"获取对话列表失败: {str(e)}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"获取对话列表失败: {str(e)}").__dict__
async def get_conv_detail(self):
"""获取指定对话详情(通过POST请求)"""
try:
data = await request.get_json()
user_id = data.get("user_id")
cid = data.get("cid")
if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").__dict__
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
if not conversation:
return Response().error("对话不存在").__dict__
return (
Response()
.ok(
{
"user_id": user_id,
"cid": cid,
"title": conversation.title,
"persona_id": conversation.persona_id,
"history": conversation.history,
"created_at": conversation.created_at,
"updated_at": conversation.updated_at,
}
)
.__dict__
)
except Exception as e:
logger.error(f"获取对话详情失败: {str(e)}\n{traceback.format_exc()}")
return Response().error(f"获取对话详情失败: {str(e)}").__dict__
async def upd_conv(self):
"""更新对话信息(标题和角色ID)"""
try:
data = await request.get_json()
user_id = data.get("user_id")
cid = data.get("cid")
title = data.get("title")
persona_id = data.get("persona_id", "")
if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").__dict__
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
if not conversation:
return Response().error("对话不存在").__dict__
if title is not None:
self.db_helper.update_conversation_title(user_id, cid, title)
if persona_id is not None:
self.db_helper.update_conversation_persona_id(user_id, cid, persona_id)
return Response().ok({"message": "对话信息更新成功"}).__dict__
except Exception as e:
logger.error(f"更新对话信息失败: {str(e)}\n{traceback.format_exc()}")
return Response().error(f"更新对话信息失败: {str(e)}").__dict__
async def del_conv(self):
"""删除对话"""
try:
data = await request.get_json()
user_id = data.get("user_id")
cid = data.get("cid")
if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").__dict__
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
if not conversation:
return Response().error("对话不存在").__dict__
self.db_helper.delete_conversation(user_id, cid)
return Response().ok({"message": "对话删除成功"}).__dict__
except Exception as e:
logger.error(f"删除对话失败: {str(e)}\n{traceback.format_exc()}")
return Response().error(f"删除对话失败: {str(e)}").__dict__
async def update_history(self):
"""更新对话历史内容"""
try:
data = await request.get_json()
user_id = data.get("user_id")
cid = data.get("cid")
history = data.get("history")
if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").__dict__
if history is None:
return Response().error("缺少必要参数: history").__dict__
# 历史记录必须是合法的 JSON 字符串
try:
if isinstance(history, list):
history = json.dumps(history)
else:
# 验证是否为有效的 JSON 字符串
json.loads(history)
except json.JSONDecodeError:
return (
Response().error("history 必须是有效的 JSON 字符串或数组").__dict__
)
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
if not conversation:
return Response().error("对话不存在").__dict__
self.db_helper.update_conversation(user_id, cid, history)
return Response().ok({"message": "对话历史更新成功"}).__dict__
except Exception as e:
logger.error(f"更新对话历史失败: {str(e)}\n{traceback.format_exc()}")
return Response().error(f"更新对话历史失败: {str(e)}").__dict__
+14 -4
View File
@@ -1,5 +1,9 @@
import traceback
import aiohttp
import ssl
import certifi
from .route import Route, Response, RouteContext
from astrbot.core import logger
from quart import request
@@ -65,9 +69,14 @@ class PluginRoute(Route):
else:
urls = ["https://api.soulter.top/astrbot/plugins"]
# 新增:创建 SSL 上下文,使用 certifi 提供的根证书
ssl_context = ssl.create_default_context(cafile=certifi.where())
connector = aiohttp.TCPConnector(ssl=ssl_context)
for url in urls:
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with aiohttp.ClientSession(
trust_env=True, connector=connector
) as session:
async with session.get(url) as response:
if response.status == 200:
result = await response.json()
@@ -188,7 +197,7 @@ class PluginRoute(Route):
try:
logger.info(f"正在安装插件 {repo_url}")
await self.plugin_manager.install_plugin(repo_url, proxy)
self.core_lifecycle.restart()
# self.core_lifecycle.restart()
logger.info(f"安装插件 {repo_url} 成功。")
return Response().ok(None, "安装成功。").__dict__
except Exception as e:
@@ -203,7 +212,7 @@ class PluginRoute(Route):
file_path = f"data/temp/{file.filename}"
await file.save(file_path)
await self.plugin_manager.install_plugin_from_file(file_path)
self.core_lifecycle.restart()
# self.core_lifecycle.restart()
logger.info(f"安装插件 {file.filename} 成功")
return Response().ok(None, "安装成功。").__dict__
except Exception as e:
@@ -229,7 +238,8 @@ class PluginRoute(Route):
try:
logger.info(f"正在更新插件 {plugin_name}")
await self.plugin_manager.update_plugin(plugin_name, proxy)
self.core_lifecycle.restart()
# self.core_lifecycle.restart()
await self.plugin_manager.reload(plugin_name)
logger.info(f"更新插件 {plugin_name} 成功。")
return Response().ok(None, "更新成功。").__dict__
except Exception as e:
+21 -4
View File
@@ -1,6 +1,7 @@
import traceback
import psutil
import time
import threading
from .route import Route, Response, RouteContext
from astrbot.core import logger
from quart import request
@@ -28,7 +29,7 @@ class StatRoute(Route):
self.core_lifecycle = core_lifecycle
async def restart_core(self):
self.core_lifecycle.restart()
await self.core_lifecycle.restart()
return Response().ok().__dict__
def format_sec(self, sec: int):
@@ -64,6 +65,20 @@ class StatRoute(Route):
stat_dict = stat.__dict__
cpu_percent = psutil.cpu_percent(interval=0.5)
thread_count = threading.active_count()
# 获取插件信息
plugins = self.core_lifecycle.star_context.get_all_stars()
plugin_info = []
for plugin in plugins:
info = {
"name": getattr(plugin, "name", plugin.__class__.__name__),
"version": getattr(plugin, "version", "1.0.0"),
"is_enabled": True,
}
plugin_info.append(info)
stat_dict.update(
{
"platform": self.db_helper.get_grouped_base_stats(
@@ -73,9 +88,8 @@ class StatRoute(Route):
"platform_count": len(
self.core_lifecycle.platform_manager.get_insts()
),
"plugin_count": len(
self.core_lifecycle.star_context.get_all_stars()
),
"plugin_count": len(plugins),
"plugins": plugin_info,
"message_time_series": message_time_based_stats,
"running": self.format_sec(
int(time.time()) - self.core_lifecycle.start_time
@@ -84,6 +98,9 @@ class StatRoute(Route):
"process": psutil.Process().memory_info().rss >> 20,
"system": psutil.virtual_memory().total >> 20,
},
"cpu_percent": round(cpu_percent, 1),
"thread_count": thread_count,
"start_time": self.core_lifecycle.start_time,
}
)
+1
View File
@@ -19,6 +19,7 @@ class StaticFileRoute(Route):
"/platforms",
"/providers",
"/about",
"/extension-marketplace",
]
for i in index_:
self.app.add_url_rule(i, view_func=self.index)
+252
View File
@@ -0,0 +1,252 @@
import os
import json
import traceback
from .route import Route, Response, RouteContext
from quart import request
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core import logger
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
class ToolsRoute(Route):
def __init__(
self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle
) -> None:
super().__init__(context)
self.core_lifecycle = core_lifecycle
self.routes = {
"/tools/mcp/servers": ("GET", self.get_mcp_servers),
"/tools/mcp/add": ("POST", self.add_mcp_server),
"/tools/mcp/update": ("POST", self.update_mcp_server),
"/tools/mcp/delete": ("POST", self.delete_mcp_server),
}
self.register_routes()
self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools
@property
def mcp_config_path(self):
current_dir = os.path.dirname(os.path.abspath(__file__))
data_dir = os.path.abspath(os.path.join(current_dir, "../../../data"))
return os.path.join(data_dir, "mcp_server.json")
def load_mcp_config(self):
if not os.path.exists(self.mcp_config_path):
# 配置文件不存在,创建默认配置
os.makedirs(os.path.dirname(self.mcp_config_path), exist_ok=True)
with open(self.mcp_config_path, "w", encoding="utf-8") as f:
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
return DEFAULT_MCP_CONFIG
try:
with open(self.mcp_config_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.error(f"加载 MCP 配置失败: {e}")
return DEFAULT_MCP_CONFIG
def save_mcp_config(self, config):
try:
with open(self.mcp_config_path, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=4)
return True
except Exception as e:
logger.error(f"保存 MCP 配置失败: {e}")
return False
async def get_mcp_servers(self):
try:
config = self.load_mcp_config()
servers = []
# 获取所有服务器并添加它们的工具列表
for name, server_config in config["mcpServers"].items():
server_info = {
"name": name,
"active": server_config.get("active", True),
}
# 复制所有配置字段
for key, value in server_config.items():
if key != "active": # active 已经处理
server_info[key] = value
# 如果MCP客户端已初始化,从客户端获取工具名称
for (
name_key,
mcp_client,
) in self.tool_mgr.mcp_client_dict.items():
if name_key == name:
server_info["tools"] = [tool.name for tool in mcp_client.tools]
break
else:
server_info["tools"] = []
servers.append(server_info)
return Response().ok(servers).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"获取 MCP 服务器列表失败: {str(e)}").__dict__
async def add_mcp_server(self):
try:
server_data = await request.json
name = server_data.get("name", "")
# 检查必填字段
if not name:
return Response().error("服务器名称不能为空").__dict__
# 移除特殊字段并检查配置是否有效
has_valid_config = False
server_config = {"active": server_data.get("active", True)}
# 复制所有配置字段
for key, value in server_data.items():
if key not in ["name", "active", "tools"]: # 排除特殊字段
server_config[key] = value
has_valid_config = True
if not has_valid_config:
return Response().error("必须提供有效的服务器配置").__dict__
config = self.load_mcp_config()
if name in config["mcpServers"]:
return Response().error(f"服务器 {name} 已存在").__dict__
config["mcpServers"][name] = server_config
if self.save_mcp_config(config):
# 动态初始化新MCP客户端
self.tool_mgr.mcp_service_queue.put_nowait(
{
"type": "init",
"name": name,
"cfg": config["mcpServers"][name],
}
)
return Response().ok(None, f"成功添加 MCP 服务器 {name}").__dict__
else:
return Response().error("保存配置失败").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"添加 MCP 服务器失败: {str(e)}").__dict__
async def update_mcp_server(self):
try:
server_data = await request.json
name = server_data.get("name", "")
if not name:
return Response().error("服务器名称不能为空").__dict__
config = self.load_mcp_config()
if name not in config["mcpServers"]:
return Response().error(f"服务器 {name} 不存在").__dict__
# 获取活动状态
active = server_data.get(
"active", config["mcpServers"][name].get("active", True)
)
# 创建新的配置对象
server_config = {"active": active}
# 仅更新活动状态的特殊处理
only_update_active = True
# 复制所有配置字段
for key, value in server_data.items():
if key not in ["name", "active", "tools"]: # 排除特殊字段
server_config[key] = value
only_update_active = False
# 如果只更新活动状态,保留原始配置
if only_update_active:
for key, value in config["mcpServers"][name].items():
if key != "active": # 除了active之外的所有字段都保留
server_config[key] = value
config["mcpServers"][name] = server_config
if self.save_mcp_config(config):
# 处理MCP客户端状态变化
if active:
# 如果要激活服务器或者配置已更改
if name in self.tool_mgr.mcp_client_dict or not only_update_active:
await self.tool_mgr.mcp_service_queue.put(
{
"type": "terminate",
"name": name,
}
)
await self.tool_mgr.mcp_service_queue.put(
{
"type": "init",
"name": name,
"cfg": config["mcpServers"][name],
}
)
else:
# 客户端不存在,初始化
self.tool_mgr.mcp_service_queue.put_nowait(
{
"type": "init",
"name": name,
"cfg": config["mcpServers"][name],
}
)
else:
# 如果要停用服务器
if name in self.tool_mgr.mcp_client_dict:
self.tool_mgr.mcp_service_queue.put_nowait(
{
"type": "terminate",
"name": name,
}
)
return Response().ok(None, f"成功更新 MCP 服务器 {name}").__dict__
else:
return Response().error("保存配置失败").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"更新 MCP 服务器失败: {str(e)}").__dict__
async def delete_mcp_server(self):
try:
server_data = await request.json
name = server_data.get("name", "")
if not name:
return Response().error("服务器名称不能为空").__dict__
config = self.load_mcp_config()
if name not in config["mcpServers"]:
return Response().error(f"服务器 {name} 不存在").__dict__
# 删除服务器配置
del config["mcpServers"][name]
if self.save_mcp_config(config):
# 关闭并删除MCP客户端
if name in self.tool_mgr.mcp_client_dict:
self.tool_mgr.mcp_service_queue.put_nowait(
{
"type": "terminate",
"name": name,
}
)
return Response().ok(None, f"成功删除 MCP 服务器 {name}").__dict__
else:
return Response().error("保存配置失败").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"删除 MCP 服务器失败: {str(e)}").__dict__
+1 -2
View File
@@ -95,8 +95,7 @@ class UpdateRoute(Route):
logger.error(f"更新依赖失败: {e}")
if reboot:
# threading.Thread(target=self.astrbot_updator._reboot, args=(2, )).start()
self.core_lifecycle.restart()
await self.core_lifecycle.restart()
return (
Response()
.ok(None, "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。")
+40 -16
View File
@@ -20,12 +20,19 @@ DATAPATH = os.path.abspath(
class AstrBotDashboard:
def __init__(self, core_lifecycle: AstrBotCoreLifecycle, db: BaseDatabase) -> None:
def __init__(
self,
core_lifecycle: AstrBotCoreLifecycle,
db: BaseDatabase,
shutdown_event: asyncio.Event,
) -> None:
self.core_lifecycle = core_lifecycle
self.config = core_lifecycle.astrbot_config
self.data_path = os.path.abspath(os.path.join(DATAPATH, "dist"))
self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
self.app.config['MAX_CONTENT_LENGTH'] = 128 * 1024 * 1024 # 将 Flask 允许的最大上传文件体大小设置为 128 MB
self.app.config["MAX_CONTENT_LENGTH"] = (
128 * 1024 * 1024
) # 将 Flask 允许的最大上传文件体大小设置为 128 MB
self.app.json.sort_keys = False
self.app.before_request(self.auth_middleware)
# token 用于验证请求
@@ -43,6 +50,10 @@ class AstrBotDashboard:
self.sfr = StaticFileRoute(self.context)
self.ar = AuthRoute(self.context)
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
self.tools_root = ToolsRoute(self.context, core_lifecycle)
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
self.shutdown_event = shutdown_event
async def auth_middleware(self):
if not request.path.startswith("/api"):
@@ -71,11 +82,6 @@ class AstrBotDashboard:
r.status_code = 401
return r
async def shutdown_trigger_placeholder(self):
while not self.core_lifecycle.event_queue.closed: # noqa: ASYNC110
await asyncio.sleep(1)
logger.info("管理面板已关闭。")
def check_port_in_use(self, port: int) -> bool:
"""
跨平台检测端口是否被占用
@@ -118,12 +124,22 @@ class AstrBotDashboard:
return f"获取进程信息失败: {str(e)}"
def run(self):
try:
ip_addr = get_local_ip_addresses()
except Exception as _:
ip_addr = []
ip_addr = []
port = self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185)
host = self.core_lifecycle.astrbot_config["dashboard"].get("host", "0.0.0.0")
logger.info(f"正在启动 WebUI, 监听地址: http://{host}:{port}")
if host == "0.0.0.0":
logger.info(
"提示: WebUI 将监听所有网络接口,请注意安全。(可在 data/cmd_config.json 中配置 dashboard.host 以修改 host"
)
if host not in ["localhost", "127.0.0.1"]:
try:
ip_addr = get_local_ip_addresses()
except Exception as _:
pass
if isinstance(port, str):
port = int(port)
@@ -140,15 +156,23 @@ class AstrBotDashboard:
raise Exception(f"端口 {port} 已被占用")
display = f"\n ✨✨✨\n AstrBot v{VERSION} 管理面板已启动,可访问\n\n"
display = f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动,可访问\n\n"
display += f" ➜ 本地: http://localhost:{port}\n"
for ip in ip_addr:
display += f" ➜ 网络: http://{ip}:{port}\n"
display += " ➜ 默认用户名和密码: astrbot\n ✨✨✨\n"
if not ip_addr:
display += (
"可在 data/cmd_config.json 中配置 dashboard.host 以便远程访问。\n"
)
logger.info(display)
return self.app.run_task(
host="0.0.0.0",
port=port,
shutdown_trigger=self.shutdown_trigger_placeholder,
host=host, port=port, shutdown_trigger=self.shutdown_trigger
)
async def shutdown_trigger(self):
await self.shutdown_event.wait()
logger.info("AstrBot WebUI 已经被优雅地关闭")
+15
View File
@@ -0,0 +1,15 @@
# What's Changed
1. ✨ 新增: 支持插件会话控制 API
2. ✨ 新增: add template of LMStudio #691
3. ✨ 新增: 更好的插件卡片的 UI,插件卡片支持显示 logo,推荐插件页面
4. ✨ 新增: 支持当消息只有 @bot 时,下一条发送人的消息**直接唤醒机器人** #714
5. ⚡ 优化: Webchat 和 Gewechat 的图片、语音等主动消息发送 #710
6. ⚡ 优化: 完善了插件的启用和禁用的生命周期管理
7. ⚡ 优化: 安装插件/更新插件/保存插件配置后直接热重载而不重启;优化了 plugin 指令
8. 🐛 修复: 主动人格情况下人格失效的问题 #719 #712
9. 🐛 修复: 404 error after installing plugins
10. 🐛 修复: telegram cannot handle /start #620
11. 🐛 修复: 修复插件在带了 __del__ 之后无法被禁用和重载的问题
12. 🐛 修复: context.get_platform() error
13. 🐛 修复: Telegram 适配器使用代理地址无法获取图片 #723
+6
View File
@@ -0,0 +1,6 @@
# What's Changed
1. ✨ 新增: 支持接入钉钉 #643
2. ✨ 新增: 支持设置私聊是否需要唤醒前缀唤醒 [#735](https://github.com/Soulter/AstrBot/issues/735)
3. 🐛 修复: 无法正常保存插件的 list 类型配置 #737
4. 🐛 修复: 部分情况下使用 aiocqhttp 报错 int 不能与 str 进行 '+' 操作的问题
+57
View File
@@ -0,0 +1,57 @@
# What's Changed
> Special thanks for all contributors and plugin developers and users who love AstrBot. 💖
## ✨ 新增的功能
1. 支持解析回复消息,支持 LLM 对所引用消息具有感知 #783
2. 支持 Dify 的文件、图片、视频、音频输出 #819
3. QQ 下支持嵌套转发(napcat) @zouyonghe
4. 配置页样式重写,更紧凑的 WebUI 配置
## 🎈 功能性优化
1. 使用系统时间而不是 UTC+8 时间作为默认时间以适应海外用户需求 @roeseth
2. 在对话隔离情况下也可以将整个群聊加入白名单 #746
3. 在调用插件异常时更完整的报错输出
4. gewechat 下对已知且没有业务处理的事件类型不显示详细日志 @diudiu62
5. 优化 WebUI 悬浮文档 @IGCrystal
6. 支持自定义 WebUI、Wecom Webhook Server, QQ Official Webhook Server 的 host #821
7. Dify 下当只有图片输入时的默认 prompt 防止一些报错 #837
## 🐛 修复的 Bug
1. fishaudio 默认 baseurl 不可用
2. gewechat 下重复登录后提示设备不存在导致无法重新登陆 @beat4ocean
3. gewechat 下用户本人发消息会触发消息回复 @beat4ocean
4. 钉钉 WebUI 文档不显示
5. 更新插件后插件热重载不完全、函数工具重复添加
6. OpenAI TTS API TypeError 报错 #755
7. EdgeTTS 部分情况下无法使用 @Soulter @需要哦
8. QQ 官方机器人平台下发送 base64 图片消息段报错 @Soulter @shuiping233
9. QQ 官方机器人平台下命令参数报错信息无法正常发送 @shuiping233
10. WebUI 错误地显示未知更新
11. 部分情况下文件无法上传到 Telegram 群组 #601
12. 插件管理的插件简介太长导致 “帮助”“操作”图标不显示 #790
13. LLOnebot 合并消息转发错误 #842
14. model_config 中自定义的配置项(如温度)类型自动变回 string #854
## 🧩 新增的插件
1. astrbot_plugin_image_understanding_Janus-Pro - 使用deepseek-ai/Janus-Pro系列模型为本地模型提供的图片理解补充 @xiewoc
2. astrbot_plugin_moyurenpro - 摸鱼人日历,支持自定义时间时区,自定义api,支持立即发送,工作日定时发送。 @quirrel-zh @DuBwTf
3. astrbot_plugin_wechat_manager - 微信关键字好友自动审核、关键字邀请进群。@diudiu62
4. astrbot_plugin_qwq_filter - qwq 思考过滤工具 @beat4ocean
5. astrbot_plugin_chatsummary - 一个通过拉取历史聊天记录,调用LLM大模型接口实现消息总结功能。@laopanmemz
6. astrBot_PGR_Dialogue - 检测到部分战双角色的名称(或别称)时,有概率发送一条语音文本 @KurisuRee7
7. astrbot_plugin_bv - 解析群内https://www.bilibili.com/video/BV号/ 的链接并获取视频数据与视频文件,以合并转发方式发送 @haliludaxuanfeng
8. astrbot_plugin_gemini_exp - 让你在AstrBot调用Gemini2.0-flash-exp来生成图片或者p图。Gemini2.0-flash-exp为原生多模态模型,其既是语言模型,也是生图模型,因此能够对图像使用简单的自然语言命令进行处理。@Elen123bot
9. astrbot_plugin_sjzb - 随机生成绝地潜兵2游戏中一组4个战备配置 @tenno1174
10. astrbot_plugin_picture_manager - 图片管理插件,允许用户通过自定义触发指令从API或直接URL获取图片。@bigshabei
11. astrbot_plugin_bilibiliParse - 解析哔哩哔哩视频,并以图片的形式发送给用户 @7Hello12
12. astrbot_plugin_sensoji - 这是一个模拟日本浅草寺抽签功能的插件。用户可以通过发送 /抽签 命令随机抽取一个签文,获取运势提示。签文包含吉凶结果(如“大吉”、“凶”等)以及对应的运势描述。 @Shouugou
13. astrbot_plugin_videosummary - 使用 bibigpt 实现视频总结 @kterna
14. astrbot_plugin_InitiativeDialogue - 使 bot 在用户长时间未发送消息时主动与用户对话的插件 @advent259141
15. astrbot_plugin_emoji - 基于达莉娅综合群娱插件的表情包制作插件,仅保留了@其他群员制作表情包的部分。由桑帛云API提供表情包制作。@KurisuRee7
16. astrbot_plugin_videos_analysis - 聚合视频分享链接解析(仅测试过napcat) @miaoxutao123
17. astrbot_plugin_daily_news - 每日 60 秒新闻推送插件 - 自动推送每日热点新闻 @anka-afk
+4
View File
@@ -0,0 +1,4 @@
# What's Changed
1. 默认账户密码登录成功后弹出修改警告
2. 将 WebUI 默认 host 改变回 v3.4.38 之前的版本以减少兼容性问题。
+59
View File
@@ -0,0 +1,59 @@
# What's Changed
> 📢 AstrBot 上架宝塔面板 Docker 应用商店了!
> 📢 在升级前,请完整阅读本次更新日志。
## ✨ 新增的功能
1. ‼️ 新增支持接入 MCP 服务器 @Soulter @AraragiEro
1. ‼️ 新增支持本地渲染 Markdown,并支持自定义字体,详见 -> [#957](https://github.com/Soulter/AstrBot/issues/957#issuecomment-2749981802)
2. 新增支持在 WebUI 管理所有与大模型的对话
3. 适配完整的 function-calling 流程。[#804](https://github.com/Soulter/AstrBot/issues/804) [#566](https://github.com/Soulter/AstrBot/issues/566)
4. 新增支持消息平台热重载,不再需要重启 AstrBot
5. 新增支持阿里云百炼应用的 RAG 应用 [#878](https://github.com/Soulter/AstrBot/issues/878)
6. 新增 `/plugin get` OP 指令下载插件。如 `/plugin get Raven95676/astrbot_plugin_wordle`
7. 新增 `/newgroup` OP 指令,支持私聊 bot 给指定群聊创建新的对话。by @LunarMeal
8. Gewechat 下支持 `添加好友`, `接收/发送视频`, `获取群信息`, `接收/发送表情包` by @Moyuyanli @Soulter @XuYingJie-cmd @NiceAir
9. Telegram 下支持接收和处理表情包(Sticker) @Raven95676
## 🎈 功能性优化
0. 更加美观的 WebUI 设计,降低疲劳程度。
1. 微信下,忽略 `微信团队` 的消息 [#859](https://github.com/Soulter/AstrBot/issues/859)
2. 完善 Dify 的图片输入功能 [#893](https://github.com/Soulter/AstrBot/issues/893)
3. 消息平台和配置提供商配置页中,自动更新旧的配置项
4. 优化钉钉在配置错误之后堵塞整个线程的问题 [#885](https://github.com/Soulter/AstrBot/issues/885)
5. WebUI 删除插件时提供二次确认避免误删 @zhx8702
6. WebUI 优化新版本时的信息显示
7. 发送消息失败时的报错回显优化
8. 改善所有消息平台的优雅退出逻辑
9. 空 @ 时调用 LLM 获得更加富有人格的回复 by @advent259141
## 🐛 修复的 Bug
1. 修复图片没有被存储到聊天上下文历史记录
2. 修复 Telegram 下无法识别图片描述(Caption) [#910](https://github.com/Soulter/AstrBot/issues/910)
3. 修复 Telegram Topic 群组下引用消息来源错误的问题 [#908](https://github.com/Soulter/AstrBot/issues/908)
4. 修复 Telegram 下 `/start` 指令的一些问题 [#751](https://github.com/Soulter/AstrBot/issues/751)
5. WebUI 插件市场卡片显示风格的过滤问题。[#927](https://github.com/Soulter/AstrBot/issues/927)
6. 统一 SSL 证书验证逻辑,修复 `SSLCertVerificationError` 的问题。by @IGCrystal [#950](https://github.com/Soulter/AstrBot/issues/950)
7. 修复可能形成 SQL 注入的风险
8. 修复本地上传插件时无法重载插件的问题 [#995](https://github.com/Soulter/AstrBot/issues/995) by @zhx8702
## 🧩 新增的插件
1. astrbot_plugin_majsoul-master - 雀魂多功能插件 - by @kterna
2. astrbot_plugin_server - 可视化服务器状态卡片,/status 或 /状态查询 查看 - by @yanfd @Meguminlove
3. astrbot_plugin_Getcwm - 刺猬猫小说数据获取与画图插件 - by @Li-shi-ling
4. astrbot_plugin_anti_withdrawal - 防撤回插件,目前只支持微信私聊群聊的文本消息,将撤回的消息记录并发送给设定的人 - by @NiceAir
5. astrbot_plugin_hello77 - 游戏梗自动回复插件 - by @ttq7
6. astrbot_plugin_push_lite - Webhook 轻量级推送插件 - @Raven95676
7. astrbot_plugin_pokecheck - 检测“戳”关键词的插件 - @huanyan434
8. astrbot_plugin_MultiAI_PollPad - 轮询调用配置的大语言模型输出多个结果。同时将 AI 结果拷贝至在线文本编辑器 - by @Ynkcc
9. astrbot_plugin_box - / - by @Zhalslar
10. astrbot_plugin_Translation - 通过调用百度翻译 API 实现翻译文本 - by @zengweis
11. astrbot_plugin_wordle_2 - Wordle 游戏插件 - by @Raven95676 @whzcc
12. astrbot_plugin_mai_sgin - 舞萌出勤与退勤签到插件 - by @Rinyin
13. astrbot_plugin_Lolicon - Lolicon API 随机动漫图片插件 - by @ttq7
14. astrbot_plugin_aiocensor - 综合内容安全+群管插件 - by @Raven95676
+12 -5
View File
@@ -1,14 +1,21 @@
version: '3.8'
# 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
services:
astrbot:
image: soulter/astrbot:latest
container_name: astrbot
restart: always
ports: # mappings description: https://github.com/Soulter/AstrBot/issues/497
- "6185:6185"
- "6195:6195" # optional, wecom default port
- "6199:6199" # optional, aiocqhttp default port
- "6196:6196" # optional, qq official webhook default port
- "11451:11451" # optional, gewechat default port
- "6185:6185" # 必选,AstrBot WebUI 端口
- "6195:6195" # 可选, 企业微信 Webhook 端口
- "6199:6199" # 可选, QQ 个人号 WebSocket 端口
- "6196:6196" # 可选, QQ 官方接口 Webhook 端口
- "11451:11451" # 可选, 微信个人号 Webhook 端口
environment:
- TZ=Asia/Shanghai
volumes:
- ./data:/AstrBot/data
# - /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
@@ -0,0 +1,44 @@
<template>
<v-dialog v-model="isOpen" max-width="400">
<v-card>
<v-card-title class="text-h6">{{ title }}</v-card-title>
<v-card-text>{{ message }}</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="gray" @click="handleCancel">取消</v-btn>
<v-btn color="red" @click="handleConfirm">确定</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref } from "vue";
const isOpen = ref(false);
const title = ref("");
const message = ref("");
let resolvePromise = null; // Promise
const open = (options) => {
title.value = options.title || "确认操作";
message.value = options.message || "你确定要执行此操作吗?";
isOpen.value = true;
return new Promise((resolve) => {
resolvePromise = resolve; // Promise
});
};
const handleConfirm = () => {
isOpen.value = false;
if (resolvePromise) resolvePromise(true); // Promise
};
const handleCancel = () => {
isOpen.value = false;
if (resolvePromise) resolvePromise(false); // Promise
};
defineExpose({ open }); // `confirmPlugin.ts` 访 `open`
</script>
+359 -125
View File
@@ -1,140 +1,374 @@
<template>
<h3 style="margin-bottom: 8px;" v-if="iterable && metadata[metadataKey]?.type === 'object'">
{{ metadata[metadataKey]?.description }}
</h3>
<v-card-text>
<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: 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">
<div style="width: 100%;" v-if="metadata[metadataKey].items[key]">
<v-select
v-if="metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" variant="outlined" :items="metadata[metadataKey].items[key]?.options"
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'" dense
:disabled="metadata[metadataKey].items[key]?.readonly"></v-select>
<v-text-field
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" :label="metadata[metadataKey].items[key]?.description + '(' + key + ')'"
variant="outlined" dense></v-text-field>
<v-text-field
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" :label="metadata[metadataKey].items[key]?.description + '(' + key + ')'"
variant="outlined" dense></v-text-field>
<v-textarea v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible" v-model="iterable[key]"
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'" variant="outlined"
dense></v-textarea>
<v-switch v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible" v-model="iterable[key]"
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'" color="primary"
inset></v-switch>
<ListConfigItem
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
:value="iterable[key]"
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'"/>
<div v-else-if="metadata[metadataKey].items[key]?.type === 'object' && !metadata[metadataKey].items[key]?.invisible"
style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px;">
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[key]"
:metadataKey=key>
</AstrBotConfig>
</div>
</div>
<div style="width: 100%;" v-else>
<!-- metadata 中没有 key -->
<v-text-field v-model="iterable[key]" :label="key" variant="outlined" dense></v-text-field>
</div>
<div
v-if="!metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint && !metadata[metadataKey].items[key]?.invisible">
<v-btn icon size="x-small" style="margin-bottom: 22px;">
<v-icon size="x-small">mdi-help</v-icon>
<v-tooltip activator="parent" location="start">{{ metadata[metadataKey].items[key]?.hint
}}</v-tooltip>
</v-btn>
</div>
<div>
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible" color="primary">{{ metadata[metadataKey].items[key]?.type }}</v-chip>
</div>
</div>
<div class="config-section" v-if="iterable && metadata[metadataKey]?.type === 'object'">
<v-list-item-title class="config-title">
{{ metadata[metadataKey]?.description }} <span class="metadata-key">({{ metadataKey }})</span>
</v-list-item-title>
<v-list-item-subtitle class="config-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint"></span>
{{ metadata[metadataKey]?.hint }}
</v-list-item-subtitle>
</div>
<v-card-text class="px-0 py-1">
<!-- Object Type Configuration -->
<div v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template" class="object-config">
<div v-for="(val, key, index) in iterable" :key="key" class="config-item">
<!-- Nested Object -->
<div v-if="metadata[metadataKey].items[key]?.type === 'object'" class="nested-object">
<div v-if="metadata[metadataKey].items[key] && !metadata[metadataKey].items[key]?.invisible" class="nested-container">
<v-expand-transition>
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[key]" :metadataKey="key">
</AstrBotConfig>
</v-expand-transition>
</div>
</div>
<div v-else>
<v-alert v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint"
style="margin-bottom: 8px" :text="metadata[metadataKey]?.hint"
:title="'💡 ' + metadata[metadataKey]?.description" type="info" variant="tonal" color="primary">
</v-alert>
<!-- Regular Property -->
<template v-else>
<v-row v-if="!metadata[metadataKey].items[key]?.invisible" class="config-row">
<v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
<span v-if="metadata[metadataKey].items[key]?.description">
{{ metadata[metadataKey].items[key]?.description }}
<span class="property-key">({{ key }})</span>
</span>
<span v-else>{{ key }}</span>
</v-list-item-title>
<div style="display: flex; align-items: center; justify-content: center; gap: 16px">
<div style="width: 100%;">
<v-select v-if="metadata[metadataKey]?.options && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" variant="outlined" :items="metadata[metadataKey]?.options"
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" dense
:disabled="metadata[metadataKey]?.readonly"></v-select>
<v-text-field
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" variant="outlined"
dense></v-text-field>
<v-text-field
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" variant="outlined"
dense></v-text-field>
<v-textarea v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" variant="outlined"
dense></v-textarea>
<v-switch v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" color="primary"
inset></v-switch>
<ListConfigItem
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
:value="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey+ ')'"/>
<div v-else-if="metadata[metadataKey]?.type === 'object' && !metadata[metadataKey]?.invisible"
style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px;">
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[metadataKey]"
:metadataKey=key>
</AstrBotConfig>
</div>
</div>
<v-list-item-subtitle class="property-hint">
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
class="important-hint"></span>
{{ metadata[metadataKey].items[key]?.hint }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
<div
v-if="!metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint && !metadata[metadataKey]?.invisible">
<v-btn icon size="x-small" style="margin-bottom: 22px;">
<v-icon size="x-small">mdi-help</v-icon>
<v-tooltip activator="parent" location="start">{{ metadata[metadataKey]?.hint
}}</v-tooltip>
</v-btn>
</div>
<v-col cols="12" sm="1" class="d-flex align-center type-indicator">
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible"
color="primary"
label
size="x-small"
variant="flat">
{{ metadata[metadataKey].items[key]?.type || 'string' }}
</v-chip>
</v-col>
<div>
<v-chip v-if="!metadata[metadataKey]?.invisible" color="primary">{{ metadata[metadataKey]?.type }}</v-chip>
</div>
</div>
</div>
</v-card-text>
<v-col cols="12" sm="5" class="config-input">
<div v-if="metadata[metadataKey].items[key]" class="w-100">
<!-- Select input -->
<v-select
v-if="metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]"
:items="metadata[metadataKey].items[key]?.options"
:disabled="metadata[metadataKey].items[key]?.readonly"
density="compact"
variant="outlined"
class="config-field"
hide-details
></v-select>
<!-- String input -->
<v-text-field
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]"
density="compact"
variant="outlined"
class="config-field"
hide-details
></v-text-field>
<!-- Numeric input -->
<v-text-field
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey]?.invisible"
v-model="iterable[key]"
density="compact"
variant="outlined"
class="config-field"
type="number"
hide-details
></v-text-field>
<!-- Text area -->
<v-textarea
v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]"
variant="outlined"
auto-grow
rows="3"
class="config-field"
hide-details
></v-textarea>
<!-- Boolean switch -->
<v-switch
v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]"
color="primary"
inset
density="compact"
hide-details
></v-switch>
<!-- List item -->
<ListConfigItem
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
:value="iterable[key]"
class="config-field"
/>
</div>
<!-- Fallback for unknown metadata -->
<div v-else class="w-100">
<v-text-field
v-model="iterable[key]"
:label="key"
density="compact"
variant="outlined"
class="config-field"
hide-details
></v-text-field>
</div>
</v-col>
</v-row>
<v-divider
v-if="index !== Object.keys(iterable).length - 1 && !metadata[metadataKey].items[key]?.invisible"
class="config-divider"
></v-divider>
</template>
</div>
</div>
<!-- Simple Value Configuration -->
<div v-else class="simple-config">
<v-row class="config-row">
<v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
{{ metadata[metadataKey]?.description }}
<span class="property-key">({{ metadataKey }})</span>
</v-list-item-title>
<v-list-item-subtitle class="property-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint"></span>
{{ metadata[metadataKey]?.hint }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="12" sm="1" class="d-flex align-center type-indicator">
<v-chip v-if="!metadata[metadataKey]?.invisible"
color="primary"
label
size="x-small"
variant="flat">
{{ metadata[metadataKey]?.type }}
</v-chip>
</v-col>
<v-col cols="12" sm="5" class="config-input">
<div class="w-100">
<!-- Select input -->
<v-select
v-if="metadata[metadataKey]?.options && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
:items="metadata[metadataKey]?.options"
:disabled="metadata[metadataKey]?.readonly"
density="compact"
variant="outlined"
class="config-field"
hide-details
></v-select>
<!-- String input -->
<v-text-field
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
density="compact"
variant="outlined"
class="config-field"
hide-details
></v-text-field>
<!-- Numeric input -->
<v-text-field
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
density="compact"
variant="outlined"
class="config-field"
type="number"
hide-details
></v-text-field>
<!-- Text area -->
<v-textarea
v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
variant="outlined"
auto-grow
rows="3"
class="config-field"
hide-details
></v-textarea>
<!-- Boolean switch -->
<v-switch
v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
color="primary"
inset
density="compact"
hide-details
></v-switch>
<!-- List item -->
<ListConfigItem
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
:value="iterable[metadataKey]"
class="config-field"
/>
</div>
</v-col>
</v-row>
<v-divider class="my-2 config-divider"></v-divider>
</div>
</v-card-text>
</template>
<script>
import { readonly } from 'vue';
import ListConfigItem from './ListConfigItem.vue';
export default {
components: {
ListConfigItem
name: 'AstrBotConfig',
components: {
ListConfigItem
},
props: {
metadata: {
type: Object,
required: true
},
props: {
metadata: Object,
iterable: Object,
metadataKey: String
iterable: {
type: Object,
required: true
},
metadataKey: {
type: String,
required: true
}
}
}
</script>
</script>
<style scoped>
.config-section {
margin-bottom: 12px;
}
.config-title {
font-weight: 600;
font-size: 1rem;
color: var(--v-primary-darken1);
}
.config-hint {
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.6);
margin-top: 2px;
}
.metadata-key, .property-key {
font-size: 0.85em;
opacity: 0.7;
font-weight: normal;
}
.important-hint {
opacity: 1;
margin-right: 4px;
}
.object-config, .simple-config {
width: 100%;
}
.config-item {
margin-bottom: 2px;
}
.nested-object {
padding-left: 16px;
}
.nested-container {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 12px;
margin: 12px 0;
background-color: rgba(0, 0, 0, 0.02);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.config-row {
margin: 0;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.config-row:hover {
background-color: rgba(0, 0, 0, 0.03);
}
.property-info {
padding: 0;
}
.property-name {
font-size: 0.875rem;
font-weight: 600;
color: rgba(0, 0, 0, 0.87);
}
.property-hint {
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.6);
margin-top: 2px;
}
.type-indicator {
display: flex;
justify-content: center;
}
.config-input {
padding: 4px 8px;
}
.config-field {
margin-bottom: 0;
}
.config-divider {
border-color: rgba(0, 0, 0, 0.1);
margin: 4px 0;
}
@media (max-width: 600px) {
.nested-object {
padding-left: 8px;
}
.config-row {
padding: 8px 0;
}
.property-info, .type-indicator, .config-input {
padding: 4px;
}
}
</style>
+195 -18
View File
@@ -1,32 +1,209 @@
<script setup lang="ts">
import { ref, computed, inject } from 'vue';
const props = defineProps({
title: String,
link: String,
logo: String,
has_update: Boolean,
activated: Boolean,
extension: {
type: Object,
required: true,
},
marketMode: {
type: Boolean,
default: false,
},
highlight: {
type: Boolean,
default: false,
},
});
//
const emit = defineEmits([
'configure',
'update',
'reload',
'install',
'uninstall',
'toggle-activation',
'view-handlers'
]);
const open = (link: string | undefined) => {
window.open(link, '_blank');
};
const reveal = ref(false);
//
const configure = () => {
emit('configure', props.extension);
};
const updateExtension = () => {
emit('update', props.extension);
};
const reloadExtension = () => {
emit('reload', props.extension);
};
const $confirm = inject("$confirm");
const uninstallExtension = async () => {
if (typeof $confirm !== "function") {
console.error("$confirm 未正确注册");
return;
}
const confirmed = await $confirm({
title: "删除确认",
message: "你确定要删除当前插件吗?",
});
if (confirmed) {
emit("uninstall", props.extension);
}
};
const toggleActivation = () => {
emit('toggle-activation', props.extension);
};
const viewHandlers = () => {
emit('view-handlers', props.extension);
};
</script>
<template>
<v-card variant="outlined" elevation="0" class="withbg">
<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: 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>
<v-card class="mx-auto d-flex flex-column" :elevation="highlight ? 0 : 1"
:style="{ height: $vuetify.display.xs ? '250px' : '220px', backgroundColor: highlight ? '#FAF0DB' : '#ffffff', color: highlight ? '#000' : '#000000' }">
<v-card-text style="padding: 16px; padding-bottom: 0px; display: flex; justify-content: space-between;">
<div class="flex-grow-1">
<div>{{ extension.author }} /</div>
<p class="text-h3 font-weight-black" :class="{ 'text-h4': $vuetify.display.xs }">
{{ extension.name }}
<v-tooltip location="top" v-if="extension?.has_update && !marketMode">
<template v-slot:activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps" color="warning" class="ml-2" icon="mdi-update" size="small"></v-icon>
</template>
<span>有新版本可用: {{ extension.online_version }}</span>
</v-tooltip>
<v-tooltip location="top" v-if="!extension.activated && !marketMode">
<template v-slot:activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps" color="error" class="ml-2" icon="mdi-cancel" size="small"></v-icon>
</template>
<span>该插件已经被禁用</span>
</v-tooltip>
</p>
<div class="mt-1 d-flex flex-wrap">
<v-chip color="primary" label size="small">
<v-icon icon="mdi-source-branch" start></v-icon>
{{ extension.version }}
</v-chip>
<v-chip v-if="extension?.has_update " color="warning" label size="small" class="ml-2">
<v-icon icon="mdi-arrow-up-bold" start></v-icon>
{{ extension.online_version }}
</v-chip>
<v-chip color="primary" label size="small" class="ml-2" v-if="extension.handlers?.length">
<v-icon icon="mdi-cogs" start></v-icon>
{{ extension.handlers?.length }}个行为
</v-chip>
</div>
<div class="mt-2" :class="{ 'text-caption': $vuetify.display.xs }" style="max-height: 65px; overflow-y: auto;">
{{ extension.desc }}
</div>
</div>
<div class="extension-image-container" v-if="extension.logo">
<img :src="extension.logo" :style="{
height: $vuetify.display.xs ? '75px' : '100px',
width: $vuetify.display.xs ? '75px' : '100px',
borderRadius: '8px',
objectFit: 'cover',
objectPosition: 'center'
}" alt="logo" />
</div>
</v-card-item>
<v-divider></v-divider>
<v-card-text style="padding: 16px;">
<slot />
</v-card-text>
<v-card-actions style="padding: 0px; margin-top: auto;">
<v-btn color="teal-accent-4" text="帮助" variant="text" @click="open(extension.repo)"></v-btn>
<v-btn v-if="!marketMode" color="teal-accent-4" text="操作" variant="text" @click="reveal = true"></v-btn>
<v-btn v-if="marketMode && !extension?.installed" color="teal-accent-4" text="安装" variant="text"
@click="emit('install', extension)"></v-btn>
<v-btn v-if="marketMode && extension?.installed" color="teal-accent-4" text="已安装" variant="text" disabled></v-btn>
</v-card-actions>
<v-expand-transition v-if="!marketMode">
<v-card v-if="reveal" class="position-absolute w-100" height="100%"
style="bottom: 0; display: flex; flex-direction: column;">
<v-card-text style="overflow-y: auto;">
<div class="d-flex align-center mb-4">
<img v-if="extension.logo" :src="extension.logo"
style="height: 50px; width: 50px; border-radius: 8px; margin-right: 16px;" alt="扩展图标" />
<h3>{{ extension.name }}</h3>
</div>
<div class="mt-4" :style="{
justifyContent: 'center',
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: '8px',
flexDirection: $vuetify.display.xs ? 'column' : 'row'
}">
<v-btn prepend-icon="mdi-cog" color="primary" variant="tonal" @click="configure"
:block="$vuetify.display.xs">
插件配置
</v-btn>
<v-btn prepend-icon="mdi-delete" color="error" variant="tonal" @click="uninstallExtension"
:block="$vuetify.display.xs">
卸载插件
</v-btn>
<v-btn prepend-icon="mdi-reload" color="primary" variant="tonal" @click="reloadExtension"
:block="$vuetify.display.xs">
重载插件
</v-btn>
<v-btn :prepend-icon="extension.activated ? 'mdi-cancel' : 'mdi-check-circle'"
:color="extension.activated ? 'error' : 'success'" variant="tonal" @click="toggleActivation"
:block="$vuetify.display.xs">
{{ extension.activated ? '禁用' : '启用' }}插件
</v-btn>
<v-btn prepend-icon="mdi-cogs" color="info" variant="tonal" @click="viewHandlers"
:block="$vuetify.display.xs">
查看行为 ({{ extension.handlers.length }})
</v-btn>
<v-btn prepend-icon="mdi-update" color="primary" variant="tonal" :disabled="!extension?.has_update "
@click="updateExtension" :block="$vuetify.display.xs">
更新到 {{ extension.online_version || extension.version }}
</v-btn>
</div>
</v-card-text>
<v-card-actions class="pt-0 d-flex justify-center">
<v-btn color="teal-accent-4" text="返回" variant="text" @click="reveal = false"></v-btn>
</v-card-actions>
</v-card>
</v-expand-transition>
</v-card>
</template>
<style scoped>
.extension-image-container {
display: flex;
align-items: center;
margin-left: 12px;
}
@media (max-width: 600px) {
.extension-image-container {
margin-left: 8px;
}
}
</style>
@@ -0,0 +1,134 @@
<template>
<div>
<v-row v-if="items.length === 0">
<v-col cols="12" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">{{ emptyIcon }}</v-icon>
<p class="text-grey mt-4">{{ emptyText }}</p>
</v-col>
</v-row>
<v-row v-else>
<v-col v-for="(item, index) in items" :key="index" cols="12" md="6" lg="4" xl="3">
<v-card class="item-card hover-elevation" :color="getItemEnabled(item) ? '' : 'grey-lighten-4'">
<div class="item-status-indicator" :class="{'active': getItemEnabled(item)}"></div>
<v-card-title class="d-flex justify-space-between align-center pb-1 pt-3">
<span class="text-h4 text-truncate" :title="getItemTitle(item)">{{ getItemTitle(item) }}</span>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-switch
color="primary"
hide-details
density="compact"
:model-value="getItemEnabled(item)"
v-bind="props"
@update:model-value="toggleEnabled(item)"
></v-switch>
</template>
<span>{{ getItemEnabled(item) ? '已启用' : '已禁用' }}</span>
</v-tooltip>
</v-card-title>
<v-card-text>
<slot name="item-details" :item="item"></slot>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-2">
<v-spacer></v-spacer>
<v-btn
variant="text"
size="small"
color="error"
prepend-icon="mdi-delete"
@click="$emit('delete', item)"
>
删除
</v-btn>
<v-btn
variant="text"
size="small"
color="primary"
prepend-icon="mdi-pencil"
@click="$emit('edit', item)"
>
编辑
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</div>
</template>
<script>
export default {
name: 'ItemCardGrid',
props: {
items: {
type: Array,
required: true
},
titleField: {
type: String,
default: 'id'
},
enabledField: {
type: String,
default: 'enable'
},
emptyIcon: {
type: String,
default: 'mdi-alert-circle-outline'
},
emptyText: {
type: String,
default: '暂无数据'
}
},
emits: ['toggle-enabled', 'delete', 'edit'],
methods: {
getItemTitle(item) {
return item[this.titleField];
},
getItemEnabled(item) {
return item[this.enabledField];
},
toggleEnabled(item) {
this.$emit('toggle-enabled', item);
}
}
}
</script>
<style scoped>
.item-card {
position: relative;
border-radius: 8px;
transition: all 0.3s ease;
overflow: hidden;
min-height: 220px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.item-status-indicator {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4px;
background-color: #e0e0e0;
}
.item-status-indicator.active {
background-color: #4CAF50;
}
.hover-elevation:hover {
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
</style>
@@ -1,93 +1,85 @@
<template>
<div class="list-config-item">
<h3>{{ label }}</h3>
<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>
<v-chip>{{ item }}</v-chip>
</v-list-item-title>
<v-btn @click="removeItem(index)" variant="plain">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-list-item-content>
</v-list-item>
</v-list>
<v-text-field
v-model="newItem"
label="添加新项,按回车确认添加"
@keyup.enter="addItem"
clearable
dense
hide-details
variant="outlined"
></v-text-field>
<div class="list-config-item">
<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>
<v-chip size="small" label color="primary">{{ item }}</v-chip>
</v-list-item-title>
<v-btn @click="removeItem(index)" variant="plain">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-list-item-content>
</v-list-item>
</v-list>
<div style="display: flex; align-items: center;">
<v-text-field v-model="newItem" label="添加新项,按回车确认添加" @keyup.enter="addItem" clearable dense hide-details
variant="outlined" density="compact"></v-text-field>
<v-btn @click="addItem" text variant="tonal">
<v-icon>mdi-plus</v-icon>
添加
</v-btn>
</div>
</template>
<script>
export default {
name: 'ListConfigItem',
props: {
value: {
type: Array,
default: () => [],
},
label: {
type: String,
default: '',
},
</div>
</template>
<script>
export default {
name: 'ListConfigItem',
props: {
value: {
type: Array,
default: () => [],
},
data() {
return {
newItem: '',
items: this.value,
};
label: {
type: String,
default: '',
},
watch: {
items(newVal) {
this.$emit('input', newVal);
},
},
data() {
return {
newItem: '',
items: this.value,
};
},
watch: {
items(newVal) {
this.$emit('input', newVal);
},
methods: {
addItem() {
if (this.newItem.trim() !== '') {
this.items.push(this.newItem.trim());
this.newItem = '';
}
},
removeItem(index) {
this.items.splice(index, 1);
},
},
methods: {
addItem() {
if (this.newItem.trim() !== '') {
this.items.push(this.newItem.trim());
this.newItem = '';
}
},
};
</script>
<style scoped>
.list-config-item {
border: 1px solid #e0e0e0;
padding: 16px;
margin-bottom: 16px;
border-radius: 10px;
background-color: #ffffff;
}
.list-config-item h3 {
margin-top: 0;
margin-bottom: 16px;
font-size: 18px;
font-weight: 500;
}
.v-list-item {
padding: 0;
}
.v-list-item-title {
font-size: 14px;
}
.v-btn {
margin-left: 8px;
}
</style>
removeItem(index) {
this.items.splice(index, 1);
},
},
};
</script>
<style scoped>
.list-config-item {
border: 1px solid #e0e0e0;
padding: 16px;
margin-bottom: 8px;
border-radius: 10px;
background-color: #ffffff;
}
.v-list-item {
padding: 0;
}
.v-list-item-title {
font-size: 14px;
}
.v-btn {
margin-left: 8px;
}
</style>
@@ -5,15 +5,18 @@ import axios from 'axios';
import { md5 } from 'js-md5';
import { useAuthStore } from '@/stores/auth';
import { useCommonStore } from '@/stores/common';
import { marked } from 'marked';
const customizer = useCustomizerStore();
let dialog = ref(false);
let accountWarning = ref(false)
let updateStatusDialog = ref(false);
let password = ref('');
let newPassword = ref('');
let newUsername = ref('');
let status = ref('');
let updateStatus = ref('')
let releaseMessage = ref('');
let hasNewVersion = ref(false);
let botCurrVersion = ref('');
let dashboardHasNewVersion = ref(false);
@@ -80,7 +83,13 @@ function checkUpdate() {
axios.get('/api/update/check')
.then((res) => {
hasNewVersion.value = res.data.data.has_new_version;
updateStatus.value = res.data.message;
if (res.data.data.has_new_version) {
releaseMessage.value = res.data.message;
updateStatus.value = '有新版本!';
} else {
updateStatus.value = res.data.message;
}
botCurrVersion.value = res.data.data.version;
dashboardCurrentVersion.value = res.data.data.dashboard_version;
dashboardHasNewVersion.value = res.data.data.dashboard_has_new_version;
@@ -177,6 +186,14 @@ checkUpdate();
const commonStore = useCommonStore();
commonStore.createWebSocket();
commonStore.getStartTime();
if (localStorage.getItem('change_pwd_hint') != null && localStorage.getItem('change_pwd_hint') == 'true') {
dialog.value = true;
accountWarning.value = true;
localStorage.removeItem('change_pwd_hint');
}
</script>
<template>
@@ -217,15 +234,23 @@ commonStore.getStartTime();
<v-card-text>
<v-container>
<v-progress-linear v-show="installLoading" class="mb-4" indeterminate color="primary"></v-progress-linear>
<div>
<h1 style="display:inline-block;">{{ botCurrVersion }}</h1>
<small style="margin-left: 4px;">{{ updateStatus }}</small>
</div>
<div
style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;"
v-html="marked(releaseMessage)" class="markdown-content">
</div>
<div class="mb-4 mt-4">
<small>💡 TIP: 跳到旧版本或者切换到某个版本不会重新下载管理面板文件这可能会造成部分数据显示错误您可在 <a href="https://github.com/Soulter/AstrBot/releases">此处</a>
找到对应的面板文件 dist.zip解压后替换 data/dist 文件夹即可当然前端源代码在 dashboard 目录下你也可以自己使用 npm install npm build 构建</small>
<small>💡 TIP: 跳到旧版本或者切换到某个版本不会重新下载管理面板文件这可能会造成部分数据显示错误您可在 <a
href="https://github.com/Soulter/AstrBot/releases">此处</a>
找到对应的面板文件 dist.zip解压后替换 data/dist 文件夹即可当然前端源代码在 dashboard 目录下你也可以自己使用 npm install npm build
构建</small>
</div>
<v-tabs v-model="tab">
@@ -260,7 +285,7 @@ commonStore.getStartTime();
</template>
</v-data-table>
</v-tabs-window-item>
<!-- 开发版 -->
<v-tabs-window-item key="1" v-show="tab == 1">
<div style="margin-top: 16px;">
@@ -310,7 +335,8 @@ commonStore.getStartTime();
</p>
</div>
<v-btn color="primary" style="border-radius: 10px;" @click="updateDashboard()" :disabled="!dashboardHasNewVersion">
<v-btn color="primary" style="border-radius: 10px;" @click="updateDashboard()"
:disabled="!dashboardHasNewVersion">
下载并更新
</v-btn>
</div>
@@ -339,6 +365,11 @@ commonStore.getStartTime();
<v-container>
<v-row>
<v-col cols="12">
<v-alert v-if="accountWarning" color="warning" style="margin-bottom: 16px;">
<div>为了安全请尽快修改默认密码</div>
</v-alert>
<v-text-field label="原密码*" type="password" v-model="password" required
variant="outlined"></v-text-field>
@@ -365,4 +396,24 @@ commonStore.getStartTime();
</v-card>
</v-dialog>
</v-app-bar>
</template>
</template>
<style>
.markdown-content h1 {
font-size: 1.3em;
}
.markdown-content ol {
padding-left: 24px;
/* Adds indentation to ordered lists */
margin-top: 8px;
margin-bottom: 8px;
}
.markdown-content ul {
padding-left: 24px;
/* Adds indentation to unordered lists */
margin-top: 8px;
margin-bottom: 8px;
}
</style>
@@ -16,7 +16,7 @@ const props = defineProps({ item: Object, level: Number });
<template v-slot:prepend>
<v-icon v-if="item.icon" :size="item.iconSize" class="hide-menu" :icon="item.icon"></v-icon>
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title>
<v-list-item-title style="font-size: 14px;">{{ item.title }}</v-list-item-title>
<v-list-item-subtitle v-if="item.subCaption" class="text-caption mt-n1 hide-menu">
{{ item.subCaption }}
</v-list-item-subtitle>
@@ -1,5 +1,6 @@
<script setup>
import { ref, shallowRef } from 'vue';
import { ref, shallowRef, onMounted } from 'vue';
import axios from 'axios';
import { useCustomizerStore } from '../../../stores/customizer';
import sidebarItems from './sidebarItem';
import NavItem from './NavItem.vue';
@@ -8,52 +9,179 @@ const customizer = useCustomizerStore();
const sidebarMenu = shallowRef(sidebarItems);
const showIframe = ref(false);
const version = ref("");
const buildVer = ref("");
const hasWebUIUpdate = ref(false);
const dragButtonStyle = {
// iframe
const iframeStyle = ref({
position: 'fixed',
bottom: '16px',
right: '16px',
width: '490px',
height: '640px',
minWidth: '300px',
minHeight: '200px',
background: 'white',
resize: 'both',
overflow: 'auto',
zIndex: '10000000',
borderRadius: '12px',
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.1)',
});
//
if (window.innerWidth < 768) {
iframeStyle.value = {
position: 'fixed',
top: '10%',
left: '0%',
width: '100%',
height: '50%',
minWidth: '300px',
minHeight: '200px',
background: 'white',
resize: 'both',
overflow: 'auto',
zIndex: '1002',
borderRadius: '12px',
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.1)',
};
//
customizer.Sidebar_drawer = false;
}
const dragHeaderStyle = {
width: '100%',
padding: '4px',
cursor: 'move',
padding: '8px',
background: '#f0f0f0',
borderBottom: '1px solid #ccc',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px'
borderTopRightRadius: '8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'move'
};
function toggleIframe() {
showIframe.value = !showIframe.value;
}
function openIframeLink() {
if (typeof window !== 'undefined') {
window.open("https://astrbot.app", "_blank");
}
}
//
let offsetX = 0;
let offsetY = 0;
let isDragging = false;
// @ts-ignore
function onMouseDown(event) {
isDragging = true;
offsetX = event.clientX - event.target.parentElement.getBoundingClientRect().left;
offsetY = event.clientY - event.target.parentElement.getBoundingClientRect().top;
//
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
// @ts-ignore
function startDrag(clientX, clientY) {
isDragging = true;
const dm = document.getElementById('draggable-iframe');
const rect = dm.getBoundingClientRect();
offsetX = clientX - rect.left;
offsetY = clientY - rect.top;
document.body.style.userSelect = 'none';
//
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
}
function onMouseDown(event) {
startDrag(event.clientX, event.clientY);
}
function onMouseMove(event) {
if (isDragging) {
const dm = document.getElementById('draggable-iframe');
// @ts-ignore
dm.style.left = (event.clientX - offsetX) + 'px';
// @ts-ignore
dm.style.top = (event.clientY - offsetY) + 'px';
moveAt(event.clientX, event.clientY);
}
}
function onMouseUp() {
isDragging = false;
endDrag();
}
function onTouchStart(event) {
if (event.touches.length === 1) {
const touch = event.touches[0];
startDrag(touch.clientX, touch.clientY);
}
}
function onTouchMove(event) {
if (isDragging && event.touches.length === 1) {
event.preventDefault();
const touch = event.touches[0];
moveAt(touch.clientX, touch.clientY);
}
}
function onTouchEnd() {
endDrag();
}
function moveAt(clientX, clientY) {
const dm = document.getElementById('draggable-iframe');
const newLeft = clamp(clientX - offsetX, 0, window.innerWidth - dm.offsetWidth);
const newTop = clamp(clientY - offsetY, 0, window.innerHeight - dm.offsetHeight);
//
iframeStyle.value.left = newLeft + 'px';
iframeStyle.value.top = newTop + 'px';
}
function endDrag() {
isDragging = false;
document.body.style.userSelect = '';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
}
//
onMounted(() => {
axios.get('/api/stat/version')
.then((res) => {
version.value = "v" + res.data.data.version;
})
.catch((err) => {
console.log(err);
});
axios.get('/api/update/check?type=dashboard')
.then((res) => {
hasWebUIUpdate.value = res.data.data.has_new_version;
buildVer.value = res.data.data.current_version;
})
.catch((err) => {
console.log(err);
});
});
</script>
<template>
<v-navigation-drawer left v-model="customizer.Sidebar_drawer" elevation="0" rail-width="80" app class="leftSidebar"
:rail="customizer.mini_sidebar">
<v-list class="pa-4 listitem" style="height: auto">
<v-navigation-drawer
left
v-model="customizer.Sidebar_drawer"
elevation="0"
rail-width="80"
app
class="leftSidebar"
width="220"
:rail="customizer.mini_sidebar"
>
<v-list class="pa-4 listitem" style="height: auto;">
<template v-for="(item, i) in sidebarMenu" :key="i">
<NavItem :item="item" class="leftPadding" />
</template>
@@ -61,75 +189,60 @@ function onMouseUp() {
<div class="text-center">
<v-chip color="inputBorder" size="small"> {{ version }} </v-chip>
</div>
<div style="position: absolute; bottom: 32px; width: 100%" class="text-center">
<div style="position: absolute; bottom: 32px; width: 100%; font-size: 13px;" class="text-center">
<v-list-item v-if="!customizer.mini_sidebar" @click="toggleIframe">
<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-if="buildVer">WebUI 版本: {{ buildVer }}</small>
<small style="display: block;" v-else>构建: embedded</small>
<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>
</v-tooltip>
<small style="display: block; margin-top: 8px;">© 2025 AstrBot</small>
<small style="display: block; margin-top: 8px;">AGPL-3.0</small>
</div>
</v-navigation-drawer>
<div v-if="showIframe"
<!-- 优化后的悬浮 iframe -->
<div
v-if="showIframe"
id="draggable-iframe"
style="position: fixed; bottom: 16px; right: 16px; width: 500px; height: 400px; border: 1px solid #ccc; background: white; resize: both; overflow: auto; z-index: 10000000; border-radius: 8px;"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp">
<div :style="dragButtonStyle" @mousedown="onMouseDown">
<v-icon icon="mdi-cursor-move" />
:style="iframeStyle"
>
<!-- 拖拽头部支持鼠标和触摸 -->
<div :style="dragHeaderStyle" @mousedown="onMouseDown" @touchstart="onTouchStart">
<div style="display: flex; align-items: center;">
<v-icon icon="mdi-cursor-move" />
<span style="margin-left: 8px;">拖拽</span>
</div>
<div style="display: flex; gap: 8px;">
<!-- 跳转按钮 -->
<v-btn
icon
@click.stop="openIframeLink"
@mousedown.stop
style="border-radius: 8px; border: 1px solid #ccc;"
>
<v-icon icon="mdi-open-in-new" />
</v-btn>
<!-- 关闭按钮 -->
<v-btn
icon
@click.stop="toggleIframe"
@mousedown.stop
style="border-radius: 8px; border: 1px solid #ccc;"
>
<v-icon icon="mdi-close" />
</v-btn>
</div>
</div>
<iframe src="https://astrbot.app" style="width: 100%; height: calc(100% - 24px); border: none; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px;"></iframe>
<!-- iframe 区域 -->
<iframe
src="https://astrbot.app"
style="width: 100%; height: calc(100% - 56px); border: none; border-bottom-left-radius: 12px; border-bottom-right-radius: 12px;"
></iframe>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'VerticalSidebar',
components: {
NavItem,
},
data: () => ({
version: "",
buildVer: "",
hasWebUIUpdate: false,
}),
mounted() {
this.get_version()
this.check_webui_update()
},
methods: {
get_version() {
axios.get('/api/stat/version')
.then((res) => {
this.version = "v" + res.data.data.version;
})
.catch((err) => {
console.log(err);
});
},
check_webui_update() {
axios.get('/api/update/check?type=dashboard')
.then((res) => {
this.hasWebUIUpdate = res.data.data.has_new_version;
this.buildVer = res.data.data.current_version;
})
.catch((err) => {
console.log(err);
});
}
},
};
</script>
@@ -31,7 +31,12 @@ const sidebarItem: menu[] = [
to: '/providers',
},
{
title: '配置',
title: 'MCP',
icon: 'mdi-function-variant',
to: '/tool-use'
},
{
title: '配置文件',
icon: 'mdi-cog',
to: '/config',
},
@@ -50,6 +55,11 @@ const sidebarItem: menu[] = [
icon: 'mdi-chat',
to: '/chat'
},
{
title: '对话数据库',
icon: 'mdi-database',
to: '/conversation'
},
{
title: '控制台',
icon: 'mdi-console',
+5 -1
View File
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router';
import vuetify from './plugins/vuetify';
import confirmPlugin from './plugins/confirmPlugin';
import '@/scss/style.scss';
import VueApexCharts from 'vue3-apexcharts';
@@ -15,7 +16,10 @@ app.use(router);
app.use(createPinia());
app.use(print);
app.use(VueApexCharts);
app.use(vuetify).mount('#app');
app.use(vuetify);
app.use(confirmPlugin);
app.mount('#app');
axios.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
+23
View File
@@ -0,0 +1,23 @@
import type { App } from "vue";
import { h, render } from "vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
export default {
install(app: App) {
const mountNode = document.createElement("div");
document.body.appendChild(mountNode);
const vNode = h(ConfirmDialog);
vNode.appContext = app._context;
render(vNode, mountNode);
const confirm = (options: { title?: string; message?: string }) => {
return new Promise<boolean>((resolve) => {
vNode.component?.exposed?.open(options).then(resolve); // ✅ 确保返回 `Promise<boolean>`
});
};
app.config.globalProperties.$confirm = confirm;
app.provide("$confirm", confirm);
},
};
+10
View File
@@ -31,6 +31,11 @@ const MainRoutes = {
path: '/providers',
component: () => import('@/views/ProviderPage.vue')
},
{
name: 'ToolUsePage',
path: '/tool-use',
component: () => import('@/views/ToolUsePage.vue')
},
{
name: 'Configs',
path: '/config',
@@ -41,6 +46,11 @@ const MainRoutes = {
path: '/dashboard/default',
component: () => import('@/views/dashboards/default/DefaultDashboard.vue')
},
{
name: 'Conversation',
path: '/conversation',
component: () => import('@/views/ConversationPage.vue')
},
{
name: 'Console',
path: '/console',
+3
View File
@@ -16,6 +16,9 @@
color: rgb(var(--v-theme-secondary));
}
}
.v-list-item--density-default.v-list-item--one-line {
min-height: 42px;
}
.leftPadding {
margin-left: 4px;
}

Some files were not shown because too many files have changed in this diff Show More