Compare commits

...

111 Commits
0.4.2 ... 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
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 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
33 changed files with 4727 additions and 876 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 }}

11
.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
@ -272,4 +275,8 @@ $RECYCLE.BIN/
config.toml
config.toml.back
test
data/qq_bot.json
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] 发送图片
@ -78,4 +78,6 @@ sequenceDiagram
- [x] 群踢人功能
# 特别鸣谢
特别感谢[@Maple127667](https://github.com/Maple127667)对本项目代码思路的支持
特别感谢[@Maple127667](https://github.com/Maple127667)对本项目代码思路的支持
以及[@墨梓柒](https://github.com/DrSmoothl)对部分代码想法的支持

View File

@ -1,8 +1,28 @@
# 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",
@ -13,7 +33,10 @@ Seg.data: Dict[str, Any] = {
}
```
其中群聊ID将会通过Group_Info.group_id自动获取。
## 群聊全体禁言
**当`duration`为 0 时相当于解除禁言。**
### 群聊全体禁言
```python
Seg.data: Dict[str, Any] = {
"name": "GROUP_WHOLE_BAN",
@ -25,18 +48,36 @@ Seg.data: Dict[str, Any] = {
其中群聊ID将会通过Group_Info.group_id自动获取。
`enable`的参数需要为boolean类型True表示开启全体禁言False表示关闭全体禁言。
## 群聊踢人
### 群聊踢人
将指定成员从群聊中踢出,可选拉黑。
```python
Seg.data: Dict[str, Any] = {
"name": "GROUP_KICK",
"args": {
"qq_id": "用户QQ号",
"group_id": 123456789, # 可选,如果在群聊上下文中可从 group_info 自动获取
"user_id": 12345678, # 必需用户QQ号
"reject_add_request": False # 可选,是否群拉黑,默认 False
},
}
```
其中群聊ID将会通过Group_Info.group_id自动获取。
## 戳一戳
### 批量踢出群成员
批量将多个成员从群聊中踢出,可选拉黑。
```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",
@ -44,4 +85,392 @@ Seg.data: Dict[str, Any] = {
"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,32 +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.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[:100]}..."
if (len(raw_message) > 100 and global_config.debug.level != "DEBUG")
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():
@ -34,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()
@ -46,31 +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.napcat_server.host, global_config.napcat_server.port) as server:
logger.info(
f"Adapter已启动监听地址: ws://{global_config.napcat_server.host}:{global_config.napcat_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...")
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:
if not task.done():
task.cancel()
await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), 15)
await mmc_stop_com() # 后置避免神秘exception
logger.info("Adapter已成功关闭")
# 等待任务完成,记录异常到日志文件
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__":
@ -80,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.6"
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,6 +5,7 @@ requests
maim_message
loguru
pillow
tomli
tomlkit
rich
rich
sqlmodel
watchdog

View File

@ -1,78 +1,42 @@
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"
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"
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,5 +1,6 @@
from .config import global_config
from .config import global_config, _config_manager as config_manager
__all__ = [
"global_config",
"config_manager",
]

View File

@ -1,5 +1,6 @@
import os
from dataclasses import dataclass
from datetime import datetime
import tomlkit
import shutil
@ -13,6 +14,7 @@ from src.config.config_base import ConfigBase
from src.config.official_configs import (
ChatConfig,
DebugConfig,
ForwardConfig,
MaiBotServerConfig,
NapcatServerConfig,
NicknameConfig,
@ -56,11 +58,16 @@ def update_config():
else:
logger.info("已有配置文件未检测到版本号,可能是旧版本。将进行更新")
# 创建备份文件夹
backup_dir = "config_backup"
os.makedirs(backup_dir, exist_ok=True)
# 备份文件名
old_backup_path = "config.toml.back"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
old_backup_path = os.path.join(backup_dir, f"config.toml.bak.{timestamp}")
# 备份旧配置文件
shutil.move(old_config_path, old_backup_path)
shutil.copy2(old_config_path, old_backup_path)
logger.info(f"已备份旧配置文件到: {old_backup_path}")
# 复制模板文件到配置目录
@ -111,6 +118,7 @@ class Config(ConfigBase):
maibot_server: MaiBotServerConfig
chat: ChatConfig
voice: VoiceConfig
forward: ForwardConfig
debug: DebugConfig
@ -136,5 +144,15 @@ def load_config(config_path: str) -> Config:
update_config()
logger.info("正在品鉴配置文件...")
global_config = load_config(config_path="config.toml")
# 创建配置管理器
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,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

@ -28,6 +28,9 @@ class NapcatServerConfig(ConfigBase):
port: int = 8095
"""Napcat服务端的端口号"""
token: str = ""
"""Napcat服务端的访问令牌若无则留空"""
heartbeat_interval: int = 30
"""Napcat心跳间隔时间单位为秒"""
@ -43,6 +46,15 @@ class MaiBotServerConfig(ConfigBase):
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):
@ -74,6 +86,14 @@ class VoiceConfig(ConfigBase):
"""是否启用TTS功能"""
@dataclass
class ForwardConfig(ConfigBase):
"""转发消息相关配置"""
image_threshold: int = 3
"""图片数量阈值转发消息中图片数量超过此值时使用占位符代替base64发送避免麦麦VLM处理卡死"""
@dataclass
class DebugConfig(ConfigBase):
level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "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,10 +1,106 @@
from loguru import logger
from .config import global_config
import sys
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>",
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",
)
# 文件输出处理器 - 详细格式,记录所有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)
# 默认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.maibot_server.platform_name: TargetConfig(
url=f"ws://{global_config.maibot_server.host}:{global_config.maibot_server.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_message)
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

@ -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",
]

View File

@ -1,14 +1,24 @@
from .logger import logger
from .config import global_config
from src.logger import logger
from src.config import global_config
from src.utils import (
get_group_info,
get_member_info,
get_image_base64,
get_record_detail,
get_self_info,
get_message_detail,
)
import base64
from .qq_emoji_list import qq_face
from .message_sending import message_send_instance
from . import RealMessageType, MessageType, ACCEPT_FORMAT
import time
import asyncio
import json
import websockets as Server
from typing import List, Tuple, Optional, Dict, Any
import uuid
from . import MetaEventType, RealMessageType, MessageType, NoticeType
from maim_message import (
UserInfo,
GroupInfo,
@ -17,97 +27,40 @@ from maim_message import (
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,
read_bot_id,
update_bot_id,
)
from .response_pool import get_response
from src.response_pool import get_response
class RecvHandler:
maibot_router: Router = None
class MessageHandler:
def __init__(self):
self.server_connection: Server.ServerConnection = None
self.interval = global_config.napcat_server.heartbeat_interval
self._interval_checking = False
self.bot_id_list: Dict[int, bool] = {}
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"):
if not self._interval_checking:
asyncio.create_task(self.check_heartbeat())
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 set_server_connection(self, server_connection: Server.ServerConnection) -> None:
"""设置Napcat连接"""
self.server_connection = server_connection
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)
async def check_allow_to_chat(self, user_id: int, group_id: Optional[int]) -> bool:
async def check_allow_to_chat(
self,
user_id: int,
group_id: Optional[int] = None,
ignore_bot: Optional[bool] = False,
ignore_global_list: Optional[bool] = False,
) -> bool:
# sourcery skip: hoist-statement-from-if, merge-else-if-into-elif
"""
检查是否允许聊天
Parameters:
user_id: int: 用户ID
group_id: int: 群ID
ignore_bot: bool: 是否忽略机器人检查
ignore_global_list: bool: 是否忽略全局黑名单检查
Returns:
bool: 是否允许聊天
"""
user_id = str(user_id)
logger.debug(f"群聊id: {group_id}, 用户id: {user_id}")
if global_config.chat.ban_qq_bot and group_id:
logger.debug("开始判断是否为机器人")
if not self.bot_id_list:
self.bot_id_list = read_bot_id()
if user_id in self.bot_id_list:
if self.bot_id_list[user_id]:
logger.warning("QQ官方机器人消息拦截已启用消息被丢弃")
return False
else:
member_info = await get_member_info(self.server_connection, group_id, user_id)
if member_info:
is_bot = member_info.get("is_robot")
if is_bot is None:
logger.warning("无法获取用户是否为机器人,默认为不是但是不进行更新")
else:
if is_bot:
logger.warning("QQ官方机器人消息拦截已启用消息被丢弃新机器人加入拦截名单")
self.bot_id_list[user_id] = True
update_bot_id(self.bot_id_list)
return False
else:
self.bot_id_list[user_id] = False
update_bot_id(self.bot_id_list)
user_id = int(user_id)
logger.debug("开始检查聊天白名单/黑名单")
if group_id:
if global_config.chat.group_list_type == "whitelist" and group_id not in global_config.chat.group_list:
@ -123,9 +76,25 @@ class RecvHandler:
elif global_config.chat.private_list_type == "blacklist" and user_id in global_config.chat.private_list:
logger.warning("私聊在聊天黑名单中,消息被丢弃")
return False
if user_id in global_config.chat.ban_user_id:
if user_id in global_config.chat.ban_user_id and not ignore_global_list:
logger.warning("用户在全局黑名单中,消息被丢弃")
return False
if global_config.chat.ban_qq_bot and group_id and not ignore_bot:
logger.debug("开始判断是否为机器人")
member_info = await get_member_info(self.server_connection, group_id, user_id)
if member_info:
is_bot = member_info.get("is_robot")
if is_bot is None:
logger.warning("无法获取用户是否为机器人,默认为不是但是不进行更新")
else:
if is_bot:
logger.warning("QQ官方机器人消息拦截已启用消息被丢弃新机器人加入拦截名单")
self.bot_id_list[user_id] = True
return False
else:
self.bot_id_list[user_id] = False
return True
async def handle_raw_message(self, raw_message: dict) -> None:
@ -143,8 +112,8 @@ class RecvHandler:
template_info: TemplateInfo = None # 模板信息,暂时为空,等待启用
format_info: FormatInfo = FormatInfo(
content_format=["text", "image", "emoji"],
accept_format=["text", "image", "emoji", "reply", "voice", "command"],
content_format=["text", "image", "emoji", "voice"],
accept_format=ACCEPT_FORMAT,
) # 格式化信息
if message_type == MessageType.private:
sub_type = raw_message.get("sub_type")
@ -237,10 +206,24 @@ class RecvHandler:
logger.warning(f"群聊消息类型 {sub_type} 不支持")
return None
additional_config: dict = {}
# 处理实际信息
if not raw_message.get("message"):
logger.warning("原始消息内容为空")
return None
# 获取Seg列表
seg_message, additional_config = await self.handle_real_message(raw_message)
if global_config.voice.use_tts:
additional_config["allow_tts"] = True
if not seg_message:
logger.warning("处理后消息内容为空")
return None
submit_seg: Seg = Seg(
type="seglist",
data=seg_message,
)
# 消息信息
message_info: BaseMessageInfo = BaseMessageInfo(
platform=global_config.maibot_server.platform_name,
@ -253,20 +236,6 @@ class RecvHandler:
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,
@ -275,9 +244,11 @@ class RecvHandler:
)
logger.info("发送到Maibot处理信息")
await self.message_process(message_base)
await message_send_instance.message_send(message_base)
async def handle_real_message(self, raw_message: dict, in_reply: bool = False) -> List[Seg] | None:
async def handle_real_message(
self, raw_message: dict, in_reply: bool = False
) -> Tuple[List[Seg] | None, Dict[str, Any]]:
# sourcery skip: low-code-quality
"""
处理实际消息
@ -286,9 +257,11 @@ class RecvHandler:
Returns:
seg_message: list[Seg]: 处理后的消息段列表
"""
additional_config: dict = {}
real_message: list = raw_message.get("message")
if not real_message:
return None
logger.warning("实际消息内容为空")
return None, {}
seg_message: List[Seg] = []
for sub_message in real_message:
sub_message: dict
@ -308,7 +281,7 @@ class RecvHandler:
logger.warning("face处理失败或不支持")
case RealMessageType.reply:
if not in_reply:
ret_seg = await self.handle_reply_message(sub_message)
ret_seg, additional_config = await self.handle_reply_message(sub_message, additional_config)
if ret_seg:
seg_message += ret_seg
else:
@ -320,9 +293,31 @@ class RecvHandler:
else:
logger.warning("image处理失败")
case RealMessageType.record:
logger.warning("不支持语音解析")
ret_seg = await self.handle_record_message(sub_message)
if ret_seg:
seg_message.clear()
seg_message.append(ret_seg)
break # 使得消息只有record消息
else:
logger.warning("record处理失败或不支持")
case RealMessageType.video:
logger.warning("不支持视频解析")
ret_seg = await self.handle_video_message(sub_message)
if ret_seg:
seg_message.append(ret_seg)
else:
logger.warning("video处理失败")
case RealMessageType.json:
ret_segs = await self.handle_json_message(sub_message)
if ret_segs:
seg_message.extend(ret_segs)
else:
logger.warning("json处理失败")
case RealMessageType.file:
ret_seg = await self.handle_file_message(sub_message)
if ret_seg:
seg_message.append(ret_seg)
else:
logger.warning("file处理失败")
case RealMessageType.at:
ret_seg = await self.handle_at_message(
sub_message,
@ -343,10 +338,10 @@ class RecvHandler:
case RealMessageType.share:
logger.warning("暂时不支持链接解析")
case RealMessageType.forward:
messages = await self.get_forward_message(sub_message)
messages = await self._get_forward_message(sub_message)
if not messages:
logger.warning("转发消息内容为空或获取失败")
return None
return None, {}
ret_seg = await self.handle_forward_message(messages)
if ret_seg:
seg_message.append(ret_seg)
@ -356,7 +351,7 @@ class RecvHandler:
logger.warning("不支持转发消息节点解析")
case _:
logger.warning(f"未知消息类型: {sub_message_type}")
return seg_message
return seg_message, additional_config
async def handle_text_message(self, raw_message: dict) -> Seg:
"""
@ -405,7 +400,7 @@ class RecvHandler:
if image_sub_type == 0:
"""这部分认为是图片"""
return Seg(type="image", data=image_base64)
elif image_sub_type == 1:
elif image_sub_type not in [4, 9]:
"""这部分认为是表情包"""
return Seg(type="emoji", data=image_base64)
else:
@ -440,41 +435,336 @@ class RecvHandler:
else:
return None
async def get_forward_message(self, raw_message: dict) -> Dict[str, Any] | None:
forward_message_data: Dict = raw_message.get("data")
if not forward_message_data:
logger.warning("转发消息内容为空")
async def handle_record_message(self, raw_message: dict) -> Seg | None:
"""
处理语音消息
Parameters:
raw_message: dict: 原始消息
Returns:
seg_data: Seg: 处理后的消息段
"""
message_data: dict = raw_message.get("data")
file: str = message_data.get("file")
if not file:
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
record_detail = await get_record_detail(self.server_connection, file)
if not record_detail:
logger.warning("获取语音消息详情失败")
return None
audio_base64: str = record_detail.get("base64")
except Exception as e:
logger.error(f"获取转发消息失败: {str(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("转发消息内容为空或获取失败")
if not audio_base64:
logger.error("语音消息处理失败,未获取到音频数据")
return None
return response_data.get("messages")
return Seg(type="voice", data=audio_base64)
async def handle_reply_message(self, raw_message: dict) -> List[Seg] | None:
async def handle_video_message(self, raw_message: dict) -> Seg | None:
"""
处理视频消息
Parameters:
raw_message: dict: 原始消息
Returns:
seg_data: Seg: 处理后的消息段video_card类型
"""
message_data: dict = raw_message.get("data")
file: str = message_data.get("file", "")
url: str = message_data.get("url", "")
file_size: str = message_data.get("file_size", "")
if not file:
logger.warning("视频消息缺少文件信息")
return None
# 返回结构化的视频卡片数据
return Seg(type="video_card", data={
"file": file,
"file_size": file_size,
"url": url
})
async def handle_json_message(self, raw_message: dict) -> List[Seg] | None:
"""
处理JSON卡片消息(小程序分享群公告等)
Parameters:
raw_message: dict: 原始消息
Returns:
seg_data: List[Seg]: 处理后的消息段列表可能包含文本和图片
"""
message_data: dict = raw_message.get("data")
json_data: str = message_data.get("data")
if not json_data:
logger.warning("JSON消息缺少数据")
return None
try:
# 尝试解析JSON获取详细信息
parsed_json = json.loads(json_data)
app = parsed_json.get("app", "")
meta = parsed_json.get("meta", {})
# 群公告由于图片URL是加密的因此无法读取
if app == "com.tencent.mannounce":
mannounce = meta.get("mannounce", {})
title = mannounce.get("title", "")
text = mannounce.get("text", "")
encode_flag = mannounce.get("encode", 0)
if encode_flag == 1:
try:
if title:
title = base64.b64decode(title).decode("utf-8", errors="ignore")
if text:
text = base64.b64decode(text).decode("utf-8", errors="ignore")
except Exception as e:
logger.warning(f"群公告Base64解码失败: {e}")
if title and text:
content = f"[{title}]:{text}"
elif title:
content = f"[{title}]"
elif text:
content = f"{text}"
else:
content = "[群公告]"
return [Seg(type="text", data=content)]
# 音乐卡片
if app in ("com.tencent.music.lua", "com.tencent.structmsg"):
music = meta.get("music", {})
if music:
title = music.get("title", "")
singer = music.get("desc", "") or music.get("singer", "")
jump_url = music.get("jumpUrl", "") or music.get("jump_url", "")
music_url = music.get("musicUrl", "") or music.get("music_url", "")
tag = music.get("tag", "")
preview = music.get("preview", "")
return [Seg(type="music_card", data={
"title": title,
"singer": singer,
"jump_url": jump_url,
"music_url": music_url,
"tag": tag,
"preview": preview
})]
# QQ小程序分享含预览图
if app == "com.tencent.miniapp_01":
detail = meta.get("detail_1", {})
if detail:
title = detail.get("title", "")
desc = detail.get("desc", "")
url = detail.get("url", "")
qqdocurl = detail.get("qqdocurl", "")
preview_url = detail.get("preview", "")
icon = detail.get("icon", "")
seg_list = [Seg(type="miniapp_card", data={
"title": title,
"desc": desc,
"url": url,
"source_url": qqdocurl,
"preview": preview_url,
"icon": icon
})]
# 下载预览图
if preview_url:
try:
image_base64 = await get_image_base64(preview_url)
seg_list.append(Seg(type="image", data=image_base64))
except Exception as e:
logger.error(f"QQ小程序预览图下载失败: {e}")
return seg_list
# 礼物消息
if app == "com.tencent.giftmall.giftark":
giftark = meta.get("giftark", {})
if giftark:
gift_name = giftark.get("title", "礼物")
desc = giftark.get("desc", "")
gift_text = f"[赠送礼物: {gift_name}]"
if desc:
gift_text += f"\n{desc}"
return [Seg(type="text", data=gift_text)]
# 推荐联系人
if app == "com.tencent.contact.lua":
contact_info = meta.get("contact", {})
name = contact_info.get("nickname", "未知联系人")
tag = contact_info.get("tag", "推荐联系人")
return [Seg(type="text", data=f"[{tag}] {name}")]
# 推荐群聊
if app == "com.tencent.troopsharecard":
contact_info = meta.get("contact", {})
name = contact_info.get("nickname", "未知群聊")
tag = contact_info.get("tag", "推荐群聊")
return [Seg(type="text", data=f"[{tag}] {name}")]
# 图文分享(如 哔哩哔哩HD、网页、群精华等
if app == "com.tencent.tuwen.lua":
news = meta.get("news", {})
title = news.get("title", "未知标题")
desc = (news.get("desc", "") or "").replace("[图片]", "").strip()
tag = news.get("tag", "图文分享")
preview_url = news.get("preview", "")
if tag and title and tag in title:
title = title.replace(tag, "", 1).strip(": -— ")
text_content = f"[{tag}] {title}:{desc}"
seg_list = [Seg(type="text", data=text_content)]
# 下载预览图
if preview_url:
try:
image_base64 = await get_image_base64(preview_url)
seg_list.append(Seg(type="image", data=image_base64))
except Exception as e:
logger.error(f"图文预览图下载失败: {e}")
return seg_list
# 群相册(含预览图)
if app == "com.tencent.feed.lua":
feed = meta.get("feed", {})
title = feed.get("title", "群相册")
tag = feed.get("tagName", "群相册")
desc = feed.get("forwardMessage", "")
cover_url = feed.get("cover", "")
if tag and title and tag in title:
title = title.replace(tag, "", 1).strip(": -— ")
text_content = f"[{tag}] {title}:{desc}"
seg_list = [Seg(type="text", data=text_content)]
# 下载封面图
if cover_url:
try:
image_base64 = await get_image_base64(cover_url)
seg_list.append(Seg(type="image", data=image_base64))
except Exception as e:
logger.error(f"群相册封面下载失败: {e}")
return seg_list
# QQ收藏分享含预览图
if app == "com.tencent.template.qqfavorite.share":
news = meta.get("news", {})
desc = news.get("desc", "").replace("[图片]", "").strip()
tag = news.get("tag", "QQ收藏")
preview_url = news.get("preview", "")
seg_list = [Seg(type="text", data=f"[{tag}] {desc}")]
# 下载预览图
if preview_url:
try:
image_base64 = await get_image_base64(preview_url)
seg_list.append(Seg(type="image", data=image_base64))
except Exception as e:
logger.error(f"QQ收藏预览图下载失败: {e}")
return seg_list
# QQ空间分享含预览图
if app == "com.tencent.miniapp.lua":
miniapp = meta.get("miniapp", {})
title = miniapp.get("title", "未知标题")
tag = miniapp.get("tag", "QQ空间")
preview_url = miniapp.get("preview", "")
seg_list = [Seg(type="text", data=f"[{tag}] {title}")]
# 下载预览图
if preview_url:
try:
image_base64 = await get_image_base64(preview_url)
seg_list.append(Seg(type="image", data=image_base64))
except Exception as e:
logger.error(f"QQ空间预览图下载失败: {e}")
return seg_list
# QQ频道分享含预览图
if app == "com.tencent.forum":
detail = meta.get("detail") if isinstance(meta, dict) else None
if detail:
feed = detail.get("feed", {})
poster = detail.get("poster", {})
channel_info = detail.get("channel_info", {})
guild_name = channel_info.get("guild_name", "")
nick = poster.get("nick", "QQ用户")
title = feed.get("title", {}).get("contents", [{}])[0].get("text_content", {}).get("text", "帖子")
face_content = ""
for item in feed.get("contents", {}).get("contents", []):
emoji = item.get("emoji_content")
if emoji:
eid = emoji.get("id")
if eid in qq_face:
face_content += qq_face.get(eid, "")
seg_list = [Seg(type="text", data=f"[频道帖子] [{guild_name}]{nick}:{title}{face_content}")]
# 下载帖子中的图片
pic_urls = [img.get("pic_url") for img in feed.get("images", []) if img.get("pic_url")]
for pic_url in pic_urls:
try:
image_base64 = await get_image_base64(pic_url)
seg_list.append(Seg(type="image", data=image_base64))
except Exception as e:
logger.error(f"QQ频道图片下载失败: {e}")
return seg_list
# QQ地图位置分享
if app == "com.tencent.map":
location = meta.get("Location.Search", {})
name = location.get("name", "未知地点")
address = location.get("address", "")
return [Seg(type="text", data=f"[位置] {address} · {name}")]
# QQ一起听歌
if app == "com.tencent.together":
invite = (meta or {}).get("invite", {})
title = invite.get("title") or "一起听歌"
summary = invite.get("summary") or ""
return [Seg(type="text", data=f"[{title}] {summary}")]
# 其他卡片消息使用prompt字段
prompt = parsed_json.get("prompt", "[卡片消息]")
return [Seg(type="text", data=prompt)]
except json.JSONDecodeError:
logger.warning("JSON消息解析失败")
return [Seg(type="text", data="[卡片消息]")]
except Exception as e:
logger.error(f"JSON消息处理异常: {e}")
return [Seg(type="text", data="[卡片消息]")]
async def handle_file_message(self, raw_message: dict) -> Seg | None:
"""
处理文件消息
Parameters:
raw_message: dict: 原始消息
Returns:
seg_data: Seg: 处理后的消息段
"""
message_data: dict = raw_message.get("data")
file_name: str = message_data.get("file")
file_size: str = message_data.get("file_size", "未知大小")
file_url: str = message_data.get("url")
if not file_name:
logger.warning("文件消息缺少文件名")
return None
file_text = f"[文件: {file_name}, 大小: {file_size}字节]"
if file_url:
file_text += f"\n文件链接: {file_url}"
return Seg(type="text", data=file_text)
async def handle_reply_message(self, raw_message: dict, additional_config: dict) -> Tuple[List[Seg] | None, dict]:
# sourcery skip: move-assign-in-block, use-named-expression
"""
处理回复消息
@ -485,14 +775,15 @@ class RecvHandler:
if raw_message_data:
message_id = raw_message_data.get("id")
else:
return None
return None, {}
additional_config["reply_message_id"] = message_id
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)
return None, {}
reply_message, _ = await self.handle_real_message(message_detail, in_reply=True)
if reply_message is None:
reply_message = "(获取发言内容失败)"
reply_message = [Seg(type="text", data="(获取发言内容失败)")]
sender_info: dict = message_detail.get("sender")
sender_nickname: str = sender_info.get("nickname")
sender_id: str = sender_info.get("user_id")
@ -504,143 +795,7 @@ class RecvHandler:
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")
if not await self.check_allow_to_chat(user_id, group_id):
logger.warning("notice消息被丢弃")
return None
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.chat.enable_poke:
handled_message: Seg = await self.handle_poke_notify(raw_message)
else:
logger.warning("戳一戳消息被禁用,取消戳一戳处理")
case _:
logger.warning(f"不支持的notify类型: {notice_type}.{sub_type}")
case _:
logger.warning(f"不支持的notice类型: {notice_type}")
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.maibot_server.platform_name,
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")
else:
logger.warning("无法获取戳一戳消息所在群的名称")
group_info = GroupInfo(
platform=global_config.maibot_server.platform_name,
group_id=group_id,
group_name=group_name,
)
message_info: BaseMessageInfo = BaseMessageInfo(
platform=global_config.maibot_server.platform_name,
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
return seg_message, additional_config
async def handle_forward_message(self, message_list: list) -> Seg | None:
"""
@ -653,18 +808,28 @@ class RecvHandler:
image_count: int
if not handled_message:
return None
if image_count < 5 and image_count > 0:
# 处理图片数量小于5的情况此时解析图片为base64
logger.trace("图片数量小于5开始解析图片为base64")
return await self._recursive_parse_image_seg(handled_message, True)
# 添加转发消息的标题和结束标识
forward_header = Seg(type="text", data="========== 转发消息开始 ==========\n")
forward_footer = Seg(type="text", data="========== 转发消息结束 ==========")
# 图片阈值超过此数量使用占位符避免麦麦VLM处理卡死
image_threshold = global_config.forward.image_threshold
if image_count < image_threshold and image_count > 0:
# 处理图片数量小于阈值的情况此时解析图片为base64
logger.trace(f"图片数量({image_count})小于{image_threshold}开始解析图片为base64")
parsed_message = await self._recursive_parse_image_seg(handled_message, True)
return Seg(type="seglist", data=[forward_header, parsed_message, forward_footer])
elif image_count > 0:
logger.trace("图片数量大于等于5开始解析图片为占位符")
# 处理图片数量大于等于5的情况此时解析图片为占位符
return await self._recursive_parse_image_seg(handled_message, False)
logger.trace(f"图片数量({image_count})大于等于{image_threshold},开始解析图片为占位符")
# 处理图片数量大于等于阈值的情况,此时解析图片为占位符
parsed_message = await self._recursive_parse_image_seg(handled_message, False)
return Seg(type="seglist", data=[forward_header, parsed_message, forward_footer])
else:
# 处理没有图片的情况,此时直接返回
logger.trace("没有图片,直接返回")
return handled_message
return Seg(type="seglist", data=[forward_header, handled_message, forward_footer])
async def _recursive_parse_image_seg(self, seg_data: Seg, to_image: bool) -> Seg:
# sourcery skip: merge-else-if-into-elif
@ -724,6 +889,8 @@ class RecvHandler:
image_count = 0
if message_list is None:
return None, 0
# 统一在最前加入【转发消息】标识(带层级缩进)
seg_list.append(Seg(type="text", data=("--" * layer) + "\n【转发消息】\n"))
for sub_message in message_list:
sub_message: dict
sender_info: dict = sub_message.get("sender")
@ -736,23 +903,17 @@ class RecvHandler:
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",
)
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])
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")
@ -798,17 +959,43 @@ class RecvHandler:
]
full_seg_data = Seg(type="seglist", data=data_list)
seg_list.append(full_seg_data)
# 在结尾追加标识
seg_list.append(Seg(type="text", data=("--" * layer) + "【转发消息结束】"))
return Seg(type="seglist", data=seg_list), image_count
async def message_process(self, message_base: MessageBase) -> None:
try:
send_status = await self.maibot_router.send_message(message_base)
if not send_status:
raise RuntimeError("发送消息失败,可能是路由未正确配置或连接异常")
except Exception as e:
logger.error(f"发送消息失败: {str(e)}")
logger.error("请检查与MaiBot之间的连接")
async def _get_forward_message(self, raw_message: dict) -> Dict[str, Any] | None:
forward_message_data: Dict = raw_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
return response_data.get("messages")
recv_handler = RecvHandler()
message_handler = MessageHandler()

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

@ -8,19 +8,19 @@ response_dict: Dict = {}
response_time_dict: Dict = {}
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")

View File

@ -1,362 +0,0 @@
import json
import websockets as Server
import uuid
from maim_message import (
UserInfo,
GroupInfo,
Seg,
BaseMessageInfo,
MessageBase,
)
from typing import Dict, Any, Tuple
from . import CommandType
from .config import global_config
from .response_pool import get_response
from .logger import logger
from .utils import get_image_format, convert_image_to_gif
class SendHandler:
def __init__(self):
self.server_connection: Server.ServerConnection = None
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_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 = await self.handle_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 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)}")
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: str = seg_data.get("name")
try:
match command_name:
case CommandType.GROUP_BAN.name:
command, args_dict = self.handle_ban_command(seg_data.get("args"), group_info)
case CommandType.GROUP_WHOLE_BAN.name:
command, args_dict = self.handle_whole_ban_command(seg_data.get("args"), group_info)
case CommandType.GROUP_KICK.name:
command, args_dict = self.handle_kick_command(seg_data.get("args"), group_info)
case CommandType.SEND_POKE.name:
command, args_dict = self.handle_poke_command(seg_data.get("args"), group_info)
case _:
logger.error(f"未知命令: {command_name}")
return
except Exception as e:
logger.error(f"处理命令时发生错误: {e}")
return None
if not command or not args_dict:
logger.error("命令或参数缺失")
return None
response = await self.send_message_to_napcat(command, args_dict)
if response.get("status") == "ok":
logger.info(f"命令 {command_name} 执行成功")
else:
logger.warning(f"命令 {command_name} 执行失败napcat返回{str(response)}")
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 payload
new_payload = self.build_payload(payload, self.handle_reply_message(target_id), True)
elif seg.type == "text":
text = seg.data
if not text:
return payload
new_payload = self.build_payload(payload, self.handle_text_message(text), False)
elif seg.type == "face":
logger.warning("MaiBot 发送了qq原生表情暂时不支持")
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)
elif seg.type == "voiceurl":
voice_url = seg.data
new_payload = self.build_payload(payload, self.handle_voiceurl_message(voice_url), False)
elif seg.type == "music":
song_id = seg.data
new_payload = self.build_payload(payload, self.handle_music_message(song_id), 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:
if i.get("type") == "reply":
logger.debug("检测到多个回复,使用最新的回复")
continue
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.voice.use_tts:
logger.warning("未启用语音消息处理")
return {}
if not encoded_voice:
return {}
return {
"type": "record",
"data": {"file": f"base64://{encoded_voice}"},
}
def handle_voiceurl_message(self, voice_url: str) -> dict:
"""处理语音链接消息"""
return {
"type": "record",
"data": {"file": voice_url},
}
def handle_music_message(self, song_id: str) -> dict:
"""处理音乐消息"""
return {
"type": "music",
"data": {"type": "163", "id": song_id},
}
def handle_ban_command(self, args: Dict[str, Any], group_info: GroupInfo) -> Tuple[str, Dict[str, Any]]:
"""处理封禁命令
Args:
args (Dict[str, Any]): 参数字典
group_info (GroupInfo): 群聊信息对应目标群聊
Returns:
Tuple[CommandType, Dict[str, Any]]
"""
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,
},
)
def handle_whole_ban_command(self, args: Dict[str, Any], group_info: GroupInfo) -> Tuple[str, Dict[str, Any]]:
"""处理全体禁言命令
Args:
args (Dict[str, Any]): 参数字典
group_info (GroupInfo): 群聊信息对应目标群聊
Returns:
Tuple[CommandType, Dict[str, Any]]
"""
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,
},
)
def handle_kick_command(self, args: Dict[str, Any], group_info: GroupInfo) -> Tuple[str, Dict[str, Any]]:
"""处理群成员踢出命令
Args:
args (Dict[str, Any]): 参数字典
group_info (GroupInfo): 群聊信息对应目标群聊
Returns:
Tuple[CommandType, Dict[str, Any]]
"""
user_id: int = int(args["qq_id"])
group_id: int = int(group_info.group_id)
if group_id <= 0:
raise ValueError("群组ID无效")
if user_id <= 0:
raise ValueError("用户ID无效")
return (
CommandType.GROUP_KICK.value,
{
"group_id": group_id,
"user_id": user_id,
"reject_add_request": False, # 不拒绝加群请求
},
)
def handle_poke_command(self, args: Dict[str, Any], group_info: GroupInfo) -> Tuple[str, Dict[str, Any]]:
"""处理戳一戳命令
Args:
args (Dict[str, Any]): 参数字典
group_info (GroupInfo): 群聊信息对应目标群聊
Returns:
Tuple[CommandType, Dict[str, Any]]
"""
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,
},
)
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,15 +2,16 @@ import websockets as Server
import json
import base64
import uuid
import urllib3
import ssl
import io
from src.database import BanUser, db_manager
from .logger import logger
from .response_pool import get_response
import urllib3
import ssl
from pathlib import Path
from PIL import Image
import io
import os
from typing import Union, List, Tuple, Optional
class SSLAdapter(urllib3.PoolManager):
@ -22,7 +23,7 @@ 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:
"""
获取群相关信息
@ -44,7 +45,29 @@ 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:
"""
获取群成员信息
@ -110,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:
@ -146,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:
@ -171,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:
@ -185,7 +208,7 @@ async def get_message_detail(websocket: Server.ServerConnection, message_id: str
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
@ -196,41 +219,92 @@ async def get_message_detail(websocket: Server.ServerConnection, message_id: str
return response.get("data")
def update_bot_id(data: dict) -> None:
async def get_record_detail(
websocket: Server.ServerConnection, file: str, file_id: Optional[str] = None
) -> dict | None:
"""
更新用户是否为机器人的字典到根目录下的data文件夹中的qq_bot.json
获取语音消息内容
Parameters:
data: dict: 包含需要更新的信息
"""
json_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "qq_bot.json")
try:
with open(json_path, "w", encoding="utf-8") as json_file:
json.dump(data, json_file, ensure_ascii=False, indent=4)
logger.info(f"ID字典已更新到文件: {json_path}")
except Exception as e:
logger.error(f"更新ID字典失败: {e}")
def read_bot_id() -> dict:
"""
从根目录下的data文件夹中的文件读取机器人ID
websocket: WebSocket连接对象
file: 文件名
file_id: 文件ID
Returns:
list: 读取的机器人ID信息
dict: 返回的语音消息详情
"""
json_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "qq_bot.json")
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:
with open(json_path, "r", encoding="utf-8") as json_file:
data = json.load(json_file)
logger.info(f"已读取机器人ID信息: {data}")
return data
except FileNotFoundError:
logger.warning(f"文件未找到: {json_path},正在自动创建文件")
json_path = Path(os.path.dirname(os.path.dirname(__file__))) / "data" / "qq_bot.json"
# 确保父目录存在
json_path.parent.mkdir(parents=True, exist_ok=True)
# 创建空文件
json_path.touch(exist_ok=True)
return {}
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"读取机器人ID失败: {e}")
return {}
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,5 +1,5 @@
[inner]
version = "0.1.1" # 版本号
version = "0.1.3" # 版本号
# 请勿修改版本号,除非你知道自己在做什么
[nickname] # 现在没用
@ -8,11 +8,15 @@ nickname = ""
[napcat_server] # Napcat连接的ws服务设置
host = "localhost" # Napcat设定的主机地址
port = 8095 # Napcat设定的端口
token = "" # Napcat设定的访问令牌若无则留空
heartbeat_interval = 30 # 与Napcat设置的心跳相同按秒计
[maibot_server] # 连接麦麦的ws服务设置
host = "localhost" # 麦麦在.env文件中设置的主机地址即HOST字段
port = 8000 # 麦麦在.env文件中设置的端口即PORT字段
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] # 黑白名单功能
group_list_type = "whitelist" # 群组名单类型可选为whitelist, blacklist
@ -30,5 +34,8 @@ enable_poke = true # 是否启用戳一戳功能
[voice] # 发送语音设置
use_tts = false # 是否使用tts语音请确保你配置了tts并有对应的adapter
[forward] # 转发消息处理设置
image_threshold = 3 # 图片数量阈值:转发消息中图片数量超过此值时使用占位符(避免麦麦VLM处理卡死)
[debug]
level = "INFO" # 日志等级DEBUG, INFO, WARNING, ERROR, CRITICAL