Compare commits
148 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
4f928b42c1 | |
|
|
e58c13c3b1 | |
|
|
09173eb135 | |
|
|
b165eff6b9 | |
|
|
866637b658 | |
|
|
bbff5702a5 | |
|
|
ce8f05361d | |
|
|
58d7be7f08 | |
|
|
e0d4b6ee55 | |
|
|
0af3559506 | |
|
|
c6f892def0 | |
|
|
29e852dcd0 | |
|
|
24ea1d2c41 | |
|
|
7b6b0d9593 | |
|
|
616ab2b9d6 | |
|
|
74b050032d | |
|
|
e6b4c0cf3a | |
|
|
3ec1499324 | |
|
|
efd98b022f | |
|
|
66a1c08405 | |
|
|
d40663709c | |
|
|
b0bfa1a42d | |
|
|
3e27e57409 | |
|
|
76b02a0d81 | |
|
|
a0bc06205c | |
|
|
af5b7f1a92 | |
|
|
417e30daca | |
|
|
250be48ea8 | |
|
|
0beb3dfdfa | |
|
|
12f6d205e1 | |
|
|
96b6487ccc | |
|
|
3de2444b0e | |
|
|
0d7733734c | |
|
|
424ca5b473 | |
|
|
4d4e82d742 | |
|
|
178912375d | |
|
|
df5a874a60 | |
|
|
ca27153fe6 | |
|
|
b4600793b9 | |
|
|
f9d780fc14 | |
|
|
8ac7d02362 | |
|
|
0584ac70c6 | |
|
|
6be87024a8 | |
|
|
86d7b67cd3 | |
|
|
f165cf3e2e | |
|
|
eaee8a45fb | |
|
|
8bf1bd1517 | |
|
|
eaa587869c | |
|
|
4debb6d783 | |
|
|
f3bcdb2986 | |
|
|
991115814b | |
|
|
81fa3aa4df | |
|
|
7b255269d1 | |
|
|
d6a97e3014 | |
|
|
10fc60e04d | |
|
|
96bae518f9 | |
|
|
b0b511ee9d | |
|
|
ee115c7e24 | |
|
|
9e281d838c | |
|
|
7b1f117af2 | |
|
|
59ec3d4425 | |
|
|
b2353747a2 | |
|
|
ab9bd1c675 | |
|
|
e44fe1ac6f | |
|
|
93a8eec15e | |
|
|
36e8f4a99a | |
|
|
e2980305a5 | |
|
|
ad844ffc43 | |
|
|
5dd2dda067 | |
|
|
883b2934d2 | |
|
|
fc3391947d | |
|
|
f93572c1f7 | |
|
|
b4ab8ffa78 | |
|
|
8923ebf534 | |
|
|
163ddefffd | |
|
|
59af134904 | |
|
|
c0416a517e | |
|
|
60e9106a89 | |
|
|
92af300035 | |
|
|
939f3b7d08 | |
|
|
7564361a63 | |
|
|
96fa750100 | |
|
|
0d3a77d140 | |
|
|
ef7b267e7c | |
|
|
e526b9fda7 | |
|
|
e1307b61ee | |
|
|
7ed33f29ac | |
|
|
1bdd165d12 | |
|
|
775f66129b | |
|
|
49f616034f | |
|
|
01e1c9e0a6 | |
|
|
9cda205c7b | |
|
|
6d3745ff0a | |
|
|
b6cabc593b | |
|
|
588ba6417e | |
|
|
f26667db73 | |
|
|
cae9645fb7 | |
|
|
bdbdc79f14 | |
|
|
5c6cdddbd3 | |
|
|
cc1fe7ce3c | |
|
|
ed9ecae9dc | |
|
|
5c57ba9c85 | |
|
|
1196909521 | |
|
|
ee873d8cbb | |
|
|
ca0fc4db11 | |
|
|
647855a076 | |
|
|
53ded44351 | |
|
|
eb062e1258 | |
|
|
6a1497f1bd | |
|
|
6a1a145307 | |
|
|
7eaaf32f0c | |
|
|
68af5e2c76 | |
|
|
77ff2ff257 | |
|
|
4ae69e0509 | |
|
|
877bcb2def | |
|
|
409f9f6b07 | |
|
|
5034039d10 | |
|
|
a76e8b8ef4 | |
|
|
cdd38503a6 | |
|
|
eb823f056b | |
|
|
e4620fb7db | |
|
|
e757196fe1 | |
|
|
29111bd921 | |
|
|
3711b2892d | |
|
|
9ef9dff4a9 | |
|
|
0753469746 | |
|
|
b5e7316b94 | |
|
|
bfb9170236 | |
|
|
7c78027d4b | |
|
|
51cbb2b227 | |
|
|
79ef02f193 | |
|
|
d72082989a | |
|
|
e1ab7b6956 | |
|
|
8fc8cedc78 | |
|
|
81a71af4aa | |
|
|
7373e75c75 | |
|
|
d64670a930 | |
|
|
36305f226c | |
|
|
5974b51754 | |
|
|
c365f2277f | |
|
|
d69bdfabbc | |
|
|
b7d3c68b92 | |
|
|
8764961418 | |
|
|
ff61069049 | |
|
|
bdb42d085e | |
|
|
4a5bf0c50e | |
|
|
9a83be8cf0 | |
|
|
c2525f4c55 |
|
|
@ -2,7 +2,8 @@ name: Docker Image CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: [ "main", "dev" ]
|
||||||
|
workflow_dispatch: # 允许手动触发工作流
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
|
|
@ -32,7 +33,11 @@ jobs:
|
||||||
- name: Determine Image Tags
|
- name: Determine Image Tags
|
||||||
id: tags
|
id: tags
|
||||||
run: |
|
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
|
- name: Build and Push Docker Image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
|
|
@ -42,8 +47,8 @@ jobs:
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: ${{ steps.tags.outputs.tags }}
|
tags: ${{ steps.tags.outputs.tags }}
|
||||||
push: true
|
push: true
|
||||||
cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:buildcache
|
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,mode=max
|
cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:buildcache-${{ github.ref_name }},mode=max
|
||||||
labels: |
|
labels: |
|
||||||
org.opencontainers.image.created=${{ steps.tags.outputs.date_tag }}
|
org.opencontainers.image.created=${{ steps.tags.outputs.date_tag }}
|
||||||
org.opencontainers.image.revision=${{ github.sha }}
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
|
@ -19,7 +19,7 @@ elua.confirmed
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
/results
|
/results
|
||||||
|
config_backup/
|
||||||
# Distribution / packaging
|
# Distribution / packaging
|
||||||
.Python
|
.Python
|
||||||
build/
|
build/
|
||||||
|
|
@ -39,6 +39,7 @@ share/python-wheels/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
MANIFEST
|
MANIFEST
|
||||||
|
dev/
|
||||||
|
|
||||||
# PyInstaller
|
# PyInstaller
|
||||||
# Usually these files are written by a python script from a template
|
# Usually these files are written by a python script from a template
|
||||||
|
|
@ -64,6 +65,7 @@ coverage.xml
|
||||||
.hypothesis/
|
.hypothesis/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
cover/
|
cover/
|
||||||
|
dev/
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
|
|
@ -148,6 +150,7 @@ venv/
|
||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
|
uv.lock
|
||||||
|
|
||||||
# Spyder project settings
|
# Spyder project settings
|
||||||
.spyderproject
|
.spyderproject
|
||||||
|
|
@ -270,4 +273,10 @@ $RECYCLE.BIN/
|
||||||
*.lnk
|
*.lnk
|
||||||
|
|
||||||
config.toml
|
config.toml
|
||||||
|
config.toml.back
|
||||||
test
|
test
|
||||||
|
data/NapcatAdapter.db
|
||||||
|
data/NapcatAdapter.db-shm
|
||||||
|
data/NapcatAdapter.db-wal
|
||||||
|
|
||||||
|
uv.lock
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM python:3.13.2-slim
|
FROM python:3.13.5-slim
|
||||||
LABEL authors="infinitycat233"
|
LABEL authors="infinitycat233"
|
||||||
|
|
||||||
# Copy uv and maim_message
|
# Copy uv and maim_message
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ sequenceDiagram
|
||||||
- [x] 读取戳一戳的自定义内容
|
- [x] 读取戳一戳的自定义内容
|
||||||
- [ ] 语音解析(?)
|
- [ ] 语音解析(?)
|
||||||
- [ ] 所有的notice类
|
- [ ] 所有的notice类
|
||||||
- [ ] <del>撤回</del>
|
- [x] 撤回(已添加相关指令)
|
||||||
- [x] 发送消息
|
- [x] 发送消息
|
||||||
- [x] 发送文本
|
- [x] 发送文本
|
||||||
- [x] 发送图片
|
- [x] 发送图片
|
||||||
|
|
@ -72,6 +72,12 @@ sequenceDiagram
|
||||||
- [ ] 戳回去(?)
|
- [ ] 戳回去(?)
|
||||||
- [x] 发送语音
|
- [x] 发送语音
|
||||||
- [x] 使用echo与uuid保证消息顺序
|
- [x] 使用echo与uuid保证消息顺序
|
||||||
|
- [x] 执行部分管理员功能
|
||||||
|
- [x] 禁言别人
|
||||||
|
- [x] 全体禁言
|
||||||
|
- [x] 群踢人功能
|
||||||
|
|
||||||
# 特别鸣谢
|
# 特别鸣谢
|
||||||
特别感谢[@Maple127667](https://github.com/Maple127667)对本项目代码思路的支持
|
特别感谢[@Maple127667](https://github.com/Maple127667)对本项目代码思路的支持
|
||||||
|
|
||||||
|
以及[@墨梓柒](https://github.com/DrSmoothl)对部分代码想法的支持
|
||||||
|
|
@ -0,0 +1,476 @@
|
||||||
|
# Command Arguments
|
||||||
|
|
||||||
|
```python
|
||||||
|
Seg.type = "command"
|
||||||
|
```
|
||||||
|
|
||||||
|
所有命令执行后都会通过自定义消息类型 `command_response` 返回响应,格式如下:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"command_name": "命令名称",
|
||||||
|
"success": True/False, # 是否执行成功
|
||||||
|
"timestamp": 1234567890.123, # 时间戳
|
||||||
|
"data": {...}, # 返回数据(成功时)
|
||||||
|
"error": "错误信息" # 错误信息(失败时)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
插件需要注册 `command_response` 自定义消息处理器来接收命令响应。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 操作类命令
|
||||||
|
|
||||||
|
### 群聊禁言
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "GROUP_BAN",
|
||||||
|
"args": {
|
||||||
|
"qq_id": "用户QQ号",
|
||||||
|
"duration": "禁言时长(秒)"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
其中,群聊ID将会通过Group_Info.group_id自动获取。
|
||||||
|
|
||||||
|
**当`duration`为 0 时相当于解除禁言。**
|
||||||
|
|
||||||
|
### 群聊全体禁言
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "GROUP_WHOLE_BAN",
|
||||||
|
"args": {
|
||||||
|
"enable": "是否开启全体禁言(True/False)"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
其中,群聊ID将会通过Group_Info.group_id自动获取。
|
||||||
|
|
||||||
|
`enable`的参数需要为boolean类型,True表示开启全体禁言,False表示关闭全体禁言。
|
||||||
|
|
||||||
|
### 群聊踢人
|
||||||
|
将指定成员从群聊中踢出,可选拉黑。
|
||||||
|
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "GROUP_KICK",
|
||||||
|
"args": {
|
||||||
|
"group_id": 123456789, # 可选,如果在群聊上下文中可从 group_info 自动获取
|
||||||
|
"user_id": 12345678, # 必需,用户QQ号
|
||||||
|
"reject_add_request": False # 可选,是否群拉黑,默认 False
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 批量踢出群成员
|
||||||
|
批量将多个成员从群聊中踢出,可选拉黑。
|
||||||
|
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "GROUP_KICK_MEMBERS",
|
||||||
|
"args": {
|
||||||
|
"group_id": 123456789, # 可选,如果在群聊上下文中可从 group_info 自动获取
|
||||||
|
"user_id": [12345678, 87654321], # 必需,用户QQ号数组
|
||||||
|
"reject_add_request": False # 可选,是否群拉黑,默认 False
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 戳一戳
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "SEND_POKE",
|
||||||
|
"args": {
|
||||||
|
"qq_id": "目标QQ号"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 撤回消息
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "DELETE_MSG",
|
||||||
|
"args": {
|
||||||
|
"message_id": "消息所对应的message_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
其中message_id是消息的实际qq_id,于新版的mmc中可以从数据库获取(如果工作正常的话)
|
||||||
|
|
||||||
|
### 给消息贴表情
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "SET_MSG_EMOJI_LIKE",
|
||||||
|
"args": {
|
||||||
|
"message_id": "消息ID",
|
||||||
|
"emoji_id": "表情ID"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设置群名
|
||||||
|
设置指定群的群名称。
|
||||||
|
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "SET_GROUP_NAME",
|
||||||
|
"args": {
|
||||||
|
"group_id": 123456789, # 可选,如果在群聊上下文中可从 group_info 自动获取
|
||||||
|
"group_name": "新群名" # 必需,新的群名称
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设置账号信息
|
||||||
|
设置Bot自己的QQ账号资料。
|
||||||
|
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "SET_QQ_PROFILE",
|
||||||
|
"args": {
|
||||||
|
"nickname": "新昵称", # 必需,昵称
|
||||||
|
"personal_note": "个性签名", # 可选,个性签名
|
||||||
|
"sex": "male" # 可选,性别:"male" | "female" | "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回数据示例:**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"result": 0, # 结果码,0为成功
|
||||||
|
"errMsg": "" # 错误信息
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 查询类命令
|
||||||
|
|
||||||
|
### 获取登录号信息
|
||||||
|
获取Bot自身的账号信息。
|
||||||
|
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "GET_LOGIN_INFO",
|
||||||
|
"args": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回数据示例:**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"user_id": 12345678,
|
||||||
|
"nickname": "Bot昵称"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取陌生人信息
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "GET_STRANGER_INFO",
|
||||||
|
"args": {
|
||||||
|
"user_id": "用户QQ号"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回数据示例:**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"user_id": 12345678,
|
||||||
|
"nickname": "用户昵称",
|
||||||
|
"sex": "male/female/unknown",
|
||||||
|
"age": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取好友列表
|
||||||
|
获取Bot的好友列表。
|
||||||
|
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "GET_FRIEND_LIST",
|
||||||
|
"args": {
|
||||||
|
"no_cache": False # 可选,是否不使用缓存,默认 False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回数据示例:**
|
||||||
|
```python
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"user_id": 12345678,
|
||||||
|
"nickname": "好友昵称",
|
||||||
|
"remark": "备注名",
|
||||||
|
"sex": "male", # "male" | "female" | "unknown"
|
||||||
|
"age": 18,
|
||||||
|
"qid": "QID字符串",
|
||||||
|
"level": 64,
|
||||||
|
"login_days": 365,
|
||||||
|
"birthday_year": 2000,
|
||||||
|
"birthday_month": 1,
|
||||||
|
"birthday_day": 1,
|
||||||
|
"phone_num": "电话号码",
|
||||||
|
"email": "邮箱",
|
||||||
|
"category_id": 0, # 分组ID
|
||||||
|
"categoryName": "我的好友", # 分组名称
|
||||||
|
"categoryId": 0
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取群信息
|
||||||
|
获取指定群的详细信息。
|
||||||
|
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "GET_GROUP_INFO",
|
||||||
|
"args": {
|
||||||
|
"group_id": 123456789 # 可选,如果在群聊上下文中可从 group_info 自动获取
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回数据示例:**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"group_id": "123456789", # 群号(字符串)
|
||||||
|
"group_name": "群名称",
|
||||||
|
"group_remark": "群备注",
|
||||||
|
"group_all_shut": 0, # 群全员禁言状态(0=未禁言)
|
||||||
|
"member_count": 100, # 当前成员数量
|
||||||
|
"max_member_count": 500 # 最大成员数量
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取群详细信息
|
||||||
|
获取指定群的详细信息(与 GET_GROUP_INFO 类似,可能提供更实时的数据)。
|
||||||
|
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "GET_GROUP_DETAIL_INFO",
|
||||||
|
"args": {
|
||||||
|
"group_id": 123456789 # 可选,如果在群聊上下文中可从 group_info 自动获取
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回数据示例:**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"group_id": 123456789, # 群号(数字)
|
||||||
|
"group_name": "群名称",
|
||||||
|
"group_remark": "群备注",
|
||||||
|
"group_all_shut": 0, # 群全员禁言状态(0=未禁言)
|
||||||
|
"member_count": 100, # 当前成员数量
|
||||||
|
"max_member_count": 500 # 最大成员数量
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取群列表
|
||||||
|
获取Bot加入的所有群列表。
|
||||||
|
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "GET_GROUP_LIST",
|
||||||
|
"args": {
|
||||||
|
"no_cache": False # 可选,是否不使用缓存,默认 False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回数据示例:**
|
||||||
|
```python
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"group_id": "123456789", # 群号(字符串)
|
||||||
|
"group_name": "群名称",
|
||||||
|
"group_remark": "群备注",
|
||||||
|
"group_all_shut": 0, # 群全员禁言状态
|
||||||
|
"member_count": 100, # 当前成员数量
|
||||||
|
"max_member_count": 500 # 最大成员数量
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取群@全体成员剩余次数
|
||||||
|
查询指定群的@全体成员剩余使用次数。
|
||||||
|
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "GET_GROUP_AT_ALL_REMAIN",
|
||||||
|
"args": {
|
||||||
|
"group_id": 123456789 # 可选,如果在群聊上下文中可从 group_info 自动获取
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回数据示例:**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"can_at_all": True, # 是否可以@全体成员
|
||||||
|
"remain_at_all_count_for_group": 10, # 群剩余@全体成员次数
|
||||||
|
"remain_at_all_count_for_uin": 5 # Bot剩余@全体成员次数
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取群成员信息
|
||||||
|
获取指定群成员的详细信息。
|
||||||
|
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "GET_GROUP_MEMBER_INFO",
|
||||||
|
"args": {
|
||||||
|
"group_id": 123456789, # 可选,如果在群聊上下文中可从 group_info 自动获取
|
||||||
|
"user_id": 12345678, # 必需,用户QQ号
|
||||||
|
"no_cache": False # 可选,是否不使用缓存,默认 False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回数据示例:**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"group_id": 123456789,
|
||||||
|
"user_id": 12345678,
|
||||||
|
"nickname": "昵称",
|
||||||
|
"card": "群名片",
|
||||||
|
"sex": "male", # "male" | "female" | "unknown"
|
||||||
|
"age": 18,
|
||||||
|
"join_time": 1234567890, # 加群时间戳
|
||||||
|
"last_sent_time": 1234567890, # 最后发言时间戳
|
||||||
|
"level": 1, # 群等级
|
||||||
|
"qq_level": 64, # QQ等级
|
||||||
|
"role": "member", # "owner" | "admin" | "member"
|
||||||
|
"title": "专属头衔",
|
||||||
|
"area": "地区",
|
||||||
|
"unfriendly": False, # 是否不友好
|
||||||
|
"title_expire_time": 1234567890, # 头衔过期时间
|
||||||
|
"card_changeable": True, # 名片是否可修改
|
||||||
|
"shut_up_timestamp": 0, # 禁言时间戳
|
||||||
|
"is_robot": False, # 是否机器人
|
||||||
|
"qage": "10年" # Q龄
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取群成员列表
|
||||||
|
获取指定群的所有成员列表。
|
||||||
|
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "GET_GROUP_MEMBER_LIST",
|
||||||
|
"args": {
|
||||||
|
"group_id": 123456789, # 可选,如果在群聊上下文中可从 group_info 自动获取
|
||||||
|
"no_cache": False # 可选,是否不使用缓存,默认 False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回数据示例:**
|
||||||
|
```python
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"group_id": 123456789,
|
||||||
|
"user_id": 12345678,
|
||||||
|
"nickname": "昵称",
|
||||||
|
"card": "群名片",
|
||||||
|
"sex": "male", # "male" | "female" | "unknown"
|
||||||
|
"age": 18,
|
||||||
|
"join_time": 1234567890,
|
||||||
|
"last_sent_time": 1234567890,
|
||||||
|
"level": 1,
|
||||||
|
"qq_level": 64,
|
||||||
|
"role": "member", # "owner" | "admin" | "member"
|
||||||
|
"title": "专属头衔",
|
||||||
|
"area": "地区",
|
||||||
|
"unfriendly": False,
|
||||||
|
"title_expire_time": 1234567890,
|
||||||
|
"card_changeable": True,
|
||||||
|
"shut_up_timestamp": 0,
|
||||||
|
"is_robot": False,
|
||||||
|
"qage": "10年"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取消息详情
|
||||||
|
获取指定消息的完整详情信息。
|
||||||
|
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "GET_MSG",
|
||||||
|
"args": {
|
||||||
|
"message_id": 123456 # 必需,消息ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回数据示例:**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"self_id": 12345678, # Bot自身ID
|
||||||
|
"user_id": 87654321, # 发送者ID
|
||||||
|
"time": 1234567890, # 时间戳
|
||||||
|
"message_id": 123456, # 消息ID
|
||||||
|
"message_seq": 123456, # 消息序列号
|
||||||
|
"real_id": 123456, # 真实消息ID
|
||||||
|
"real_seq": "123456", # 真实序列号(字符串)
|
||||||
|
"message_type": "group", # "private" | "group"
|
||||||
|
"sub_type": "normal", # 子类型
|
||||||
|
"message_format": "array", # 消息格式
|
||||||
|
"post_type": "message", # 事件类型
|
||||||
|
"group_id": 123456789, # 群号(群消息时存在)
|
||||||
|
"sender": {
|
||||||
|
"user_id": 87654321,
|
||||||
|
"nickname": "昵称",
|
||||||
|
"sex": "male", # "male" | "female" | "unknown"
|
||||||
|
"age": 18,
|
||||||
|
"card": "群名片", # 群消息时存在
|
||||||
|
"level": "1", # 群等级(字符串)
|
||||||
|
"role": "member" # "owner" | "admin" | "member"
|
||||||
|
},
|
||||||
|
"message": [...], # 消息段数组
|
||||||
|
"raw_message": "消息文本内容", # 原始消息文本
|
||||||
|
"font": 0 # 字体
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取合并转发消息
|
||||||
|
获取合并转发消息的所有子消息内容。
|
||||||
|
|
||||||
|
```python
|
||||||
|
Seg.data: Dict[str, Any] = {
|
||||||
|
"name": "GET_FORWARD_MSG",
|
||||||
|
"args": {
|
||||||
|
"message_id": "7123456789012345678" # 必需,合并转发消息ID(字符串)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回数据示例:**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"sender": {
|
||||||
|
"user_id": 87654321,
|
||||||
|
"nickname": "昵称",
|
||||||
|
"sex": "male",
|
||||||
|
"age": 18,
|
||||||
|
"card": "群名片",
|
||||||
|
"level": "1",
|
||||||
|
"role": "member"
|
||||||
|
},
|
||||||
|
"time": 1234567890,
|
||||||
|
"message": [...] # 消息段数组
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 157 KiB |
271
main.py
271
main.py
|
|
@ -1,26 +1,39 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
import http
|
||||||
import websockets as Server
|
import websockets as Server
|
||||||
from src.logger import logger
|
from src.logger import logger
|
||||||
from src.recv_handler import recv_handler
|
from src.recv_handler.message_handler import message_handler
|
||||||
from src.send_handler import send_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.config import global_config
|
||||||
from src.mmc_com_layer import mmc_start_com, mmc_stop_com, router
|
from src.mmc_com_layer import mmc_start_com, mmc_stop_com, router
|
||||||
from src.message_queue import message_queue, put_response, check_timeout_response
|
from src.response_pool import put_response, check_timeout_response
|
||||||
|
|
||||||
|
message_queue = asyncio.Queue()
|
||||||
|
websocket_server = None # 保存WebSocket服务器实例以便关闭
|
||||||
|
|
||||||
|
|
||||||
async def message_recv(server_connection: Server.ServerConnection):
|
async def message_recv(server_connection: Server.ServerConnection):
|
||||||
recv_handler.server_connection = server_connection
|
try:
|
||||||
send_handler.server_connection = server_connection
|
await message_handler.set_server_connection(server_connection)
|
||||||
async for raw_message in server_connection:
|
asyncio.create_task(notice_handler.set_server_connection(server_connection))
|
||||||
logger.debug(f"{raw_message[:80]}..." if len(raw_message) > 80 else raw_message)
|
await nc_message_sender.set_server_connection(server_connection)
|
||||||
decoded_raw_message: dict = json.loads(raw_message)
|
async for raw_message in server_connection:
|
||||||
post_type = decoded_raw_message.get("post_type")
|
logger.debug(f"{raw_message[:1500]}..." if (len(raw_message) > 1500) else raw_message)
|
||||||
if post_type in ["meta_event", "message", "notice"]:
|
decoded_raw_message: dict = json.loads(raw_message)
|
||||||
await message_queue.put(decoded_raw_message)
|
post_type = decoded_raw_message.get("post_type")
|
||||||
elif post_type is None:
|
if post_type in ["meta_event", "message", "notice"]:
|
||||||
await put_response(decoded_raw_message)
|
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():
|
async def message_process():
|
||||||
|
|
@ -28,11 +41,11 @@ async def message_process():
|
||||||
message = await message_queue.get()
|
message = await message_queue.get()
|
||||||
post_type = message.get("post_type")
|
post_type = message.get("post_type")
|
||||||
if post_type == "message":
|
if post_type == "message":
|
||||||
await recv_handler.handle_raw_message(message)
|
await message_handler.handle_raw_message(message)
|
||||||
elif post_type == "meta_event":
|
elif post_type == "meta_event":
|
||||||
await recv_handler.handle_meta_event(message)
|
await meta_event_handler.handle_meta_event(message)
|
||||||
elif post_type == "notice":
|
elif post_type == "notice":
|
||||||
await recv_handler.handle_notice(message)
|
await notice_handler.handle_notice(message)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"未知的post_type: {post_type}")
|
logger.warning(f"未知的post_type: {post_type}")
|
||||||
message_queue.task_done()
|
message_queue.task_done()
|
||||||
|
|
@ -40,27 +53,170 @@ async def message_process():
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
recv_handler.maibot_router = router
|
# 启动配置文件监控并注册napcat_server配置变更回调
|
||||||
_ = await asyncio.gather(napcat_server(), mmc_start_com(), message_process(), check_timeout_response())
|
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():
|
async def napcat_server():
|
||||||
logger.info("正在启动adapter...")
|
global websocket_server
|
||||||
async with Server.serve(message_recv, global_config.server_host, global_config.server_port) as server:
|
logger.info("正在启动 MaiBot-Napcat-Adapter...")
|
||||||
logger.info(f"Adapter已启动,监听地址: ws://{global_config.server_host}:{global_config.server_port}")
|
logger.debug(f"日志等级: {global_config.debug.level}")
|
||||||
await server.serve_forever()
|
logger.debug("日志文件: logs/adapter_*.log")
|
||||||
|
|
||||||
|
|
||||||
async def graceful_shutdown():
|
|
||||||
try:
|
try:
|
||||||
logger.info("正在关闭adapter...")
|
async with Server.serve(
|
||||||
await mmc_stop_com()
|
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()]
|
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:
|
for task in tasks:
|
||||||
task.cancel()
|
if not task.done():
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
task.cancel()
|
||||||
|
|
||||||
|
# 等待任务完成,记录异常到日志文件
|
||||||
|
if tasks:
|
||||||
|
try:
|
||||||
|
results = await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=3)
|
||||||
|
# 记录任务取消的详细信息到日志文件
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
logger.debug(f"任务 {i+1} 清理时产生异常: {type(result).__name__}: {result}")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.debug("任务清理超时")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"任务清理时出现错误: {e}")
|
||||||
|
|
||||||
|
if not silent:
|
||||||
|
logger.info("Adapter已成功关闭")
|
||||||
|
else:
|
||||||
|
logger.debug("资源清理完成")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Adapter关闭中出现错误: {e}")
|
logger.debug(f"graceful_shutdown异常: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
@ -70,11 +226,58 @@ if __name__ == "__main__":
|
||||||
loop.run_until_complete(main())
|
loop.run_until_complete(main())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.warning("收到中断信号,正在优雅关闭...")
|
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:
|
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)
|
sys.exit(1)
|
||||||
finally:
|
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)
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -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)的信息
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
[project]
|
[project]
|
||||||
name = "MaiBotNapcatAdapter"
|
name = "MaiBotNapcatAdapter"
|
||||||
version = "0.2.3"
|
version = "0.7.0"
|
||||||
description = "A MaiBot adapter for Napcat"
|
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]
|
[tool.ruff]
|
||||||
|
|
||||||
|
|
@ -21,7 +35,7 @@ select = [
|
||||||
"B", # flake8-bugbear
|
"B", # flake8-bugbear
|
||||||
]
|
]
|
||||||
|
|
||||||
ignore = ["E711","E501"]
|
ignore = ["E711", "E501"]
|
||||||
|
|
||||||
[tool.ruff.format]
|
[tool.ruff.format]
|
||||||
docstring-code-format = true
|
docstring-code-format = true
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,7 @@ requests
|
||||||
maim_message
|
maim_message
|
||||||
loguru
|
loguru
|
||||||
pillow
|
pillow
|
||||||
tomli
|
tomlkit
|
||||||
|
rich
|
||||||
|
sqlmodel
|
||||||
|
watchdog
|
||||||
|
|
@ -1,63 +1,42 @@
|
||||||
class MetaEventType:
|
from enum import Enum
|
||||||
lifecycle = "lifecycle" # 生命周期
|
import tomlkit
|
||||||
|
import os
|
||||||
class Lifecycle:
|
from .logger import logger
|
||||||
connect = "connect" # 生命周期 - WebSocket 连接成功
|
|
||||||
|
|
||||||
heartbeat = "heartbeat" # 心跳
|
|
||||||
|
|
||||||
|
|
||||||
class MessageType: # 接受消息大类
|
class CommandType(Enum):
|
||||||
private = "private" # 私聊消息
|
"""命令类型"""
|
||||||
|
|
||||||
class Private:
|
# 操作类命令
|
||||||
friend = "friend" # 私聊消息 - 好友
|
GROUP_BAN = "set_group_ban" # 禁言用户
|
||||||
group = "group" # 私聊消息 - 群临时
|
GROUP_WHOLE_BAN = "set_group_whole_ban" # 群全体禁言
|
||||||
group_self = "group_self" # 私聊消息 - 群中自身发送
|
GROUP_KICK = "set_group_kick" # 踢出群聊
|
||||||
other = "other" # 私聊消息 - 其他
|
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" # 设置账号信息
|
||||||
|
|
||||||
group = "group" # 群聊消息
|
# 查询类命令
|
||||||
|
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" # 获取合并转发消息
|
||||||
|
|
||||||
class Group:
|
def __str__(self) -> str:
|
||||||
normal = "normal" # 群聊消息 - 普通
|
return self.value
|
||||||
anonymous = "anonymous" # 群聊消息 - 匿名消息
|
|
||||||
notice = "notice" # 群聊消息 - 系统提示
|
|
||||||
|
|
||||||
|
|
||||||
class NoticeType: # 通知事件
|
pyproject_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "pyproject.toml")
|
||||||
friend_recall = "friend_recall" # 私聊消息撤回
|
toml_data = tomlkit.parse(open(pyproject_path, "r", encoding="utf-8").read())
|
||||||
group_recall = "group_recall" # 群聊消息撤回
|
version = toml_data["project"]["version"]
|
||||||
notify = "notify"
|
logger.info(f"版本\n\nMaiBot-Napcat-Adapter 版本: {version}\n喜欢的话点个star喵~\n")
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import tomli
|
|
||||||
import shutil
|
|
||||||
from .logger import logger
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
platform: str = "qq"
|
|
||||||
nickname: Optional[str] = None
|
|
||||||
server_host: str = "localhost"
|
|
||||||
server_port: int = 8095
|
|
||||||
napcat_heartbeat_interval: int = 30
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._get_config_path()
|
|
||||||
|
|
||||||
def _get_config_path(self):
|
|
||||||
current_file_path = os.path.abspath(__file__)
|
|
||||||
src_path = os.path.dirname(current_file_path)
|
|
||||||
self.root_path = os.path.join(src_path, "..")
|
|
||||||
self.config_path = os.path.join(self.root_path, "config.toml")
|
|
||||||
|
|
||||||
def load_config(self): # sourcery skip: extract-method, move-assign
|
|
||||||
include_configs = ["Napcat_Server", "MaiBot_Server", "Chat", "Voice", "Debug"]
|
|
||||||
if not os.path.exists(self.config_path):
|
|
||||||
logger.error("配置文件不存在!")
|
|
||||||
logger.info("正在创建配置文件...")
|
|
||||||
shutil.copy(
|
|
||||||
os.path.join(self.root_path, "template", "template_config.toml"),
|
|
||||||
os.path.join(self.root_path, "config.toml"),
|
|
||||||
)
|
|
||||||
logger.info("配置文件创建成功,请修改配置文件后重启程序。")
|
|
||||||
sys.exit(1)
|
|
||||||
with open(self.config_path, "rb") as f:
|
|
||||||
try:
|
|
||||||
raw_config = tomli.load(f)
|
|
||||||
except tomli.TOMLDecodeError as e:
|
|
||||||
logger.critical(f"配置文件bot_config.toml填写有误,请检查第{e.lineno}行第{e.colno}处:{e.msg}")
|
|
||||||
sys.exit(1)
|
|
||||||
for key in include_configs:
|
|
||||||
if key not in raw_config:
|
|
||||||
logger.error(f"配置文件中缺少必需的字段: '{key}'")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
self.server_host = raw_config["Napcat_Server"].get("host", "localhost")
|
|
||||||
self.server_port = raw_config["Napcat_Server"].get("port", 8095)
|
|
||||||
self.napcat_heartbeat_interval = raw_config["Napcat_Server"].get("heartbeat", 30)
|
|
||||||
|
|
||||||
self.mai_host = raw_config["MaiBot_Server"].get("host", "localhost")
|
|
||||||
self.mai_port = raw_config["MaiBot_Server"].get("port", 8000)
|
|
||||||
self.platform = raw_config["MaiBot_Server"].get("platform_name")
|
|
||||||
if not self.platform:
|
|
||||||
logger.critical("请在配置文件中指定平台")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
self.group_list_type: str = raw_config["Chat"].get("group_list_type")
|
|
||||||
self.group_list: list = raw_config["Chat"].get("group_list", [])
|
|
||||||
self.private_list_type: str = raw_config["Chat"].get("private_list_type")
|
|
||||||
self.private_list: list = raw_config["Chat"].get("private_list", [])
|
|
||||||
self.ban_user_id: list = raw_config["Chat"].get("ban_user_id", [])
|
|
||||||
self.enable_poke: bool = raw_config["Chat"].get("enable_poke", True)
|
|
||||||
if self.group_list_type not in ["whitelist", "blacklist"]:
|
|
||||||
logger.critical("请在配置文件中指定group_list_type或group_list_type填写错误")
|
|
||||||
sys.exit(1)
|
|
||||||
if self.private_list_type not in ["whitelist", "blacklist"]:
|
|
||||||
logger.critical("请在配置文件中指定private_list_type或private_list_type填写错误")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
self.use_tts = raw_config["Voice"].get("use_tts", False)
|
|
||||||
|
|
||||||
self.debug_level = raw_config["Debug"].get("level", "INFO")
|
|
||||||
|
|
||||||
|
|
||||||
global_config = Config()
|
|
||||||
global_config.load_config()
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
from .config import global_config, _config_manager as config_manager
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"global_config",
|
||||||
|
"config_manager",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import tomlkit
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from tomlkit import TOMLDocument
|
||||||
|
from tomlkit.items import Table
|
||||||
|
from ..logger import logger
|
||||||
|
from rich.traceback import install
|
||||||
|
|
||||||
|
from src.config.config_base import ConfigBase
|
||||||
|
from src.config.official_configs import (
|
||||||
|
ChatConfig,
|
||||||
|
DebugConfig,
|
||||||
|
ForwardConfig,
|
||||||
|
MaiBotServerConfig,
|
||||||
|
NapcatServerConfig,
|
||||||
|
NicknameConfig,
|
||||||
|
VoiceConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
install(extra_lines=3)
|
||||||
|
|
||||||
|
TEMPLATE_DIR = "template"
|
||||||
|
|
||||||
|
|
||||||
|
def update_config():
|
||||||
|
# 定义文件路径
|
||||||
|
template_path = f"{TEMPLATE_DIR}/template_config.toml"
|
||||||
|
old_config_path = "config.toml"
|
||||||
|
new_config_path = "config.toml"
|
||||||
|
|
||||||
|
# 检查配置文件是否存在
|
||||||
|
if not os.path.exists(old_config_path):
|
||||||
|
logger.info("配置文件不存在,从模板创建新配置")
|
||||||
|
shutil.copy2(template_path, old_config_path) # 复制模板文件
|
||||||
|
logger.info(f"已创建新配置文件,请填写后重新运行: {old_config_path}")
|
||||||
|
# 如果是新创建的配置文件,直接返回
|
||||||
|
quit()
|
||||||
|
|
||||||
|
# 读取旧配置文件和模板文件
|
||||||
|
with open(old_config_path, "r", encoding="utf-8") as f:
|
||||||
|
old_config = tomlkit.load(f)
|
||||||
|
with open(template_path, "r", encoding="utf-8") as f:
|
||||||
|
new_config = tomlkit.load(f)
|
||||||
|
|
||||||
|
# 检查version是否相同
|
||||||
|
if old_config and "inner" in old_config and "inner" in new_config:
|
||||||
|
old_version = old_config["inner"].get("version")
|
||||||
|
new_version = new_config["inner"].get("version")
|
||||||
|
if old_version and new_version and old_version == new_version:
|
||||||
|
logger.info(f"检测到配置文件版本号相同 (v{old_version}),跳过更新")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
logger.info(f"检测到版本号不同: 旧版本 v{old_version} -> 新版本 v{new_version}")
|
||||||
|
else:
|
||||||
|
logger.info("已有配置文件未检测到版本号,可能是旧版本。将进行更新")
|
||||||
|
|
||||||
|
# 创建备份文件夹
|
||||||
|
backup_dir = "config_backup"
|
||||||
|
os.makedirs(backup_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# 备份文件名
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
old_backup_path = os.path.join(backup_dir, f"config.toml.bak.{timestamp}")
|
||||||
|
|
||||||
|
# 备份旧配置文件
|
||||||
|
shutil.copy2(old_config_path, old_backup_path)
|
||||||
|
logger.info(f"已备份旧配置文件到: {old_backup_path}")
|
||||||
|
|
||||||
|
# 复制模板文件到配置目录
|
||||||
|
shutil.copy2(template_path, new_config_path)
|
||||||
|
logger.info(f"已创建新配置文件: {new_config_path}")
|
||||||
|
|
||||||
|
def update_dict(target: TOMLDocument | dict, source: TOMLDocument | dict):
|
||||||
|
"""
|
||||||
|
将source字典的值更新到target字典中(如果target中存在相同的键)
|
||||||
|
"""
|
||||||
|
for key, value in source.items():
|
||||||
|
# 跳过version字段的更新
|
||||||
|
if key == "version":
|
||||||
|
continue
|
||||||
|
if key in target:
|
||||||
|
if isinstance(value, dict) and isinstance(target[key], (dict, Table)):
|
||||||
|
update_dict(target[key], value)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# 对数组类型进行特殊处理
|
||||||
|
if isinstance(value, list):
|
||||||
|
# 如果是空数组,确保它保持为空数组
|
||||||
|
target[key] = tomlkit.array(str(value)) if value else tomlkit.array()
|
||||||
|
else:
|
||||||
|
# 其他类型使用item方法创建新值
|
||||||
|
target[key] = tomlkit.item(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
# 如果转换失败,直接赋值
|
||||||
|
target[key] = value
|
||||||
|
|
||||||
|
# 将旧配置的值更新到新配置中
|
||||||
|
logger.info("开始合并新旧配置...")
|
||||||
|
update_dict(new_config, old_config)
|
||||||
|
|
||||||
|
# 保存更新后的配置(保留注释和格式)
|
||||||
|
with open(new_config_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(tomlkit.dumps(new_config))
|
||||||
|
logger.info("配置文件更新完成,建议检查新配置文件中的内容,以免丢失重要信息")
|
||||||
|
quit()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config(ConfigBase):
|
||||||
|
"""总配置类"""
|
||||||
|
|
||||||
|
nickname: NicknameConfig
|
||||||
|
napcat_server: NapcatServerConfig
|
||||||
|
maibot_server: MaiBotServerConfig
|
||||||
|
chat: ChatConfig
|
||||||
|
voice: VoiceConfig
|
||||||
|
forward: ForwardConfig
|
||||||
|
debug: DebugConfig
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: str) -> Config:
|
||||||
|
"""
|
||||||
|
加载配置文件
|
||||||
|
:param config_path: 配置文件路径
|
||||||
|
:return: Config对象
|
||||||
|
"""
|
||||||
|
# 读取配置文件
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
config_data = tomlkit.load(f)
|
||||||
|
|
||||||
|
# 创建Config对象
|
||||||
|
try:
|
||||||
|
return Config.from_dict(config_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.critical("配置文件解析失败")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
# 更新配置
|
||||||
|
update_config()
|
||||||
|
|
||||||
|
logger.info("正在品鉴配置文件...")
|
||||||
|
|
||||||
|
# 创建配置管理器
|
||||||
|
from .config_manager import ConfigManager
|
||||||
|
|
||||||
|
_config_manager = ConfigManager()
|
||||||
|
_config_manager.load(config_path="config.toml")
|
||||||
|
|
||||||
|
# 向后兼容:global_config 指向配置管理器
|
||||||
|
# 所有现有代码可以继续使用 global_config.chat.xxx 访问配置
|
||||||
|
global_config = _config_manager
|
||||||
|
|
||||||
|
logger.info("非常的新鲜,非常的美味!")
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
from dataclasses import dataclass, fields, MISSING
|
||||||
|
from typing import TypeVar, Type, Any, get_origin, get_args, Literal, Dict, Union
|
||||||
|
|
||||||
|
T = TypeVar("T", bound="ConfigBase")
|
||||||
|
|
||||||
|
TOML_DICT_TYPE = {
|
||||||
|
int,
|
||||||
|
float,
|
||||||
|
str,
|
||||||
|
bool,
|
||||||
|
list,
|
||||||
|
dict,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConfigBase:
|
||||||
|
"""配置类的基类"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls: Type[T], data: Dict[str, Any]) -> T:
|
||||||
|
"""从字典加载配置字段"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise TypeError(f"Expected a dictionary, got {type(data).__name__}")
|
||||||
|
|
||||||
|
init_args: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
for f in fields(cls):
|
||||||
|
field_name = f.name
|
||||||
|
field_type = f.type
|
||||||
|
if field_name.startswith("_"):
|
||||||
|
# 跳过以 _ 开头的字段
|
||||||
|
continue
|
||||||
|
|
||||||
|
if field_name not in data:
|
||||||
|
if f.default is not MISSING or f.default_factory is not MISSING:
|
||||||
|
# 跳过未提供且有默认值/默认构造方法的字段
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Missing required field: '{field_name}'")
|
||||||
|
|
||||||
|
value = data[field_name]
|
||||||
|
try:
|
||||||
|
init_args[field_name] = cls._convert_field(value, field_type)
|
||||||
|
except TypeError as e:
|
||||||
|
raise TypeError(f"字段 '{field_name}' 出现类型错误: {e}") from e
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"无法将字段 '{field_name}' 转换为目标类型,出现错误: {e}") from e
|
||||||
|
|
||||||
|
return cls(**init_args)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _convert_field(cls, value: Any, field_type: Type[Any]) -> Any:
|
||||||
|
"""
|
||||||
|
转换字段值为指定类型
|
||||||
|
|
||||||
|
1. 对于嵌套的 dataclass,递归调用相应的 from_dict 方法
|
||||||
|
2. 对于泛型集合类型(list, set, tuple),递归转换每个元素
|
||||||
|
3. 对于基础类型(int, str, float, bool),直接转换
|
||||||
|
4. 对于其他类型,尝试直接转换,如果失败则抛出异常
|
||||||
|
"""
|
||||||
|
# 如果是嵌套的 dataclass,递归调用 from_dict 方法
|
||||||
|
if isinstance(field_type, type) and issubclass(field_type, ConfigBase):
|
||||||
|
return field_type.from_dict(value)
|
||||||
|
|
||||||
|
field_origin_type = get_origin(field_type)
|
||||||
|
field_args_type = get_args(field_type)
|
||||||
|
|
||||||
|
# 处理泛型集合类型(list, set, tuple)
|
||||||
|
if field_origin_type in {list, set, tuple}:
|
||||||
|
# 检查提供的value是否为list
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise TypeError(f"Expected an list for {field_type.__name__}, got {type(value).__name__}")
|
||||||
|
|
||||||
|
if field_origin_type is list:
|
||||||
|
return [cls._convert_field(item, field_args_type[0]) for item in value]
|
||||||
|
if field_origin_type is set:
|
||||||
|
return {cls._convert_field(item, field_args_type[0]) for item in value}
|
||||||
|
if field_origin_type is tuple:
|
||||||
|
# 检查提供的value长度是否与类型参数一致
|
||||||
|
if len(value) != len(field_args_type):
|
||||||
|
raise TypeError(
|
||||||
|
f"Expected {len(field_args_type)} items for {field_type.__name__}, got {len(value)}"
|
||||||
|
)
|
||||||
|
return tuple(cls._convert_field(item, arg_type) for item, arg_type in zip(value, field_args_type))
|
||||||
|
|
||||||
|
if field_origin_type is dict:
|
||||||
|
# 检查提供的value是否为dict
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise TypeError(f"Expected a dictionary for {field_type.__name__}, got {type(value).__name__}")
|
||||||
|
|
||||||
|
# 检查字典的键值类型
|
||||||
|
if len(field_args_type) != 2:
|
||||||
|
raise TypeError(f"Expected a dictionary with two type arguments for {field_type.__name__}")
|
||||||
|
key_type, value_type = field_args_type
|
||||||
|
|
||||||
|
return {cls._convert_field(k, key_type): cls._convert_field(v, value_type) for k, v in value.items()}
|
||||||
|
|
||||||
|
# 处理Optional类型
|
||||||
|
if field_origin_type is Union: # assert get_origin(Optional[Any]) is Union
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
# 如果有数据,检查实际类型
|
||||||
|
if type(value) not in field_args_type:
|
||||||
|
raise TypeError(f"Expected {field_args_type} for {field_type.__name__}, got {type(value).__name__}")
|
||||||
|
return cls._convert_field(value, field_args_type[0])
|
||||||
|
|
||||||
|
# 处理int, str, float, bool等基础类型
|
||||||
|
if field_origin_type is None:
|
||||||
|
if isinstance(value, field_type):
|
||||||
|
return field_type(value)
|
||||||
|
else:
|
||||||
|
raise TypeError(f"Expected {field_type.__name__}, got {type(value).__name__}")
|
||||||
|
|
||||||
|
# 处理Literal类型
|
||||||
|
if field_origin_type is Literal:
|
||||||
|
# 获取Literal的允许值
|
||||||
|
allowed_values = get_args(field_type)
|
||||||
|
if value in allowed_values:
|
||||||
|
return value
|
||||||
|
else:
|
||||||
|
raise TypeError(f"Value '{value}' is not in allowed values {allowed_values} for Literal type")
|
||||||
|
|
||||||
|
# 处理其他类型
|
||||||
|
if field_type is Any:
|
||||||
|
return value
|
||||||
|
|
||||||
|
# 其他类型直接转换
|
||||||
|
try:
|
||||||
|
return field_type(value)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
raise TypeError(f"无法将 {type(value).__name__} 转换为 {field_type.__name__}") from e
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""返回配置类的字符串表示"""
|
||||||
|
return f"{self.__class__.__name__}({', '.join(f'{f.name}={getattr(self, f.name)}' for f in fields(self))})"
|
||||||
|
|
@ -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}>"
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from src.config.config_base import ConfigBase
|
||||||
|
|
||||||
|
"""
|
||||||
|
须知:
|
||||||
|
1. 本文件中记录了所有的配置项
|
||||||
|
2. 所有新增的class都需要继承自ConfigBase
|
||||||
|
3. 所有新增的class都应在config.py中的Config类中添加字段
|
||||||
|
4. 对于新增的字段,若为可选项,则应在其后添加field()并设置default_factory或default
|
||||||
|
"""
|
||||||
|
|
||||||
|
ADAPTER_PLATFORM = "qq"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NicknameConfig(ConfigBase):
|
||||||
|
nickname: str
|
||||||
|
"""机器人昵称"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NapcatServerConfig(ConfigBase):
|
||||||
|
host: str = "localhost"
|
||||||
|
"""Napcat服务端的主机地址"""
|
||||||
|
|
||||||
|
port: int = 8095
|
||||||
|
"""Napcat服务端的端口号"""
|
||||||
|
|
||||||
|
token: str = ""
|
||||||
|
"""Napcat服务端的访问令牌,若无则留空"""
|
||||||
|
|
||||||
|
heartbeat_interval: int = 30
|
||||||
|
"""Napcat心跳间隔时间,单位为秒"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MaiBotServerConfig(ConfigBase):
|
||||||
|
platform_name: str = field(default=ADAPTER_PLATFORM, init=False)
|
||||||
|
"""平台名称,“qq”"""
|
||||||
|
|
||||||
|
host: str = "localhost"
|
||||||
|
"""MaiMCore的主机地址"""
|
||||||
|
|
||||||
|
port: int = 8000
|
||||||
|
"""MaiMCore的端口号"""
|
||||||
|
|
||||||
|
enable_api_server: bool = False
|
||||||
|
"""是否启用API-Server模式连接"""
|
||||||
|
|
||||||
|
base_url: str = ""
|
||||||
|
"""API-Server连接地址 (ws://ipp:port/path)"""
|
||||||
|
|
||||||
|
api_key: str = ""
|
||||||
|
"""API Key (仅在enable_api_server为True时使用)"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChatConfig(ConfigBase):
|
||||||
|
group_list_type: Literal["whitelist", "blacklist"] = "whitelist"
|
||||||
|
"""群聊列表类型 白名单/黑名单"""
|
||||||
|
|
||||||
|
group_list: list[int] = field(default_factory=[])
|
||||||
|
"""群聊列表"""
|
||||||
|
|
||||||
|
private_list_type: Literal["whitelist", "blacklist"] = "whitelist"
|
||||||
|
"""私聊列表类型 白名单/黑名单"""
|
||||||
|
|
||||||
|
private_list: list[int] = field(default_factory=[])
|
||||||
|
"""私聊列表"""
|
||||||
|
|
||||||
|
ban_user_id: list[int] = field(default_factory=[])
|
||||||
|
"""被封禁的用户ID列表,封禁后将无法与其进行交互"""
|
||||||
|
|
||||||
|
ban_qq_bot: bool = False
|
||||||
|
"""是否屏蔽QQ官方机器人,若为True,则所有QQ官方机器人将无法与MaiMCore进行交互"""
|
||||||
|
|
||||||
|
enable_poke: bool = True
|
||||||
|
"""是否启用戳一戳功能"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VoiceConfig(ConfigBase):
|
||||||
|
use_tts: bool = False
|
||||||
|
"""是否启用TTS功能"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ForwardConfig(ConfigBase):
|
||||||
|
"""转发消息相关配置"""
|
||||||
|
|
||||||
|
image_threshold: int = 3
|
||||||
|
"""图片数量阈值:转发消息中图片数量超过此值时,使用占位符代替base64发送,避免麦麦VLM处理卡死"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DebugConfig(ConfigBase):
|
||||||
|
level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
|
||||||
|
"""日志级别,默认为INFO"""
|
||||||
|
|
@ -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()
|
||||||
107
src/logger.py
107
src/logger.py
|
|
@ -1,25 +1,106 @@
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from .config import global_config
|
from .config import global_config
|
||||||
import sys
|
import sys
|
||||||
# import builtins
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# 日志目录配置
|
||||||
|
LOG_DIR = Path(__file__).parent.parent / "logs"
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# 日志等级映射(用于显示单字母)
|
||||||
|
LEVEL_ABBR = {
|
||||||
|
"TRACE": "T",
|
||||||
|
"DEBUG": "D",
|
||||||
|
"INFO": "I",
|
||||||
|
"SUCCESS": "S",
|
||||||
|
"WARNING": "W",
|
||||||
|
"ERROR": "E",
|
||||||
|
"CRITICAL": "C"
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_level_abbr(record):
|
||||||
|
"""获取日志等级的缩写"""
|
||||||
|
return LEVEL_ABBR.get(record["level"].name, record["level"].name[0])
|
||||||
|
|
||||||
|
def clean_old_logs(days: int = 30):
|
||||||
|
"""清理超过指定天数的日志文件"""
|
||||||
|
try:
|
||||||
|
cutoff_date = datetime.now() - timedelta(days=days)
|
||||||
|
for log_file in LOG_DIR.glob("*.log"):
|
||||||
|
try:
|
||||||
|
file_time = datetime.fromtimestamp(log_file.stat().st_mtime)
|
||||||
|
if file_time < cutoff_date:
|
||||||
|
log_file.unlink()
|
||||||
|
print(f"已清理过期日志: {log_file.name}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"清理日志文件 {log_file.name} 失败: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"清理日志目录失败: {e}")
|
||||||
|
|
||||||
|
# 清理过期日志
|
||||||
|
clean_old_logs(30)
|
||||||
|
|
||||||
|
# 移除默认处理器
|
||||||
logger.remove()
|
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(
|
logger.add(
|
||||||
sys.stderr,
|
sys.stderr,
|
||||||
level=global_config.debug_level,
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
# def handle_output(message: str):
|
# 文件输出处理器 - 详细格式,记录所有TRACE级别
|
||||||
# if "连接失败" in message:
|
log_file = LOG_DIR / f"adapter_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
||||||
# logger.error(message)
|
logger.add(
|
||||||
# elif "收到无效的" in message:
|
log_file,
|
||||||
# logger.warning(message)
|
level="TRACE",
|
||||||
# elif "检测到平台" in message:
|
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | [{level}] | {extra[module_name]} | {name}:{function}:{line} - {message}",
|
||||||
# logger.warning(message)
|
rotation="100 MB", # 单个日志文件最大100MB
|
||||||
# else:
|
retention="30 days", # 保留30天
|
||||||
# logger.info(message)
|
encoding="utf-8",
|
||||||
|
enqueue=True, # 异步写入,避免阻塞
|
||||||
|
filter=format_log, # 确保extra字段存在
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_logger(module_name: str = "Adapter"):
|
||||||
|
"""
|
||||||
|
获取自定义模块名的logger
|
||||||
|
|
||||||
# builtins.print = handle_output
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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 .config import global_config
|
||||||
from .logger import logger
|
from .logger import logger, custom_logger
|
||||||
from .send_handler import send_handler
|
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(
|
# 检查 maim_message 版本是否支持 MessageConverter (>= 0.6.2)
|
||||||
route_config={
|
try:
|
||||||
global_config.platform: TargetConfig(
|
maim_message_version = importlib.metadata.version("maim_message")
|
||||||
url=f"ws://{global_config.mai_host}:{global_config.mai_port}/ws",
|
version_int = [int(x) for x in maim_message_version.split(".")]
|
||||||
token=None,
|
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,
|
||||||
)
|
)
|
||||||
}
|
return await self.client.send_message(api_message)
|
||||||
)
|
|
||||||
router = Router(route_config)
|
|
||||||
|
|
||||||
|
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():
|
async def mmc_start_com():
|
||||||
logger.info("正在连接MaiBot")
|
global router
|
||||||
router.register_class_handler(send_handler.handle_seg)
|
config = global_config.maibot_server
|
||||||
await router.run()
|
|
||||||
|
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():
|
async def mmc_stop_com():
|
||||||
await router.stop()
|
if router:
|
||||||
|
await router.stop()
|
||||||
|
|
|
||||||
|
|
@ -1,783 +0,0 @@
|
||||||
from .logger import logger
|
|
||||||
from .config import global_config
|
|
||||||
from .qq_emoji_list import qq_face
|
|
||||||
import time
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import websockets as Server
|
|
||||||
from typing import List, Tuple, Optional
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from . import MetaEventType, RealMessageType, MessageType, NoticeType
|
|
||||||
from maim_message import (
|
|
||||||
UserInfo,
|
|
||||||
GroupInfo,
|
|
||||||
Seg,
|
|
||||||
BaseMessageInfo,
|
|
||||||
MessageBase,
|
|
||||||
TemplateInfo,
|
|
||||||
FormatInfo,
|
|
||||||
Router,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .utils import (
|
|
||||||
get_group_info,
|
|
||||||
get_member_info,
|
|
||||||
get_image_base64,
|
|
||||||
get_self_info,
|
|
||||||
get_stranger_info,
|
|
||||||
get_message_detail,
|
|
||||||
)
|
|
||||||
from .message_queue import get_response
|
|
||||||
|
|
||||||
|
|
||||||
class RecvHandler:
|
|
||||||
maibot_router: Router = None
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.server_connection: Server.ServerConnection = None
|
|
||||||
self.interval = global_config.napcat_heartbeat_interval
|
|
||||||
|
|
||||||
async def handle_meta_event(self, message: dict) -> None:
|
|
||||||
event_type = message.get("meta_event_type")
|
|
||||||
if event_type == MetaEventType.lifecycle:
|
|
||||||
sub_type = message.get("sub_type")
|
|
||||||
if sub_type == MetaEventType.Lifecycle.connect:
|
|
||||||
self_id = message.get("self_id")
|
|
||||||
self.last_heart_beat = time.time()
|
|
||||||
logger.info(f"Bot {self_id} 连接成功")
|
|
||||||
asyncio.create_task(self.check_heartbeat(self_id))
|
|
||||||
elif event_type == MetaEventType.heartbeat:
|
|
||||||
if message["status"].get("online") and message["status"].get("good"):
|
|
||||||
self.last_heart_beat = time.time()
|
|
||||||
self.interval = message.get("interval") / 1000
|
|
||||||
else:
|
|
||||||
self_id = message.get("self_id")
|
|
||||||
logger.warning(f"Bot {self_id} Napcat 端异常!")
|
|
||||||
|
|
||||||
async def check_heartbeat(self, id: int) -> None:
|
|
||||||
while True:
|
|
||||||
now_time = time.time()
|
|
||||||
if now_time - self.last_heart_beat > self.interval + 3:
|
|
||||||
logger.warning(f"Bot {id} 连接已断开")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
logger.debug("心跳正常")
|
|
||||||
await asyncio.sleep(self.interval)
|
|
||||||
|
|
||||||
def check_allow_to_chat(self, user_id: int, group_id: Optional[int]) -> bool:
|
|
||||||
# sourcery skip: hoist-statement-from-if, merge-else-if-into-elif
|
|
||||||
"""
|
|
||||||
检查是否允许聊天
|
|
||||||
Parameters:
|
|
||||||
user_id: int: 用户ID
|
|
||||||
group_id: int: 群ID
|
|
||||||
Returns:
|
|
||||||
bool: 是否允许聊天
|
|
||||||
"""
|
|
||||||
if group_id:
|
|
||||||
if global_config.group_list_type == "whitelist" and group_id not in global_config.group_list:
|
|
||||||
logger.warning("群聊不在聊天白名单中,消息被丢弃")
|
|
||||||
return False
|
|
||||||
elif global_config.group_list_type == "blacklist" and group_id in global_config.group_list:
|
|
||||||
logger.warning("群聊在聊天黑名单中,消息被丢弃")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
if global_config.private_list_type == "whitelist" and user_id not in global_config.private_list:
|
|
||||||
logger.warning("用户不在聊天白名单中,消息被丢弃")
|
|
||||||
return False
|
|
||||||
elif global_config.private_list_type == "blacklist" and user_id in global_config.private_list:
|
|
||||||
logger.warning("用户在聊天黑名单中,消息被丢弃")
|
|
||||||
return False
|
|
||||||
if user_id in global_config.ban_user_id:
|
|
||||||
logger.warning("用户在全局黑名单中,消息被丢弃")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def handle_raw_message(self, raw_message: dict) -> None:
|
|
||||||
# sourcery skip: low-code-quality, remove-unreachable-code
|
|
||||||
"""
|
|
||||||
从Napcat接受的原始消息处理
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
raw_message: dict: 原始消息
|
|
||||||
"""
|
|
||||||
message_type: str = raw_message.get("message_type")
|
|
||||||
message_id: int = raw_message.get("message_id")
|
|
||||||
# message_time: int = raw_message.get("time")
|
|
||||||
message_time: float = time.time() # 应可乐要求,现在是float了
|
|
||||||
|
|
||||||
template_info: TemplateInfo = None # 模板信息,暂时为空,等待启用
|
|
||||||
format_info: FormatInfo = FormatInfo(
|
|
||||||
content_format=["text", "image", "emoji"],
|
|
||||||
accept_format=["text", "image", "emoji", "reply"],
|
|
||||||
) # 格式化信息
|
|
||||||
if message_type == MessageType.private:
|
|
||||||
sub_type = raw_message.get("sub_type")
|
|
||||||
if sub_type == MessageType.Private.friend:
|
|
||||||
sender_info: dict = raw_message.get("sender")
|
|
||||||
|
|
||||||
if not self.check_allow_to_chat(sender_info.get("user_id"), None):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 发送者用户信息
|
|
||||||
user_info: UserInfo = UserInfo(
|
|
||||||
platform=global_config.platform,
|
|
||||||
user_id=sender_info.get("user_id"),
|
|
||||||
user_nickname=sender_info.get("nickname"),
|
|
||||||
user_cardname=sender_info.get("card"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 不存在群信息
|
|
||||||
group_info: GroupInfo = None
|
|
||||||
elif sub_type == MessageType.Private.group:
|
|
||||||
"""
|
|
||||||
本部分暂时不做支持,先放着
|
|
||||||
"""
|
|
||||||
logger.warning("群临时消息类型不支持")
|
|
||||||
return None
|
|
||||||
|
|
||||||
sender_info: dict = raw_message.get("sender")
|
|
||||||
|
|
||||||
# 由于临时会话中,Napcat默认不发送成员昵称,所以需要单独获取
|
|
||||||
fetched_member_info: dict = await get_member_info(
|
|
||||||
self.server_connection,
|
|
||||||
raw_message.get("group_id"),
|
|
||||||
sender_info.get("user_id"),
|
|
||||||
)
|
|
||||||
nickname = fetched_member_info.get("nickname") if fetched_member_info else None
|
|
||||||
# 发送者用户信息
|
|
||||||
user_info: UserInfo = UserInfo(
|
|
||||||
platform=global_config.platform,
|
|
||||||
user_id=sender_info.get("user_id"),
|
|
||||||
user_nickname=nickname,
|
|
||||||
user_cardname=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# -------------------这里需要群信息吗?-------------------
|
|
||||||
|
|
||||||
# 获取群聊相关信息,在此单独处理group_name,因为默认发送的消息中没有
|
|
||||||
fetched_group_info: dict = await get_group_info(self.server_connection, raw_message.get("group_id"))
|
|
||||||
group_name = ""
|
|
||||||
if fetched_group_info.get("group_name"):
|
|
||||||
group_name = fetched_group_info.get("group_name")
|
|
||||||
|
|
||||||
group_info: GroupInfo = GroupInfo(
|
|
||||||
platform=global_config.platform,
|
|
||||||
group_id=raw_message.get("group_id"),
|
|
||||||
group_name=group_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.warning("私聊消息类型不支持")
|
|
||||||
return None
|
|
||||||
elif message_type == MessageType.group:
|
|
||||||
sub_type = raw_message.get("sub_type")
|
|
||||||
if sub_type == MessageType.Group.normal:
|
|
||||||
sender_info: dict = raw_message.get("sender")
|
|
||||||
|
|
||||||
if not self.check_allow_to_chat(sender_info.get("user_id"), raw_message.get("group_id")):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 发送者用户信息
|
|
||||||
user_info: UserInfo = UserInfo(
|
|
||||||
platform=global_config.platform,
|
|
||||||
user_id=sender_info.get("user_id"),
|
|
||||||
user_nickname=sender_info.get("nickname"),
|
|
||||||
user_cardname=sender_info.get("card"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 获取群聊相关信息,在此单独处理group_name,因为默认发送的消息中没有
|
|
||||||
fetched_group_info = await get_group_info(self.server_connection, raw_message.get("group_id"))
|
|
||||||
group_name: str = None
|
|
||||||
if fetched_group_info:
|
|
||||||
group_name = fetched_group_info.get("group_name")
|
|
||||||
|
|
||||||
group_info: GroupInfo = GroupInfo(
|
|
||||||
platform=global_config.platform,
|
|
||||||
group_id=raw_message.get("group_id"),
|
|
||||||
group_name=group_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.warning("群聊消息类型不支持")
|
|
||||||
return None
|
|
||||||
|
|
||||||
additional_config: dict = {}
|
|
||||||
if global_config.use_tts:
|
|
||||||
additional_config["allow_tts"] = True
|
|
||||||
|
|
||||||
# 消息信息
|
|
||||||
message_info: BaseMessageInfo = BaseMessageInfo(
|
|
||||||
platform=global_config.platform,
|
|
||||||
message_id=message_id,
|
|
||||||
time=message_time,
|
|
||||||
user_info=user_info,
|
|
||||||
group_info=group_info,
|
|
||||||
template_info=template_info,
|
|
||||||
format_info=format_info,
|
|
||||||
additional_config=additional_config,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 处理实际信息
|
|
||||||
if not raw_message.get("message"):
|
|
||||||
logger.warning("消息内容为空")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 获取Seg列表
|
|
||||||
seg_message: List[Seg] = await self.handle_real_message(raw_message)
|
|
||||||
if not seg_message:
|
|
||||||
logger.warning("消息内容为空")
|
|
||||||
return None
|
|
||||||
submit_seg: Seg = Seg(
|
|
||||||
type="seglist",
|
|
||||||
data=seg_message,
|
|
||||||
)
|
|
||||||
# MessageBase创建
|
|
||||||
message_base: MessageBase = MessageBase(
|
|
||||||
message_info=message_info,
|
|
||||||
message_segment=submit_seg,
|
|
||||||
raw_message=raw_message.get("raw_message"),
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("发送到Maibot处理信息")
|
|
||||||
await self.message_process(message_base)
|
|
||||||
|
|
||||||
async def handle_real_message(self, raw_message: dict, in_reply: bool = False) -> List[Seg] | None:
|
|
||||||
# sourcery skip: low-code-quality
|
|
||||||
"""
|
|
||||||
处理实际消息
|
|
||||||
Parameters:
|
|
||||||
real_message: dict: 实际消息
|
|
||||||
Returns:
|
|
||||||
seg_message: list[Seg]: 处理后的消息段列表
|
|
||||||
"""
|
|
||||||
real_message: list = raw_message.get("message")
|
|
||||||
if not real_message:
|
|
||||||
return None
|
|
||||||
seg_message: List[Seg] = []
|
|
||||||
for sub_message in real_message:
|
|
||||||
sub_message: dict
|
|
||||||
sub_message_type = sub_message.get("type")
|
|
||||||
match sub_message_type:
|
|
||||||
case RealMessageType.text:
|
|
||||||
ret_seg = await self.handle_text_message(sub_message)
|
|
||||||
if ret_seg:
|
|
||||||
seg_message.append(ret_seg)
|
|
||||||
else:
|
|
||||||
logger.warning("text处理失败")
|
|
||||||
case RealMessageType.face:
|
|
||||||
ret_seg = await self.handle_face_message(sub_message)
|
|
||||||
if ret_seg:
|
|
||||||
seg_message.append(ret_seg)
|
|
||||||
else:
|
|
||||||
logger.warning("face处理失败或不支持")
|
|
||||||
case RealMessageType.reply:
|
|
||||||
if not in_reply:
|
|
||||||
ret_seg = await self.handle_reply_message(sub_message)
|
|
||||||
if ret_seg:
|
|
||||||
seg_message += ret_seg
|
|
||||||
else:
|
|
||||||
logger.warning("reply处理失败")
|
|
||||||
case RealMessageType.image:
|
|
||||||
ret_seg = await self.handle_image_message(sub_message)
|
|
||||||
if ret_seg:
|
|
||||||
seg_message.append(ret_seg)
|
|
||||||
else:
|
|
||||||
logger.warning("image处理失败")
|
|
||||||
case RealMessageType.record:
|
|
||||||
logger.warning("不支持语音解析")
|
|
||||||
case RealMessageType.video:
|
|
||||||
logger.warning("不支持视频解析")
|
|
||||||
case RealMessageType.at:
|
|
||||||
ret_seg = await self.handle_at_message(
|
|
||||||
sub_message,
|
|
||||||
raw_message.get("self_id"),
|
|
||||||
raw_message.get("group_id"),
|
|
||||||
)
|
|
||||||
if ret_seg:
|
|
||||||
seg_message.append(ret_seg)
|
|
||||||
else:
|
|
||||||
logger.warning("at处理失败")
|
|
||||||
case RealMessageType.rps:
|
|
||||||
logger.warning("暂时不支持猜拳魔法表情解析")
|
|
||||||
case RealMessageType.dice:
|
|
||||||
logger.warning("暂时不支持骰子表情解析")
|
|
||||||
case RealMessageType.shake:
|
|
||||||
# 预计等价于戳一戳
|
|
||||||
logger.warning("暂时不支持窗口抖动解析")
|
|
||||||
case RealMessageType.share:
|
|
||||||
logger.warning("暂时不支持链接解析")
|
|
||||||
case RealMessageType.forward:
|
|
||||||
forward_message_data = sub_message.get("data")
|
|
||||||
if not forward_message_data:
|
|
||||||
logger.warning("转发消息内容为空")
|
|
||||||
return None
|
|
||||||
forward_message_id = forward_message_data.get("id")
|
|
||||||
request_uuid = str(uuid.uuid4())
|
|
||||||
payload = json.dumps(
|
|
||||||
{
|
|
||||||
"action": "get_forward_msg",
|
|
||||||
"params": {"message_id": forward_message_id},
|
|
||||||
"echo": request_uuid,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await self.server_connection.send(payload)
|
|
||||||
response: dict = await get_response(request_uuid)
|
|
||||||
except TimeoutError:
|
|
||||||
logger.error("获取转发消息超时")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取转发消息失败: {str(e)}")
|
|
||||||
return None
|
|
||||||
logger.debug(
|
|
||||||
f"转发消息原始格式:{json.dumps(response)[:80]}..."
|
|
||||||
if len(json.dumps(response)) > 80
|
|
||||||
else json.dumps(response)
|
|
||||||
)
|
|
||||||
response_data: dict = response.get("data")
|
|
||||||
if not response_data:
|
|
||||||
logger.warning("转发消息内容为空或获取失败")
|
|
||||||
return None
|
|
||||||
messages = response_data.get("messages")
|
|
||||||
if not messages:
|
|
||||||
logger.warning("转发消息内容为空或获取失败")
|
|
||||||
return None
|
|
||||||
ret_seg = await self.handle_forward_message(messages)
|
|
||||||
if ret_seg:
|
|
||||||
seg_message.append(ret_seg)
|
|
||||||
else:
|
|
||||||
logger.warning("转发消息处理失败")
|
|
||||||
case RealMessageType.node:
|
|
||||||
logger.warning("不支持转发消息节点解析")
|
|
||||||
case _:
|
|
||||||
logger.warning(f"未知消息类型:{sub_message_type}")
|
|
||||||
return seg_message
|
|
||||||
|
|
||||||
async def handle_text_message(self, raw_message: dict) -> Seg:
|
|
||||||
"""
|
|
||||||
处理纯文本信息
|
|
||||||
Parameters:
|
|
||||||
raw_message: dict: 原始消息
|
|
||||||
Returns:
|
|
||||||
seg_data: Seg: 处理后的消息段
|
|
||||||
"""
|
|
||||||
message_data: dict = raw_message.get("data")
|
|
||||||
plain_text: str = message_data.get("text")
|
|
||||||
return Seg(type=RealMessageType.text, data=plain_text)
|
|
||||||
|
|
||||||
async def handle_face_message(self, raw_message: dict) -> Seg | None:
|
|
||||||
"""
|
|
||||||
处理表情消息
|
|
||||||
Parameters:
|
|
||||||
raw_message: dict: 原始消息
|
|
||||||
Returns:
|
|
||||||
seg_data: Seg: 处理后的消息段
|
|
||||||
"""
|
|
||||||
message_data: dict = raw_message.get("data")
|
|
||||||
face_raw_id: str = str(message_data.get("id"))
|
|
||||||
if face_raw_id in qq_face:
|
|
||||||
face_content: str = qq_face.get(face_raw_id)
|
|
||||||
return Seg(type="text", data=face_content)
|
|
||||||
else:
|
|
||||||
logger.warning(f"不支持的表情:{face_raw_id}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def handle_image_message(self, raw_message: dict) -> Seg | None:
|
|
||||||
"""
|
|
||||||
处理图片消息与表情包消息
|
|
||||||
Parameters:
|
|
||||||
raw_message: dict: 原始消息
|
|
||||||
Returns:
|
|
||||||
seg_data: Seg: 处理后的消息段
|
|
||||||
"""
|
|
||||||
message_data: dict = raw_message.get("data")
|
|
||||||
image_sub_type = message_data.get("sub_type")
|
|
||||||
try:
|
|
||||||
image_base64 = await get_image_base64(message_data.get("url"))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"图片消息处理失败: {str(e)}")
|
|
||||||
return None
|
|
||||||
if image_sub_type == 0:
|
|
||||||
"""这部分认为是图片"""
|
|
||||||
return Seg(type="image", data=image_base64)
|
|
||||||
elif image_sub_type == 1:
|
|
||||||
"""这部分认为是表情包"""
|
|
||||||
return Seg(type="emoji", data=image_base64)
|
|
||||||
else:
|
|
||||||
logger.warning(f"不支持的图片类型:{image_sub_type}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def handle_at_message(self, raw_message: dict, self_id: int, group_id: int) -> Seg | None:
|
|
||||||
# sourcery skip: use-named-expression
|
|
||||||
"""
|
|
||||||
处理at消息
|
|
||||||
Parameters:
|
|
||||||
raw_message: dict: 原始消息
|
|
||||||
self_id: int: 机器人QQ号
|
|
||||||
group_id: int: 群号
|
|
||||||
Returns:
|
|
||||||
seg_data: Seg: 处理后的消息段
|
|
||||||
"""
|
|
||||||
message_data: dict = raw_message.get("data")
|
|
||||||
if message_data:
|
|
||||||
qq_id = message_data.get("qq")
|
|
||||||
if str(self_id) == str(qq_id):
|
|
||||||
self_info: dict = await get_self_info(self.server_connection)
|
|
||||||
if self_info:
|
|
||||||
return Seg(
|
|
||||||
type=RealMessageType.text, data=f"@<{self_info.get('nickname')}:{self_info.get('user_id')}>"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
member_info: dict = await get_member_info(self.server_connection, group_id=group_id, user_id=qq_id)
|
|
||||||
if member_info:
|
|
||||||
return Seg(
|
|
||||||
type=RealMessageType.text, data=f"@<{member_info.get('nickname')}:{member_info.get('user_id')}>"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def handle_reply_message(self, raw_message: dict) -> Seg | None:
|
|
||||||
# sourcery skip: move-assign-in-block, use-named-expression
|
|
||||||
"""
|
|
||||||
处理回复消息
|
|
||||||
|
|
||||||
"""
|
|
||||||
raw_message_data: dict = raw_message.get("data")
|
|
||||||
message_id: int = None
|
|
||||||
if raw_message_data:
|
|
||||||
message_id = raw_message_data.get("id")
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
message_detail: dict = await get_message_detail(self.server_connection, message_id)
|
|
||||||
if not message_detail:
|
|
||||||
logger.warning("获取被引用的消息详情失败")
|
|
||||||
return None
|
|
||||||
reply_message = await self.handle_real_message(message_detail, in_reply=True)
|
|
||||||
if reply_message is None:
|
|
||||||
reply_message = "(获取发言内容失败)"
|
|
||||||
sender_info: dict = message_detail.get("sender")
|
|
||||||
sender_nickname: str = sender_info.get("nickname")
|
|
||||||
sender_id: str = sender_info.get("user_id")
|
|
||||||
seg_message: List[Seg] = []
|
|
||||||
if not sender_nickname:
|
|
||||||
logger.warning("无法获取被引用的人的昵称,返回默认值")
|
|
||||||
seg_message.append(Seg(type="text", data="[回复 未知用户:"))
|
|
||||||
else:
|
|
||||||
seg_message.append(Seg(type="text", data=f"[回复<{sender_nickname}:{sender_id}>:"))
|
|
||||||
seg_message += reply_message
|
|
||||||
seg_message.append(Seg(type="text", data="],说:"))
|
|
||||||
return seg_message
|
|
||||||
|
|
||||||
async def handle_notice(self, raw_message: dict) -> None:
|
|
||||||
notice_type = raw_message.get("notice_type")
|
|
||||||
# message_time: int = raw_message.get("time")
|
|
||||||
message_time: float = time.time() # 应可乐要求,现在是float了
|
|
||||||
|
|
||||||
group_id = raw_message.get("group_id")
|
|
||||||
user_id = raw_message.get("user_id")
|
|
||||||
handled_message: Seg = None
|
|
||||||
|
|
||||||
match notice_type:
|
|
||||||
case NoticeType.friend_recall:
|
|
||||||
logger.info("好友撤回一条消息")
|
|
||||||
logger.info(f"撤回消息ID:{raw_message.get('message_id')}, 撤回时间:{raw_message.get('time')}")
|
|
||||||
logger.warning("暂时不支持撤回消息处理")
|
|
||||||
case NoticeType.group_recall:
|
|
||||||
logger.info("群内用户撤回一条消息")
|
|
||||||
logger.info(f"撤回消息ID:{raw_message.get('message_id')}, 撤回时间:{raw_message.get('time')}")
|
|
||||||
logger.warning("暂时不支持撤回消息处理")
|
|
||||||
case NoticeType.notify:
|
|
||||||
sub_type = raw_message.get("sub_type")
|
|
||||||
match sub_type:
|
|
||||||
case NoticeType.Notify.poke:
|
|
||||||
if global_config.enable_poke:
|
|
||||||
handled_message: Seg = await self.handle_poke_notify(raw_message)
|
|
||||||
else:
|
|
||||||
logger.warning("戳一戳消息被禁用")
|
|
||||||
case _:
|
|
||||||
logger.warning("不支持的notify类型")
|
|
||||||
case _:
|
|
||||||
logger.warning("不支持的notice类型")
|
|
||||||
return None
|
|
||||||
if not handled_message:
|
|
||||||
logger.warning("notice处理失败或不支持")
|
|
||||||
return None
|
|
||||||
|
|
||||||
source_name: str = None
|
|
||||||
source_cardname: str = None
|
|
||||||
if group_id:
|
|
||||||
member_info: dict = await get_member_info(self.server_connection, group_id, user_id)
|
|
||||||
if member_info:
|
|
||||||
source_name = member_info.get("nickname")
|
|
||||||
source_cardname = member_info.get("card")
|
|
||||||
else:
|
|
||||||
logger.warning("无法获取戳一戳消息发送者的昵称,消息可能会无效")
|
|
||||||
source_name = "QQ用户"
|
|
||||||
else:
|
|
||||||
stranger_info = await get_stranger_info(self.server_connection, user_id)
|
|
||||||
if stranger_info:
|
|
||||||
source_name = stranger_info.get("nickname")
|
|
||||||
else:
|
|
||||||
logger.warning("无法获取戳一戳消息发送者的昵称,消息可能会无效")
|
|
||||||
source_name = "QQ用户"
|
|
||||||
|
|
||||||
user_info: UserInfo = UserInfo(
|
|
||||||
platform=global_config.platform,
|
|
||||||
user_id=user_id,
|
|
||||||
user_nickname=source_name,
|
|
||||||
user_cardname=source_cardname,
|
|
||||||
)
|
|
||||||
|
|
||||||
group_info: GroupInfo = None
|
|
||||||
if group_id:
|
|
||||||
fetched_group_info = await get_group_info(self.server_connection, group_id)
|
|
||||||
group_name: str = None
|
|
||||||
if fetched_group_info:
|
|
||||||
group_name = fetched_group_info.get("group_name")
|
|
||||||
group_info = GroupInfo(
|
|
||||||
platform=global_config.platform,
|
|
||||||
group_id=group_id,
|
|
||||||
group_name=group_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
message_info: BaseMessageInfo = BaseMessageInfo(
|
|
||||||
platform=global_config.platform,
|
|
||||||
message_id="notice",
|
|
||||||
time=message_time,
|
|
||||||
user_info=user_info,
|
|
||||||
group_info=group_info,
|
|
||||||
template_info=None,
|
|
||||||
format_info=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
message_base: MessageBase = MessageBase(
|
|
||||||
message_info=message_info,
|
|
||||||
message_segment=handled_message,
|
|
||||||
raw_message=json.dumps(raw_message),
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("发送到Maibot处理通知信息")
|
|
||||||
await self.message_process(message_base)
|
|
||||||
|
|
||||||
async def handle_poke_notify(self, raw_message: dict) -> Seg | None:
|
|
||||||
self_info: dict = await get_self_info(self.server_connection)
|
|
||||||
if not self_info:
|
|
||||||
logger.error("自身信息获取失败")
|
|
||||||
return None
|
|
||||||
self_id = raw_message.get("self_id")
|
|
||||||
target_id = raw_message.get("target_id")
|
|
||||||
target_name: str = None
|
|
||||||
raw_info: list = raw_message.get("raw_info")
|
|
||||||
# 计算Seg
|
|
||||||
if self_id == target_id:
|
|
||||||
target_name = self_info.get("nickname")
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
first_txt = raw_info[2].get("txt", "戳了戳")
|
|
||||||
second_txt = raw_info[4].get("txt", "")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"解析戳一戳消息失败,使用默认文本:{str(e)}")
|
|
||||||
first_txt = "戳了戳"
|
|
||||||
second_txt = ""
|
|
||||||
"""
|
|
||||||
# 不启用戳其他人的处理
|
|
||||||
else:
|
|
||||||
# 由于Napcat不支持获取昵称,所以需要单独获取
|
|
||||||
group_id = raw_message.get("group_id")
|
|
||||||
fetched_member_info: dict = await get_member_info(
|
|
||||||
self.server_connection, group_id, target_id
|
|
||||||
)
|
|
||||||
if fetched_member_info:
|
|
||||||
target_name = fetched_member_info.get("nickname")
|
|
||||||
"""
|
|
||||||
seg_data: Seg = Seg(
|
|
||||||
type="text",
|
|
||||||
data=f"{first_txt}{target_name}{second_txt}(这是QQ的一个功能,用于提及某人,但没那么明显)",
|
|
||||||
)
|
|
||||||
return seg_data
|
|
||||||
|
|
||||||
async def handle_forward_message(self, message_list: list) -> Seg | None:
|
|
||||||
"""
|
|
||||||
递归处理转发消息,并按照动态方式确定图片处理方式
|
|
||||||
Parameters:
|
|
||||||
message_list: list: 转发消息列表
|
|
||||||
"""
|
|
||||||
handled_message, image_count = await self._handle_forward_message(message_list, 0)
|
|
||||||
handled_message: Seg
|
|
||||||
image_count: int
|
|
||||||
if not handled_message:
|
|
||||||
return None
|
|
||||||
if image_count < 5 and image_count > 0:
|
|
||||||
# 处理图片数量小于5的情况,此时解析图片为base64
|
|
||||||
return await self._recursive_parse_image_seg(handled_message, True)
|
|
||||||
elif image_count > 0:
|
|
||||||
# 处理图片数量大于等于5的情况,此时解析图片为占位符
|
|
||||||
return await self._recursive_parse_image_seg(handled_message, False)
|
|
||||||
else:
|
|
||||||
# 处理没有图片的情况,此时直接返回
|
|
||||||
return handled_message
|
|
||||||
|
|
||||||
async def _recursive_parse_image_seg(self, seg_data: Seg, to_image: bool) -> Seg:
|
|
||||||
# sourcery skip: merge-else-if-into-elif
|
|
||||||
if to_image:
|
|
||||||
if seg_data.type == "seglist":
|
|
||||||
new_seg_list = []
|
|
||||||
for i_seg in seg_data.data:
|
|
||||||
parsed_seg = await self._recursive_parse_image_seg(i_seg, to_image)
|
|
||||||
new_seg_list.append(parsed_seg)
|
|
||||||
return Seg(type="seglist", data=new_seg_list)
|
|
||||||
elif seg_data.type == "image":
|
|
||||||
image_url = seg_data.data
|
|
||||||
try:
|
|
||||||
encoded_image = await get_image_base64(image_url)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"图片处理失败: {str(e)}")
|
|
||||||
return Seg(type="text", data="[图片]")
|
|
||||||
return Seg(type="image", data=encoded_image)
|
|
||||||
elif seg_data.type == "emoji":
|
|
||||||
image_url = seg_data.data
|
|
||||||
try:
|
|
||||||
encoded_image = await get_image_base64(image_url)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"图片处理失败: {str(e)}")
|
|
||||||
return Seg(type="text", data="[表情包]")
|
|
||||||
return Seg(type="emoji", data=encoded_image)
|
|
||||||
else:
|
|
||||||
return seg_data
|
|
||||||
else:
|
|
||||||
if seg_data.type == "seglist":
|
|
||||||
new_seg_list = []
|
|
||||||
for i_seg in seg_data.data:
|
|
||||||
parsed_seg = await self._recursive_parse_image_seg(i_seg, to_image)
|
|
||||||
new_seg_list.append(parsed_seg)
|
|
||||||
return Seg(type="seglist", data=new_seg_list)
|
|
||||||
elif seg_data.type == "image":
|
|
||||||
image_url = seg_data.data
|
|
||||||
return Seg(type="text", data="[图片]")
|
|
||||||
elif seg_data.type == "emoji":
|
|
||||||
image_url = seg_data.data
|
|
||||||
return Seg(type="text", data="[动画表情]")
|
|
||||||
else:
|
|
||||||
return seg_data
|
|
||||||
|
|
||||||
async def _handle_forward_message(self, message_list: list, layer: int) -> Tuple[Seg, int] | Tuple[None, int]:
|
|
||||||
# sourcery skip: low-code-quality
|
|
||||||
"""
|
|
||||||
递归处理实际转发消息
|
|
||||||
Parameters:
|
|
||||||
message_list: list: 转发消息列表,首层对应messages字段,后面对应content字段
|
|
||||||
layer: int: 当前层级
|
|
||||||
Returns:
|
|
||||||
seg_data: Seg: 处理后的消息段
|
|
||||||
image_count: int: 图片数量
|
|
||||||
"""
|
|
||||||
seg_list = []
|
|
||||||
image_count = 0
|
|
||||||
if message_list is None:
|
|
||||||
return None, 0
|
|
||||||
for sub_message in message_list:
|
|
||||||
sub_message: dict
|
|
||||||
sender_info: dict = sub_message.get("sender")
|
|
||||||
user_nickname: str = sender_info.get("nickname", "QQ用户")
|
|
||||||
user_nickname_str = f"【{user_nickname}】:"
|
|
||||||
break_seg = Seg(type="text", data="\n")
|
|
||||||
message_of_sub_message_list: dict = sub_message.get("message")
|
|
||||||
if not message_of_sub_message_list:
|
|
||||||
logger.warning("转发消息内容为空")
|
|
||||||
continue
|
|
||||||
message_of_sub_message = message_of_sub_message_list[0]
|
|
||||||
if message_of_sub_message.get("type") == RealMessageType.forward:
|
|
||||||
if layer >= 3:
|
|
||||||
full_seg_data = (
|
|
||||||
Seg(
|
|
||||||
type="text",
|
|
||||||
data=("--" * layer) + f"【{user_nickname}】:【转发消息】\n",
|
|
||||||
),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
sub_message_data = message_of_sub_message.get("data")
|
|
||||||
if not sub_message_data:
|
|
||||||
continue
|
|
||||||
contents = sub_message_data.get("content")
|
|
||||||
seg_data, count = await self._handle_forward_message(contents, layer + 1)
|
|
||||||
image_count += count
|
|
||||||
head_tip = Seg(
|
|
||||||
type="text",
|
|
||||||
data=("--" * layer) + f"【{user_nickname}】: 合并转发消息内容:\n",
|
|
||||||
)
|
|
||||||
full_seg_data = Seg(type="seglist", data=[head_tip, seg_data])
|
|
||||||
seg_list.append(full_seg_data)
|
|
||||||
elif message_of_sub_message.get("type") == RealMessageType.text:
|
|
||||||
sub_message_data = message_of_sub_message.get("data")
|
|
||||||
if not sub_message_data:
|
|
||||||
continue
|
|
||||||
text_message = sub_message_data.get("text")
|
|
||||||
seg_data = Seg(type="text", data=text_message)
|
|
||||||
if layer > 0:
|
|
||||||
seg_list.append(
|
|
||||||
Seg(
|
|
||||||
type="seglist",
|
|
||||||
data=[
|
|
||||||
Seg(type="text", data=("--" * layer) + user_nickname_str),
|
|
||||||
seg_data,
|
|
||||||
break_seg,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
seg_list.append(
|
|
||||||
Seg(
|
|
||||||
type="seglist",
|
|
||||||
data=[
|
|
||||||
Seg(type="text", data=user_nickname_str),
|
|
||||||
seg_data,
|
|
||||||
break_seg,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif message_of_sub_message.get("type") == RealMessageType.image:
|
|
||||||
image_count += 1
|
|
||||||
image_data = message_of_sub_message.get("data")
|
|
||||||
sub_type = image_data.get("sub_type")
|
|
||||||
image_url = image_data.get("url")
|
|
||||||
if sub_type == 0:
|
|
||||||
seg_data = Seg(type="image", data=image_url)
|
|
||||||
else:
|
|
||||||
seg_data = Seg(type="emoji", data=image_url)
|
|
||||||
if layer > 0:
|
|
||||||
full_seg_data = Seg(
|
|
||||||
type="seglist",
|
|
||||||
data=[
|
|
||||||
Seg(type="text", data=("--" * layer) + user_nickname_str),
|
|
||||||
seg_data,
|
|
||||||
break_seg,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
full_seg_data = Seg(
|
|
||||||
type="seglist",
|
|
||||||
data=[
|
|
||||||
Seg(type="text", data=user_nickname_str),
|
|
||||||
seg_data,
|
|
||||||
break_seg,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
seg_list.append(full_seg_data)
|
|
||||||
return Seg(type="seglist", data=seg_list), image_count
|
|
||||||
|
|
||||||
async def message_process(self, message_base: MessageBase) -> None:
|
|
||||||
try:
|
|
||||||
await self.maibot_router.send_message(message_base)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"发送消息失败: {str(e)}")
|
|
||||||
logger.error("请检查与MaiBot之间的连接")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
recv_handler = RecvHandler()
|
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class MetaEventType:
|
||||||
|
lifecycle = "lifecycle" # 生命周期
|
||||||
|
|
||||||
|
class Lifecycle:
|
||||||
|
connect = "connect" # 生命周期 - WebSocket 连接成功
|
||||||
|
|
||||||
|
heartbeat = "heartbeat" # 心跳
|
||||||
|
|
||||||
|
|
||||||
|
class MessageType: # 接受消息大类
|
||||||
|
private = "private" # 私聊消息
|
||||||
|
|
||||||
|
class Private:
|
||||||
|
friend = "friend" # 私聊消息 - 好友
|
||||||
|
group = "group" # 私聊消息 - 群临时
|
||||||
|
group_self = "group_self" # 私聊消息 - 群中自身发送
|
||||||
|
other = "other" # 私聊消息 - 其他
|
||||||
|
|
||||||
|
group = "group" # 群聊消息
|
||||||
|
|
||||||
|
class Group:
|
||||||
|
normal = "normal" # 群聊消息 - 普通
|
||||||
|
anonymous = "anonymous" # 群聊消息 - 匿名消息
|
||||||
|
notice = "notice" # 群聊消息 - 系统提示
|
||||||
|
|
||||||
|
|
||||||
|
class NoticeType: # 通知事件
|
||||||
|
friend_recall = "friend_recall" # 私聊消息撤回
|
||||||
|
group_recall = "group_recall" # 群聊消息撤回
|
||||||
|
notify = "notify"
|
||||||
|
group_ban = "group_ban" # 群禁言
|
||||||
|
group_msg_emoji_like = "group_msg_emoji_like" # 群消息表情回应
|
||||||
|
group_upload = "group_upload" # 群文件上传
|
||||||
|
group_increase = "group_increase" # 群成员增加
|
||||||
|
group_decrease = "group_decrease" # 群成员减少
|
||||||
|
group_admin = "group_admin" # 群管理员变动
|
||||||
|
essence = "essence" # 精华消息
|
||||||
|
|
||||||
|
class Notify:
|
||||||
|
poke = "poke" # 戳一戳
|
||||||
|
group_name = "group_name" # 群名称变更
|
||||||
|
|
||||||
|
class GroupBan:
|
||||||
|
ban = "ban" # 禁言
|
||||||
|
lift_ban = "lift_ban" # 解除禁言
|
||||||
|
|
||||||
|
class GroupIncrease:
|
||||||
|
approve = "approve" # 管理员同意入群
|
||||||
|
invite = "invite" # 被邀请入群
|
||||||
|
|
||||||
|
class GroupDecrease:
|
||||||
|
leave = "leave" # 主动退群
|
||||||
|
kick = "kick" # 被踢出群
|
||||||
|
kick_me = "kick_me" # 机器人被踢
|
||||||
|
|
||||||
|
class GroupAdmin:
|
||||||
|
set = "set" # 设置管理员
|
||||||
|
unset = "unset" # 取消管理员
|
||||||
|
|
||||||
|
class Essence:
|
||||||
|
add = "add" # 添加精华消息
|
||||||
|
delete = "delete" # 移除精华消息
|
||||||
|
|
||||||
|
|
||||||
|
class RealMessageType: # 实际消息分类
|
||||||
|
text = "text" # 纯文本
|
||||||
|
face = "face" # qq表情
|
||||||
|
image = "image" # 图片
|
||||||
|
record = "record" # 语音
|
||||||
|
video = "video" # 视频
|
||||||
|
at = "at" # @某人
|
||||||
|
rps = "rps" # 猜拳魔法表情
|
||||||
|
dice = "dice" # 骰子
|
||||||
|
shake = "shake" # 私聊窗口抖动(只收)
|
||||||
|
poke = "poke" # 群聊戳一戳
|
||||||
|
share = "share" # 链接分享(json形式)
|
||||||
|
reply = "reply" # 回复消息
|
||||||
|
forward = "forward" # 转发消息
|
||||||
|
node = "node" # 转发消息节点
|
||||||
|
json = "json" # JSON卡片消息
|
||||||
|
file = "file" # 文件消息
|
||||||
|
|
||||||
|
|
||||||
|
class MessageSentType:
|
||||||
|
private = "private"
|
||||||
|
|
||||||
|
class Private:
|
||||||
|
friend = "friend"
|
||||||
|
group = "group"
|
||||||
|
|
||||||
|
group = "group"
|
||||||
|
|
||||||
|
class Group:
|
||||||
|
normal = "normal"
|
||||||
|
|
||||||
|
|
||||||
|
class CommandType(Enum):
|
||||||
|
"""命令类型"""
|
||||||
|
|
||||||
|
GROUP_BAN = "set_group_ban" # 禁言用户
|
||||||
|
GROUP_WHOLE_BAN = "set_group_whole_ban" # 群全体禁言
|
||||||
|
GROUP_KICK = "set_group_kick" # 踢出群聊
|
||||||
|
SEND_POKE = "send_poke" # 戳一戳
|
||||||
|
DELETE_MSG = "delete_msg" # 撤回消息
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
ACCEPT_FORMAT = [
|
||||||
|
"text",
|
||||||
|
"image",
|
||||||
|
"emoji",
|
||||||
|
"reply",
|
||||||
|
"voice",
|
||||||
|
"command",
|
||||||
|
"voiceurl",
|
||||||
|
"music",
|
||||||
|
"videourl",
|
||||||
|
"file",
|
||||||
|
"imageurl",
|
||||||
|
"forward",
|
||||||
|
"video",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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()
|
||||||
|
|
@ -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
|
|
@ -31,7 +31,7 @@ qq_face: dict = {
|
||||||
"30": "[表情:奋斗]",
|
"30": "[表情:奋斗]",
|
||||||
"31": "[表情:咒骂]",
|
"31": "[表情:咒骂]",
|
||||||
"32": "[表情:疑问]",
|
"32": "[表情:疑问]",
|
||||||
"33": "[表情: 嘘]",
|
"33": "[表情:嘘]",
|
||||||
"34": "[表情:晕]",
|
"34": "[表情:晕]",
|
||||||
"35": "[表情:折磨]",
|
"35": "[表情:折磨]",
|
||||||
"36": "[表情:衰]",
|
"36": "[表情:衰]",
|
||||||
|
|
@ -117,7 +117,7 @@ qq_face: dict = {
|
||||||
"268": "[表情:问号脸]",
|
"268": "[表情:问号脸]",
|
||||||
"269": "[表情:暗中观察]",
|
"269": "[表情:暗中观察]",
|
||||||
"270": "[表情:emm]",
|
"270": "[表情:emm]",
|
||||||
"271": "[表情:吃 瓜]",
|
"271": "[表情:吃瓜]",
|
||||||
"272": "[表情:呵呵哒]",
|
"272": "[表情:呵呵哒]",
|
||||||
"273": "[表情:我酸了]",
|
"273": "[表情:我酸了]",
|
||||||
"277": "[表情:汪汪]",
|
"277": "[表情:汪汪]",
|
||||||
|
|
@ -146,7 +146,7 @@ qq_face: dict = {
|
||||||
"314": "[表情:仔细分析]",
|
"314": "[表情:仔细分析]",
|
||||||
"317": "[表情:菜汪]",
|
"317": "[表情:菜汪]",
|
||||||
"318": "[表情:崇拜]",
|
"318": "[表情:崇拜]",
|
||||||
"319": "[表情: 比心]",
|
"319": "[表情:比心]",
|
||||||
"320": "[表情:庆祝]",
|
"320": "[表情:庆祝]",
|
||||||
"323": "[表情:嫌弃]",
|
"323": "[表情:嫌弃]",
|
||||||
"324": "[表情:吃糖]",
|
"324": "[表情:吃糖]",
|
||||||
|
|
@ -175,13 +175,65 @@ qq_face: dict = {
|
||||||
"355": "[表情:耶]",
|
"355": "[表情:耶]",
|
||||||
"356": "[表情:666]",
|
"356": "[表情:666]",
|
||||||
"357": "[表情:裂开]",
|
"357": "[表情:裂开]",
|
||||||
"392": "[表情:龙年 快乐]",
|
"392": "[表情:龙年快乐]",
|
||||||
"393": "[表情:新年中龙]",
|
"393": "[表情:新年中龙]",
|
||||||
"394": "[表情:新年大龙]",
|
"394": "[表情:新年大龙]",
|
||||||
"395": "[表情:略略略]",
|
"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 = {
|
||||||
"☀": "[表情:晴天]",
|
"☀": "[表情:晴天]",
|
||||||
"❔": "[表情:问号]",
|
"❔": "[表情:问号]",
|
||||||
"🔫": "[表情:手枪]",
|
"🔫": "[表情:手枪]",
|
||||||
"💓": "[表情:爱 心]",
|
"💓": "[表情:爱心]",
|
||||||
"🏪": "[表情:便利店]",
|
"🏪": "[表情:便利店]",
|
||||||
}
|
}
|
||||||
|
|
@ -6,27 +6,28 @@ from .logger import logger
|
||||||
|
|
||||||
response_dict: Dict = {}
|
response_dict: Dict = {}
|
||||||
response_time_dict: Dict = {}
|
response_time_dict: Dict = {}
|
||||||
message_queue = asyncio.Queue()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_response(request_id: str) -> dict:
|
async def get_response(request_id: str, timeout: int = 10) -> dict:
|
||||||
retry_count = 0
|
response = await asyncio.wait_for(_get_response(request_id), timeout)
|
||||||
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)
|
|
||||||
_ = response_time_dict.pop(request_id)
|
_ = response_time_dict.pop(request_id)
|
||||||
|
logger.trace(f"响应信息id: {request_id} 已从响应字典中取出")
|
||||||
return response
|
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):
|
async def put_response(response: dict):
|
||||||
echo_id = response.get("echo")
|
echo_id = response.get("echo")
|
||||||
now_time = time.time()
|
now_time = time.time()
|
||||||
response_dict[echo_id] = response
|
response_dict[echo_id] = response
|
||||||
response_time_dict[echo_id] = now_time
|
response_time_dict[echo_id] = now_time
|
||||||
|
logger.trace(f"响应信息id: {echo_id} 已存入响应字典")
|
||||||
|
|
||||||
|
|
||||||
async def check_timeout_response() -> None:
|
async def check_timeout_response() -> None:
|
||||||
|
|
@ -34,10 +35,10 @@ async def check_timeout_response() -> None:
|
||||||
cleaned_message_count: int = 0
|
cleaned_message_count: int = 0
|
||||||
now_time = time.time()
|
now_time = time.time()
|
||||||
for echo_id, response_time in list(response_time_dict.items()):
|
for echo_id, response_time in list(response_time_dict.items()):
|
||||||
if now_time - response_time > global_config.napcat_heartbeat_interval:
|
if now_time - response_time > global_config.napcat_server.heartbeat_interval:
|
||||||
cleaned_message_count += 1
|
cleaned_message_count += 1
|
||||||
response_dict.pop(echo_id)
|
response_dict.pop(echo_id)
|
||||||
response_time_dict.pop(echo_id)
|
response_time_dict.pop(echo_id)
|
||||||
logger.warning(f"响应消息 {echo_id} 超时,已删除")
|
logger.warning(f"响应消息 {echo_id} 超时,已删除")
|
||||||
logger.info(f"已删除 {cleaned_message_count} 条超时响应消息")
|
logger.info(f"已删除 {cleaned_message_count} 条超时响应消息")
|
||||||
await asyncio.sleep(global_config.napcat_heartbeat_interval)
|
await asyncio.sleep(global_config.napcat_server.heartbeat_interval)
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
import json
|
|
||||||
import websockets as Server
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from .config import global_config
|
|
||||||
|
|
||||||
# 白名单机制不启用
|
|
||||||
from .message_queue import get_response
|
|
||||||
from .logger import logger
|
|
||||||
|
|
||||||
from maim_message import (
|
|
||||||
UserInfo,
|
|
||||||
GroupInfo,
|
|
||||||
Seg,
|
|
||||||
BaseMessageInfo,
|
|
||||||
MessageBase,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .utils import get_image_format, convert_image_to_gif
|
|
||||||
|
|
||||||
|
|
||||||
class SendHandler:
|
|
||||||
def __init__(self):
|
|
||||||
self.server_connection: Server.ServerConnection = None
|
|
||||||
|
|
||||||
async def handle_seg(self, raw_message_base_dict: dict) -> None:
|
|
||||||
raw_message_base: MessageBase = MessageBase.from_dict(raw_message_base_dict)
|
|
||||||
message_info: BaseMessageInfo = raw_message_base.message_info
|
|
||||||
message_segment: Seg = raw_message_base.message_segment
|
|
||||||
group_info: GroupInfo = message_info.group_info
|
|
||||||
user_info: UserInfo = message_info.user_info
|
|
||||||
target_id: int = None
|
|
||||||
action: str = None
|
|
||||||
id_name: str = None
|
|
||||||
processed_message: list = []
|
|
||||||
logger.info("接收到来自MaiBot的消息,处理中")
|
|
||||||
try:
|
|
||||||
processed_message = await self.handle_seg_recursive(message_segment)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"处理消息时发生错误: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if processed_message:
|
|
||||||
if group_info and user_info:
|
|
||||||
target_id = group_info.group_id
|
|
||||||
action = "send_group_msg"
|
|
||||||
id_name = "group_id"
|
|
||||||
elif user_info:
|
|
||||||
target_id = user_info.user_id
|
|
||||||
action = "send_private_msg"
|
|
||||||
id_name = "user_id"
|
|
||||||
else:
|
|
||||||
logger.error("无法识别的消息类型")
|
|
||||||
return
|
|
||||||
logger.info("尝试发送到napcat")
|
|
||||||
response = await self.send_message_to_napcat(
|
|
||||||
action,
|
|
||||||
{
|
|
||||||
id_name: target_id,
|
|
||||||
"message": processed_message,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if response.get("status") == "ok":
|
|
||||||
logger.info("消息发送成功")
|
|
||||||
else:
|
|
||||||
logger.warning(f"消息发送失败,napcat返回:{str(response)}")
|
|
||||||
else:
|
|
||||||
logger.critical("现在暂时不支持解析此回复!")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_level(self, seg_data: Seg) -> int:
|
|
||||||
if seg_data.type == "seglist":
|
|
||||||
return 1 + max(self.get_level(seg) for seg in seg_data.data)
|
|
||||||
else:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
async def handle_seg_recursive(self, seg_data: Seg) -> list:
|
|
||||||
payload: list = []
|
|
||||||
if seg_data.type == "seglist":
|
|
||||||
# level = self.get_level(seg_data) # 给以后可能的多层嵌套做准备,此处不使用
|
|
||||||
if not seg_data.data:
|
|
||||||
return []
|
|
||||||
for seg in seg_data.data:
|
|
||||||
payload = self.process_message_by_type(seg, payload)
|
|
||||||
else:
|
|
||||||
payload = self.process_message_by_type(seg_data, payload)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def process_message_by_type(self, seg: Seg, payload: list) -> list:
|
|
||||||
# sourcery skip: reintroduce-else, swap-if-else-branches, use-named-expression
|
|
||||||
new_payload = payload
|
|
||||||
if seg.type == "reply":
|
|
||||||
target_id = seg.data
|
|
||||||
if target_id == "notice":
|
|
||||||
return []
|
|
||||||
new_payload = self.build_payload(payload, self.handle_reply_message(target_id), True)
|
|
||||||
elif seg.type == "text":
|
|
||||||
text = seg.data
|
|
||||||
if not text:
|
|
||||||
return []
|
|
||||||
new_payload = self.build_payload(payload, self.handle_text_message(text), False)
|
|
||||||
elif seg.type == "face":
|
|
||||||
pass
|
|
||||||
elif seg.type == "image":
|
|
||||||
image = seg.data
|
|
||||||
new_payload = self.build_payload(payload, self.handle_image_message(image), False)
|
|
||||||
elif seg.type == "emoji":
|
|
||||||
emoji = seg.data
|
|
||||||
new_payload = self.build_payload(payload, self.handle_emoji_message(emoji), False)
|
|
||||||
elif seg.type == "voice":
|
|
||||||
voice = seg.data
|
|
||||||
new_payload = self.build_payload(payload, self.handle_voice_message(voice), False)
|
|
||||||
return new_payload
|
|
||||||
|
|
||||||
def build_payload(self, payload: list, addon: dict, is_reply: bool = False) -> list:
|
|
||||||
# sourcery skip: for-append-to-extend, merge-list-append, simplify-generator
|
|
||||||
"""构建发送的消息体"""
|
|
||||||
if is_reply:
|
|
||||||
temp_list = []
|
|
||||||
temp_list.append(addon)
|
|
||||||
for i in payload:
|
|
||||||
temp_list.append(i)
|
|
||||||
return temp_list
|
|
||||||
else:
|
|
||||||
payload.append(addon)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def handle_reply_message(self, id: str) -> dict:
|
|
||||||
"""处理回复消息"""
|
|
||||||
return {"type": "reply", "data": {"id": id}}
|
|
||||||
|
|
||||||
def handle_text_message(self, message: str) -> dict:
|
|
||||||
"""处理文本消息"""
|
|
||||||
return {"type": "text", "data": {"text": message}}
|
|
||||||
|
|
||||||
def handle_image_message(self, encoded_image: str) -> dict:
|
|
||||||
"""处理图片消息"""
|
|
||||||
return {
|
|
||||||
"type": "image",
|
|
||||||
"data": {
|
|
||||||
"file": f"base64://{encoded_image}",
|
|
||||||
"subtype": 0,
|
|
||||||
},
|
|
||||||
} # base64 编码的图片
|
|
||||||
|
|
||||||
def handle_emoji_message(self, encoded_emoji: str) -> dict:
|
|
||||||
"""处理表情消息"""
|
|
||||||
encoded_image = encoded_emoji
|
|
||||||
image_format = get_image_format(encoded_emoji)
|
|
||||||
if image_format != "gif":
|
|
||||||
encoded_image = convert_image_to_gif(encoded_emoji)
|
|
||||||
return {
|
|
||||||
"type": "image",
|
|
||||||
"data": {
|
|
||||||
"file": f"base64://{encoded_image}",
|
|
||||||
"subtype": 1,
|
|
||||||
"summary": "[动画表情]",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def handle_voice_message(self, encoded_voice: str) -> dict:
|
|
||||||
"""处理语音消息"""
|
|
||||||
if not global_config.use_tts:
|
|
||||||
logger.warning("未启用语音消息处理")
|
|
||||||
return {}
|
|
||||||
if not encoded_voice:
|
|
||||||
return {}
|
|
||||||
return {
|
|
||||||
"type": "record",
|
|
||||||
"data": {"file": f"base64://{encoded_voice}"},
|
|
||||||
}
|
|
||||||
|
|
||||||
async def send_message_to_napcat(self, action: str, params: dict) -> dict:
|
|
||||||
request_uuid = str(uuid.uuid4())
|
|
||||||
payload = json.dumps({"action": action, "params": params, "echo": request_uuid})
|
|
||||||
await self.server_connection.send(payload)
|
|
||||||
try:
|
|
||||||
response = await get_response(request_uuid)
|
|
||||||
except TimeoutError:
|
|
||||||
logger.error("发送消息超时,未收到响应")
|
|
||||||
return {"status": "error", "message": "timeout"}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"发送消息失败: {e}")
|
|
||||||
return {"status": "error", "message": str(e)}
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
send_handler = SendHandler()
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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)},
|
||||||
|
)
|
||||||
|
|
@ -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}"},
|
||||||
|
}
|
||||||
150
src/utils.py
150
src/utils.py
|
|
@ -2,14 +2,16 @@ import websockets as Server
|
||||||
import json
|
import json
|
||||||
import base64
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
from .logger import logger
|
|
||||||
from .message_queue import get_response
|
|
||||||
|
|
||||||
import urllib3
|
import urllib3
|
||||||
import ssl
|
import ssl
|
||||||
|
import io
|
||||||
|
|
||||||
|
from src.database import BanUser, db_manager
|
||||||
|
from .logger import logger
|
||||||
|
from .response_pool import get_response
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import io
|
from typing import Union, List, Tuple, Optional
|
||||||
|
|
||||||
|
|
||||||
class SSLAdapter(urllib3.PoolManager):
|
class SSLAdapter(urllib3.PoolManager):
|
||||||
|
|
@ -21,12 +23,13 @@ class SSLAdapter(urllib3.PoolManager):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
async def get_group_info(websocket: Server.ServerConnection, group_id: int) -> dict:
|
async def get_group_info(websocket: Server.ServerConnection, group_id: int) -> dict | None:
|
||||||
"""
|
"""
|
||||||
获取群相关信息
|
获取群相关信息
|
||||||
|
|
||||||
返回值需要处理可能为空的情况
|
返回值需要处理可能为空的情况
|
||||||
"""
|
"""
|
||||||
|
logger.debug("获取群聊信息中")
|
||||||
request_uuid = str(uuid.uuid4())
|
request_uuid = str(uuid.uuid4())
|
||||||
payload = json.dumps({"action": "get_group_info", "params": {"group_id": group_id}, "echo": request_uuid})
|
payload = json.dumps({"action": "get_group_info", "params": {"group_id": group_id}, "echo": request_uuid})
|
||||||
try:
|
try:
|
||||||
|
|
@ -42,12 +45,35 @@ async def get_group_info(websocket: Server.ServerConnection, group_id: int) -> d
|
||||||
return socket_response.get("data")
|
return socket_response.get("data")
|
||||||
|
|
||||||
|
|
||||||
async def get_member_info(websocket: Server.ServerConnection, group_id: int, user_id: int) -> dict:
|
async def get_group_detail_info(websocket: Server.ServerConnection, group_id: int) -> dict | None:
|
||||||
|
"""
|
||||||
|
获取群详细信息
|
||||||
|
|
||||||
|
返回值需要处理可能为空的情况
|
||||||
|
"""
|
||||||
|
logger.debug("获取群详细信息中")
|
||||||
|
request_uuid = str(uuid.uuid4())
|
||||||
|
payload = json.dumps({"action": "get_group_detail_info", "params": {"group_id": group_id}, "echo": request_uuid})
|
||||||
|
try:
|
||||||
|
await websocket.send(payload)
|
||||||
|
socket_response: dict = await get_response(request_uuid)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.error(f"获取群详细信息超时,群号: {group_id}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取群详细信息失败: {e}")
|
||||||
|
return None
|
||||||
|
logger.debug(socket_response)
|
||||||
|
return socket_response.get("data")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_member_info(websocket: Server.ServerConnection, group_id: int, user_id: int) -> dict | None:
|
||||||
"""
|
"""
|
||||||
获取群成员信息
|
获取群成员信息
|
||||||
|
|
||||||
返回值需要处理可能为空的情况
|
返回值需要处理可能为空的情况
|
||||||
"""
|
"""
|
||||||
|
logger.debug("获取群成员信息中")
|
||||||
request_uuid = str(uuid.uuid4())
|
request_uuid = str(uuid.uuid4())
|
||||||
payload = json.dumps(
|
payload = json.dumps(
|
||||||
{
|
{
|
||||||
|
|
@ -72,6 +98,7 @@ async def get_member_info(websocket: Server.ServerConnection, group_id: int, use
|
||||||
async def get_image_base64(url: str) -> str:
|
async def get_image_base64(url: str) -> str:
|
||||||
# sourcery skip: raise-specific-error
|
# sourcery skip: raise-specific-error
|
||||||
"""获取图片/表情包的Base64"""
|
"""获取图片/表情包的Base64"""
|
||||||
|
logger.debug(f"下载图片: {url}")
|
||||||
http = SSLAdapter()
|
http = SSLAdapter()
|
||||||
try:
|
try:
|
||||||
response = http.request("GET", url, timeout=10)
|
response = http.request("GET", url, timeout=10)
|
||||||
|
|
@ -85,6 +112,15 @@ async def get_image_base64(url: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def convert_image_to_gif(image_base64: str) -> str:
|
def convert_image_to_gif(image_base64: str) -> str:
|
||||||
|
# sourcery skip: extract-method
|
||||||
|
"""
|
||||||
|
将Base64编码的图片转换为GIF格式
|
||||||
|
Parameters:
|
||||||
|
image_base64: str: Base64编码的图片数据
|
||||||
|
Returns:
|
||||||
|
str: Base64编码的GIF图片数据
|
||||||
|
"""
|
||||||
|
logger.debug("转换图片为GIF格式")
|
||||||
try:
|
try:
|
||||||
image_bytes = base64.b64decode(image_base64)
|
image_bytes = base64.b64decode(image_base64)
|
||||||
image = Image.open(io.BytesIO(image_bytes))
|
image = Image.open(io.BytesIO(image_bytes))
|
||||||
|
|
@ -97,7 +133,7 @@ def convert_image_to_gif(image_base64: str) -> str:
|
||||||
return image_base64
|
return image_base64
|
||||||
|
|
||||||
|
|
||||||
async def get_self_info(websocket: Server.ServerConnection) -> dict:
|
async def get_self_info(websocket: Server.ServerConnection) -> dict | None:
|
||||||
"""
|
"""
|
||||||
获取自身信息
|
获取自身信息
|
||||||
Parameters:
|
Parameters:
|
||||||
|
|
@ -105,6 +141,7 @@ async def get_self_info(websocket: Server.ServerConnection) -> dict:
|
||||||
Returns:
|
Returns:
|
||||||
data: dict: 返回的自身信息
|
data: dict: 返回的自身信息
|
||||||
"""
|
"""
|
||||||
|
logger.debug("获取自身信息中")
|
||||||
request_uuid = str(uuid.uuid4())
|
request_uuid = str(uuid.uuid4())
|
||||||
payload = json.dumps({"action": "get_login_info", "params": {}, "echo": request_uuid})
|
payload = json.dumps({"action": "get_login_info", "params": {}, "echo": request_uuid})
|
||||||
try:
|
try:
|
||||||
|
|
@ -132,7 +169,7 @@ def get_image_format(raw_data: str) -> str:
|
||||||
return Image.open(io.BytesIO(image_bytes)).format.lower()
|
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:
|
Parameters:
|
||||||
|
|
@ -141,6 +178,7 @@ async def get_stranger_info(websocket: Server.ServerConnection, user_id: int) ->
|
||||||
Returns:
|
Returns:
|
||||||
dict: 返回的陌生人信息
|
dict: 返回的陌生人信息
|
||||||
"""
|
"""
|
||||||
|
logger.debug("获取陌生人信息中")
|
||||||
request_uuid = str(uuid.uuid4())
|
request_uuid = str(uuid.uuid4())
|
||||||
payload = json.dumps({"action": "get_stranger_info", "params": {"user_id": user_id}, "echo": request_uuid})
|
payload = json.dumps({"action": "get_stranger_info", "params": {"user_id": user_id}, "echo": request_uuid})
|
||||||
try:
|
try:
|
||||||
|
|
@ -156,7 +194,7 @@ async def get_stranger_info(websocket: Server.ServerConnection, user_id: int) ->
|
||||||
return response.get("data")
|
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:
|
Parameters:
|
||||||
|
|
@ -165,11 +203,12 @@ async def get_message_detail(websocket: Server.ServerConnection, message_id: str
|
||||||
Returns:
|
Returns:
|
||||||
dict: 返回的消息详情
|
dict: 返回的消息详情
|
||||||
"""
|
"""
|
||||||
|
logger.debug("获取消息详情中")
|
||||||
request_uuid = str(uuid.uuid4())
|
request_uuid = str(uuid.uuid4())
|
||||||
payload = json.dumps({"action": "get_msg", "params": {"message_id": message_id}, "echo": request_uuid})
|
payload = json.dumps({"action": "get_msg", "params": {"message_id": message_id}, "echo": request_uuid})
|
||||||
try:
|
try:
|
||||||
await websocket.send(payload)
|
await websocket.send(payload)
|
||||||
response: dict = await get_response(request_uuid)
|
response: dict = await get_response(request_uuid, 30) # 增加超时时间到30秒
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.error(f"获取消息详情超时,消息ID: {message_id}")
|
logger.error(f"获取消息详情超时,消息ID: {message_id}")
|
||||||
return None
|
return None
|
||||||
|
|
@ -178,3 +217,94 @@ async def get_message_detail(websocket: Server.ServerConnection, message_id: str
|
||||||
return None
|
return None
|
||||||
logger.debug(response)
|
logger.debug(response)
|
||||||
return response.get("data")
|
return response.get("data")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_record_detail(
|
||||||
|
websocket: Server.ServerConnection, file: str, file_id: Optional[str] = None
|
||||||
|
) -> dict | None:
|
||||||
|
"""
|
||||||
|
获取语音消息内容
|
||||||
|
Parameters:
|
||||||
|
websocket: WebSocket连接对象
|
||||||
|
file: 文件名
|
||||||
|
file_id: 文件ID
|
||||||
|
Returns:
|
||||||
|
dict: 返回的语音消息详情
|
||||||
|
"""
|
||||||
|
logger.debug("获取语音消息详情中")
|
||||||
|
request_uuid = str(uuid.uuid4())
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"action": "get_record",
|
||||||
|
"params": {"file": file, "file_id": file_id, "out_format": "wav"},
|
||||||
|
"echo": request_uuid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await websocket.send(payload)
|
||||||
|
response: dict = await get_response(request_uuid, 30) # 增加超时时间到30秒
|
||||||
|
except TimeoutError:
|
||||||
|
logger.error(f"获取语音消息详情超时,文件: {file}, 文件ID: {file_id}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取语音消息详情失败: {e}")
|
||||||
|
return None
|
||||||
|
logger.debug(f"{str(response)[:200]}...") # 防止语音的超长base64编码导致日志过长
|
||||||
|
return response.get("data")
|
||||||
|
|
||||||
|
|
||||||
|
async def read_ban_list(
|
||||||
|
websocket: Server.ServerConnection,
|
||||||
|
) -> Tuple[List[BanUser], List[BanUser]]:
|
||||||
|
"""
|
||||||
|
从根目录下的data文件夹中的文件读取禁言列表。
|
||||||
|
同时自动更新已经失效禁言
|
||||||
|
Returns:
|
||||||
|
Tuple[
|
||||||
|
一个仍在禁言中的用户的BanUser列表,
|
||||||
|
一个已经自然解除禁言的用户的BanUser列表,
|
||||||
|
一个仍在全体禁言中的群的BanUser列表,
|
||||||
|
一个已经自然解除全体禁言的群的BanUser列表,
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ban_list = db_manager.get_ban_records()
|
||||||
|
lifted_list: List[BanUser] = []
|
||||||
|
logger.info("已经读取禁言列表")
|
||||||
|
for ban_record in ban_list:
|
||||||
|
if ban_record.user_id == 0:
|
||||||
|
fetched_group_info = await get_group_info(websocket, ban_record.group_id)
|
||||||
|
if fetched_group_info is None:
|
||||||
|
logger.warning(f"无法获取群信息,群号: {ban_record.group_id},默认禁言解除")
|
||||||
|
lifted_list.append(ban_record)
|
||||||
|
ban_list.remove(ban_record)
|
||||||
|
continue
|
||||||
|
group_all_shut: int = fetched_group_info.get("group_all_shut")
|
||||||
|
if group_all_shut == 0:
|
||||||
|
lifted_list.append(ban_record)
|
||||||
|
ban_list.remove(ban_record)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
fetched_member_info = await get_member_info(websocket, ban_record.group_id, ban_record.user_id)
|
||||||
|
if fetched_member_info is None:
|
||||||
|
logger.warning(
|
||||||
|
f"无法获取群成员信息,用户ID: {ban_record.user_id}, 群号: {ban_record.group_id},默认禁言解除"
|
||||||
|
)
|
||||||
|
lifted_list.append(ban_record)
|
||||||
|
ban_list.remove(ban_record)
|
||||||
|
continue
|
||||||
|
lift_ban_time: int = fetched_member_info.get("shut_up_timestamp")
|
||||||
|
if lift_ban_time == 0:
|
||||||
|
lifted_list.append(ban_record)
|
||||||
|
ban_list.remove(ban_record)
|
||||||
|
else:
|
||||||
|
ban_record.lift_time = lift_ban_time
|
||||||
|
db_manager.update_ban_record(ban_list)
|
||||||
|
return ban_list, lifted_list
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"读取禁言列表失败: {e}")
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
|
||||||
|
def save_ban_record(list: List[BanUser]):
|
||||||
|
return db_manager.update_ban_record(list)
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,41 @@
|
||||||
[Nickname] # 现在没用
|
[inner]
|
||||||
|
version = "0.1.3" # 版本号
|
||||||
|
# 请勿修改版本号,除非你知道自己在做什么
|
||||||
|
|
||||||
|
[nickname] # 现在没用
|
||||||
nickname = ""
|
nickname = ""
|
||||||
|
|
||||||
[Napcat_Server] # Napcat连接的ws服务设置
|
[napcat_server] # Napcat连接的ws服务设置
|
||||||
host = "localhost" # Napcat设定的主机地址
|
host = "localhost" # Napcat设定的主机地址
|
||||||
port = 8095 # Napcat设定的端口
|
port = 8095 # Napcat设定的端口
|
||||||
heartbeat = 30 # 与Napcat设置的心跳相同(按秒计)
|
token = "" # Napcat设定的访问令牌,若无则留空
|
||||||
|
heartbeat_interval = 30 # 与Napcat设置的心跳相同(按秒计)
|
||||||
|
|
||||||
[MaiBot_Server] # 连接麦麦的ws服务设置
|
[maibot_server] # 连接麦麦的ws服务设置
|
||||||
platform_name = "qq" # 标识adapter的名称(必填)
|
host = "localhost" # 麦麦在.env文件中设置的主机地址,即HOST字段
|
||||||
host = "localhost" # 麦麦在.env文件中设置的主机地址,即HOST字段
|
port = 8000 # 麦麦在.env文件中设置的端口,即PORT字段
|
||||||
port = 8000 # 麦麦在.env文件中设置的端口,即PORT字段
|
enable_api_server = false # 是否启用API-Server模式连接
|
||||||
|
base_url = "ws://127.0.0.1:18095/ws" # API-Server连接地址 (ws://ip:port/path),仅在enable_api_server为true时使用
|
||||||
|
api_key = "maibot" # API Key (仅在enable_api_server为true时使用)
|
||||||
|
|
||||||
[Chat] # 黑白名单功能
|
[chat] # 黑白名单功能
|
||||||
group_list_type = "whitelist" # 群组名单类型,可选为:whitelist, blacklist
|
group_list_type = "whitelist" # 群组名单类型,可选为:whitelist, blacklist
|
||||||
group_list = [] # 群组名单
|
group_list = [] # 群组名单
|
||||||
# 当group_list_type为whitelist时,只有群组名单中的群组可以聊天
|
# 当group_list_type为whitelist时,只有群组名单中的群组可以聊天
|
||||||
# 当group_list_type为blacklist时,群组名单中的任何群组无法聊天
|
# 当group_list_type为blacklist时,群组名单中的任何群组无法聊天
|
||||||
private_list_type = "whitelist" # 私聊名单类型,可选为:whitelist, blacklist
|
private_list_type = "whitelist" # 私聊名单类型,可选为:whitelist, blacklist
|
||||||
private_list = [] # 私聊名单
|
private_list = [] # 私聊名单
|
||||||
# 当private_list_type为whitelist时,只有私聊名单中的用户可以聊天
|
# 当private_list_type为whitelist时,只有私聊名单中的用户可以聊天
|
||||||
# 当private_list_type为blacklist时,私聊名单中的任何用户无法聊天
|
# 当private_list_type为blacklist时,私聊名单中的任何用户无法聊天
|
||||||
ban_user_id = [] # 全局禁止名单(全局禁止名单中的用户无法进行任何聊天)
|
ban_user_id = [] # 全局禁止名单(全局禁止名单中的用户无法进行任何聊天)
|
||||||
|
ban_qq_bot = false # 是否屏蔽QQ官方机器人
|
||||||
enable_poke = true # 是否启用戳一戳功能
|
enable_poke = true # 是否启用戳一戳功能
|
||||||
|
|
||||||
[Voice] # 发送语音设置
|
[voice] # 发送语音设置
|
||||||
use_tts = false # 是否使用tts语音(请确保你配置了tts并有对应的adapter)
|
use_tts = false # 是否使用tts语音(请确保你配置了tts并有对应的adapter)
|
||||||
|
|
||||||
[Debug]
|
[forward] # 转发消息处理设置
|
||||||
level = "INFO" # 日志等级(DEBUG, INFO, WARNING, ERROR)
|
image_threshold = 3 # 图片数量阈值:转发消息中图片数量超过此值时使用占位符(避免麦麦VLM处理卡死)
|
||||||
|
|
||||||
|
[debug]
|
||||||
|
level = "INFO" # 日志等级(DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue