Compare commits

...

148 Commits
0.2.3 ... main

Author SHA1 Message Date
UnCLAS-Prommer 4f928b42c1
版本号更新 2026-01-19 10:08:37 +08:00
UnCLAS-Prommer e58c13c3b1
Merge pull request #78 from tcmofashi/dev
feat: 支持maim_message API mode(未验证)
2026-01-18 21:42:44 +08:00
UnCLAS-Prommer 09173eb135
Merge pull request #77 from HyperSharkawa/dev
fix: 修复不正确的贴表情action
2026-01-16 23:37:21 +08:00
tcmofashi b165eff6b9 req: 修复依赖 2026-01-16 15:31:04 +00:00
tcmofashi 866637b658 feat: 增加maim_message自定义logger 2026-01-16 09:31:09 +00:00
tcmofashi bbff5702a5 Merge branch 'dev' of github.com:Mai-with-u/MaiBot-Napcat-Adapter into dev 2026-01-16 07:44:37 +00:00
tcmofashi ce8f05361d Merge remote-tracking branch 'remote/main'
- 添加配置文件监控和配置变更时自动重启WebSocket服务器功能
- 修复main.py中router引用问题

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 07:41:36 +00:00
tcmofashi 58d7be7f08 feat: 支持maim_message API mode 2026-01-16 07:21:15 +00:00
UnCLAS-Prommer e0d4b6ee55
删除uvlock 2026-01-16 00:27:20 +08:00
UnCLAS-Prommer 0af3559506
删除uvlock 2026-01-16 00:26:20 +08:00
UnCLAS-Prommer c6f892def0
移除uvlock 2026-01-15 19:25:51 +08:00
sharkie 29e852dcd0 fix: 修复不正确的贴表情action 2026-01-10 07:07:10 +08:00
墨梓柒 24ea1d2c41
Merge pull request #76 from Mai-with-u/dev
Dev
2026-01-10 02:35:00 +08:00
墨梓柒 7b6b0d9593
feat: 添加消息大小限制和消息发送处理
- 在 message_sending.py 中引入了 95MB 的最大消息大小限制,以防止连接中断。
- 为超出大小限制的消息添加了调试和错误日志记录。
- 增强了 meta_event_handler.py 中的心跳处理,提供了更详细的机器人状态日志。
- 重构了 notice_handler.py 中的表情符号处理,使用集中式 qq_face 映射代替硬编码值。
- 更新了 qq_emoji_list.py 以修正格式并添加新的表情符号映射。
- 改进了 main_send_handler.py 中的命令处理,以便向平台发送结构化响应。
- 扩展了 send_command_handler.py 中的命令处理,添加了用于设置组名和管理组成员的新命令。
- 增强了 send_message_handler.py 中的文件消息处理,以支持文件路径和详细的文件信息。
2026-01-03 19:59:02 +08:00
墨梓柒 616ab2b9d6
feat: 添加消息大小计算和警告日志,优化消息发送调试信息 2026-01-03 14:34:30 +08:00
墨梓柒 74b050032d
feat: 处理通知notice消息的时候检查是否在群聊白名单中 2026-01-03 14:09:11 +08:00
墨梓柒 e6b4c0cf3a
feat: 更新版本号至0.1.3 2026-01-03 14:06:07 +08:00
墨梓柒 3ec1499324
feat: 添加转发消息配置,支持图片数量阈值设置 2026-01-03 14:03:14 +08:00
墨梓柒 efd98b022f
feat: 移除冗余的配置属性访问方法,改为动态代理访问 2026-01-03 02:34:15 +08:00
墨梓柒 66a1c08405
feat: 优化配置重载逻辑,增加重载状态标记和时间检查 2026-01-03 02:31:22 +08:00
墨梓柒 d40663709c
feat: 添加uv.lock到.gitignore文件 2026-01-03 02:08:35 +08:00
墨梓柒 b0bfa1a42d
feat: 添加配置管理器支持热重载功能
- 实现 ConfigManager 类,支持加载和热重载配置文件
- 使用 watchdog 监控配置文件变化,自动重载配置
- 支持为特定配置项注册回调函数,便于处理配置变更
- 提供多种配置属性访问接口,如 nickname、chat、voice 等
- 增加防抖机制,避免频繁重载导致的性能问题
2026-01-03 02:00:58 +08:00
墨梓柒 3e27e57409
Merge pull request #74 from A0000Xz/dev
修复引用系统消息(如红包)时的回复处理异常
2025-12-27 21:36:05 +08:00
A0000Xz 76b02a0d81
修复引用系统消息(如红包)时的回复处理异常
修正reply_message在意外情况时的错误类型,使其从str变为list[Seg],保证正常的引用消息发出
2025-12-24 04:08:02 +08:00
墨梓柒 a0bc06205c
Merge pull request #72 from ShiroRikka/uv-deps-add
📦 build(deps): 添加 pyproject.toml 依赖
2025-12-20 13:44:53 +08:00
墨梓柒 af5b7f1a92
优化视频和音乐消息处理,返回结构化数据格式,增加小程序分享消息的处理 2025-12-13 15:12:01 +08:00
墨梓柒 417e30daca
revert CI 2025-12-11 15:45:57 +08:00
墨梓柒 250be48ea8
增加对群名称变更通知的处理,支持Base64解码群公告标题和内容,优化音乐消息处理逻辑以支持多种数据格式 2025-12-11 15:43:39 +08:00
墨梓柒 0beb3dfdfa
增加对音乐卡片消息的处理,提取歌曲和歌手信息并构建相应文本 2025-12-11 15:29:48 +08:00
墨梓柒 12f6d205e1
增强对JSON卡片消息的处理,支持群公告解析并构建相应文本 2025-12-11 15:26:25 +08:00
墨梓柒 96b6487ccc
增加对群消息表情回应、文件上传、群成员增减及管理员变动的处理,优化日志记录,清理过期日志,更新适配器启动信息 2025-12-11 15:22:36 +08:00
ShiroRikka 3de2444b0e feat(deps): 添加 pyproject.toml 依赖
- 向 pyproject.toml 添加了 aiohttp, asyncio, loguru, maim-message, pillow, requests, rich, sqlmodel, tomlkit, websockets 依赖
2025-12-03 23:44:27 +08:00
SengokuCola 0d7733734c 给所有转发统一加上了标题 2025-10-05 17:31:31 +08:00
UnCLAS-Prommer 424ca5b473
继续修复unpack问题 2025-09-24 13:38:20 +08:00
UnCLAS-Prommer 4d4e82d742
修复unpack错误 2025-09-22 21:11:09 +08:00
UnCLAS-Prommer 178912375d
更新版本号 2025-09-14 16:20:28 +08:00
UnCLAS-Prommer df5a874a60
echo消息处理 2025-09-14 16:19:06 +08:00
UnCLAS-Prommer ca27153fe6
修复转发的小问题,更新版本号 2025-09-14 00:04:13 +08:00
UnCLAS-Prommer b4600793b9
配置文件版本号更新 2025-09-13 13:44:49 +08:00
UnCLAS-Prommer f9d780fc14
Merge pull request #58 from CKylinMC/feat/impl-napcat-token
feat: add napcat token verify
2025-09-13 13:43:13 +08:00
UnCLAS-Prommer 8ac7d02362
Update main.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-13 13:42:44 +08:00
UnCLAS-Prommer 0584ac70c6
accept video 2025-09-13 13:35:15 +08:00
UnCLAS-Prommer 6be87024a8
Merge pull request #59 from XinxInxiN0/main
添加视频发送功能
2025-09-13 13:33:00 +08:00
Zuole 86d7b67cd3 添加视频发送功能 2025-09-13 13:15:09 +08:00
CKylinMC f165cf3e2e feat: add napcat token verify 2025-09-11 18:35:33 +08:00
UnCLAS-Prommer eaee8a45fb
manually cherry-pick commit of imageurl to dev 2025-09-10 22:47:58 +08:00
UnCLAS-Prommer 8bf1bd1517
添加更多种类的消息解析,重构send_handler部分 2025-09-10 22:44:55 +08:00
UnCLAS-Prommer eaa587869c
reply_message_id 2025-08-12 21:11:17 +08:00
UnCLAS-Prommer 4debb6d783
手动触发workflow 2025-08-07 23:12:22 +08:00
UnCLAS-Prommer f3bcdb2986
Merge pull request #53 from MaiM-with-u/dev
Dev
2025-07-31 19:16:38 +08:00
UnCLAS-Prommer 991115814b
增加大小防止文件过大,增加超时时长防炸 2025-07-31 19:14:23 +08:00
UnCLAS-Prommer 81fa3aa4df 增加对多次禁言的兼容 2025-07-22 22:10:00 +08:00
UnCLAS-Prommer 7b255269d1 先判断是否允许以增加处理速度 2025-07-18 22:32:35 +08:00
UnCLAS-Prommer d6a97e3014 错误处理 2025-07-18 15:58:09 +08:00
UnCLAS-Prommer 10fc60e04d accept format change 2025-07-09 19:55:10 +08:00
UnCLAS-Prommer 96bae518f9 Merge branch 'dev' of https://github.com/MaiM-with-u/MaiBot-Napcat-Adapter into dev 2025-07-09 19:54:49 +08:00
UnCLAS-Prommer b0b511ee9d accept format change 2025-07-09 19:53:42 +08:00
UnCLAS-Prommer ee115c7e24
Merge pull request #51 from SkillfulPainter/dev
新增发送本地文件方法
2025-07-08 22:07:04 +08:00
Donaldzhao 9e281d838c 新增发送文件方法 2025-07-08 21:26:37 +08:00
UnCLAS-Prommer 7b1f117af2 Merge branch 'main' of https://github.com/MaiM-with-u/MaiBot-Napcat-Adapter 2025-07-07 16:50:52 +08:00
UnCLAS-Prommer 59ec3d4425 version update 2025-07-07 16:50:30 +08:00
UnCLAS-Prommer b2353747a2 Merge branch 'dev' 2025-07-07 16:49:47 +08:00
UnCLAS-Prommer ab9bd1c675 fix #49 2025-07-07 16:49:07 +08:00
UnCLAS-Prommer e44fe1ac6f
Merge pull request #50 from xuqian13/feat/videourl
feat:发送视频消息
2025-07-07 15:45:02 +08:00
1334431750 93a8eec15e feat:发送视频消息 2025-07-07 05:44:29 +00:00
墨梓柒 36e8f4a99a
feat: 添加config_backup到.gitignore 2025-07-06 23:03:16 +08:00
UnCLAS-Prommer e2980305a5 写错函数了 2025-07-06 12:30:05 +08:00
UnCLAS-Prommer ad844ffc43 写错函数了 2025-07-06 12:29:28 +08:00
UnCLAS-Prommer 5dd2dda067 保证文件夹存在 2025-07-06 12:14:04 +08:00
UnCLAS-Prommer 883b2934d2
Merge pull request #46 from infinitycat233/dev
更新dev分支docker镜像自构建
2025-07-03 21:21:49 +08:00
infinitycat fc3391947d feat: 备份改为放入到文件夹,并改为覆写的模式 2025-07-03 15:08:32 +08:00
infinitycat f93572c1f7 ci(docker): 更新dev分支镜像自构建 2025-07-03 14:16:42 +08:00
infinitycat b4ab8ffa78 feat: 更新底层镜像 2025-07-03 14:14:10 +08:00
UnCLAS-Prommer 8923ebf534 求star 2025-07-02 16:25:18 +08:00
UnCLAS-Prommer 163ddefffd minor fix 2025-07-01 17:20:59 +08:00
UnCLAS-Prommer 59af134904 issue #27, thanks pr #45 2025-07-01 16:31:56 +08:00
UnCLAS-Prommer c0416a517e logger change (hope it dont crash) 2025-07-01 16:25:54 +08:00
UnCLAS-Prommer 60e9106a89 语音消息识别 2025-06-30 18:17:07 +08:00
UnCLAS-Prommer 92af300035 docs过时了 2025-06-30 17:20:20 +08:00
UnCLAS-Prommer 939f3b7d08 增加表情包种类尝试 2025-06-30 11:34:04 +08:00
UnCLAS-Prommer 7564361a63 增加表情包种类尝试 2025-06-30 11:28:07 +08:00
UnCLAS-Prommer 96fa750100 fix bug 2025-06-29 01:18:34 +08:00
UnCLAS-Prommer 0d3a77d140 版本号和ruff 2025-06-29 01:15:56 +08:00
UnCLAS-Prommer ef7b267e7c
Merge pull request #43 from A0000Xz/dev
新一代回报信息
2025-06-29 00:45:01 +08:00
A0000Xz e526b9fda7 不再进行类型判断 2025-06-29 00:42:53 +08:00
A0000Xz e1307b61ee Merge branch 'dev' of https://github.com/A0000Xz/MaiBot-Napcat-Adapter into dev 2025-06-28 20:33:33 +08:00
A0000Xz 7ed33f29ac Merge branch 'dev' of https://github.com/A0000Xz/MaiBot-Napcat-Adapter into dev 2025-06-28 20:28:47 +08:00
A0000Xz 1bdd165d12 支持ada回调信息,需要mmc那边有相应代码配合 2025-06-28 20:28:40 +08:00
UnCLAS-Prommer 775f66129b ruff and minor fix 2025-06-28 17:59:31 +08:00
UnCLAS-Prommer 49f616034f
Merge pull request #42 from A0000Xz/dev
戳一戳优化;更新文档;包含了部分你已经修改的错误
2025-06-28 17:52:51 +08:00
UnCLAS-Prommer 01e1c9e0a6
Merge branch 'dev' into dev 2025-06-28 17:52:43 +08:00
A0000Xz 9cda205c7b 加上target_id 2025-06-28 17:46:20 +08:00
A0000Xz 6d3745ff0a 回退上报消息处理器 2025-06-28 17:33:00 +08:00
A0000Xz b6cabc593b 私聊修复 2025-06-28 17:10:59 +08:00
A0000Xz 588ba6417e 修复戳一戳,同时阻止自身的戳一戳回调 2025-06-28 17:04:00 +08:00
UnCLAS-Prommer f26667db73 requirements.txt update and minor fix 2025-06-28 16:17:19 +08:00
A0000Xz cae9645fb7 移除不用的初始化参数,优化了撤回指令捕捉错误的能力 2025-06-28 14:49:40 +08:00
A0000Xz bdbdc79f14 更新文档,顺便补一个缺失依赖项 2025-06-28 14:37:22 +08:00
A0000Xz 5c6cdddbd3 添加了撤回消息指令;添加了对自身上报信息的处理器 2025-06-28 14:21:42 +08:00
A0000Xz cc1fe7ce3c Merge branch 'dev' of https://github.com/A0000Xz/MaiBot-Napcat-Adapter into dev 2025-06-28 13:57:22 +08:00
UnCLAS-Prommer ed9ecae9dc maim_message logger传入,版本显示 2025-06-28 12:23:50 +08:00
UnCLAS-Prommer 5c57ba9c85 修bug,改版本号 2025-06-28 11:44:35 +08:00
UnCLAS-Prommer 1196909521 remove database and update gitignore 2025-06-28 10:18:24 +08:00
UnCLAS-Prommer ee873d8cbb fix 类型注解 2025-06-28 01:57:46 +08:00
UnCLAS-Prommer ca0fc4db11 REFACTOR 与禁言检测 2025-06-28 01:54:03 +08:00
A0000Xz 647855a076
Merge branch 'MaiM-with-u:main' into dev 2025-06-26 19:07:41 +08:00
UnCLAS-Prommer 53ded44351 fix 2025-06-26 13:31:30 +08:00
UnCLAS-Prommer eb062e1258 配置文件版本更新 2025-06-24 15:10:11 +08:00
A0000Xz 6a1497f1bd Merge branch 'dev' of https://github.com/A0000Xz/MaiBot-Napcat-Adapter into dev 2025-06-24 14:01:24 +08:00
A0000Xz 6a1a145307 Update recv_handler.py 2025-06-24 14:01:10 +08:00
A0000Xz 7eaaf32f0c Revert "使其他人的戳一戳能够被解析,并在addtional_config附加被戳人的ID以方便开发"
This reverts commit 409f9f6b07.
2025-06-24 12:59:36 +08:00
UnCLAS-Prommer 68af5e2c76
Merge pull request #37 from Dreamwxz/main
添加缺失的依赖
2025-06-24 12:27:41 +08:00
UnCLAS-Prommer 77ff2ff257 屏蔽官方机器人功能 2025-06-24 11:40:15 +08:00
Dreamwxz 4ae69e0509 添加缺失的依赖
水个pr(
2025-06-23 16:55:25 +08:00
UnCLAS-Prommer 877bcb2def
Merge pull request #36 from MaiM-with-u/main
Dev update to main
2025-06-23 15:34:46 +08:00
A0000Xz 409f9f6b07
使其他人的戳一戳能够被解析,并在addtional_config附加被戳人的ID以方便开发 2025-06-23 14:02:20 +08:00
UnCLAS-Prommer 5034039d10 删除桌面提示 2025-06-22 11:05:40 +08:00
UnCLAS-Prommer a76e8b8ef4 删除系统提示防止在无GUI环境中出错 2025-06-22 11:05:09 +08:00
UnCLAS-Prommer cdd38503a6 Merge branch 'dev' 2025-06-22 10:46:34 +08:00
UnCLAS-Prommer eb823f056b
Merge pull request #33 from xuqian13/feat/voice
可以发送音乐卡片了
2025-06-22 10:43:36 +08:00
UnCLAS-Prommer e4620fb7db
Merge branch 'dev' into feat/voice 2025-06-22 10:43:14 +08:00
1334431750 e757196fe1 优化代码 2025-06-22 02:22:50 +00:00
UnCLAS-Prommer 29111bd921 doc update 2025-06-22 10:17:28 +08:00
UnCLAS-Prommer 3711b2892d command update 2025-06-22 10:03:07 +08:00
UnCLAS-Prommer 9ef9dff4a9 ruff 2025-06-22 09:49:23 +08:00
UnCLAS-Prommer 0753469746
Merge pull request #34 from A0000Xz/dev
添加了戳一戳的命令
2025-06-22 09:48:27 +08:00
A0000Xz b5e7316b94
Add files via upload 2025-06-22 01:59:42 +08:00
1334431750 bfb9170236 feat: 添加发送音乐卡片消息 2025-06-21 09:50:20 +00:00
1334431750 7c78027d4b feat: 添加发送音乐卡片消息 2025-06-21 09:40:40 +00:00
UnCLAS-Prommer 51cbb2b227 requirements.txt更新 2025-06-17 16:33:04 +08:00
UnCLAS-Prommer 79ef02f193 又忘了ruff了 2025-06-17 16:31:56 +08:00
UnCLAS-Prommer d72082989a 时尚小垃圾之系统提示 2025-06-17 16:31:27 +08:00
UnCLAS-Prommer e1ab7b6956 ruff 2025-06-15 16:46:13 +08:00
UnCLAS-Prommer 8fc8cedc78 Merge branch 'dev' of https://github.com/MaiM-with-u/MaiBot-Napcat-Adapter into dev 2025-06-15 16:40:39 +08:00
UnCLAS-Prommer 81a71af4aa 修复Config类型没转换的问题 2025-06-15 16:40:25 +08:00
xuqian13 7373e75c75 feat: 新增发送语音可选本地路径和网络路径 2025-06-14 08:04:33 +00:00
Oct-autumn d64670a930
fix: 修改上个提交漏掉的几处global_config的使用 2025-06-04 23:34:11 +08:00
Oct-autumn 36305f226c
refactor: 重构config模块 2025-06-04 21:16:06 +08:00
UnCLAS-Prommer 5974b51754 ruff 2025-06-04 13:45:30 +08:00
春河晴 c365f2277f
重构响应处理模块,将message_queue替换为response_pool
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-04 14:40:25 +09:00
UnCLAS-Prommer d69bdfabbc 小版本号 2025-06-03 09:35:13 +08:00
UnCLAS-Prommer b7d3c68b92 修复notice处理意外的群notice 2025-06-03 09:34:07 +08:00
UnCLAS-Prommer 8764961418 修改类型注解 2025-06-01 16:31:38 +08:00
UnCLAS-Prommer ff61069049 改成硬编码 2025-06-01 16:11:39 +08:00
UnCLAS-Prommer bdb42d085e accept format update 2025-05-28 12:52:39 +08:00
UnCLAS-Prommer 4a5bf0c50e feat: command 2025-05-25 15:32:34 +08:00
UnCLAS-Prommer 9a83be8cf0 fix 2025-05-24 20:41:00 +08:00
UnCLAS-Prommer c2525f4c55 修复潜在问题,拆分部分内容,增强logger输出 2025-05-24 17:23:29 +08:00
36 changed files with 5697 additions and 1231 deletions

View File

@ -2,7 +2,8 @@ name: Docker Image CI
on:
push:
branches: [ "main" ]
branches: [ "main", "dev" ]
workflow_dispatch: # 允许手动触发工作流
jobs:
@ -32,7 +33,11 @@ jobs:
- name: Determine Image Tags
id: tags
run: |
echo "tags=${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:latest,${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:main-$(date -u +'%Y%m%d%H%M%S')" >> $GITHUB_OUTPUT
if [ "${{ github.ref_name }}" == "main" ]; then
echo "tags=${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:latest,${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:main-$(date -u +'%Y%m%d%H%M%S')" >> $GITHUB_OUTPUT
elif [ "${{ github.ref_name }}" == "dev" ]; then
echo "tags=${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:dev,${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:dev-$(date -u +'%Y%m%d%H%M%S')" >> $GITHUB_OUTPUT
fi
- name: Build and Push Docker Image
uses: docker/build-push-action@v5
@ -42,8 +47,8 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.tags.outputs.tags }}
push: true
cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:buildcache,mode=max
cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:buildcache-${{ github.ref_name }}
cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:buildcache-${{ github.ref_name }},mode=max
labels: |
org.opencontainers.image.created=${{ steps.tags.outputs.date_tag }}
org.opencontainers.image.revision=${{ github.sha }}

13
.gitignore vendored
View File

@ -19,7 +19,7 @@ elua.confirmed
# C extensions
*.so
/results
config_backup/
# Distribution / packaging
.Python
build/
@ -39,6 +39,7 @@ share/python-wheels/
.installed.cfg
*.egg
MANIFEST
dev/
# PyInstaller
# Usually these files are written by a python script from a template
@ -64,6 +65,7 @@ coverage.xml
.hypothesis/
.pytest_cache/
cover/
dev/
# Translations
*.mo
@ -148,6 +150,7 @@ venv/
ENV/
env.bak/
venv.bak/
uv.lock
# Spyder project settings
.spyderproject
@ -270,4 +273,10 @@ $RECYCLE.BIN/
*.lnk
config.toml
test
config.toml.back
test
data/NapcatAdapter.db
data/NapcatAdapter.db-shm
data/NapcatAdapter.db-wal
uv.lock

View File

@ -1,4 +1,4 @@
FROM python:3.13.2-slim
FROM python:3.13.5-slim
LABEL authors="infinitycat233"
# Copy uv and maim_message

View File

@ -63,7 +63,7 @@ sequenceDiagram
- [x] 读取戳一戳的自定义内容
- [ ] 语音解析(?)
- [ ] 所有的notice类
- [ ] <del>撤回</del>
- [x] 撤回(已添加相关指令)
- [x] 发送消息
- [x] 发送文本
- [x] 发送图片
@ -72,6 +72,12 @@ sequenceDiagram
- [ ] 戳回去(?)
- [x] 发送语音
- [x] 使用echo与uuid保证消息顺序
- [x] 执行部分管理员功能
- [x] 禁言别人
- [x] 全体禁言
- [x] 群踢人功能
# 特别鸣谢
特别感谢[@Maple127667](https://github.com/Maple127667)对本项目代码思路的支持
特别感谢[@Maple127667](https://github.com/Maple127667)对本项目代码思路的支持
以及[@墨梓柒](https://github.com/DrSmoothl)对部分代码想法的支持

476
command_args.md 100644
View File

@ -0,0 +1,476 @@
# Command Arguments
```python
Seg.type = "command"
```
所有命令执行后都会通过自定义消息类型 `command_response` 返回响应,格式如下:
```python
{
"command_name": "命令名称",
"success": True/False, # 是否执行成功
"timestamp": 1234567890.123, # 时间戳
"data": {...}, # 返回数据(成功时)
"error": "错误信息" # 错误信息(失败时)
}
```
插件需要注册 `command_response` 自定义消息处理器来接收命令响应。
---
## 操作类命令
### 群聊禁言
```python
Seg.data: Dict[str, Any] = {
"name": "GROUP_BAN",
"args": {
"qq_id": "用户QQ号",
"duration": "禁言时长(秒)"
},
}
```
其中群聊ID将会通过Group_Info.group_id自动获取。
**当`duration`为 0 时相当于解除禁言。**
### 群聊全体禁言
```python
Seg.data: Dict[str, Any] = {
"name": "GROUP_WHOLE_BAN",
"args": {
"enable": "是否开启全体禁言True/False"
},
}
```
其中群聊ID将会通过Group_Info.group_id自动获取。
`enable`的参数需要为boolean类型True表示开启全体禁言False表示关闭全体禁言。
### 群聊踢人
将指定成员从群聊中踢出,可选拉黑。
```python
Seg.data: Dict[str, Any] = {
"name": "GROUP_KICK",
"args": {
"group_id": 123456789, # 可选,如果在群聊上下文中可从 group_info 自动获取
"user_id": 12345678, # 必需用户QQ号
"reject_add_request": False # 可选,是否群拉黑,默认 False
},
}
```
### 批量踢出群成员
批量将多个成员从群聊中踢出,可选拉黑。
```python
Seg.data: Dict[str, Any] = {
"name": "GROUP_KICK_MEMBERS",
"args": {
"group_id": 123456789, # 可选,如果在群聊上下文中可从 group_info 自动获取
"user_id": [12345678, 87654321], # 必需用户QQ号数组
"reject_add_request": False # 可选,是否群拉黑,默认 False
},
}
```
### 戳一戳
```python
Seg.data: Dict[str, Any] = {
"name": "SEND_POKE",
"args": {
"qq_id": "目标QQ号"
}
}
```
### 撤回消息
```python
Seg.data: Dict[str, Any] = {
"name": "DELETE_MSG",
"args": {
"message_id": "消息所对应的message_id"
}
}
```
其中message_id是消息的实际qq_id于新版的mmc中可以从数据库获取如果工作正常的话
### 给消息贴表情
```python
Seg.data: Dict[str, Any] = {
"name": "SET_MSG_EMOJI_LIKE",
"args": {
"message_id": "消息ID",
"emoji_id": "表情ID"
}
}
```
### 设置群名
设置指定群的群名称。
```python
Seg.data: Dict[str, Any] = {
"name": "SET_GROUP_NAME",
"args": {
"group_id": 123456789, # 可选,如果在群聊上下文中可从 group_info 自动获取
"group_name": "新群名" # 必需,新的群名称
}
}
```
### 设置账号信息
设置Bot自己的QQ账号资料。
```python
Seg.data: Dict[str, Any] = {
"name": "SET_QQ_PROFILE",
"args": {
"nickname": "新昵称", # 必需,昵称
"personal_note": "个性签名", # 可选,个性签名
"sex": "male" # 可选,性别:"male" | "female" | "unknown"
}
}
```
**返回数据示例:**
```python
{
"result": 0, # 结果码0为成功
"errMsg": "" # 错误信息
}
```
---
## 查询类命令
### 获取登录号信息
获取Bot自身的账号信息。
```python
Seg.data: Dict[str, Any] = {
"name": "GET_LOGIN_INFO",
"args": {}
}
```
**返回数据示例:**
```python
{
"user_id": 12345678,
"nickname": "Bot昵称"
}
```
### 获取陌生人信息
```python
Seg.data: Dict[str, Any] = {
"name": "GET_STRANGER_INFO",
"args": {
"user_id": "用户QQ号"
}
}
```
**返回数据示例:**
```python
{
"user_id": 12345678,
"nickname": "用户昵称",
"sex": "male/female/unknown",
"age": 0
}
```
### 获取好友列表
获取Bot的好友列表。
```python
Seg.data: Dict[str, Any] = {
"name": "GET_FRIEND_LIST",
"args": {
"no_cache": False # 可选,是否不使用缓存,默认 False
}
}
```
**返回数据示例:**
```python
[
{
"user_id": 12345678,
"nickname": "好友昵称",
"remark": "备注名",
"sex": "male", # "male" | "female" | "unknown"
"age": 18,
"qid": "QID字符串",
"level": 64,
"login_days": 365,
"birthday_year": 2000,
"birthday_month": 1,
"birthday_day": 1,
"phone_num": "电话号码",
"email": "邮箱",
"category_id": 0, # 分组ID
"categoryName": "我的好友", # 分组名称
"categoryId": 0
},
...
]
```
### 获取群信息
获取指定群的详细信息。
```python
Seg.data: Dict[str, Any] = {
"name": "GET_GROUP_INFO",
"args": {
"group_id": 123456789 # 可选,如果在群聊上下文中可从 group_info 自动获取
}
}
```
**返回数据示例:**
```python
{
"group_id": "123456789", # 群号(字符串)
"group_name": "群名称",
"group_remark": "群备注",
"group_all_shut": 0, # 群全员禁言状态0=未禁言)
"member_count": 100, # 当前成员数量
"max_member_count": 500 # 最大成员数量
}
```
### 获取群详细信息
获取指定群的详细信息(与 GET_GROUP_INFO 类似,可能提供更实时的数据)。
```python
Seg.data: Dict[str, Any] = {
"name": "GET_GROUP_DETAIL_INFO",
"args": {
"group_id": 123456789 # 可选,如果在群聊上下文中可从 group_info 自动获取
}
}
```
**返回数据示例:**
```python
{
"group_id": 123456789, # 群号(数字)
"group_name": "群名称",
"group_remark": "群备注",
"group_all_shut": 0, # 群全员禁言状态0=未禁言)
"member_count": 100, # 当前成员数量
"max_member_count": 500 # 最大成员数量
}
```
### 获取群列表
获取Bot加入的所有群列表。
```python
Seg.data: Dict[str, Any] = {
"name": "GET_GROUP_LIST",
"args": {
"no_cache": False # 可选,是否不使用缓存,默认 False
}
}
```
**返回数据示例:**
```python
[
{
"group_id": "123456789", # 群号(字符串)
"group_name": "群名称",
"group_remark": "群备注",
"group_all_shut": 0, # 群全员禁言状态
"member_count": 100, # 当前成员数量
"max_member_count": 500 # 最大成员数量
},
...
]
```
### 获取群@全体成员剩余次数
查询指定群的@全体成员剩余使用次数。
```python
Seg.data: Dict[str, Any] = {
"name": "GET_GROUP_AT_ALL_REMAIN",
"args": {
"group_id": 123456789 # 可选,如果在群聊上下文中可从 group_info 自动获取
}
}
```
**返回数据示例:**
```python
{
"can_at_all": True, # 是否可以@全体成员
"remain_at_all_count_for_group": 10, # 群剩余@全体成员次数
"remain_at_all_count_for_uin": 5 # Bot剩余@全体成员次数
}
```
### 获取群成员信息
获取指定群成员的详细信息。
```python
Seg.data: Dict[str, Any] = {
"name": "GET_GROUP_MEMBER_INFO",
"args": {
"group_id": 123456789, # 可选,如果在群聊上下文中可从 group_info 自动获取
"user_id": 12345678, # 必需用户QQ号
"no_cache": False # 可选,是否不使用缓存,默认 False
}
}
```
**返回数据示例:**
```python
{
"group_id": 123456789,
"user_id": 12345678,
"nickname": "昵称",
"card": "群名片",
"sex": "male", # "male" | "female" | "unknown"
"age": 18,
"join_time": 1234567890, # 加群时间戳
"last_sent_time": 1234567890, # 最后发言时间戳
"level": 1, # 群等级
"qq_level": 64, # QQ等级
"role": "member", # "owner" | "admin" | "member"
"title": "专属头衔",
"area": "地区",
"unfriendly": False, # 是否不友好
"title_expire_time": 1234567890, # 头衔过期时间
"card_changeable": True, # 名片是否可修改
"shut_up_timestamp": 0, # 禁言时间戳
"is_robot": False, # 是否机器人
"qage": "10年" # Q龄
}
```
### 获取群成员列表
获取指定群的所有成员列表。
```python
Seg.data: Dict[str, Any] = {
"name": "GET_GROUP_MEMBER_LIST",
"args": {
"group_id": 123456789, # 可选,如果在群聊上下文中可从 group_info 自动获取
"no_cache": False # 可选,是否不使用缓存,默认 False
}
}
```
**返回数据示例:**
```python
[
{
"group_id": 123456789,
"user_id": 12345678,
"nickname": "昵称",
"card": "群名片",
"sex": "male", # "male" | "female" | "unknown"
"age": 18,
"join_time": 1234567890,
"last_sent_time": 1234567890,
"level": 1,
"qq_level": 64,
"role": "member", # "owner" | "admin" | "member"
"title": "专属头衔",
"area": "地区",
"unfriendly": False,
"title_expire_time": 1234567890,
"card_changeable": True,
"shut_up_timestamp": 0,
"is_robot": False,
"qage": "10年"
},
...
]
```
### 获取消息详情
获取指定消息的完整详情信息。
```python
Seg.data: Dict[str, Any] = {
"name": "GET_MSG",
"args": {
"message_id": 123456 # 必需消息ID
}
}
```
**返回数据示例:**
```python
{
"self_id": 12345678, # Bot自身ID
"user_id": 87654321, # 发送者ID
"time": 1234567890, # 时间戳
"message_id": 123456, # 消息ID
"message_seq": 123456, # 消息序列号
"real_id": 123456, # 真实消息ID
"real_seq": "123456", # 真实序列号(字符串)
"message_type": "group", # "private" | "group"
"sub_type": "normal", # 子类型
"message_format": "array", # 消息格式
"post_type": "message", # 事件类型
"group_id": 123456789, # 群号(群消息时存在)
"sender": {
"user_id": 87654321,
"nickname": "昵称",
"sex": "male", # "male" | "female" | "unknown"
"age": 18,
"card": "群名片", # 群消息时存在
"level": "1", # 群等级(字符串)
"role": "member" # "owner" | "admin" | "member"
},
"message": [...], # 消息段数组
"raw_message": "消息文本内容", # 原始消息文本
"font": 0 # 字体
}
```
### 获取合并转发消息
获取合并转发消息的所有子消息内容。
```python
Seg.data: Dict[str, Any] = {
"name": "GET_FORWARD_MSG",
"args": {
"message_id": "7123456789012345678" # 必需合并转发消息ID字符串
}
}
```
**返回数据示例:**
```python
{
"messages": [
{
"sender": {
"user_id": 87654321,
"nickname": "昵称",
"sex": "male",
"age": 18,
"card": "群名片",
"level": "1",
"role": "member"
},
"time": 1234567890,
"message": [...] # 消息段数组
},
...
]
}
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

271
main.py
View File

@ -1,26 +1,39 @@
import asyncio
import sys
import json
import http
import websockets as Server
from src.logger import logger
from src.recv_handler import recv_handler
from src.send_handler import send_handler
from src.recv_handler.message_handler import message_handler
from src.recv_handler.meta_event_handler import meta_event_handler
from src.recv_handler.notice_handler import notice_handler
from src.recv_handler.message_sending import message_send_instance
from src.send_handler.nc_sending import nc_message_sender
from src.config import global_config
from src.mmc_com_layer import mmc_start_com, mmc_stop_com, router
from src.message_queue import message_queue, put_response, check_timeout_response
from src.response_pool import put_response, check_timeout_response
message_queue = asyncio.Queue()
websocket_server = None # 保存WebSocket服务器实例以便关闭
async def message_recv(server_connection: Server.ServerConnection):
recv_handler.server_connection = server_connection
send_handler.server_connection = server_connection
async for raw_message in server_connection:
logger.debug(f"{raw_message[:80]}..." if len(raw_message) > 80 else raw_message)
decoded_raw_message: dict = json.loads(raw_message)
post_type = decoded_raw_message.get("post_type")
if post_type in ["meta_event", "message", "notice"]:
await message_queue.put(decoded_raw_message)
elif post_type is None:
await put_response(decoded_raw_message)
try:
await message_handler.set_server_connection(server_connection)
asyncio.create_task(notice_handler.set_server_connection(server_connection))
await nc_message_sender.set_server_connection(server_connection)
async for raw_message in server_connection:
logger.debug(f"{raw_message[:1500]}..." if (len(raw_message) > 1500) else raw_message)
decoded_raw_message: dict = json.loads(raw_message)
post_type = decoded_raw_message.get("post_type")
if post_type in ["meta_event", "message", "notice"]:
await message_queue.put(decoded_raw_message)
elif post_type is None:
await put_response(decoded_raw_message)
except asyncio.CancelledError:
logger.debug("message_recv 收到取消信号,正在关闭连接")
await server_connection.close()
raise
async def message_process():
@ -28,11 +41,11 @@ async def message_process():
message = await message_queue.get()
post_type = message.get("post_type")
if post_type == "message":
await recv_handler.handle_raw_message(message)
await message_handler.handle_raw_message(message)
elif post_type == "meta_event":
await recv_handler.handle_meta_event(message)
await meta_event_handler.handle_meta_event(message)
elif post_type == "notice":
await recv_handler.handle_notice(message)
await notice_handler.handle_notice(message)
else:
logger.warning(f"未知的post_type: {post_type}")
message_queue.task_done()
@ -40,27 +53,170 @@ async def message_process():
async def main():
recv_handler.maibot_router = router
_ = await asyncio.gather(napcat_server(), mmc_start_com(), message_process(), check_timeout_response())
# 启动配置文件监控并注册napcat_server配置变更回调
from src.config import config_manager
# 保存napcat_server任务的引用用于重启
napcat_task = None
restart_event = asyncio.Event()
async def on_napcat_config_change(old_value, new_value):
"""当napcat_server配置变更时重启WebSocket服务器"""
nonlocal napcat_task
logger.warning(
f"NapCat配置已变更:\n"
f" 旧配置: {old_value.host}:{old_value.port}\n"
f" 新配置: {new_value.host}:{new_value.port}"
)
# 关闭当前WebSocket服务器
global websocket_server
if websocket_server:
try:
logger.info("正在关闭旧的WebSocket服务器...")
websocket_server.close()
await websocket_server.wait_closed()
logger.info("旧的WebSocket服务器已关闭")
except Exception as e:
logger.error(f"关闭旧WebSocket服务器失败: {e}")
# 取消旧任务
if napcat_task and not napcat_task.done():
napcat_task.cancel()
try:
await napcat_task
except asyncio.CancelledError:
pass
# 触发重启
restart_event.set()
config_manager.on_config_change("napcat_server", on_napcat_config_change)
# 启动文件监控
asyncio.create_task(config_manager.start_watch())
# WebSocket服务器重启循环
async def napcat_with_restart():
nonlocal napcat_task
while True:
restart_event.clear()
try:
await napcat_server()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"NapCat服务器异常: {e}")
break
# 等待重启信号
if not restart_event.is_set():
break
logger.info("正在重启WebSocket服务器...")
await asyncio.sleep(1) # 等待1秒后重启
_ = await asyncio.gather(napcat_with_restart(), mmc_start_com(), message_process(), check_timeout_response())
def check_napcat_server_token(conn, request):
token = global_config.napcat_server.token
if not token or token.strip() == "":
return None
auth_header = request.headers.get("Authorization")
if auth_header != f"Bearer {token}":
return Server.Response(
status=http.HTTPStatus.UNAUTHORIZED,
headers=Server.Headers([("Content-Type", "text/plain")]),
body=b"Unauthorized\n"
)
return None
async def napcat_server():
logger.info("正在启动adapter...")
async with Server.serve(message_recv, global_config.server_host, global_config.server_port) as server:
logger.info(f"Adapter已启动监听地址: ws://{global_config.server_host}:{global_config.server_port}")
await server.serve_forever()
async def graceful_shutdown():
global websocket_server
logger.info("正在启动 MaiBot-Napcat-Adapter...")
logger.debug(f"日志等级: {global_config.debug.level}")
logger.debug("日志文件: logs/adapter_*.log")
try:
logger.info("正在关闭adapter...")
await mmc_stop_com()
async with Server.serve(
message_recv,
global_config.napcat_server.host,
global_config.napcat_server.port,
max_size=2**26,
process_request=check_napcat_server_token
) as server:
websocket_server = server
logger.success(
f"✅ Adapter 启动成功! 监听: ws://{global_config.napcat_server.host}:{global_config.napcat_server.port}"
)
try:
await server.serve_forever()
except asyncio.CancelledError:
logger.debug("napcat_server 收到取消信号")
raise
except OSError:
# 端口绑定失败时抛出异常让外层处理
raise
async def graceful_shutdown(silent: bool = False):
"""
优雅关闭adapter
Args:
silent: 静默模式,控制台不输出日志,但仍记录到文件
"""
global websocket_server
try:
if not silent:
logger.info("正在关闭adapter...")
else:
logger.debug("正在清理资源...")
# 先关闭WebSocket服务器
if websocket_server:
try:
logger.debug("正在关闭WebSocket服务器")
websocket_server.close()
await websocket_server.wait_closed()
logger.debug("WebSocket服务器已关闭")
except Exception as e:
logger.debug(f"关闭WebSocket服务器时出现错误: {e}")
# 关闭MMC连接
try:
await asyncio.wait_for(mmc_stop_com(), timeout=3)
except asyncio.TimeoutError:
logger.debug("关闭MMC连接超时")
except Exception as e:
logger.debug(f"关闭MMC连接时出现错误: {e}")
# 取消所有任务
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
if tasks:
logger.debug(f"正在取消 {len(tasks)} 个任务")
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
if not task.done():
task.cancel()
# 等待任务完成,记录异常到日志文件
if tasks:
try:
results = await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=3)
# 记录任务取消的详细信息到日志文件
for i, result in enumerate(results):
if isinstance(result, Exception):
logger.debug(f"任务 {i+1} 清理时产生异常: {type(result).__name__}: {result}")
except asyncio.TimeoutError:
logger.debug("任务清理超时")
except Exception as e:
logger.debug(f"任务清理时出现错误: {e}")
if not silent:
logger.info("Adapter已成功关闭")
else:
logger.debug("资源清理完成")
except Exception as e:
logger.error(f"Adapter关闭中出现错误: {e}")
logger.debug(f"graceful_shutdown异常: {e}", exc_info=True)
if __name__ == "__main__":
@ -70,11 +226,58 @@ if __name__ == "__main__":
loop.run_until_complete(main())
except KeyboardInterrupt:
logger.warning("收到中断信号,正在优雅关闭...")
loop.run_until_complete(graceful_shutdown())
try:
loop.run_until_complete(graceful_shutdown(silent=False))
except Exception:
pass
except OSError as e:
# 处理端口占用等网络错误
if e.errno == 10048 or "address already in use" in str(e).lower():
logger.error(f"❌ 端口 {global_config.napcat_server.port} 已被占用,请检查:")
logger.error(" 1. 是否有其他 MaiBot-Napcat-Adapter 实例正在运行")
logger.error(" 2. 修改 config.toml 中的 port 配置")
logger.error(f" 3. 使用命令查看占用进程: netstat -ano | findstr {global_config.napcat_server.port}")
else:
logger.error(f"❌ 网络错误: {str(e)}")
logger.debug("完整错误信息:", exc_info=True)
# 端口占用时静默清理(控制台不输出,但记录到日志文件)
try:
loop.run_until_complete(graceful_shutdown(silent=True))
except Exception as e:
logger.debug(f"清理资源时出现错误: {e}", exc_info=True)
sys.exit(1)
except Exception as e:
logger.exception(f"主程序异常: {str(e)}")
logger.error(f"❌ 主程序异常: {str(e)}")
logger.debug("详细错误信息:", exc_info=True)
try:
loop.run_until_complete(graceful_shutdown(silent=True))
except Exception as e:
logger.debug(f"清理资源时出现错误: {e}", exc_info=True)
sys.exit(1)
finally:
if loop and not loop.is_closed():
loop.close()
# 清理事件循环
try:
# 取消所有剩余任务
pending = asyncio.all_tasks(loop)
if pending:
logger.debug(f"finally块清理 {len(pending)} 个剩余任务")
for task in pending:
task.cancel()
# 给任务一点时间完成取消
try:
results = loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
# 记录清理结果到日志文件
for i, result in enumerate(results):
if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError):
logger.debug(f"剩余任务 {i+1} 清理异常: {type(result).__name__}: {result}")
except Exception as e:
logger.debug(f"清理剩余任务时出现错误: {e}")
except Exception as e:
logger.debug(f"finally块清理出现错误: {e}")
finally:
if loop and not loop.is_closed():
logger.debug("关闭事件循环")
loop.close()
sys.exit(0)

44
notify_args.md 100644
View File

@ -0,0 +1,44 @@
# Notify Args
```python
Seg.type = "notify"
```
## 群聊成员被禁言
```python
Seg.data: Dict[str, Any] = {
"sub_type": "ban",
"duration": "对应的禁言时间,单位为秒",
"banned_user_info": "被禁言的用户的信息为标准UserInfo转换成的字典"
}
```
此时`MessageBase.UserInfo`,即消息的`UserInfo`为操作者(operator)的信息
**注意: `banned_user_info`需要自行调用`UserInfo.from_dict()`函数转换为标准UserInfo对象**
## 群聊开启全体禁言
```python
Seg.data: Dict[str, Any] = {
"sub_type": "whole_ban",
"duration": -1,
"banned_user_info": None
}
```
此时`MessageBase.UserInfo`,即消息的`UserInfo`为操作者(operator)的信息
## 群聊成员被解除禁言
```python
Seg.data: Dict[str, Any] = {
"sub_type": "whole_lift_ban",
"lifted_user_info": "被解除禁言的用户的信息为标准UserInfo对象"
}
```
**对于自然禁言解除的情况,此时`MessageBase.UserInfo`为`None`**
对于手动解除禁言的情况,此时`MessageBase.UserInfo`,即消息的`UserInfo`为操作者(operator)的信息
**注意: `lifted_user_info`需要自行调用`UserInfo.from_dict()`函数转换为标准UserInfo对象**
## 群聊关闭全体禁言
```python
Seg.data: Dict[str, Any] = {
"sub_type": "whole_lift_ban",
"lifted_user_info": None,
}
```
此时`MessageBase.UserInfo`,即消息的`UserInfo`为操作者(operator)的信息

View File

@ -1,7 +1,21 @@
[project]
name = "MaiBotNapcatAdapter"
version = "0.2.3"
version = "0.7.0"
description = "A MaiBot adapter for Napcat"
requires-python = ">=3.10"
dependencies = [
"aiohttp>=3.13.2",
"asyncio>=4.0.0",
"loguru>=0.7.3",
"maim-message>=0.6.2",
"pillow>=12.0.0",
"requests>=2.32.5",
"rich>=14.2.0",
"sqlmodel>=0.0.27",
"tomlkit>=0.13.3",
"websockets>=15.0.1",
"watchdog>=3.0.0",
]
[tool.ruff]
@ -21,7 +35,7 @@ select = [
"B", # flake8-bugbear
]
ignore = ["E711","E501"]
ignore = ["E711", "E501"]
[tool.ruff.format]
docstring-code-format = true

View File

@ -5,4 +5,7 @@ requests
maim_message
loguru
pillow
tomli
tomlkit
rich
sqlmodel
watchdog

View File

@ -1,63 +1,42 @@
class MetaEventType:
lifecycle = "lifecycle" # 生命周期
class Lifecycle:
connect = "connect" # 生命周期 - WebSocket 连接成功
heartbeat = "heartbeat" # 心跳
class MessageType: # 接受消息大类
private = "private" # 私聊消息
class Private:
friend = "friend" # 私聊消息 - 好友
group = "group" # 私聊消息 - 群临时
group_self = "group_self" # 私聊消息 - 群中自身发送
other = "other" # 私聊消息 - 其他
group = "group" # 群聊消息
class Group:
normal = "normal" # 群聊消息 - 普通
anonymous = "anonymous" # 群聊消息 - 匿名消息
notice = "notice" # 群聊消息 - 系统提示
class NoticeType: # 通知事件
friend_recall = "friend_recall" # 私聊消息撤回
group_recall = "group_recall" # 群聊消息撤回
notify = "notify"
class Notify:
poke = "poke" # 戳一戳
class RealMessageType: # 实际消息分类
text = "text" # 纯文本
face = "face" # qq表情
image = "image" # 图片
record = "record" # 语音
video = "video" # 视频
at = "at" # @某人
rps = "rps" # 猜拳魔法表情
dice = "dice" # 骰子
shake = "shake" # 私聊窗口抖动(只收)
poke = "poke" # 群聊戳一戳
share = "share" # 链接分享json形式
reply = "reply" # 回复消息
forward = "forward" # 转发消息
node = "node" # 转发消息节点
class MessageSentType:
private = "private"
class Private:
friend = "friend"
group = "group"
group = "group"
class Group:
normal = "normal"
from enum import Enum
import tomlkit
import os
from .logger import logger
class CommandType(Enum):
"""命令类型"""
# 操作类命令
GROUP_BAN = "set_group_ban" # 禁言用户
GROUP_WHOLE_BAN = "set_group_whole_ban" # 群全体禁言
GROUP_KICK = "set_group_kick" # 踢出群聊
GROUP_KICK_MEMBERS = "set_group_kick_members" # 批量踢出群成员
SET_GROUP_NAME = "set_group_name" # 设置群名
SEND_POKE = "send_poke" # 戳一戳
DELETE_MSG = "delete_msg" # 撤回消息
AI_VOICE_SEND = "send_group_ai_record" # 发送群AI语音
SET_MSG_EMOJI_LIKE = "set_msg_emoji_like" # 给消息贴表情
SET_QQ_PROFILE = "set_qq_profile" # 设置账号信息
# 查询类命令
GET_LOGIN_INFO = "get_login_info" # 获取登录号信息
GET_STRANGER_INFO = "get_stranger_info" # 获取陌生人信息
GET_FRIEND_LIST = "get_friend_list" # 获取好友列表
GET_GROUP_INFO = "get_group_info" # 获取群信息
GET_GROUP_DETAIL_INFO = "get_group_detail_info" # 获取群详细信息
GET_GROUP_LIST = "get_group_list" # 获取群列表
GET_GROUP_AT_ALL_REMAIN = "get_group_at_all_remain" # 获取群@全体成员剩余次数
GET_GROUP_MEMBER_INFO = "get_group_member_info" # 获取群成员信息
GET_GROUP_MEMBER_LIST = "get_group_member_list" # 获取群成员列表
GET_MSG = "get_msg" # 获取消息
GET_FORWARD_MSG = "get_forward_msg" # 获取合并转发消息
def __str__(self) -> str:
return self.value
pyproject_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "pyproject.toml")
toml_data = tomlkit.parse(open(pyproject_path, "r", encoding="utf-8").read())
version = toml_data["project"]["version"]
logger.info(f"版本\n\nMaiBot-Napcat-Adapter 版本: {version}\n喜欢的话点个star喵~\n")

View File

@ -1,77 +0,0 @@
import os
import sys
import tomli
import shutil
from .logger import logger
from typing import Optional
class Config:
platform: str = "qq"
nickname: Optional[str] = None
server_host: str = "localhost"
server_port: int = 8095
napcat_heartbeat_interval: int = 30
def __init__(self):
self._get_config_path()
def _get_config_path(self):
current_file_path = os.path.abspath(__file__)
src_path = os.path.dirname(current_file_path)
self.root_path = os.path.join(src_path, "..")
self.config_path = os.path.join(self.root_path, "config.toml")
def load_config(self): # sourcery skip: extract-method, move-assign
include_configs = ["Napcat_Server", "MaiBot_Server", "Chat", "Voice", "Debug"]
if not os.path.exists(self.config_path):
logger.error("配置文件不存在!")
logger.info("正在创建配置文件...")
shutil.copy(
os.path.join(self.root_path, "template", "template_config.toml"),
os.path.join(self.root_path, "config.toml"),
)
logger.info("配置文件创建成功,请修改配置文件后重启程序。")
sys.exit(1)
with open(self.config_path, "rb") as f:
try:
raw_config = tomli.load(f)
except tomli.TOMLDecodeError as e:
logger.critical(f"配置文件bot_config.toml填写有误请检查第{e.lineno}行第{e.colno}处:{e.msg}")
sys.exit(1)
for key in include_configs:
if key not in raw_config:
logger.error(f"配置文件中缺少必需的字段: '{key}'")
sys.exit(1)
self.server_host = raw_config["Napcat_Server"].get("host", "localhost")
self.server_port = raw_config["Napcat_Server"].get("port", 8095)
self.napcat_heartbeat_interval = raw_config["Napcat_Server"].get("heartbeat", 30)
self.mai_host = raw_config["MaiBot_Server"].get("host", "localhost")
self.mai_port = raw_config["MaiBot_Server"].get("port", 8000)
self.platform = raw_config["MaiBot_Server"].get("platform_name")
if not self.platform:
logger.critical("请在配置文件中指定平台")
sys.exit(1)
self.group_list_type: str = raw_config["Chat"].get("group_list_type")
self.group_list: list = raw_config["Chat"].get("group_list", [])
self.private_list_type: str = raw_config["Chat"].get("private_list_type")
self.private_list: list = raw_config["Chat"].get("private_list", [])
self.ban_user_id: list = raw_config["Chat"].get("ban_user_id", [])
self.enable_poke: bool = raw_config["Chat"].get("enable_poke", True)
if self.group_list_type not in ["whitelist", "blacklist"]:
logger.critical("请在配置文件中指定group_list_type或group_list_type填写错误")
sys.exit(1)
if self.private_list_type not in ["whitelist", "blacklist"]:
logger.critical("请在配置文件中指定private_list_type或private_list_type填写错误")
sys.exit(1)
self.use_tts = raw_config["Voice"].get("use_tts", False)
self.debug_level = raw_config["Debug"].get("level", "INFO")
global_config = Config()
global_config.load_config()

View File

@ -0,0 +1,6 @@
from .config import global_config, _config_manager as config_manager
__all__ = [
"global_config",
"config_manager",
]

View File

@ -0,0 +1,158 @@
import os
from dataclasses import dataclass
from datetime import datetime
import tomlkit
import shutil
from tomlkit import TOMLDocument
from tomlkit.items import Table
from ..logger import logger
from rich.traceback import install
from src.config.config_base import ConfigBase
from src.config.official_configs import (
ChatConfig,
DebugConfig,
ForwardConfig,
MaiBotServerConfig,
NapcatServerConfig,
NicknameConfig,
VoiceConfig,
)
install(extra_lines=3)
TEMPLATE_DIR = "template"
def update_config():
# 定义文件路径
template_path = f"{TEMPLATE_DIR}/template_config.toml"
old_config_path = "config.toml"
new_config_path = "config.toml"
# 检查配置文件是否存在
if not os.path.exists(old_config_path):
logger.info("配置文件不存在,从模板创建新配置")
shutil.copy2(template_path, old_config_path) # 复制模板文件
logger.info(f"已创建新配置文件,请填写后重新运行: {old_config_path}")
# 如果是新创建的配置文件,直接返回
quit()
# 读取旧配置文件和模板文件
with open(old_config_path, "r", encoding="utf-8") as f:
old_config = tomlkit.load(f)
with open(template_path, "r", encoding="utf-8") as f:
new_config = tomlkit.load(f)
# 检查version是否相同
if old_config and "inner" in old_config and "inner" in new_config:
old_version = old_config["inner"].get("version")
new_version = new_config["inner"].get("version")
if old_version and new_version and old_version == new_version:
logger.info(f"检测到配置文件版本号相同 (v{old_version}),跳过更新")
return
else:
logger.info(f"检测到版本号不同: 旧版本 v{old_version} -> 新版本 v{new_version}")
else:
logger.info("已有配置文件未检测到版本号,可能是旧版本。将进行更新")
# 创建备份文件夹
backup_dir = "config_backup"
os.makedirs(backup_dir, exist_ok=True)
# 备份文件名
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
old_backup_path = os.path.join(backup_dir, f"config.toml.bak.{timestamp}")
# 备份旧配置文件
shutil.copy2(old_config_path, old_backup_path)
logger.info(f"已备份旧配置文件到: {old_backup_path}")
# 复制模板文件到配置目录
shutil.copy2(template_path, new_config_path)
logger.info(f"已创建新配置文件: {new_config_path}")
def update_dict(target: TOMLDocument | dict, source: TOMLDocument | dict):
"""
将source字典的值更新到target字典中如果target中存在相同的键
"""
for key, value in source.items():
# 跳过version字段的更新
if key == "version":
continue
if key in target:
if isinstance(value, dict) and isinstance(target[key], (dict, Table)):
update_dict(target[key], value)
else:
try:
# 对数组类型进行特殊处理
if isinstance(value, list):
# 如果是空数组,确保它保持为空数组
target[key] = tomlkit.array(str(value)) if value else tomlkit.array()
else:
# 其他类型使用item方法创建新值
target[key] = tomlkit.item(value)
except (TypeError, ValueError):
# 如果转换失败,直接赋值
target[key] = value
# 将旧配置的值更新到新配置中
logger.info("开始合并新旧配置...")
update_dict(new_config, old_config)
# 保存更新后的配置(保留注释和格式)
with open(new_config_path, "w", encoding="utf-8") as f:
f.write(tomlkit.dumps(new_config))
logger.info("配置文件更新完成,建议检查新配置文件中的内容,以免丢失重要信息")
quit()
@dataclass
class Config(ConfigBase):
"""总配置类"""
nickname: NicknameConfig
napcat_server: NapcatServerConfig
maibot_server: MaiBotServerConfig
chat: ChatConfig
voice: VoiceConfig
forward: ForwardConfig
debug: DebugConfig
def load_config(config_path: str) -> Config:
"""
加载配置文件
:param config_path: 配置文件路径
:return: Config对象
"""
# 读取配置文件
with open(config_path, "r", encoding="utf-8") as f:
config_data = tomlkit.load(f)
# 创建Config对象
try:
return Config.from_dict(config_data)
except Exception as e:
logger.critical("配置文件解析失败")
raise e
# 更新配置
update_config()
logger.info("正在品鉴配置文件...")
# 创建配置管理器
from .config_manager import ConfigManager
_config_manager = ConfigManager()
_config_manager.load(config_path="config.toml")
# 向后兼容global_config 指向配置管理器
# 所有现有代码可以继续使用 global_config.chat.xxx 访问配置
global_config = _config_manager
logger.info("非常的新鲜,非常的美味!")

View File

@ -0,0 +1,136 @@
from dataclasses import dataclass, fields, MISSING
from typing import TypeVar, Type, Any, get_origin, get_args, Literal, Dict, Union
T = TypeVar("T", bound="ConfigBase")
TOML_DICT_TYPE = {
int,
float,
str,
bool,
list,
dict,
}
@dataclass
class ConfigBase:
"""配置类的基类"""
@classmethod
def from_dict(cls: Type[T], data: Dict[str, Any]) -> T:
"""从字典加载配置字段"""
if not isinstance(data, dict):
raise TypeError(f"Expected a dictionary, got {type(data).__name__}")
init_args: Dict[str, Any] = {}
for f in fields(cls):
field_name = f.name
field_type = f.type
if field_name.startswith("_"):
# 跳过以 _ 开头的字段
continue
if field_name not in data:
if f.default is not MISSING or f.default_factory is not MISSING:
# 跳过未提供且有默认值/默认构造方法的字段
continue
else:
raise ValueError(f"Missing required field: '{field_name}'")
value = data[field_name]
try:
init_args[field_name] = cls._convert_field(value, field_type)
except TypeError as e:
raise TypeError(f"字段 '{field_name}' 出现类型错误: {e}") from e
except Exception as e:
raise RuntimeError(f"无法将字段 '{field_name}' 转换为目标类型,出现错误: {e}") from e
return cls(**init_args)
@classmethod
def _convert_field(cls, value: Any, field_type: Type[Any]) -> Any:
"""
转换字段值为指定类型
1. 对于嵌套的 dataclass递归调用相应的 from_dict 方法
2. 对于泛型集合类型list, set, tuple递归转换每个元素
3. 对于基础类型int, str, float, bool直接转换
4. 对于其他类型尝试直接转换如果失败则抛出异常
"""
# 如果是嵌套的 dataclass递归调用 from_dict 方法
if isinstance(field_type, type) and issubclass(field_type, ConfigBase):
return field_type.from_dict(value)
field_origin_type = get_origin(field_type)
field_args_type = get_args(field_type)
# 处理泛型集合类型list, set, tuple
if field_origin_type in {list, set, tuple}:
# 检查提供的value是否为list
if not isinstance(value, list):
raise TypeError(f"Expected an list for {field_type.__name__}, got {type(value).__name__}")
if field_origin_type is list:
return [cls._convert_field(item, field_args_type[0]) for item in value]
if field_origin_type is set:
return {cls._convert_field(item, field_args_type[0]) for item in value}
if field_origin_type is tuple:
# 检查提供的value长度是否与类型参数一致
if len(value) != len(field_args_type):
raise TypeError(
f"Expected {len(field_args_type)} items for {field_type.__name__}, got {len(value)}"
)
return tuple(cls._convert_field(item, arg_type) for item, arg_type in zip(value, field_args_type))
if field_origin_type is dict:
# 检查提供的value是否为dict
if not isinstance(value, dict):
raise TypeError(f"Expected a dictionary for {field_type.__name__}, got {type(value).__name__}")
# 检查字典的键值类型
if len(field_args_type) != 2:
raise TypeError(f"Expected a dictionary with two type arguments for {field_type.__name__}")
key_type, value_type = field_args_type
return {cls._convert_field(k, key_type): cls._convert_field(v, value_type) for k, v in value.items()}
# 处理Optional类型
if field_origin_type is Union: # assert get_origin(Optional[Any]) is Union
if value is None:
return None
# 如果有数据,检查实际类型
if type(value) not in field_args_type:
raise TypeError(f"Expected {field_args_type} for {field_type.__name__}, got {type(value).__name__}")
return cls._convert_field(value, field_args_type[0])
# 处理int, str, float, bool等基础类型
if field_origin_type is None:
if isinstance(value, field_type):
return field_type(value)
else:
raise TypeError(f"Expected {field_type.__name__}, got {type(value).__name__}")
# 处理Literal类型
if field_origin_type is Literal:
# 获取Literal的允许值
allowed_values = get_args(field_type)
if value in allowed_values:
return value
else:
raise TypeError(f"Value '{value}' is not in allowed values {allowed_values} for Literal type")
# 处理其他类型
if field_type is Any:
return value
# 其他类型直接转换
try:
return field_type(value)
except (ValueError, TypeError) as e:
raise TypeError(f"无法将 {type(value).__name__} 转换为 {field_type.__name__}") from e
def __str__(self):
"""返回配置类的字符串表示"""
return f"{self.__class__.__name__}({', '.join(f'{f.name}={getattr(self, f.name)}' for f in fields(self))})"

View File

@ -0,0 +1,281 @@
"""配置管理器 - 支持热重载"""
import asyncio
import os
from typing import Callable, Dict, List, Any, Optional
from datetime import datetime
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileModifiedEvent
from ..logger import logger
from .config import Config, load_config
class ConfigManager:
"""配置管理器 - 混合模式(属性代理 + 选择性回调)
支持热重载配置文件使用watchdog实时监控文件变化
需要特殊处理的配置项可以注册回调函数
"""
def __init__(self) -> None:
self._config: Optional[Config] = None
self._config_path: str = "config.toml"
self._lock: asyncio.Lock = asyncio.Lock()
self._callbacks: Dict[str, List[Callable]] = {}
# Watchdog相关
self._observer: Optional[Observer] = None
self._event_handler: Optional[FileSystemEventHandler] = None
self._reload_debounce_task: Optional[asyncio.Task] = None
self._debounce_delay: float = 0.5 # 防抖延迟(秒)
self._loop: Optional[asyncio.AbstractEventLoop] = None # 事件循环引用
self._is_reloading: bool = False # 标记是否正在重载
self._last_reload_trigger: float = 0.0 # 最后一次触发重载的时间
def load(self, config_path: str = "config.toml") -> None:
"""加载配置文件
Args:
config_path: 配置文件路径
"""
self._config_path = os.path.abspath(config_path)
self._config = load_config(config_path)
logger.info(f"配置已加载: {config_path}")
async def reload(self, config_path: Optional[str] = None) -> bool:
"""重载配置文件(热重载)
Args:
config_path: 配置文件路径如果为None则使用初始路径
Returns:
bool: 是否重载成功
"""
if config_path is None:
config_path = self._config_path
async with self._lock:
old_config = self._config
try:
new_config = load_config(config_path)
if old_config is not None:
await self._notify_changes(old_config, new_config)
self._config = new_config
logger.info(f"配置重载成功: {config_path}")
return True
except Exception as e:
logger.error(f"配置重载失败: {e}", exc_info=True)
return False
def on_config_change(
self,
config_path: str,
callback: Callable[[Any, Any], Any]
) -> None:
"""为特定配置路径注册回调函数
Args:
config_path: 配置路径 'napcat_server', 'chat.ban_user_id', 'debug.level'
callback: 回调函数签名为 async def callback(old_value, new_value)
"""
if config_path not in self._callbacks:
self._callbacks[config_path] = []
self._callbacks[config_path].append(callback)
logger.debug(f"已注册配置变更回调: {config_path}")
async def _notify_changes(self, old_config: Config, new_config: Config) -> None:
"""通知配置变更
Args:
old_config: 旧配置对象
new_config: 新配置对象
"""
for config_path, callbacks in self._callbacks.items():
try:
old_value = self._get_value(old_config, config_path)
new_value = self._get_value(new_config, config_path)
if old_value != new_value:
logger.info(f"检测到配置变更: {config_path}")
for callback in callbacks:
try:
if asyncio.iscoroutinefunction(callback):
await callback(old_value, new_value)
else:
callback(old_value, new_value)
except Exception as e:
logger.error(
f"配置变更回调执行失败 [{config_path}]: {e}",
exc_info=True
)
except Exception as e:
logger.error(f"获取配置值失败 [{config_path}]: {e}")
def _get_value(self, config: Config, path: str) -> Any:
"""获取嵌套配置值
Args:
config: 配置对象
path: 配置路径支持点分隔的嵌套路径
Returns:
Any: 配置值
Raises:
AttributeError: 配置路径不存在
"""
parts = path.split('.')
value = config
for part in parts:
value = getattr(value, part)
return value
def __getattr__(self, name: str) -> Any:
"""动态代理配置属性访问
支持直接访问配置对象的属性
- config_manager.napcat_server
- config_manager.chat
- config_manager.debug
Args:
name: 属性名
Returns:
Any: 配置对象的对应属性值
Raises:
RuntimeError: 配置尚未加载
AttributeError: 属性不存在
"""
# 私有属性不代理
if name.startswith('_'):
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)
# 检查配置是否已加载
if self._config is None:
raise RuntimeError("配置尚未加载,请先调用 load() 方法")
# 尝试从 _config 获取属性
try:
return getattr(self._config, name)
except AttributeError as e:
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
) from e
async def start_watch(self) -> None:
"""启动配置文件监控(需要在事件循环中调用)"""
if self._observer is not None:
logger.warning("配置文件监控已在运行")
return
# 保存当前事件循环引用
self._loop = asyncio.get_running_loop()
# 创建文件监控事件处理器
config_file_path = self._config_path
class ConfigFileHandler(FileSystemEventHandler):
def __init__(handler_self, manager: "ConfigManager"):
handler_self.manager = manager
handler_self.config_path = config_file_path
def on_modified(handler_self, event):
# 检查是否是目标配置文件修改事件
if isinstance(event, FileModifiedEvent) and os.path.abspath(event.src_path) == handler_self.config_path:
logger.debug(f"检测到配置文件变更: {event.src_path}")
# 使用防抖机制避免重复重载
# watchdog运行在独立线程需要使用run_coroutine_threadsafe
if handler_self.manager._loop:
asyncio.run_coroutine_threadsafe(
handler_self.manager._debounced_reload(),
handler_self.manager._loop
)
self._event_handler = ConfigFileHandler(self)
# 创建Observer并监控配置文件所在目录
self._observer = Observer()
watch_dir = os.path.dirname(self._config_path) or "."
self._observer.schedule(self._event_handler, watch_dir, recursive=False)
self._observer.start()
logger.info(f"已启动配置文件实时监控: {self._config_path}")
async def stop_watch(self) -> None:
"""停止配置文件监控"""
if self._observer is None:
return
logger.debug("正在停止配置文件监控")
# 取消防抖任务
if self._reload_debounce_task:
self._reload_debounce_task.cancel()
try:
await self._reload_debounce_task
except asyncio.CancelledError:
pass
# 停止observer
self._observer.stop()
self._observer.join(timeout=2)
self._observer = None
self._event_handler = None
logger.info("配置文件监控已停止")
async def _debounced_reload(self) -> None:
"""防抖重载:避免短时间内多次文件修改事件导致重复重载"""
import time
# 记录当前触发时间
trigger_time = time.time()
self._last_reload_trigger = trigger_time
# 等待防抖延迟
await asyncio.sleep(self._debounce_delay)
# 检查是否有更新的触发
if self._last_reload_trigger > trigger_time:
# 有更新的触发,放弃本次重载
logger.debug("放弃过时的重载请求")
return
# 检查是否已有重载在进行
if self._is_reloading:
logger.debug("重载已在进行中,跳过")
return
# 执行重载
self._is_reloading = True
try:
modified_time = datetime.fromtimestamp(
os.path.getmtime(self._config_path)
).strftime("%Y-%m-%d %H:%M:%S")
logger.info(
f"配置文件已更新 (修改时间: {modified_time}),正在重载..."
)
success = await self.reload()
if not success:
logger.error(
"配置文件重载失败!请检查配置文件格式是否正确。\n"
"当前仍使用旧配置运行,修复配置文件后将自动重试。"
)
finally:
self._is_reloading = False
def __repr__(self) -> str:
watching = self._observer is not None and self._observer.is_alive()
return f"<ConfigManager config_path={self._config_path} watching={watching}>"

View File

@ -0,0 +1,100 @@
from dataclasses import dataclass, field
from typing import Literal
from src.config.config_base import ConfigBase
"""
须知
1. 本文件中记录了所有的配置项
2. 所有新增的class都需要继承自ConfigBase
3. 所有新增的class都应在config.py中的Config类中添加字段
4. 对于新增的字段若为可选项则应在其后添加field()并设置default_factory或default
"""
ADAPTER_PLATFORM = "qq"
@dataclass
class NicknameConfig(ConfigBase):
nickname: str
"""机器人昵称"""
@dataclass
class NapcatServerConfig(ConfigBase):
host: str = "localhost"
"""Napcat服务端的主机地址"""
port: int = 8095
"""Napcat服务端的端口号"""
token: str = ""
"""Napcat服务端的访问令牌若无则留空"""
heartbeat_interval: int = 30
"""Napcat心跳间隔时间单位为秒"""
@dataclass
class MaiBotServerConfig(ConfigBase):
platform_name: str = field(default=ADAPTER_PLATFORM, init=False)
"""平台名称“qq”"""
host: str = "localhost"
"""MaiMCore的主机地址"""
port: int = 8000
"""MaiMCore的端口号"""
enable_api_server: bool = False
"""是否启用API-Server模式连接"""
base_url: str = ""
"""API-Server连接地址 (ws://ipp:port/path)"""
api_key: str = ""
"""API Key (仅在enable_api_server为True时使用)"""
@dataclass
class ChatConfig(ConfigBase):
group_list_type: Literal["whitelist", "blacklist"] = "whitelist"
"""群聊列表类型 白名单/黑名单"""
group_list: list[int] = field(default_factory=[])
"""群聊列表"""
private_list_type: Literal["whitelist", "blacklist"] = "whitelist"
"""私聊列表类型 白名单/黑名单"""
private_list: list[int] = field(default_factory=[])
"""私聊列表"""
ban_user_id: list[int] = field(default_factory=[])
"""被封禁的用户ID列表封禁后将无法与其进行交互"""
ban_qq_bot: bool = False
"""是否屏蔽QQ官方机器人若为True则所有QQ官方机器人将无法与MaiMCore进行交互"""
enable_poke: bool = True
"""是否启用戳一戳功能"""
@dataclass
class VoiceConfig(ConfigBase):
use_tts: bool = False
"""是否启用TTS功能"""
@dataclass
class ForwardConfig(ConfigBase):
"""转发消息相关配置"""
image_threshold: int = 3
"""图片数量阈值转发消息中图片数量超过此值时使用占位符代替base64发送避免麦麦VLM处理卡死"""
@dataclass
class DebugConfig(ConfigBase):
level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
"""日志级别默认为INFO"""

162
src/database.py 100644
View File

@ -0,0 +1,162 @@
import os
from typing import Optional, List
from dataclasses import dataclass
from sqlmodel import Field, Session, SQLModel, create_engine, select
from src.logger import logger
"""
表记录的方式
| group_id | user_id | lift_time |
|----------|---------|-----------|
其中使用 user_id == 0 表示群全体禁言
"""
@dataclass
class BanUser:
"""
程序处理使用的实例
"""
user_id: int
group_id: int
lift_time: Optional[int] = Field(default=-1)
class DB_BanUser(SQLModel, table=True):
"""
表示数据库中的用户禁言记录
使用双重主键
"""
user_id: int = Field(index=True, primary_key=True) # 被禁言用户的用户 ID
group_id: int = Field(index=True, primary_key=True) # 用户被禁言的群组 ID
lift_time: Optional[int] # 禁言解除的时间(时间戳)
def is_identical(obj1: BanUser, obj2: BanUser) -> bool:
"""
检查两个 BanUser 对象是否相同
"""
return obj1.user_id == obj2.user_id and obj1.group_id == obj2.group_id
class DatabaseManager:
"""
数据库管理类负责与数据库交互
"""
def __init__(self):
os.makedirs(os.path.join(os.path.dirname(__file__), "..", "data"), exist_ok=True) # 确保数据目录存在
DATABASE_FILE = os.path.join(os.path.dirname(__file__), "..", "data", "NapcatAdapter.db")
self.sqlite_url = f"sqlite:///{DATABASE_FILE}" # SQLite 数据库 URL
self.engine = create_engine(self.sqlite_url, echo=False) # 创建数据库引擎
self._ensure_database() # 确保数据库和表已创建
def _ensure_database(self) -> None:
"""
确保数据库和表已创建
"""
logger.info("确保数据库文件和表已创建...")
SQLModel.metadata.create_all(self.engine)
logger.success("数据库和表已创建或已存在")
def update_ban_record(self, ban_list: List[BanUser]) -> None:
# sourcery skip: class-extract-method
"""
更新禁言列表到数据库
支持在不存在时创建新记录对于多余的项目自动删除
"""
with Session(self.engine) as session:
all_records = session.exec(select(DB_BanUser)).all()
for ban_user in ban_list:
statement = select(DB_BanUser).where(
DB_BanUser.user_id == ban_user.user_id, DB_BanUser.group_id == ban_user.group_id
)
if existing_record := session.exec(statement).first():
if existing_record.lift_time == ban_user.lift_time:
logger.debug(f"禁言记录未变更: {existing_record}")
continue
# 更新现有记录的 lift_time
existing_record.lift_time = ban_user.lift_time
session.add(existing_record)
logger.debug(f"更新禁言记录: {existing_record}")
else:
# 创建新记录
db_record = DB_BanUser(
user_id=ban_user.user_id, group_id=ban_user.group_id, lift_time=ban_user.lift_time
)
session.add(db_record)
logger.debug(f"创建新禁言记录: {ban_user}")
# 删除不在 ban_list 中的记录
for db_record in all_records:
record = BanUser(user_id=db_record.user_id, group_id=db_record.group_id, lift_time=db_record.lift_time)
if not any(is_identical(record, ban_user) for ban_user in ban_list):
statement = select(DB_BanUser).where(
DB_BanUser.user_id == record.user_id, DB_BanUser.group_id == record.group_id
)
if ban_record := session.exec(statement).first():
session.delete(ban_record)
session.commit()
logger.debug(f"删除禁言记录: {ban_record}")
else:
logger.info(f"未找到禁言记录: {ban_record}")
session.commit()
logger.info("禁言记录已更新")
def get_ban_records(self) -> List[BanUser]:
"""
读取所有禁言记录
"""
with Session(self.engine) as session:
statement = select(DB_BanUser)
records = session.exec(statement).all()
return [BanUser(user_id=item.user_id, group_id=item.group_id, lift_time=item.lift_time) for item in records]
def create_ban_record(self, ban_record: BanUser) -> None:
"""
为特定群组中的用户创建禁言记录
一个简化版本的添加方式防止 update_ban_record 方法的复杂性
其同时还是简化版的更新方式
"""
with Session(self.engine) as session:
# 检查记录是否已存在
statement = select(DB_BanUser).where(
DB_BanUser.user_id == ban_record.user_id, DB_BanUser.group_id == ban_record.group_id
)
existing_record = session.exec(statement).first()
if existing_record:
# 如果记录已存在,更新 lift_time
existing_record.lift_time = ban_record.lift_time
session.add(existing_record)
logger.debug(f"更新禁言记录: {ban_record}")
else:
# 如果记录不存在,创建新记录
db_record = DB_BanUser(
user_id=ban_record.user_id, group_id=ban_record.group_id, lift_time=ban_record.lift_time
)
session.add(db_record)
logger.debug(f"创建新禁言记录: {ban_record}")
session.commit()
def delete_ban_record(self, ban_record: BanUser):
"""
删除特定用户在特定群组中的禁言记录
一个简化版本的删除方式防止 update_ban_record 方法的复杂性
"""
user_id = ban_record.user_id
group_id = ban_record.group_id
with Session(self.engine) as session:
statement = select(DB_BanUser).where(DB_BanUser.user_id == user_id, DB_BanUser.group_id == group_id)
if ban_record := session.exec(statement).first():
session.delete(ban_record)
session.commit()
logger.debug(f"删除禁言记录: {ban_record}")
else:
logger.info(f"未找到禁言记录: user_id: {user_id}, group_id: {group_id}")
db_manager = DatabaseManager()

View File

@ -1,25 +1,106 @@
from loguru import logger
from .config import global_config
import sys
# import builtins
from pathlib import Path
from datetime import datetime, timedelta
# 日志目录配置
LOG_DIR = Path(__file__).parent.parent / "logs"
LOG_DIR.mkdir(exist_ok=True)
# 日志等级映射(用于显示单字母)
LEVEL_ABBR = {
"TRACE": "T",
"DEBUG": "D",
"INFO": "I",
"SUCCESS": "S",
"WARNING": "W",
"ERROR": "E",
"CRITICAL": "C"
}
def get_level_abbr(record):
"""获取日志等级的缩写"""
return LEVEL_ABBR.get(record["level"].name, record["level"].name[0])
def clean_old_logs(days: int = 30):
"""清理超过指定天数的日志文件"""
try:
cutoff_date = datetime.now() - timedelta(days=days)
for log_file in LOG_DIR.glob("*.log"):
try:
file_time = datetime.fromtimestamp(log_file.stat().st_mtime)
if file_time < cutoff_date:
log_file.unlink()
print(f"已清理过期日志: {log_file.name}")
except Exception as e:
print(f"清理日志文件 {log_file.name} 失败: {e}")
except Exception as e:
print(f"清理日志目录失败: {e}")
# 清理过期日志
clean_old_logs(30)
# 移除默认处理器
logger.remove()
# 自定义格式化函数
def format_log(record):
"""格式化日志记录"""
record["extra"]["level_abbr"] = get_level_abbr(record)
if "module_name" not in record["extra"]:
record["extra"]["module_name"] = "Adapter"
return True
# 控制台输出处理器 - 简洁格式
logger.add(
sys.stderr,
level=global_config.debug_level,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
level=global_config.debug.level,
format="<blue>{time:MM-DD HH:mm:ss}</blue> | <level>[{extra[level_abbr]}]</level> | <cyan>{extra[module_name]}</cyan> | <level>{message}</level>",
filter=lambda record: format_log(record) and record["extra"].get("module_name") != "maim_message",
)
# maim_message 单独处理
logger.add(
sys.stderr,
level="INFO",
format="<red>{time:MM-DD HH:mm:ss}</red> | <level>[{extra[level_abbr]}]</level> | <cyan>{extra[module_name]}</cyan> | <level>{message}</level>",
filter=lambda record: format_log(record) and record["extra"].get("module_name") == "maim_message",
)
# def handle_output(message: str):
# if "连接失败" in message:
# logger.error(message)
# elif "收到无效的" in message:
# logger.warning(message)
# elif "检测到平台" in message:
# logger.warning(message)
# else:
# logger.info(message)
# 文件输出处理器 - 详细格式,记录所有TRACE级别
log_file = LOG_DIR / f"adapter_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
logger.add(
log_file,
level="TRACE",
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | [{level}] | {extra[module_name]} | {name}:{function}:{line} - {message}",
rotation="100 MB", # 单个日志文件最大100MB
retention="30 days", # 保留30天
encoding="utf-8",
enqueue=True, # 异步写入,避免阻塞
filter=format_log, # 确保extra字段存在
)
def get_logger(module_name: str = "Adapter"):
"""
获取自定义模块名的logger
Args:
module_name: 模块名称,用于日志输出中标识来源
Returns:
配置好的logger实例
Example:
>>> from src.logger import get_logger
>>> logger = get_logger("MyModule")
>>> logger.info("这是一条日志")
MM-DD HH:mm:ss | [I] | MyModule | 这是一条日志
"""
return logger.bind(module_name=module_name)
# builtins.print = handle_output
# 默认logger实例(用于向后兼容)
logger = logger.bind(module_name="Adapter")
# maim_message的logger
custom_logger = logger.bind(module_name="maim_message")

View File

@ -1,24 +1,164 @@
from maim_message import Router, RouteConfig, TargetConfig
from maim_message import Router, RouteConfig, TargetConfig, MessageBase
from .config import global_config
from .logger import logger
from .send_handler import send_handler
from .logger import logger, custom_logger
from .send_handler.main_send_handler import send_handler
from .recv_handler.message_sending import message_send_instance
from maim_message.client import create_client_config, WebSocketClient
from maim_message.message import APIMessageBase
from typing import Dict, Any
import importlib.metadata
route_config = RouteConfig(
route_config={
global_config.platform: TargetConfig(
url=f"ws://{global_config.mai_host}:{global_config.mai_port}/ws",
token=None,
# 检查 maim_message 版本是否支持 MessageConverter (>= 0.6.2)
try:
maim_message_version = importlib.metadata.version("maim_message")
version_int = [int(x) for x in maim_message_version.split(".")]
HAS_MESSAGE_CONVERTER = version_int >= [0, 6, 2]
except (importlib.metadata.PackageNotFoundError, ValueError):
HAS_MESSAGE_CONVERTER = False
# router = Router(route_config, custom_logger)
# router will be initialized in mmc_start_com
router = None
class APIServerWrapper:
"""
Wrapper to make WebSocketClient compatible with legacy Router interface
"""
def __init__(self, client: WebSocketClient):
self.client = client
self.platform = global_config.maibot_server.platform_name
def register_class_handler(self, handler):
# In API Server mode, we register the on_message callback in config,
# but here we might need to bridge it if the handler structure is different.
# However, WebSocketClient config handles on_message.
# The legacy Router.register_class_handler registers a handler for received messages.
# We need to adapt the callback style.
pass
async def send_message(self, message: MessageBase) -> bool:
# 使用 MessageConverter 转换 Legacy MessageBase 到 APIMessageBase
# 接收场景Adapter 收到来自 Napcat 的消息,发送给 MaiMBot
# group_info/user_info 是消息发送者信息,放入 sender_info
from maim_message import MessageConverter
api_message = MessageConverter.to_api_receive(
message=message,
api_key=global_config.maibot_server.api_key,
platform=message.message_info.platform or self.platform,
)
}
)
router = Router(route_config)
return await self.client.send_message(api_message)
async def send_custom_message(self, platform: str, message_type_name: str, message: Dict) -> bool:
return await self.client.send_custom_message(message_type_name, message)
async def run(self):
await self.client.start()
await self.client.connect()
async def stop(self):
await self.client.stop()
# Global variable to hold the communication object (Router or Wrapper)
router = None
async def _legacy_message_handler_adapter(message: APIMessageBase, metadata: dict):
# Adapter to call the legacy handler with dict as expected by main_send_handler
# send_handler.handle_message expects a dict.
# We need to convert APIMessageBase back to dict legacy format if possible.
# Or check what handle_message expects.
# main_send_handler.py: handle_message takes raw_message_base_dict: dict
# and does MessageBase.from_dict(raw_message_base_dict).
# So we need to serialize APIMessageBase to a dict that looks like legacy MessageBase dict.
# This might be tricky if structures diverged.
# Let's try `to_dict()` if available, otherwise construct it.
# Inspecting APIMessageBase structure from docs:
# APIMessageBase has message_info, message_segment, message_dim.
# Legacy MessageBase has message_info, message_segment.
# We can try to construct the dict.
data = {
"message_info": {
"id": message.message_info.message_id,
"timestamp": message.message_info.time,
"group_info": {}, # Fill if available
"user_info": {}, # Fill if available
},
"message_segment": {
"type": message.message_segment.type,
"data": message.message_segment.data
}
}
# Note: This is an approximation. Ideally we should check strict compatibility.
# However, for the adapter -> bot direction (sending to napcat),
# the bot sends messages to adapter? No, Adapter sends to Bot?
# mmc_com_layer seems to be for Adapter talking to MaiBot Core.
# recv_handler/message_sending.py uses this router to send TO MaiBot.
# The `register_class_handler` in `mmc_start_com` suggests MaiBot sends messages TO Adapter?
# Wait, `send_handler.handle_message` seems to be handling messages RECEIVED FROM MaiBot.
# So `router` is bidirectional.
# If explicit to_dict is needed:
await send_handler.handle_message(data)
async def mmc_start_com():
logger.info("正在连接MaiBot")
router.register_class_handler(send_handler.handle_seg)
await router.run()
global router
config = global_config.maibot_server
if config.enable_api_server and HAS_MESSAGE_CONVERTER:
logger.info("使用 API-Server 模式连接 MaiBot")
# Create legacy adapter handler
# We need to define the on_message callback here to bridge to send_handler
async def on_message_bridge(message: APIMessageBase, metadata: Dict[str, Any]):
# 使用 MessageConverter 转换 APIMessageBase 到 Legacy MessageBase
# 发送场景:收到来自 MaiMBot 的回复消息,需要发送给 Napcat
# receiver_info 包含消息接收者信息,需要提取到 group_info/user_info
try:
from maim_message import MessageConverter
legacy_message = MessageConverter.from_api_send(message)
msg_dict = legacy_message.to_dict()
await send_handler.handle_message(msg_dict)
except Exception as e:
logger.error(f"消息桥接转换失败: {e}")
import traceback
logger.error(traceback.format_exc())
client_config = create_client_config(
url=config.base_url,
api_key=config.api_key,
platform=config.platform_name,
on_message=on_message_bridge,
custom_logger=custom_logger # 传入自定义logger
)
client = WebSocketClient(client_config)
router = APIServerWrapper(client)
message_send_instance.maibot_router = router
await router.run()
else:
logger.info("使用 Legacy WebSocket 模式连接 MaiBot")
route_config = RouteConfig(
route_config={
config.platform_name: TargetConfig(
url=f"ws://{config.host}:{config.port}/ws",
token=None,
)
}
)
router = Router(route_config, custom_logger)
router.register_class_handler(send_handler.handle_message)
message_send_instance.maibot_router = router
await router.run()
async def mmc_stop_com():
await router.stop()
if router:
await router.stop()

View File

@ -1,783 +0,0 @@
from .logger import logger
from .config import global_config
from .qq_emoji_list import qq_face
import time
import asyncio
import json
import websockets as Server
from typing import List, Tuple, Optional
import uuid
from . import MetaEventType, RealMessageType, MessageType, NoticeType
from maim_message import (
UserInfo,
GroupInfo,
Seg,
BaseMessageInfo,
MessageBase,
TemplateInfo,
FormatInfo,
Router,
)
from .utils import (
get_group_info,
get_member_info,
get_image_base64,
get_self_info,
get_stranger_info,
get_message_detail,
)
from .message_queue import get_response
class RecvHandler:
maibot_router: Router = None
def __init__(self):
self.server_connection: Server.ServerConnection = None
self.interval = global_config.napcat_heartbeat_interval
async def handle_meta_event(self, message: dict) -> None:
event_type = message.get("meta_event_type")
if event_type == MetaEventType.lifecycle:
sub_type = message.get("sub_type")
if sub_type == MetaEventType.Lifecycle.connect:
self_id = message.get("self_id")
self.last_heart_beat = time.time()
logger.info(f"Bot {self_id} 连接成功")
asyncio.create_task(self.check_heartbeat(self_id))
elif event_type == MetaEventType.heartbeat:
if message["status"].get("online") and message["status"].get("good"):
self.last_heart_beat = time.time()
self.interval = message.get("interval") / 1000
else:
self_id = message.get("self_id")
logger.warning(f"Bot {self_id} Napcat 端异常!")
async def check_heartbeat(self, id: int) -> None:
while True:
now_time = time.time()
if now_time - self.last_heart_beat > self.interval + 3:
logger.warning(f"Bot {id} 连接已断开")
break
else:
logger.debug("心跳正常")
await asyncio.sleep(self.interval)
def check_allow_to_chat(self, user_id: int, group_id: Optional[int]) -> bool:
# sourcery skip: hoist-statement-from-if, merge-else-if-into-elif
"""
检查是否允许聊天
Parameters:
user_id: int: 用户ID
group_id: int: 群ID
Returns:
bool: 是否允许聊天
"""
if group_id:
if global_config.group_list_type == "whitelist" and group_id not in global_config.group_list:
logger.warning("群聊不在聊天白名单中,消息被丢弃")
return False
elif global_config.group_list_type == "blacklist" and group_id in global_config.group_list:
logger.warning("群聊在聊天黑名单中,消息被丢弃")
return False
else:
if global_config.private_list_type == "whitelist" and user_id not in global_config.private_list:
logger.warning("用户不在聊天白名单中,消息被丢弃")
return False
elif global_config.private_list_type == "blacklist" and user_id in global_config.private_list:
logger.warning("用户在聊天黑名单中,消息被丢弃")
return False
if user_id in global_config.ban_user_id:
logger.warning("用户在全局黑名单中,消息被丢弃")
return False
return True
async def handle_raw_message(self, raw_message: dict) -> None:
# sourcery skip: low-code-quality, remove-unreachable-code
"""
从Napcat接受的原始消息处理
Parameters:
raw_message: dict: 原始消息
"""
message_type: str = raw_message.get("message_type")
message_id: int = raw_message.get("message_id")
# message_time: int = raw_message.get("time")
message_time: float = time.time() # 应可乐要求现在是float了
template_info: TemplateInfo = None # 模板信息,暂时为空,等待启用
format_info: FormatInfo = FormatInfo(
content_format=["text", "image", "emoji"],
accept_format=["text", "image", "emoji", "reply"],
) # 格式化信息
if message_type == MessageType.private:
sub_type = raw_message.get("sub_type")
if sub_type == MessageType.Private.friend:
sender_info: dict = raw_message.get("sender")
if not self.check_allow_to_chat(sender_info.get("user_id"), None):
return None
# 发送者用户信息
user_info: UserInfo = UserInfo(
platform=global_config.platform,
user_id=sender_info.get("user_id"),
user_nickname=sender_info.get("nickname"),
user_cardname=sender_info.get("card"),
)
# 不存在群信息
group_info: GroupInfo = None
elif sub_type == MessageType.Private.group:
"""
本部分暂时不做支持先放着
"""
logger.warning("群临时消息类型不支持")
return None
sender_info: dict = raw_message.get("sender")
# 由于临时会话中Napcat默认不发送成员昵称所以需要单独获取
fetched_member_info: dict = await get_member_info(
self.server_connection,
raw_message.get("group_id"),
sender_info.get("user_id"),
)
nickname = fetched_member_info.get("nickname") if fetched_member_info else None
# 发送者用户信息
user_info: UserInfo = UserInfo(
platform=global_config.platform,
user_id=sender_info.get("user_id"),
user_nickname=nickname,
user_cardname=None,
)
# -------------------这里需要群信息吗?-------------------
# 获取群聊相关信息在此单独处理group_name因为默认发送的消息中没有
fetched_group_info: dict = await get_group_info(self.server_connection, raw_message.get("group_id"))
group_name = ""
if fetched_group_info.get("group_name"):
group_name = fetched_group_info.get("group_name")
group_info: GroupInfo = GroupInfo(
platform=global_config.platform,
group_id=raw_message.get("group_id"),
group_name=group_name,
)
else:
logger.warning("私聊消息类型不支持")
return None
elif message_type == MessageType.group:
sub_type = raw_message.get("sub_type")
if sub_type == MessageType.Group.normal:
sender_info: dict = raw_message.get("sender")
if not self.check_allow_to_chat(sender_info.get("user_id"), raw_message.get("group_id")):
return None
# 发送者用户信息
user_info: UserInfo = UserInfo(
platform=global_config.platform,
user_id=sender_info.get("user_id"),
user_nickname=sender_info.get("nickname"),
user_cardname=sender_info.get("card"),
)
# 获取群聊相关信息在此单独处理group_name因为默认发送的消息中没有
fetched_group_info = await get_group_info(self.server_connection, raw_message.get("group_id"))
group_name: str = None
if fetched_group_info:
group_name = fetched_group_info.get("group_name")
group_info: GroupInfo = GroupInfo(
platform=global_config.platform,
group_id=raw_message.get("group_id"),
group_name=group_name,
)
else:
logger.warning("群聊消息类型不支持")
return None
additional_config: dict = {}
if global_config.use_tts:
additional_config["allow_tts"] = True
# 消息信息
message_info: BaseMessageInfo = BaseMessageInfo(
platform=global_config.platform,
message_id=message_id,
time=message_time,
user_info=user_info,
group_info=group_info,
template_info=template_info,
format_info=format_info,
additional_config=additional_config,
)
# 处理实际信息
if not raw_message.get("message"):
logger.warning("消息内容为空")
return None
# 获取Seg列表
seg_message: List[Seg] = await self.handle_real_message(raw_message)
if not seg_message:
logger.warning("消息内容为空")
return None
submit_seg: Seg = Seg(
type="seglist",
data=seg_message,
)
# MessageBase创建
message_base: MessageBase = MessageBase(
message_info=message_info,
message_segment=submit_seg,
raw_message=raw_message.get("raw_message"),
)
logger.info("发送到Maibot处理信息")
await self.message_process(message_base)
async def handle_real_message(self, raw_message: dict, in_reply: bool = False) -> List[Seg] | None:
# sourcery skip: low-code-quality
"""
处理实际消息
Parameters:
real_message: dict: 实际消息
Returns:
seg_message: list[Seg]: 处理后的消息段列表
"""
real_message: list = raw_message.get("message")
if not real_message:
return None
seg_message: List[Seg] = []
for sub_message in real_message:
sub_message: dict
sub_message_type = sub_message.get("type")
match sub_message_type:
case RealMessageType.text:
ret_seg = await self.handle_text_message(sub_message)
if ret_seg:
seg_message.append(ret_seg)
else:
logger.warning("text处理失败")
case RealMessageType.face:
ret_seg = await self.handle_face_message(sub_message)
if ret_seg:
seg_message.append(ret_seg)
else:
logger.warning("face处理失败或不支持")
case RealMessageType.reply:
if not in_reply:
ret_seg = await self.handle_reply_message(sub_message)
if ret_seg:
seg_message += ret_seg
else:
logger.warning("reply处理失败")
case RealMessageType.image:
ret_seg = await self.handle_image_message(sub_message)
if ret_seg:
seg_message.append(ret_seg)
else:
logger.warning("image处理失败")
case RealMessageType.record:
logger.warning("不支持语音解析")
case RealMessageType.video:
logger.warning("不支持视频解析")
case RealMessageType.at:
ret_seg = await self.handle_at_message(
sub_message,
raw_message.get("self_id"),
raw_message.get("group_id"),
)
if ret_seg:
seg_message.append(ret_seg)
else:
logger.warning("at处理失败")
case RealMessageType.rps:
logger.warning("暂时不支持猜拳魔法表情解析")
case RealMessageType.dice:
logger.warning("暂时不支持骰子表情解析")
case RealMessageType.shake:
# 预计等价于戳一戳
logger.warning("暂时不支持窗口抖动解析")
case RealMessageType.share:
logger.warning("暂时不支持链接解析")
case RealMessageType.forward:
forward_message_data = sub_message.get("data")
if not forward_message_data:
logger.warning("转发消息内容为空")
return None
forward_message_id = forward_message_data.get("id")
request_uuid = str(uuid.uuid4())
payload = json.dumps(
{
"action": "get_forward_msg",
"params": {"message_id": forward_message_id},
"echo": request_uuid,
}
)
try:
await self.server_connection.send(payload)
response: dict = await get_response(request_uuid)
except TimeoutError:
logger.error("获取转发消息超时")
return None
except Exception as e:
logger.error(f"获取转发消息失败: {str(e)}")
return None
logger.debug(
f"转发消息原始格式:{json.dumps(response)[:80]}..."
if len(json.dumps(response)) > 80
else json.dumps(response)
)
response_data: dict = response.get("data")
if not response_data:
logger.warning("转发消息内容为空或获取失败")
return None
messages = response_data.get("messages")
if not messages:
logger.warning("转发消息内容为空或获取失败")
return None
ret_seg = await self.handle_forward_message(messages)
if ret_seg:
seg_message.append(ret_seg)
else:
logger.warning("转发消息处理失败")
case RealMessageType.node:
logger.warning("不支持转发消息节点解析")
case _:
logger.warning(f"未知消息类型:{sub_message_type}")
return seg_message
async def handle_text_message(self, raw_message: dict) -> Seg:
"""
处理纯文本信息
Parameters:
raw_message: dict: 原始消息
Returns:
seg_data: Seg: 处理后的消息段
"""
message_data: dict = raw_message.get("data")
plain_text: str = message_data.get("text")
return Seg(type=RealMessageType.text, data=plain_text)
async def handle_face_message(self, raw_message: dict) -> Seg | None:
"""
处理表情消息
Parameters:
raw_message: dict: 原始消息
Returns:
seg_data: Seg: 处理后的消息段
"""
message_data: dict = raw_message.get("data")
face_raw_id: str = str(message_data.get("id"))
if face_raw_id in qq_face:
face_content: str = qq_face.get(face_raw_id)
return Seg(type="text", data=face_content)
else:
logger.warning(f"不支持的表情:{face_raw_id}")
return None
async def handle_image_message(self, raw_message: dict) -> Seg | None:
"""
处理图片消息与表情包消息
Parameters:
raw_message: dict: 原始消息
Returns:
seg_data: Seg: 处理后的消息段
"""
message_data: dict = raw_message.get("data")
image_sub_type = message_data.get("sub_type")
try:
image_base64 = await get_image_base64(message_data.get("url"))
except Exception as e:
logger.error(f"图片消息处理失败: {str(e)}")
return None
if image_sub_type == 0:
"""这部分认为是图片"""
return Seg(type="image", data=image_base64)
elif image_sub_type == 1:
"""这部分认为是表情包"""
return Seg(type="emoji", data=image_base64)
else:
logger.warning(f"不支持的图片类型:{image_sub_type}")
return None
async def handle_at_message(self, raw_message: dict, self_id: int, group_id: int) -> Seg | None:
# sourcery skip: use-named-expression
"""
处理at消息
Parameters:
raw_message: dict: 原始消息
self_id: int: 机器人QQ号
group_id: int: 群号
Returns:
seg_data: Seg: 处理后的消息段
"""
message_data: dict = raw_message.get("data")
if message_data:
qq_id = message_data.get("qq")
if str(self_id) == str(qq_id):
self_info: dict = await get_self_info(self.server_connection)
if self_info:
return Seg(
type=RealMessageType.text, data=f"@<{self_info.get('nickname')}:{self_info.get('user_id')}>"
)
else:
return None
else:
member_info: dict = await get_member_info(self.server_connection, group_id=group_id, user_id=qq_id)
if member_info:
return Seg(
type=RealMessageType.text, data=f"@<{member_info.get('nickname')}:{member_info.get('user_id')}>"
)
else:
return None
async def handle_reply_message(self, raw_message: dict) -> Seg | None:
# sourcery skip: move-assign-in-block, use-named-expression
"""
处理回复消息
"""
raw_message_data: dict = raw_message.get("data")
message_id: int = None
if raw_message_data:
message_id = raw_message_data.get("id")
else:
return None
message_detail: dict = await get_message_detail(self.server_connection, message_id)
if not message_detail:
logger.warning("获取被引用的消息详情失败")
return None
reply_message = await self.handle_real_message(message_detail, in_reply=True)
if reply_message is None:
reply_message = "(获取发言内容失败)"
sender_info: dict = message_detail.get("sender")
sender_nickname: str = sender_info.get("nickname")
sender_id: str = sender_info.get("user_id")
seg_message: List[Seg] = []
if not sender_nickname:
logger.warning("无法获取被引用的人的昵称,返回默认值")
seg_message.append(Seg(type="text", data="[回复 未知用户:"))
else:
seg_message.append(Seg(type="text", data=f"[回复<{sender_nickname}:{sender_id}>"))
seg_message += reply_message
seg_message.append(Seg(type="text", data="],说:"))
return seg_message
async def handle_notice(self, raw_message: dict) -> None:
notice_type = raw_message.get("notice_type")
# message_time: int = raw_message.get("time")
message_time: float = time.time() # 应可乐要求现在是float了
group_id = raw_message.get("group_id")
user_id = raw_message.get("user_id")
handled_message: Seg = None
match notice_type:
case NoticeType.friend_recall:
logger.info("好友撤回一条消息")
logger.info(f"撤回消息ID{raw_message.get('message_id')}, 撤回时间:{raw_message.get('time')}")
logger.warning("暂时不支持撤回消息处理")
case NoticeType.group_recall:
logger.info("群内用户撤回一条消息")
logger.info(f"撤回消息ID{raw_message.get('message_id')}, 撤回时间:{raw_message.get('time')}")
logger.warning("暂时不支持撤回消息处理")
case NoticeType.notify:
sub_type = raw_message.get("sub_type")
match sub_type:
case NoticeType.Notify.poke:
if global_config.enable_poke:
handled_message: Seg = await self.handle_poke_notify(raw_message)
else:
logger.warning("戳一戳消息被禁用")
case _:
logger.warning("不支持的notify类型")
case _:
logger.warning("不支持的notice类型")
return None
if not handled_message:
logger.warning("notice处理失败或不支持")
return None
source_name: str = None
source_cardname: str = None
if group_id:
member_info: dict = await get_member_info(self.server_connection, group_id, user_id)
if member_info:
source_name = member_info.get("nickname")
source_cardname = member_info.get("card")
else:
logger.warning("无法获取戳一戳消息发送者的昵称,消息可能会无效")
source_name = "QQ用户"
else:
stranger_info = await get_stranger_info(self.server_connection, user_id)
if stranger_info:
source_name = stranger_info.get("nickname")
else:
logger.warning("无法获取戳一戳消息发送者的昵称,消息可能会无效")
source_name = "QQ用户"
user_info: UserInfo = UserInfo(
platform=global_config.platform,
user_id=user_id,
user_nickname=source_name,
user_cardname=source_cardname,
)
group_info: GroupInfo = None
if group_id:
fetched_group_info = await get_group_info(self.server_connection, group_id)
group_name: str = None
if fetched_group_info:
group_name = fetched_group_info.get("group_name")
group_info = GroupInfo(
platform=global_config.platform,
group_id=group_id,
group_name=group_name,
)
message_info: BaseMessageInfo = BaseMessageInfo(
platform=global_config.platform,
message_id="notice",
time=message_time,
user_info=user_info,
group_info=group_info,
template_info=None,
format_info=None,
)
message_base: MessageBase = MessageBase(
message_info=message_info,
message_segment=handled_message,
raw_message=json.dumps(raw_message),
)
logger.info("发送到Maibot处理通知信息")
await self.message_process(message_base)
async def handle_poke_notify(self, raw_message: dict) -> Seg | None:
self_info: dict = await get_self_info(self.server_connection)
if not self_info:
logger.error("自身信息获取失败")
return None
self_id = raw_message.get("self_id")
target_id = raw_message.get("target_id")
target_name: str = None
raw_info: list = raw_message.get("raw_info")
# 计算Seg
if self_id == target_id:
target_name = self_info.get("nickname")
else:
return None
try:
first_txt = raw_info[2].get("txt", "戳了戳")
second_txt = raw_info[4].get("txt", "")
except Exception as e:
logger.warning(f"解析戳一戳消息失败,使用默认文本:{str(e)}")
first_txt = "戳了戳"
second_txt = ""
"""
# 不启用戳其他人的处理
else:
# 由于Napcat不支持获取昵称所以需要单独获取
group_id = raw_message.get("group_id")
fetched_member_info: dict = await get_member_info(
self.server_connection, group_id, target_id
)
if fetched_member_info:
target_name = fetched_member_info.get("nickname")
"""
seg_data: Seg = Seg(
type="text",
data=f"{first_txt}{target_name}{second_txt}这是QQ的一个功能用于提及某人但没那么明显",
)
return seg_data
async def handle_forward_message(self, message_list: list) -> Seg | None:
"""
递归处理转发消息并按照动态方式确定图片处理方式
Parameters:
message_list: list: 转发消息列表
"""
handled_message, image_count = await self._handle_forward_message(message_list, 0)
handled_message: Seg
image_count: int
if not handled_message:
return None
if image_count < 5 and image_count > 0:
# 处理图片数量小于5的情况此时解析图片为base64
return await self._recursive_parse_image_seg(handled_message, True)
elif image_count > 0:
# 处理图片数量大于等于5的情况此时解析图片为占位符
return await self._recursive_parse_image_seg(handled_message, False)
else:
# 处理没有图片的情况,此时直接返回
return handled_message
async def _recursive_parse_image_seg(self, seg_data: Seg, to_image: bool) -> Seg:
# sourcery skip: merge-else-if-into-elif
if to_image:
if seg_data.type == "seglist":
new_seg_list = []
for i_seg in seg_data.data:
parsed_seg = await self._recursive_parse_image_seg(i_seg, to_image)
new_seg_list.append(parsed_seg)
return Seg(type="seglist", data=new_seg_list)
elif seg_data.type == "image":
image_url = seg_data.data
try:
encoded_image = await get_image_base64(image_url)
except Exception as e:
logger.error(f"图片处理失败: {str(e)}")
return Seg(type="text", data="[图片]")
return Seg(type="image", data=encoded_image)
elif seg_data.type == "emoji":
image_url = seg_data.data
try:
encoded_image = await get_image_base64(image_url)
except Exception as e:
logger.error(f"图片处理失败: {str(e)}")
return Seg(type="text", data="[表情包]")
return Seg(type="emoji", data=encoded_image)
else:
return seg_data
else:
if seg_data.type == "seglist":
new_seg_list = []
for i_seg in seg_data.data:
parsed_seg = await self._recursive_parse_image_seg(i_seg, to_image)
new_seg_list.append(parsed_seg)
return Seg(type="seglist", data=new_seg_list)
elif seg_data.type == "image":
image_url = seg_data.data
return Seg(type="text", data="[图片]")
elif seg_data.type == "emoji":
image_url = seg_data.data
return Seg(type="text", data="[动画表情]")
else:
return seg_data
async def _handle_forward_message(self, message_list: list, layer: int) -> Tuple[Seg, int] | Tuple[None, int]:
# sourcery skip: low-code-quality
"""
递归处理实际转发消息
Parameters:
message_list: list: 转发消息列表首层对应messages字段后面对应content字段
layer: int: 当前层级
Returns:
seg_data: Seg: 处理后的消息段
image_count: int: 图片数量
"""
seg_list = []
image_count = 0
if message_list is None:
return None, 0
for sub_message in message_list:
sub_message: dict
sender_info: dict = sub_message.get("sender")
user_nickname: str = sender_info.get("nickname", "QQ用户")
user_nickname_str = f"{user_nickname}】:"
break_seg = Seg(type="text", data="\n")
message_of_sub_message_list: dict = sub_message.get("message")
if not message_of_sub_message_list:
logger.warning("转发消息内容为空")
continue
message_of_sub_message = message_of_sub_message_list[0]
if message_of_sub_message.get("type") == RealMessageType.forward:
if layer >= 3:
full_seg_data = (
Seg(
type="text",
data=("--" * layer) + f"{user_nickname}】:【转发消息】\n",
),
0,
)
else:
sub_message_data = message_of_sub_message.get("data")
if not sub_message_data:
continue
contents = sub_message_data.get("content")
seg_data, count = await self._handle_forward_message(contents, layer + 1)
image_count += count
head_tip = Seg(
type="text",
data=("--" * layer) + f"{user_nickname}】: 合并转发消息内容:\n",
)
full_seg_data = Seg(type="seglist", data=[head_tip, seg_data])
seg_list.append(full_seg_data)
elif message_of_sub_message.get("type") == RealMessageType.text:
sub_message_data = message_of_sub_message.get("data")
if not sub_message_data:
continue
text_message = sub_message_data.get("text")
seg_data = Seg(type="text", data=text_message)
if layer > 0:
seg_list.append(
Seg(
type="seglist",
data=[
Seg(type="text", data=("--" * layer) + user_nickname_str),
seg_data,
break_seg,
],
)
)
else:
seg_list.append(
Seg(
type="seglist",
data=[
Seg(type="text", data=user_nickname_str),
seg_data,
break_seg,
],
)
)
elif message_of_sub_message.get("type") == RealMessageType.image:
image_count += 1
image_data = message_of_sub_message.get("data")
sub_type = image_data.get("sub_type")
image_url = image_data.get("url")
if sub_type == 0:
seg_data = Seg(type="image", data=image_url)
else:
seg_data = Seg(type="emoji", data=image_url)
if layer > 0:
full_seg_data = Seg(
type="seglist",
data=[
Seg(type="text", data=("--" * layer) + user_nickname_str),
seg_data,
break_seg,
],
)
else:
full_seg_data = Seg(
type="seglist",
data=[
Seg(type="text", data=user_nickname_str),
seg_data,
break_seg,
],
)
seg_list.append(full_seg_data)
return Seg(type="seglist", data=seg_list), image_count
async def message_process(self, message_base: MessageBase) -> None:
try:
await self.maibot_router.send_message(message_base)
except Exception as e:
logger.error(f"发送消息失败: {str(e)}")
logger.error("请检查与MaiBot之间的连接")
return None
recv_handler = RecvHandler()

View File

@ -0,0 +1,127 @@
from enum import Enum
class MetaEventType:
lifecycle = "lifecycle" # 生命周期
class Lifecycle:
connect = "connect" # 生命周期 - WebSocket 连接成功
heartbeat = "heartbeat" # 心跳
class MessageType: # 接受消息大类
private = "private" # 私聊消息
class Private:
friend = "friend" # 私聊消息 - 好友
group = "group" # 私聊消息 - 群临时
group_self = "group_self" # 私聊消息 - 群中自身发送
other = "other" # 私聊消息 - 其他
group = "group" # 群聊消息
class Group:
normal = "normal" # 群聊消息 - 普通
anonymous = "anonymous" # 群聊消息 - 匿名消息
notice = "notice" # 群聊消息 - 系统提示
class NoticeType: # 通知事件
friend_recall = "friend_recall" # 私聊消息撤回
group_recall = "group_recall" # 群聊消息撤回
notify = "notify"
group_ban = "group_ban" # 群禁言
group_msg_emoji_like = "group_msg_emoji_like" # 群消息表情回应
group_upload = "group_upload" # 群文件上传
group_increase = "group_increase" # 群成员增加
group_decrease = "group_decrease" # 群成员减少
group_admin = "group_admin" # 群管理员变动
essence = "essence" # 精华消息
class Notify:
poke = "poke" # 戳一戳
group_name = "group_name" # 群名称变更
class GroupBan:
ban = "ban" # 禁言
lift_ban = "lift_ban" # 解除禁言
class GroupIncrease:
approve = "approve" # 管理员同意入群
invite = "invite" # 被邀请入群
class GroupDecrease:
leave = "leave" # 主动退群
kick = "kick" # 被踢出群
kick_me = "kick_me" # 机器人被踢
class GroupAdmin:
set = "set" # 设置管理员
unset = "unset" # 取消管理员
class Essence:
add = "add" # 添加精华消息
delete = "delete" # 移除精华消息
class RealMessageType: # 实际消息分类
text = "text" # 纯文本
face = "face" # qq表情
image = "image" # 图片
record = "record" # 语音
video = "video" # 视频
at = "at" # @某人
rps = "rps" # 猜拳魔法表情
dice = "dice" # 骰子
shake = "shake" # 私聊窗口抖动(只收)
poke = "poke" # 群聊戳一戳
share = "share" # 链接分享json形式
reply = "reply" # 回复消息
forward = "forward" # 转发消息
node = "node" # 转发消息节点
json = "json" # JSON卡片消息
file = "file" # 文件消息
class MessageSentType:
private = "private"
class Private:
friend = "friend"
group = "group"
group = "group"
class Group:
normal = "normal"
class CommandType(Enum):
"""命令类型"""
GROUP_BAN = "set_group_ban" # 禁言用户
GROUP_WHOLE_BAN = "set_group_whole_ban" # 群全体禁言
GROUP_KICK = "set_group_kick" # 踢出群聊
SEND_POKE = "send_poke" # 戳一戳
DELETE_MSG = "delete_msg" # 撤回消息
def __str__(self) -> str:
return self.value
ACCEPT_FORMAT = [
"text",
"image",
"emoji",
"reply",
"voice",
"command",
"voiceurl",
"music",
"videourl",
"file",
"imageurl",
"forward",
"video",
]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,79 @@
from typing import Dict
import json
from src.logger import logger
from maim_message import MessageBase, Router
# 消息大小限制 (字节)
# WebSocket 服务端限制为 100MB这里设置 95MB 留一点余量
MAX_MESSAGE_SIZE_BYTES = 95 * 1024 * 1024 # 95MB
MAX_MESSAGE_SIZE_KB = MAX_MESSAGE_SIZE_BYTES / 1024
MAX_MESSAGE_SIZE_MB = MAX_MESSAGE_SIZE_KB / 1024
class MessageSending:
"""
负责把消息发送到麦麦
"""
maibot_router: Router = None
def __init__(self):
pass
async def message_send(self, message_base: MessageBase) -> bool:
"""
发送消息
Parameters:
message_base: MessageBase: 消息基类包含发送目标和消息内容等信息
"""
try:
# 计算消息大小用于调试
msg_dict = message_base.to_dict()
msg_json = json.dumps(msg_dict, ensure_ascii=False)
msg_size_bytes = len(msg_json.encode('utf-8'))
msg_size_kb = msg_size_bytes / 1024
msg_size_mb = msg_size_kb / 1024
logger.debug(f"发送消息大小: {msg_size_kb:.2f} KB")
# 检查消息是否超过大小限制
if msg_size_bytes > MAX_MESSAGE_SIZE_BYTES:
logger.error(
f"消息大小 ({msg_size_mb:.2f} MB) 超过限制 ({MAX_MESSAGE_SIZE_MB:.0f} MB)"
f"消息已被丢弃以避免连接断开"
)
logger.warning(
f"被丢弃的消息来源: platform={message_base.message_info.platform}, "
f"group_id={message_base.message_info.group_info.group_id if message_base.message_info.group_info else 'N/A'}, "
f"user_id={message_base.message_info.user_info.user_id if message_base.message_info.user_info else 'N/A'}"
)
return False
if msg_size_kb > 1024: # 超过 1MB 时警告
logger.warning(f"发送的消息较大 ({msg_size_mb:.2f} MB),可能导致传输延迟")
send_status = await self.maibot_router.send_message(message_base)
if not send_status:
raise RuntimeError("可能是路由未正确配置或连接异常")
logger.debug("消息发送成功")
return send_status
except Exception as e:
logger.error(f"发送消息失败: {str(e)}")
logger.error("请检查与MaiBot之间的连接")
return False
async def send_custom_message(self, custom_message: Dict, platform: str, message_type: str) -> bool:
"""
发送自定义消息
"""
try:
await self.maibot_router.send_custom_message(platform=platform, message_type_name=message_type, message=custom_message)
return True
except Exception as e:
logger.error(f"发送自定义消息失败: {str(e)}")
logger.error("请检查与MaiBot之间的连接")
return False
message_send_instance = MessageSending()

View File

@ -0,0 +1,61 @@
from src.logger import logger
from src.config import global_config
import time
import asyncio
from . import MetaEventType
class MetaEventHandler:
"""
处理Meta事件
"""
def __init__(self):
self.interval = global_config.napcat_server.heartbeat_interval
self._interval_checking = False
async def handle_meta_event(self, message: dict) -> None:
event_type = message.get("meta_event_type")
if event_type == MetaEventType.lifecycle:
sub_type = message.get("sub_type")
if sub_type == MetaEventType.Lifecycle.connect:
self_id = message.get("self_id")
self.last_heart_beat = time.time()
logger.success(f"Bot {self_id} 连接成功")
asyncio.create_task(self.check_heartbeat(self_id))
elif event_type == MetaEventType.heartbeat:
self_id = message.get("self_id")
status = message.get("status", {})
is_online = status.get("online", False)
is_good = status.get("good", False)
if is_online and is_good:
# 正常心跳
if not self._interval_checking:
asyncio.create_task(self.check_heartbeat(self_id))
self.last_heart_beat = time.time()
self.interval = message.get("interval", 30000) / 1000
else:
# Bot 离线或状态异常
if not is_online:
logger.error(f"🔴 Bot {self_id} 已下线 (online=false)")
logger.warning("Bot 可能被踢下线、网络断开或主动退出登录")
elif not is_good:
logger.warning(f"⚠️ Bot {self_id} 状态异常 (good=false)")
else:
logger.warning(f"Bot {self_id} Napcat 端异常!")
async def check_heartbeat(self, id: int) -> None:
self._interval_checking = True
while True:
now_time = time.time()
if now_time - self.last_heart_beat > self.interval * 2:
logger.error(f"Bot {id} 可能发生了连接断开被下线或者Napcat卡死")
break
else:
logger.debug("心跳正常")
await asyncio.sleep(self.interval)
meta_event_handler = MetaEventHandler()

File diff suppressed because it is too large Load Diff

View File

@ -31,7 +31,7 @@ qq_face: dict = {
"30": "[表情:奋斗]",
"31": "[表情:咒骂]",
"32": "[表情:疑问]",
"33": "[表情: 嘘]",
"33": "[表情:嘘]",
"34": "[表情:晕]",
"35": "[表情:折磨]",
"36": "[表情:衰]",
@ -117,7 +117,7 @@ qq_face: dict = {
"268": "[表情:问号脸]",
"269": "[表情:暗中观察]",
"270": "[表情emm]",
"271": "[表情:吃 瓜]",
"271": "[表情:吃瓜]",
"272": "[表情:呵呵哒]",
"273": "[表情:我酸了]",
"277": "[表情:汪汪]",
@ -146,7 +146,7 @@ qq_face: dict = {
"314": "[表情:仔细分析]",
"317": "[表情:菜汪]",
"318": "[表情:崇拜]",
"319": "[表情: 比心]",
"319": "[表情:比心]",
"320": "[表情:庆祝]",
"323": "[表情:嫌弃]",
"324": "[表情:吃糖]",
@ -175,13 +175,65 @@ qq_face: dict = {
"355": "[表情:耶]",
"356": "[表情666]",
"357": "[表情:裂开]",
"392": "[表情:龙年 快乐]",
"392": "[表情:龙年快乐]",
"393": "[表情:新年中龙]",
"394": "[表情:新年大龙]",
"395": "[表情:略略略]",
"128522": "[表情:嘿嘿]",
"128524": "[表情:羞涩]",
"128538": "[表情:亲亲]",
"128531": "[表情:汗]",
"128560": "[表情:紧张]",
"128541": "[表情:吐舌]",
"128513": "[表情:呲牙]",
"128540": "[表情:淘气]",
"9786": "[表情:可爱]",
"128532": "[表情:失落]",
"128516": "[表情:高兴]",
"128527": "[表情:哼哼]",
"128530": "[表情:不屑]",
"128563": "[表情:瞪眼]",
"128536": "[表情:飞吻]",
"128557": "[表情:大哭]",
"128514": "[表情:激动]",
"128170": "[表情:肌肉]",
"128074": "[表情:拳头]",
"128077": "[表情:厉害]",
"128079": "[表情:鼓掌]",
"128076": "[表情:好的]",
"127836": "[表情:拉面]",
"127847": "[表情:刨冰]",
"127838": "[表情:面包]",
"127866": "[表情:啤酒]",
"127867": "[表情:干杯]",
"9749": "[表情:咖啡]",
"127822": "[表情:苹果]",
"127827": "[表情:草莓]",
"127817": "[表情:西瓜]",
"127801": "[表情:玫瑰]",
"127881": "[表情:庆祝]",
"128157": "[表情:礼物]",
"10024": "[表情:闪光]",
"128168": "[表情:吹气]",
"128166": "[表情:水]",
"128293": "[表情:火]",
"128164": "[表情:睡觉]",
"128235": "[表情:邮箱]",
"128103": "[表情:女孩]",
"128102": "[表情:男孩]",
"128053": "[表情:猴]",
"128046": "[表情:牛]",
"128027": "[表情:虫]",
"128051": "[表情:鲸鱼]",
"9728": "[表情:晴天]",
"10068": "[表情:问号]",
"128147": "[表情:爱心]",
"10060": "[表情:错误]",
"128089": "[表情:内衣]",
"128104": "[表情:爸爸]",
"😊": "[表情:嘿嘿]",
"😌": "[表情:羞涩]",
"😚": "[ 表情:亲亲]",
"😚": "[表情:亲亲]",
"😓": "[表情:汗]",
"😰": "[表情:紧张]",
"😝": "[表情:吐舌]",
@ -200,7 +252,7 @@ qq_face: dict = {
"😂": "[表情:激动]",
"💪": "[表情:肌肉]",
"👊": "[表情:拳头]",
"👍": "[表情 :厉害]",
"👍": "[表情:厉害]",
"👏": "[表情:鼓掌]",
"👎": "[表情:鄙视]",
"🙏": "[表情:合十]",
@ -245,6 +297,6 @@ qq_face: dict = {
"": "[表情:晴天]",
"": "[表情:问号]",
"🔫": "[表情:手枪]",
"💓": "[表情:爱 心]",
"💓": "[表情:爱心]",
"🏪": "[表情:便利店]",
}
}

View File

@ -6,27 +6,28 @@ from .logger import logger
response_dict: Dict = {}
response_time_dict: Dict = {}
message_queue = asyncio.Queue()
async def get_response(request_id: str) -> dict:
retry_count = 0
max_retries = 50 # 10秒超时
while request_id not in response_dict:
retry_count += 1
if retry_count >= max_retries:
raise TimeoutError(f"请求超时未收到响应request_id: {request_id}")
await asyncio.sleep(0.2)
response = response_dict.pop(request_id)
async def get_response(request_id: str, timeout: int = 10) -> dict:
response = await asyncio.wait_for(_get_response(request_id), timeout)
_ = response_time_dict.pop(request_id)
logger.trace(f"响应信息id: {request_id} 已从响应字典中取出")
return response
async def _get_response(request_id: str) -> dict:
"""
内部使用的获取响应函数主要用于在需要时获取响应
"""
while request_id not in response_dict:
await asyncio.sleep(0.2)
return response_dict.pop(request_id)
async def put_response(response: dict):
echo_id = response.get("echo")
now_time = time.time()
response_dict[echo_id] = response
response_time_dict[echo_id] = now_time
logger.trace(f"响应信息id: {echo_id} 已存入响应字典")
async def check_timeout_response() -> None:
@ -34,10 +35,10 @@ async def check_timeout_response() -> None:
cleaned_message_count: int = 0
now_time = time.time()
for echo_id, response_time in list(response_time_dict.items()):
if now_time - response_time > global_config.napcat_heartbeat_interval:
if now_time - response_time > global_config.napcat_server.heartbeat_interval:
cleaned_message_count += 1
response_dict.pop(echo_id)
response_time_dict.pop(echo_id)
logger.warning(f"响应消息 {echo_id} 超时,已删除")
logger.info(f"已删除 {cleaned_message_count} 条超时响应消息")
await asyncio.sleep(global_config.napcat_heartbeat_interval)
await asyncio.sleep(global_config.napcat_server.heartbeat_interval)

View File

@ -1,188 +0,0 @@
import json
import websockets as Server
import uuid
from .config import global_config
# 白名单机制不启用
from .message_queue import get_response
from .logger import logger
from maim_message import (
UserInfo,
GroupInfo,
Seg,
BaseMessageInfo,
MessageBase,
)
from .utils import get_image_format, convert_image_to_gif
class SendHandler:
def __init__(self):
self.server_connection: Server.ServerConnection = None
async def handle_seg(self, raw_message_base_dict: dict) -> None:
raw_message_base: MessageBase = MessageBase.from_dict(raw_message_base_dict)
message_info: BaseMessageInfo = raw_message_base.message_info
message_segment: Seg = raw_message_base.message_segment
group_info: GroupInfo = message_info.group_info
user_info: UserInfo = message_info.user_info
target_id: int = None
action: str = None
id_name: str = None
processed_message: list = []
logger.info("接收到来自MaiBot的消息处理中")
try:
processed_message = await self.handle_seg_recursive(message_segment)
except Exception as e:
logger.error(f"处理消息时发生错误: {e}")
return
if processed_message:
if group_info and user_info:
target_id = group_info.group_id
action = "send_group_msg"
id_name = "group_id"
elif user_info:
target_id = user_info.user_id
action = "send_private_msg"
id_name = "user_id"
else:
logger.error("无法识别的消息类型")
return
logger.info("尝试发送到napcat")
response = await self.send_message_to_napcat(
action,
{
id_name: target_id,
"message": processed_message,
},
)
if response.get("status") == "ok":
logger.info("消息发送成功")
else:
logger.warning(f"消息发送失败napcat返回{str(response)}")
else:
logger.critical("现在暂时不支持解析此回复!")
return None
def get_level(self, seg_data: Seg) -> int:
if seg_data.type == "seglist":
return 1 + max(self.get_level(seg) for seg in seg_data.data)
else:
return 1
async def handle_seg_recursive(self, seg_data: Seg) -> list:
payload: list = []
if seg_data.type == "seglist":
# level = self.get_level(seg_data) # 给以后可能的多层嵌套做准备,此处不使用
if not seg_data.data:
return []
for seg in seg_data.data:
payload = self.process_message_by_type(seg, payload)
else:
payload = self.process_message_by_type(seg_data, payload)
return payload
def process_message_by_type(self, seg: Seg, payload: list) -> list:
# sourcery skip: reintroduce-else, swap-if-else-branches, use-named-expression
new_payload = payload
if seg.type == "reply":
target_id = seg.data
if target_id == "notice":
return []
new_payload = self.build_payload(payload, self.handle_reply_message(target_id), True)
elif seg.type == "text":
text = seg.data
if not text:
return []
new_payload = self.build_payload(payload, self.handle_text_message(text), False)
elif seg.type == "face":
pass
elif seg.type == "image":
image = seg.data
new_payload = self.build_payload(payload, self.handle_image_message(image), False)
elif seg.type == "emoji":
emoji = seg.data
new_payload = self.build_payload(payload, self.handle_emoji_message(emoji), False)
elif seg.type == "voice":
voice = seg.data
new_payload = self.build_payload(payload, self.handle_voice_message(voice), False)
return new_payload
def build_payload(self, payload: list, addon: dict, is_reply: bool = False) -> list:
# sourcery skip: for-append-to-extend, merge-list-append, simplify-generator
"""构建发送的消息体"""
if is_reply:
temp_list = []
temp_list.append(addon)
for i in payload:
temp_list.append(i)
return temp_list
else:
payload.append(addon)
return payload
def handle_reply_message(self, id: str) -> dict:
"""处理回复消息"""
return {"type": "reply", "data": {"id": id}}
def handle_text_message(self, message: str) -> dict:
"""处理文本消息"""
return {"type": "text", "data": {"text": message}}
def handle_image_message(self, encoded_image: str) -> dict:
"""处理图片消息"""
return {
"type": "image",
"data": {
"file": f"base64://{encoded_image}",
"subtype": 0,
},
} # base64 编码的图片
def handle_emoji_message(self, encoded_emoji: str) -> dict:
"""处理表情消息"""
encoded_image = encoded_emoji
image_format = get_image_format(encoded_emoji)
if image_format != "gif":
encoded_image = convert_image_to_gif(encoded_emoji)
return {
"type": "image",
"data": {
"file": f"base64://{encoded_image}",
"subtype": 1,
"summary": "[动画表情]",
},
}
def handle_voice_message(self, encoded_voice: str) -> dict:
"""处理语音消息"""
if not global_config.use_tts:
logger.warning("未启用语音消息处理")
return {}
if not encoded_voice:
return {}
return {
"type": "record",
"data": {"file": f"base64://{encoded_voice}"},
}
async def send_message_to_napcat(self, action: str, params: dict) -> dict:
request_uuid = str(uuid.uuid4())
payload = json.dumps({"action": action, "params": params, "echo": request_uuid})
await self.server_connection.send(payload)
try:
response = await get_response(request_uuid)
except TimeoutError:
logger.error("发送消息超时,未收到响应")
return {"status": "error", "message": "timeout"}
except Exception as e:
logger.error(f"发送消息失败: {e}")
return {"status": "error", "message": str(e)}
return response
send_handler = SendHandler()

View File

View File

@ -0,0 +1,174 @@
from typing import Any, Dict, Optional
import time
from maim_message import (
UserInfo,
GroupInfo,
Seg,
BaseMessageInfo,
MessageBase,
)
from src.logger import logger
from .send_command_handler import SendCommandHandleClass
from .send_message_handler import SendMessageHandleClass
from .nc_sending import nc_message_sender
from src.recv_handler.message_sending import message_send_instance
class SendHandler:
def __init__(self):
pass
async def handle_message(self, raw_message_base_dict: dict) -> None:
raw_message_base: MessageBase = MessageBase.from_dict(raw_message_base_dict)
message_segment: Seg = raw_message_base.message_segment
logger.info("接收到来自MaiBot的消息处理中")
if message_segment.type == "command":
return await self.send_command(raw_message_base)
else:
return await self.send_normal_message(raw_message_base)
async def send_command(self, raw_message_base: MessageBase) -> None:
"""
处理命令类
"""
logger.info("处理命令中")
message_info: BaseMessageInfo = raw_message_base.message_info
message_segment: Seg = raw_message_base.message_segment
group_info: GroupInfo = message_info.group_info
seg_data: Dict[str, Any] = message_segment.data
command_name = seg_data.get('name', 'UNKNOWN')
try:
command, args_dict = SendCommandHandleClass.handle_command(seg_data, group_info)
except Exception as e:
logger.error(f"处理命令时出错: {str(e)}")
# 发送错误响应给麦麦
await self._send_command_response(
platform=message_info.platform,
command_name=command_name,
success=False,
error=str(e)
)
return
if not command or not args_dict:
logger.error("命令或参数缺失")
await self._send_command_response(
platform=message_info.platform,
command_name=command_name,
success=False,
error="命令或参数缺失"
)
return None
response = await nc_message_sender.send_message_to_napcat(command, args_dict)
# 根据响应状态发送结果给麦麦
if response.get("status") == "ok":
logger.info(f"命令 {command_name} 执行成功")
await self._send_command_response(
platform=message_info.platform,
command_name=command_name,
success=True,
data=response.get("data")
)
else:
logger.warning(f"命令 {command_name} 执行失败napcat返回{str(response)}")
await self._send_command_response(
platform=message_info.platform,
command_name=command_name,
success=False,
error=str(response),
data=response.get("data") # 有些错误响应也可能包含部分数据
)
async def _send_command_response(
self,
platform: str,
command_name: str,
success: bool,
data: Optional[Dict] = None,
error: Optional[str] = None
) -> None:
"""发送命令响应回麦麦
Args:
platform: 平台标识
command_name: 命令名称
success: 是否执行成功
data: 返回数据成功时
error: 错误信息失败时
"""
response_data = {
"command_name": command_name,
"success": success,
"timestamp": time.time()
}
if data is not None:
response_data["data"] = data
if error:
response_data["error"] = error
try:
await message_send_instance.send_custom_message(
custom_message=response_data,
platform=platform,
message_type="command_response"
)
logger.debug(f"已发送命令响应: {command_name}, success={success}")
except Exception as e:
logger.error(f"发送命令响应失败: {e}")
async def send_normal_message(self, raw_message_base: MessageBase) -> None:
"""
处理普通消息发送
"""
logger.info("处理普通信息中")
message_info: BaseMessageInfo = raw_message_base.message_info
message_segment: Seg = raw_message_base.message_segment
group_info: GroupInfo = message_info.group_info
user_info: UserInfo = message_info.user_info
target_id: int = None
action: str = None
id_name: str = None
processed_message: list = []
try:
processed_message = SendMessageHandleClass.process_seg_recursive(message_segment)
except Exception as e:
logger.error(f"处理消息时发生错误: {e}")
return
if not processed_message:
logger.critical("现在暂时不支持解析此回复!")
return None
if group_info and user_info:
logger.debug("发送群聊消息")
target_id = group_info.group_id
action = "send_group_msg"
id_name = "group_id"
elif user_info:
logger.debug("发送私聊消息")
target_id = user_info.user_id
action = "send_private_msg"
id_name = "user_id"
else:
logger.error("无法识别的消息类型")
return
logger.info("尝试发送到napcat")
response = await nc_message_sender.send_message_to_napcat(
action,
{
id_name: target_id,
"message": processed_message,
},
)
if response.get("status") == "ok":
logger.info("消息发送成功")
qq_message_id = response.get("data", {}).get("message_id")
await nc_message_sender.message_sent_back(raw_message_base, qq_message_id)
else:
logger.warning(f"消息发送失败napcat返回{str(response)}")
send_handler = SendHandler()

View File

@ -0,0 +1,61 @@
import json
import uuid
import websockets as Server
from maim_message import MessageBase
from src.response_pool import get_response
from src.logger import logger
from src.recv_handler.message_sending import message_send_instance
class NCMessageSender:
def __init__(self):
self.server_connection: Server.ServerConnection = None
async def set_server_connection(self, connection: Server.ServerConnection):
self.server_connection = connection
async def send_message_to_napcat(self, action: str, params: dict) -> dict:
request_uuid = str(uuid.uuid4())
payload = json.dumps({"action": action, "params": params, "echo": request_uuid})
await self.server_connection.send(payload)
try:
response = await get_response(request_uuid)
except TimeoutError:
logger.error("发送消息超时,未收到响应")
return {"status": "error", "message": "timeout"}
except Exception as e:
logger.error(f"发送消息失败: {e}")
return {"status": "error", "message": str(e)}
return response
async def message_sent_back(self, message_base: MessageBase, qq_message_id: str) -> None:
# # 修改 additional_config添加 echo 字段
# if message_base.message_info.additional_config is None:
# message_base.message_info.additional_config = {}
# message_base.message_info.additional_config["echo"] = True
# # 获取原始的 mmc_message_id
# mmc_message_id = message_base.message_info.message_id
# # 修改 message_segment 为 notify 类型
# message_base.message_segment = Seg(
# type="notify", data={"sub_type": "echo", "echo": mmc_message_id, "actual_id": qq_message_id}
# )
# await message_send_instance.message_send(message_base)
# logger.debug("已回送消息ID")
# return
platform = message_base.message_info.platform
mmc_message_id = message_base.message_info.message_id
echo_data = {
"type": "echo",
"echo": mmc_message_id,
"actual_id": qq_message_id,
}
success = await message_send_instance.send_custom_message(echo_data, platform, "message_id_echo")
if success:
logger.debug("已回送消息ID")
else:
logger.error("回送消息ID失败")
nc_message_sender = NCMessageSender()

View File

@ -0,0 +1,719 @@
from maim_message import GroupInfo
from typing import Any, Dict, Tuple, Callable, Optional
from src import CommandType
# 全局命令处理器注册表(在类外部定义以避免循环引用)
_command_handlers: Dict[str, Dict[str, Any]] = {}
def register_command(command_type: CommandType, require_group: bool = True):
"""装饰器:注册命令处理器
Args:
command_type: 命令类型
require_group: 是否需要群聊信息默认为True
Returns:
装饰器函数
"""
def decorator(func: Callable) -> Callable:
_command_handlers[command_type.name] = {
"handler": func,
"require_group": require_group,
}
return func
return decorator
class SendCommandHandleClass:
@classmethod
def handle_command(cls, raw_command_data: Dict[str, Any], group_info: Optional[GroupInfo]):
"""统一命令处理入口
Args:
raw_command_data: 原始命令数据
group_info: 群聊信息可选
Returns:
Tuple[str, Dict[str, Any]]: (action, params) 用于发送给NapCat
Raises:
RuntimeError: 命令类型未知或处理失败
"""
command_name: str = raw_command_data.get("name")
if command_name not in _command_handlers:
raise RuntimeError(f"未知的命令类型: {command_name}")
try:
handler_info = _command_handlers[command_name]
handler = handler_info["handler"]
require_group = handler_info["require_group"]
# 检查群聊信息要求
if require_group and not group_info:
raise ValueError(f"命令 {command_name} 需要在群聊上下文中使用")
# 调用处理器
args = raw_command_data.get("args", {})
return handler(args, group_info)
except Exception as e:
raise RuntimeError(f"处理命令 {command_name} 时出错: {str(e)}") from e
# ============ 命令处理器(使用装饰器注册)============
@staticmethod
@register_command(CommandType.GROUP_BAN, require_group=True)
def handle_ban_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""处理封禁命令
Args:
args: 参数字典 {"qq_id": int, "duration": int}
group_info: 群聊信息对应目标群聊
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
duration: int = int(args["duration"])
user_id: int = int(args["qq_id"])
group_id: int = int(group_info.group_id)
if duration < 0:
raise ValueError("封禁时间必须大于等于0")
if not user_id or not group_id:
raise ValueError("封禁命令缺少必要参数")
if duration > 2592000:
raise ValueError("封禁时间不能超过30天")
return (
CommandType.GROUP_BAN.value,
{
"group_id": group_id,
"user_id": user_id,
"duration": duration,
},
)
@staticmethod
@register_command(CommandType.GROUP_WHOLE_BAN, require_group=True)
def handle_whole_ban_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""处理全体禁言命令
Args:
args: 参数字典 {"enable": bool}
group_info: 群聊信息对应目标群聊
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
enable = args["enable"]
assert isinstance(enable, bool), "enable参数必须是布尔值"
group_id: int = int(group_info.group_id)
if group_id <= 0:
raise ValueError("群组ID无效")
return (
CommandType.GROUP_WHOLE_BAN.value,
{
"group_id": group_id,
"enable": enable,
},
)
@staticmethod
@register_command(CommandType.GROUP_KICK, require_group=False)
def handle_kick_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""处理群成员踢出命令
Args:
args: 参数字典 {"group_id": int, "user_id": int, "reject_add_request": bool (可选)}
group_info: 群聊信息可选可自动获取 group_id
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
if not args:
raise ValueError("群踢人命令缺少参数")
# 优先从 args 获取 group_id否则从 group_info 获取
group_id = args.get("group_id")
if not group_id and group_info:
group_id = int(group_info.group_id)
user_id = args.get("user_id")
if not group_id:
raise ValueError("群踢人命令缺少必要参数: group_id")
if not user_id:
raise ValueError("群踢人命令缺少必要参数: user_id")
group_id = int(group_id)
user_id = int(user_id)
if group_id <= 0:
raise ValueError("群组ID无效")
if user_id <= 0:
raise ValueError("用户ID无效")
# reject_add_request 是可选参数,默认 False
reject_add_request = args.get("reject_add_request", False)
return (
CommandType.GROUP_KICK.value,
{
"group_id": group_id,
"user_id": user_id,
"reject_add_request": bool(reject_add_request),
},
)
@staticmethod
@register_command(CommandType.GROUP_KICK_MEMBERS, require_group=False)
def handle_kick_members_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""处理批量踢出群成员命令
Args:
args: 参数字典 {"group_id": int, "user_id": List[int], "reject_add_request": bool (可选)}
group_info: 群聊信息可选可自动获取 group_id
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
if not args:
raise ValueError("批量踢人命令缺少参数")
# 优先从 args 获取 group_id否则从 group_info 获取
group_id = args.get("group_id")
if not group_id and group_info:
group_id = int(group_info.group_id)
user_id = args.get("user_id")
if not group_id:
raise ValueError("批量踢人命令缺少必要参数: group_id")
if not user_id:
raise ValueError("批量踢人命令缺少必要参数: user_id")
# 验证 user_id 是数组
if not isinstance(user_id, list):
raise ValueError("user_id 必须是数组类型")
if len(user_id) == 0:
raise ValueError("user_id 数组不能为空")
# 转换并验证每个 user_id
user_id_list = []
for uid in user_id:
try:
uid_int = int(uid)
if uid_int <= 0:
raise ValueError(f"用户ID无效: {uid}")
user_id_list.append(uid_int)
except (ValueError, TypeError) as e:
raise ValueError(f"用户ID格式错误: {uid} - {str(e)}") from None
group_id = int(group_id)
if group_id <= 0:
raise ValueError("群组ID无效")
# reject_add_request 是可选参数,默认 False
reject_add_request = args.get("reject_add_request", False)
return (
CommandType.GROUP_KICK_MEMBERS.value,
{
"group_id": group_id,
"user_id": user_id_list,
"reject_add_request": bool(reject_add_request),
},
)
@staticmethod
@register_command(CommandType.SEND_POKE, require_group=False)
def handle_poke_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""处理戳一戳命令
Args:
args: 参数字典 {"qq_id": int}
group_info: 群聊信息可选私聊时为None
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
user_id: int = int(args["qq_id"])
if group_info is None:
group_id = None
else:
group_id: int = int(group_info.group_id)
if group_id <= 0:
raise ValueError("群组ID无效")
if user_id <= 0:
raise ValueError("用户ID无效")
return (
CommandType.SEND_POKE.value,
{
"group_id": group_id,
"user_id": user_id,
},
)
@staticmethod
@register_command(CommandType.SET_GROUP_NAME, require_group=False)
def handle_set_group_name_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""设置群名
Args:
args: 参数字典 {"group_id": int, "group_name": str}
group_info: 群聊信息可选可自动获取 group_id
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
if not args:
raise ValueError("设置群名命令缺少参数")
# 优先从 args 获取 group_id否则从 group_info 获取
group_id = args.get("group_id")
if not group_id and group_info:
group_id = int(group_info.group_id)
group_name = args.get("group_name")
if not group_id:
raise ValueError("设置群名命令缺少必要参数: group_id")
if not group_name:
raise ValueError("设置群名命令缺少必要参数: group_name")
group_id = int(group_id)
if group_id <= 0:
raise ValueError("群组ID无效")
return (
CommandType.SET_GROUP_NAME.value,
{
"group_id": group_id,
"group_name": str(group_name),
},
)
@staticmethod
@register_command(CommandType.DELETE_MSG, require_group=False)
def delete_msg_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""处理撤回消息命令
Args:
args: 参数字典 {"message_id": int}
group_info: 群聊信息不使用
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
try:
message_id = int(args["message_id"])
if message_id <= 0:
raise ValueError("消息ID无效")
except KeyError:
raise ValueError("缺少必需参数: message_id") from None
except (ValueError, TypeError) as e:
raise ValueError(f"消息ID无效: {args['message_id']} - {str(e)}") from None
return (CommandType.DELETE_MSG.value, {"message_id": message_id})
@staticmethod
@register_command(CommandType.SET_QQ_PROFILE, require_group=False)
def handle_set_qq_profile_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""设置账号信息
Args:
args: 参数字典 {"nickname": str, "personal_note": str (可选), "sex": str (可选)}
group_info: 群聊信息不使用
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
if not args:
raise ValueError("设置账号信息命令缺少参数")
nickname = args.get("nickname")
if not nickname:
raise ValueError("设置账号信息命令缺少必要参数: nickname")
params = {"nickname": str(nickname)}
# 可选参数
if "personal_note" in args:
params["personal_note"] = str(args["personal_note"])
if "sex" in args:
sex = str(args["sex"]).lower()
if sex not in ["male", "female", "unknown"]:
raise ValueError(f"性别参数无效: {sex},必须为 male/female/unknown 之一")
params["sex"] = sex
return (CommandType.SET_QQ_PROFILE.value, params)
@staticmethod
@register_command(CommandType.AI_VOICE_SEND, require_group=True)
def handle_ai_voice_send_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""处理AI语音发送命令
Args:
args: 参数字典 {"character": str, "text": str}
group_info: 群聊信息
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
if not group_info or not group_info.group_id:
raise ValueError("AI语音发送命令必须在群聊上下文中使用")
if not args:
raise ValueError("AI语音发送命令缺少参数")
group_id: int = int(group_info.group_id)
character_id = args.get("character")
text_content = args.get("text")
if not character_id or not text_content:
raise ValueError(f"AI语音发送命令参数不完整: character='{character_id}', text='{text_content}'")
return (
CommandType.AI_VOICE_SEND.value,
{
"group_id": group_id,
"text": text_content,
"character": character_id,
},
)
@staticmethod
@register_command(CommandType.SET_MSG_EMOJI_LIKE, require_group=False)
def handle_set_msg_emoji_like_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""处理给消息贴表情命令
Args:
args: 参数字典 {"message_id": int, "emoji_id": int}
group_info: 群聊信息不使用
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
if not args:
raise ValueError("消息贴表情命令缺少参数")
message_id = args.get("message_id")
emoji_id = args.get("emoji_id")
if not message_id:
raise ValueError("消息贴表情命令缺少必要参数: message_id")
if not emoji_id:
raise ValueError("消息贴表情命令缺少必要参数: emoji_id")
message_id = int(message_id)
emoji_id = int(emoji_id)
if message_id <= 0:
raise ValueError("消息ID无效")
if emoji_id <= 0:
raise ValueError("表情ID无效")
return (
CommandType.SET_MSG_EMOJI_LIKE.value,
{
"message_id": message_id,
"emoji_id": emoji_id,
"set": True,
},
)
# ============ 查询类命令处理器 ============
@staticmethod
@register_command(CommandType.GET_LOGIN_INFO, require_group=False)
def handle_get_login_info_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""获取登录号信息Bot自身信息
Args:
args: 参数字典无需参数
group_info: 群聊信息不使用
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
return (CommandType.GET_LOGIN_INFO.value, {})
@staticmethod
@register_command(CommandType.GET_STRANGER_INFO, require_group=False)
def handle_get_stranger_info_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""获取陌生人信息
Args:
args: 参数字典 {"user_id": int}
group_info: 群聊信息不使用
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
if not args:
raise ValueError("获取陌生人信息命令缺少参数")
user_id = args.get("user_id")
if not user_id:
raise ValueError("获取陌生人信息命令缺少必要参数: user_id")
user_id = int(user_id)
if user_id <= 0:
raise ValueError("用户ID无效")
return (
CommandType.GET_STRANGER_INFO.value,
{"user_id": user_id},
)
@staticmethod
@register_command(CommandType.GET_FRIEND_LIST, require_group=False)
def handle_get_friend_list_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""获取好友列表
Args:
args: 参数字典 {"no_cache": bool} (可选默认 false)
group_info: 群聊信息不使用
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
# no_cache 参数是可选的,默认为 false
no_cache = args.get("no_cache", False) if args else False
return (CommandType.GET_FRIEND_LIST.value, {"no_cache": bool(no_cache)})
@staticmethod
@register_command(CommandType.GET_GROUP_INFO, require_group=False)
def handle_get_group_info_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""获取群信息
Args:
args: 参数字典 {"group_id": int} 或从 group_info 自动获取
group_info: 群聊信息可选
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
# 优先从 args 获取,否则从 group_info 获取
group_id = args.get("group_id") if args else None
if not group_id and group_info:
group_id = int(group_info.group_id)
if not group_id:
raise ValueError("获取群信息命令缺少必要参数: group_id")
group_id = int(group_id)
if group_id <= 0:
raise ValueError("群组ID无效")
return (
CommandType.GET_GROUP_INFO.value,
{"group_id": group_id},
)
@staticmethod
@register_command(CommandType.GET_GROUP_DETAIL_INFO, require_group=False)
def handle_get_group_detail_info_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""获取群详细信息
Args:
args: 参数字典 {"group_id": int} 或从 group_info 自动获取
group_info: 群聊信息可选
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
# 优先从 args 获取,否则从 group_info 获取
group_id = args.get("group_id") if args else None
if not group_id and group_info:
group_id = int(group_info.group_id)
if not group_id:
raise ValueError("获取群详细信息命令缺少必要参数: group_id")
group_id = int(group_id)
if group_id <= 0:
raise ValueError("群组ID无效")
return (
CommandType.GET_GROUP_DETAIL_INFO.value,
{"group_id": group_id},
)
@staticmethod
@register_command(CommandType.GET_GROUP_LIST, require_group=False)
def handle_get_group_list_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""获取群列表
Args:
args: 参数字典 {"no_cache": bool} (可选默认 false)
group_info: 群聊信息不使用
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
# no_cache 参数是可选的,默认为 false
no_cache = args.get("no_cache", False) if args else False
return (CommandType.GET_GROUP_LIST.value, {"no_cache": bool(no_cache)})
@staticmethod
@register_command(CommandType.GET_GROUP_AT_ALL_REMAIN, require_group=False)
def handle_get_group_at_all_remain_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""获取群@全体成员剩余次数
Args:
args: 参数字典 {"group_id": int} 或从 group_info 自动获取
group_info: 群聊信息可选
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
# 优先从 args 获取,否则从 group_info 获取
group_id = args.get("group_id") if args else None
if not group_id and group_info:
group_id = int(group_info.group_id)
if not group_id:
raise ValueError("获取群@全体成员剩余次数命令缺少必要参数: group_id")
group_id = int(group_id)
if group_id <= 0:
raise ValueError("群组ID无效")
return (
CommandType.GET_GROUP_AT_ALL_REMAIN.value,
{"group_id": group_id},
)
@staticmethod
@register_command(CommandType.GET_GROUP_MEMBER_INFO, require_group=False)
def handle_get_group_member_info_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""获取群成员信息
Args:
args: 参数字典 {"group_id": int, "user_id": int, "no_cache": bool} group_id group_info 自动获取
group_info: 群聊信息可选
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
if not args:
raise ValueError("获取群成员信息命令缺少参数")
# 优先从 args 获取,否则从 group_info 获取
group_id = args.get("group_id")
if not group_id and group_info:
group_id = int(group_info.group_id)
user_id = args.get("user_id")
no_cache = args.get("no_cache", False)
if not group_id:
raise ValueError("获取群成员信息命令缺少必要参数: group_id")
if not user_id:
raise ValueError("获取群成员信息命令缺少必要参数: user_id")
group_id = int(group_id)
user_id = int(user_id)
if group_id <= 0:
raise ValueError("群组ID无效")
if user_id <= 0:
raise ValueError("用户ID无效")
return (
CommandType.GET_GROUP_MEMBER_INFO.value,
{
"group_id": group_id,
"user_id": user_id,
"no_cache": bool(no_cache),
},
)
@staticmethod
@register_command(CommandType.GET_GROUP_MEMBER_LIST, require_group=False)
def handle_get_group_member_list_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""获取群成员列表
Args:
args: 参数字典 {"group_id": int, "no_cache": bool} group_id group_info 自动获取
group_info: 群聊信息可选
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
# 优先从 args 获取,否则从 group_info 获取
group_id = args.get("group_id") if args else None
if not group_id and group_info:
group_id = int(group_info.group_id)
no_cache = args.get("no_cache", False) if args else False
if not group_id:
raise ValueError("获取群成员列表命令缺少必要参数: group_id")
group_id = int(group_id)
if group_id <= 0:
raise ValueError("群组ID无效")
return (
CommandType.GET_GROUP_MEMBER_LIST.value,
{
"group_id": group_id,
"no_cache": bool(no_cache),
},
)
@staticmethod
@register_command(CommandType.GET_MSG, require_group=False)
def handle_get_msg_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""获取消息详情
Args:
args: 参数字典 {"message_id": int}
group_info: 群聊信息不使用
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
if not args:
raise ValueError("获取消息命令缺少参数")
message_id = args.get("message_id")
if not message_id:
raise ValueError("获取消息命令缺少必要参数: message_id")
message_id = int(message_id)
if message_id <= 0:
raise ValueError("消息ID无效")
return (
CommandType.GET_MSG.value,
{"message_id": message_id},
)
@staticmethod
@register_command(CommandType.GET_FORWARD_MSG, require_group=False)
def handle_get_forward_msg_command(args: Dict[str, Any], group_info: Optional[GroupInfo]) -> Tuple[str, Dict[str, Any]]:
"""获取合并转发消息
Args:
args: 参数字典 {"message_id": str}
group_info: 群聊信息不使用
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
if not args:
raise ValueError("获取合并转发消息命令缺少参数")
message_id = args.get("message_id")
if not message_id:
raise ValueError("获取合并转发消息命令缺少必要参数: message_id")
return (
CommandType.GET_FORWARD_MSG.value,
{"message_id": str(message_id)},
)

View File

@ -0,0 +1,295 @@
from maim_message import Seg, MessageBase
from typing import List, Dict
from src.logger import logger
from src.config import global_config
from src.utils import get_image_format, convert_image_to_gif
class SendMessageHandleClass:
@classmethod
def parse_seg_to_nc_format(cls, message_segment: Seg):
parsed_payload: List = cls.process_seg_recursive(message_segment)
return parsed_payload
@classmethod
def process_seg_recursive(cls, seg_data: Seg, in_forward: bool = False) -> List:
payload: List = []
if seg_data.type == "seglist":
if not seg_data.data:
return []
for seg in seg_data.data:
payload = cls.process_message_by_type(seg, payload, in_forward)
else:
payload = cls.process_message_by_type(seg_data, payload, in_forward)
return payload
@classmethod
def process_message_by_type(cls, seg: Seg, payload: List, in_forward: bool = False) -> List:
# sourcery skip: for-append-to-extend, reintroduce-else, swap-if-else-branches, use-named-expression
new_payload = payload
if seg.type == "reply":
target_id = seg.data
if target_id == "notice":
return payload
new_payload = cls.build_payload(payload, cls.handle_reply_message(target_id), True)
elif seg.type == "text":
text = seg.data
if not text:
return payload
new_payload = cls.build_payload(payload, cls.handle_text_message(text), False)
elif seg.type == "face":
face_id = seg.data
new_payload = cls.build_payload(payload, cls.handle_native_face_message(face_id), False)
elif seg.type == "image":
image = seg.data
new_payload = cls.build_payload(payload, cls.handle_image_message(image), False)
elif seg.type == "emoji":
emoji = seg.data
new_payload = cls.build_payload(payload, cls.handle_emoji_message(emoji), False)
elif seg.type == "voice":
voice = seg.data
new_payload = cls.build_payload(payload, cls.handle_voice_message(voice), False)
elif seg.type == "voiceurl":
voice_url = seg.data
new_payload = cls.build_payload(payload, cls.handle_voiceurl_message(voice_url), False)
elif seg.type == "music":
music_data = seg.data
new_payload = cls.build_payload(payload, cls.handle_music_message(music_data), False)
elif seg.type == "videourl":
video_url = seg.data
new_payload = cls.build_payload(payload, cls.handle_videourl_message(video_url), False)
elif seg.type == "file":
file_path = seg.data
new_payload = cls.build_payload(payload, cls.handle_file_message(file_path), False)
elif seg.type == "imageurl":
image_url = seg.data
new_payload = cls.build_payload(payload, cls.handle_imageurl_message(image_url), False)
elif seg.type == "video":
video_path = seg.data
new_payload = cls.build_payload(payload, cls.handle_video_message(video_path), False)
elif seg.type == "forward" and not in_forward:
forward_message_content: List[Dict] = seg.data
new_payload: List[Dict] = [
cls.handle_forward_message(MessageBase.from_dict(item)) for item in forward_message_content
] # 转发消息不能和其他消息一起发送
return new_payload
@classmethod
def handle_forward_message(cls, item: MessageBase) -> Dict:
# sourcery skip: remove-unnecessary-else
message_segment: Seg = item.message_segment
if message_segment.type == "id":
return {"type": "node", "data": {"id": message_segment.data}}
else:
user_info = item.message_info.user_info
content = cls.process_seg_recursive(message_segment, True)
return {
"type": "node",
"data": {"name": user_info.user_nickname or "QQ用户", "uin": user_info.user_id, "content": content},
}
@staticmethod
def build_payload(payload: List, addon: dict, is_reply: bool = False) -> List:
# sourcery skip: for-append-to-extend, merge-list-append, simplify-generator
if is_reply:
temp_list = []
temp_list.append(addon)
for i in payload:
if i.get("type") == "reply":
logger.debug("检测到多个回复,使用最新的回复")
continue
temp_list.append(i)
return temp_list
else:
payload.append(addon)
return payload
@staticmethod
def handle_reply_message(id: str) -> dict:
"""处理回复消息"""
return {"type": "reply", "data": {"id": id}}
@staticmethod
def handle_text_message(message: str) -> dict:
"""处理文本消息"""
return {"type": "text", "data": {"text": message}}
@staticmethod
def handle_native_face_message(face_id: int) -> dict:
# sourcery skip: remove-unnecessary-cast
"""处理原生表情消息"""
return {"type": "face", "data": {"id": int(face_id)}}
@staticmethod
def handle_image_message(encoded_image: str) -> dict:
"""处理图片消息"""
return {
"type": "image",
"data": {
"file": f"base64://{encoded_image}",
"subtype": 0,
},
} # base64 编码的图片
@staticmethod
def handle_emoji_message(encoded_emoji: str) -> dict:
"""处理表情消息"""
encoded_image = encoded_emoji
image_format = get_image_format(encoded_emoji)
if image_format != "gif":
encoded_image = convert_image_to_gif(encoded_emoji)
return {
"type": "image",
"data": {
"file": f"base64://{encoded_image}",
"subtype": 1,
"summary": "[动画表情]",
},
}
@staticmethod
def handle_voice_message(encoded_voice: str) -> dict:
"""处理语音消息"""
if not global_config.voice.use_tts:
logger.warning("未启用语音消息处理")
return {}
if not encoded_voice:
return {}
return {
"type": "record",
"data": {"file": f"base64://{encoded_voice}"},
}
@staticmethod
def handle_voiceurl_message(voice_url: str) -> dict:
"""处理语音链接消息"""
return {
"type": "record",
"data": {"file": voice_url},
}
@staticmethod
def handle_music_message(music_data) -> dict:
"""
处理音乐消息
music_data 可以是
1. 字符串默认为网易云音乐ID
2. 字典{"type": "163"/"qq", "id": "歌曲ID"}
"""
# 兼容旧格式直接传入歌曲ID字符串
if isinstance(music_data, str):
return {
"type": "music",
"data": {"type": "163", "id": music_data},
}
# 新格式字典包含平台和ID
if isinstance(music_data, dict):
platform = music_data.get("type", "163") # 默认网易云
song_id = music_data.get("id", "")
# 验证平台类型
if platform not in ["163", "qq"]:
logger.warning(f"不支持的音乐平台: {platform}使用默认平台163")
platform = "163"
# 确保ID是字符串
if not isinstance(song_id, str):
song_id = str(song_id)
return {
"type": "music",
"data": {"type": platform, "id": song_id},
}
# 其他情况返回空
logger.error(f"不支持的音乐数据格式: {type(music_data)}")
return {}
@staticmethod
def handle_videourl_message(video_url: str) -> dict:
"""处理视频链接消息"""
return {
"type": "video",
"data": {"file": video_url},
}
@staticmethod
def handle_file_message(file_data) -> dict:
"""处理文件消息
Args:
file_data: 可以是字符串文件路径或字典完整文件信息
- 字符串简单的文件路径
- 字典包含 file, name, path, thumb, url 等字段
Returns:
NapCat 格式的文件消息段
"""
# 如果是简单的字符串路径(兼容旧版本)
if isinstance(file_data, str):
return {
"type": "file",
"data": {"file": f"file://{file_data}"},
}
# 如果是完整的字典数据
if isinstance(file_data, dict):
data = {}
# file 字段是必需的
if "file" in file_data:
file_value = file_data["file"]
# 如果是本地路径且没有协议前缀,添加 file:// 前缀
if not any(file_value.startswith(prefix) for prefix in ["file://", "http://", "https://", "base64://"]):
data["file"] = f"file://{file_value}"
else:
data["file"] = file_value
else:
# 没有 file 字段,尝试使用 path 或 url
if "path" in file_data:
data["file"] = f"file://{file_data['path']}"
elif "url" in file_data:
data["file"] = file_data["url"]
else:
logger.warning("文件消息缺少必要的 file/path/url 字段")
return None
# 添加可选字段
if "name" in file_data:
data["name"] = file_data["name"]
if "thumb" in file_data:
data["thumb"] = file_data["thumb"]
if "url" in file_data and "file" not in file_data:
data["file"] = file_data["url"]
return {
"type": "file",
"data": data,
}
logger.warning(f"不支持的文件数据类型: {type(file_data)}")
return None
@staticmethod
def handle_imageurl_message(image_url: str) -> dict:
"""处理图片链接消息"""
return {
"type": "image",
"data": {"file": image_url},
}
@staticmethod
def handle_video_message(encoded_video: str) -> dict:
"""处理视频消息base64格式"""
if not encoded_video:
logger.error("视频数据为空")
return {}
logger.info(f"处理视频消息,数据长度: {len(encoded_video)} 字符")
return {
"type": "video",
"data": {"file": f"base64://{encoded_video}"},
}

View File

@ -2,14 +2,16 @@ import websockets as Server
import json
import base64
import uuid
from .logger import logger
from .message_queue import get_response
import urllib3
import ssl
import io
from src.database import BanUser, db_manager
from .logger import logger
from .response_pool import get_response
from PIL import Image
import io
from typing import Union, List, Tuple, Optional
class SSLAdapter(urllib3.PoolManager):
@ -21,12 +23,13 @@ class SSLAdapter(urllib3.PoolManager):
super().__init__(*args, **kwargs)
async def get_group_info(websocket: Server.ServerConnection, group_id: int) -> dict:
async def get_group_info(websocket: Server.ServerConnection, group_id: int) -> dict | None:
"""
获取群相关信息
返回值需要处理可能为空的情况
"""
logger.debug("获取群聊信息中")
request_uuid = str(uuid.uuid4())
payload = json.dumps({"action": "get_group_info", "params": {"group_id": group_id}, "echo": request_uuid})
try:
@ -42,12 +45,35 @@ async def get_group_info(websocket: Server.ServerConnection, group_id: int) -> d
return socket_response.get("data")
async def get_member_info(websocket: Server.ServerConnection, group_id: int, user_id: int) -> dict:
async def get_group_detail_info(websocket: Server.ServerConnection, group_id: int) -> dict | None:
"""
获取群详细信息
返回值需要处理可能为空的情况
"""
logger.debug("获取群详细信息中")
request_uuid = str(uuid.uuid4())
payload = json.dumps({"action": "get_group_detail_info", "params": {"group_id": group_id}, "echo": request_uuid})
try:
await websocket.send(payload)
socket_response: dict = await get_response(request_uuid)
except TimeoutError:
logger.error(f"获取群详细信息超时,群号: {group_id}")
return None
except Exception as e:
logger.error(f"获取群详细信息失败: {e}")
return None
logger.debug(socket_response)
return socket_response.get("data")
async def get_member_info(websocket: Server.ServerConnection, group_id: int, user_id: int) -> dict | None:
"""
获取群成员信息
返回值需要处理可能为空的情况
"""
logger.debug("获取群成员信息中")
request_uuid = str(uuid.uuid4())
payload = json.dumps(
{
@ -72,6 +98,7 @@ async def get_member_info(websocket: Server.ServerConnection, group_id: int, use
async def get_image_base64(url: str) -> str:
# sourcery skip: raise-specific-error
"""获取图片/表情包的Base64"""
logger.debug(f"下载图片: {url}")
http = SSLAdapter()
try:
response = http.request("GET", url, timeout=10)
@ -85,6 +112,15 @@ async def get_image_base64(url: str) -> str:
def convert_image_to_gif(image_base64: str) -> str:
# sourcery skip: extract-method
"""
将Base64编码的图片转换为GIF格式
Parameters:
image_base64: str: Base64编码的图片数据
Returns:
str: Base64编码的GIF图片数据
"""
logger.debug("转换图片为GIF格式")
try:
image_bytes = base64.b64decode(image_base64)
image = Image.open(io.BytesIO(image_bytes))
@ -97,7 +133,7 @@ def convert_image_to_gif(image_base64: str) -> str:
return image_base64
async def get_self_info(websocket: Server.ServerConnection) -> dict:
async def get_self_info(websocket: Server.ServerConnection) -> dict | None:
"""
获取自身信息
Parameters:
@ -105,6 +141,7 @@ async def get_self_info(websocket: Server.ServerConnection) -> dict:
Returns:
data: dict: 返回的自身信息
"""
logger.debug("获取自身信息中")
request_uuid = str(uuid.uuid4())
payload = json.dumps({"action": "get_login_info", "params": {}, "echo": request_uuid})
try:
@ -132,7 +169,7 @@ def get_image_format(raw_data: str) -> str:
return Image.open(io.BytesIO(image_bytes)).format.lower()
async def get_stranger_info(websocket: Server.ServerConnection, user_id: int) -> dict:
async def get_stranger_info(websocket: Server.ServerConnection, user_id: int) -> dict | None:
"""
获取陌生人信息
Parameters:
@ -141,6 +178,7 @@ async def get_stranger_info(websocket: Server.ServerConnection, user_id: int) ->
Returns:
dict: 返回的陌生人信息
"""
logger.debug("获取陌生人信息中")
request_uuid = str(uuid.uuid4())
payload = json.dumps({"action": "get_stranger_info", "params": {"user_id": user_id}, "echo": request_uuid})
try:
@ -156,7 +194,7 @@ async def get_stranger_info(websocket: Server.ServerConnection, user_id: int) ->
return response.get("data")
async def get_message_detail(websocket: Server.ServerConnection, message_id: str) -> dict:
async def get_message_detail(websocket: Server.ServerConnection, message_id: Union[str, int]) -> dict | None:
"""
获取消息详情可能为空
Parameters:
@ -165,11 +203,12 @@ async def get_message_detail(websocket: Server.ServerConnection, message_id: str
Returns:
dict: 返回的消息详情
"""
logger.debug("获取消息详情中")
request_uuid = str(uuid.uuid4())
payload = json.dumps({"action": "get_msg", "params": {"message_id": message_id}, "echo": request_uuid})
try:
await websocket.send(payload)
response: dict = await get_response(request_uuid)
response: dict = await get_response(request_uuid, 30) # 增加超时时间到30秒
except TimeoutError:
logger.error(f"获取消息详情超时消息ID: {message_id}")
return None
@ -178,3 +217,94 @@ async def get_message_detail(websocket: Server.ServerConnection, message_id: str
return None
logger.debug(response)
return response.get("data")
async def get_record_detail(
websocket: Server.ServerConnection, file: str, file_id: Optional[str] = None
) -> dict | None:
"""
获取语音消息内容
Parameters:
websocket: WebSocket连接对象
file: 文件名
file_id: 文件ID
Returns:
dict: 返回的语音消息详情
"""
logger.debug("获取语音消息详情中")
request_uuid = str(uuid.uuid4())
payload = json.dumps(
{
"action": "get_record",
"params": {"file": file, "file_id": file_id, "out_format": "wav"},
"echo": request_uuid,
}
)
try:
await websocket.send(payload)
response: dict = await get_response(request_uuid, 30) # 增加超时时间到30秒
except TimeoutError:
logger.error(f"获取语音消息详情超时,文件: {file}, 文件ID: {file_id}")
return None
except Exception as e:
logger.error(f"获取语音消息详情失败: {e}")
return None
logger.debug(f"{str(response)[:200]}...") # 防止语音的超长base64编码导致日志过长
return response.get("data")
async def read_ban_list(
websocket: Server.ServerConnection,
) -> Tuple[List[BanUser], List[BanUser]]:
"""
从根目录下的data文件夹中的文件读取禁言列表
同时自动更新已经失效禁言
Returns:
Tuple[
一个仍在禁言中的用户的BanUser列表,
一个已经自然解除禁言的用户的BanUser列表,
一个仍在全体禁言中的群的BanUser列表,
一个已经自然解除全体禁言的群的BanUser列表,
]
"""
try:
ban_list = db_manager.get_ban_records()
lifted_list: List[BanUser] = []
logger.info("已经读取禁言列表")
for ban_record in ban_list:
if ban_record.user_id == 0:
fetched_group_info = await get_group_info(websocket, ban_record.group_id)
if fetched_group_info is None:
logger.warning(f"无法获取群信息,群号: {ban_record.group_id},默认禁言解除")
lifted_list.append(ban_record)
ban_list.remove(ban_record)
continue
group_all_shut: int = fetched_group_info.get("group_all_shut")
if group_all_shut == 0:
lifted_list.append(ban_record)
ban_list.remove(ban_record)
continue
else:
fetched_member_info = await get_member_info(websocket, ban_record.group_id, ban_record.user_id)
if fetched_member_info is None:
logger.warning(
f"无法获取群成员信息用户ID: {ban_record.user_id}, 群号: {ban_record.group_id},默认禁言解除"
)
lifted_list.append(ban_record)
ban_list.remove(ban_record)
continue
lift_ban_time: int = fetched_member_info.get("shut_up_timestamp")
if lift_ban_time == 0:
lifted_list.append(ban_record)
ban_list.remove(ban_record)
else:
ban_record.lift_time = lift_ban_time
db_manager.update_ban_record(ban_list)
return ban_list, lifted_list
except Exception as e:
logger.error(f"读取禁言列表失败: {e}")
return [], []
def save_ban_record(list: List[BanUser]):
return db_manager.update_ban_record(list)

View File

@ -1,30 +1,41 @@
[Nickname] # 现在没用
[inner]
version = "0.1.3" # 版本号
# 请勿修改版本号,除非你知道自己在做什么
[nickname] # 现在没用
nickname = ""
[Napcat_Server] # Napcat连接的ws服务设置
host = "localhost" # Napcat设定的主机地址
port = 8095 # Napcat设定的端口
heartbeat = 30 # 与Napcat设置的心跳相同按秒计
[napcat_server] # Napcat连接的ws服务设置
host = "localhost" # Napcat设定的主机地址
port = 8095 # Napcat设定的端口
token = "" # Napcat设定的访问令牌若无则留空
heartbeat_interval = 30 # 与Napcat设置的心跳相同按秒计
[MaiBot_Server] # 连接麦麦的ws服务设置
platform_name = "qq" # 标识adapter的名称必填
host = "localhost" # 麦麦在.env文件中设置的主机地址即HOST字段
port = 8000 # 麦麦在.env文件中设置的端口即PORT字段
[maibot_server] # 连接麦麦的ws服务设置
host = "localhost" # 麦麦在.env文件中设置的主机地址即HOST字段
port = 8000 # 麦麦在.env文件中设置的端口即PORT字段
enable_api_server = false # 是否启用API-Server模式连接
base_url = "ws://127.0.0.1:18095/ws" # API-Server连接地址 (ws://ip:port/path)仅在enable_api_server为true时使用
api_key = "maibot" # API Key (仅在enable_api_server为true时使用)
[Chat] # 黑白名单功能
[chat] # 黑白名单功能
group_list_type = "whitelist" # 群组名单类型可选为whitelist, blacklist
group_list = [] # 群组名单
group_list = [] # 群组名单
# 当group_list_type为whitelist时只有群组名单中的群组可以聊天
# 当group_list_type为blacklist时群组名单中的任何群组无法聊天
private_list_type = "whitelist" # 私聊名单类型可选为whitelist, blacklist
private_list = [] # 私聊名单
private_list = [] # 私聊名单
# 当private_list_type为whitelist时只有私聊名单中的用户可以聊天
# 当private_list_type为blacklist时私聊名单中的任何用户无法聊天
ban_user_id = [] # 全局禁止名单(全局禁止名单中的用户无法进行任何聊天)
ban_user_id = [] # 全局禁止名单(全局禁止名单中的用户无法进行任何聊天)
ban_qq_bot = false # 是否屏蔽QQ官方机器人
enable_poke = true # 是否启用戳一戳功能
[Voice] # 发送语音设置
[voice] # 发送语音设置
use_tts = false # 是否使用tts语音请确保你配置了tts并有对应的adapter
[Debug]
level = "INFO" # 日志等级DEBUG, INFO, WARNING, ERROR
[forward] # 转发消息处理设置
image_threshold = 3 # 图片数量阈值:转发消息中图片数量超过此值时使用占位符(避免麦麦VLM处理卡死)
[debug]
level = "INFO" # 日志等级DEBUG, INFO, WARNING, ERROR, CRITICAL