mirror of
https://github.com/snowy-yang/qqbotsdk.git
synced 2026-10-21 17:04:48 +00:00
feat!: intents 只按大类订阅、群消息全量模式与事件流优化
破坏性变更:intents.toml 订阅粒度收敛到 Intents 大类(分组)—— 配置从「[分组] 表内逐事件开关」改为「分组名 = true/false」。网关只接受 分组位掩码,组内事件本就无法单独订阅,此前的逐事件开关仅是「组内任一 为 true 即订阅整组」的装饰。旧格式不再兼容,与拼错的分组名、非布尔值 一样在启动时直接抛 ValueError,而非静默少订阅。 新增: - GROUP_MESSAGE_CREATE 事件接收(群消息全量模式,与 GROUP_AT_MESSAGE_CREATE 共用 GroupAtMessage);GroupAtMessage 补齐 message_type/message_scene/ attachments/mentions/ark_data/msg_elements 字段 优化: - 形参注入缓存化:合并缓存 handler 签名与类型标注解析(此前每条事件都重跑 inspect.signature/get_type_hints),services 命中改为每条事件实时判定, 保证「先注册 handler、后登记组件」的既有装配顺序不受影响 - EventQueue.put_event/put_reply 去掉多余的 return await - model.NoneType 联合成员顺序调整(None 置于末尾) 文档同步 GUIDE/API/README/CHANGELOG 与示例;测试 76 全绿,ruff/pyright 干净。
This commit is contained in:
1 parent
3237558128
commit
71cbfdaf5d
16 files changed
+203
-115
No files matched your search
@@ -12,6 +12,7 @@
|
|||||||
- 按钮快捷构造与点击处理:`button()`/`keyboard()` 生成内嵌按钮(免手写嵌套 dict);`Interaction` 新增 `button_data` 属性(被点按钮的 action.data);examples/group_bot 补 `/btn` 卡片与 `INTERACTION_CREATE` 回调示例
|
- 按钮快捷构造与点击处理:`button()`/`keyboard()` 生成内嵌按钮(免手写嵌套 dict);`Interaction` 新增 `button_data` 属性(被点按钮的 action.data);examples/group_bot 补 `/btn` 卡片与 `INTERACTION_CREATE` 回调示例
|
||||||
- handler 后台并发:`@ee.on(..., background=True)` 把 handler 放进后台任务,不阻塞事件流(同事件内失去先后保证,返回值不回流);`run_loop` 退出经 `emitter.close()` 统一取消在飞任务
|
- handler 后台并发:`@ee.on(..., background=True)` 把 handler 放进后台任务,不阻塞事件流(同事件内失去先后保证,返回值不回流);`run_loop` 退出经 `emitter.close()` 统一取消在飞任务
|
||||||
- OpenAPI 限流与鉴权自愈:`BotApi.request` 对 429 自动重试(最多 3 次,优先 `Retry-After`,脏值容错解析,退避封顶 30s);401 强制刷新 token 后重试一次,token 被服务端提前作废不再直接抛错
|
- OpenAPI 限流与鉴权自愈:`BotApi.request` 对 429 自动重试(最多 3 次,优先 `Retry-After`,脏值容错解析,退避封顶 30s);401 强制刷新 token 后重试一次,token 被服务端提前作废不再直接抛错
|
||||||
|
- 群消息全量模式:新增 `GROUP_MESSAGE_CREATE` 事件接收,与 `GROUP_AT_MESSAGE_CREATE` 共用 `GroupAtMessage`(需在开放平台开通"接收所有消息"权限,不限 @机器人);`GroupAtMessage` 补齐官方新增的 `message_type`/`message_scene`/`attachments`/`mentions`/`ark_data`/`msg_elements` 字段
|
||||||
- 文档站点:基于 docsify(侧边栏、全文搜索、代码复制、mermaid 图渲染、分页),`npx docsify-cli serve` 本地预览;新增 `examples/README.md` 示例索引
|
- 文档站点:基于 docsify(侧边栏、全文搜索、代码复制、mermaid 图渲染、分页),`npx docsify-cli serve` 本地预览;新增 `examples/README.md` 示例索引
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
@@ -23,6 +24,8 @@
|
|||||||
- 重连退避只在连接稳定存活(≥60s)后才重置归一,抖动的服务端不再被 1s 间隔反复探测
|
- 重连退避只在连接稳定存活(≥60s)后才重置归一,抖动的服务端不再被 1s 间隔反复探测
|
||||||
- `AccessToken` 并发安全:换取过程持 `asyncio.Lock` 双重检查(并发首调只发一次请求);到期判断改用 monotonic 时钟并预留 30s 提前刷新余量;新增 `invalidate()` 供 401 时强制作废
|
- `AccessToken` 并发安全:换取过程持 `asyncio.Lock` 双重检查(并发首调只发一次请求);到期判断改用 monotonic 时钟并预留 30s 提前刷新余量;新增 `invalidate()` 供 401 时强制作废
|
||||||
- 组件构造签名瘦身:`WebsocketProtocol(config, session, token)` 移除未使用的 `queue`;`WebsocketConnecter` 改收 `(config, http, token, session, queue)`;`WebhookConnecter` 改收 `(config, queue, handle_event)`,op=13 应答经注入的回调取得,不再依赖具体 `EventEmitter`;`Session` 合并 `sequence_id`/`seq` 双属性为 `seq`(心跳序列号无历史时发 0 而非 null);移除服务端不会推送的 HEARTBEAT 死代码 handler;`ApiError`/`TokenError` 的错误响应解析合并为 `model.error_parts`
|
- 组件构造签名瘦身:`WebsocketProtocol(config, session, token)` 移除未使用的 `queue`;`WebsocketConnecter` 改收 `(config, http, token, session, queue)`;`WebhookConnecter` 改收 `(config, queue, handle_event)`,op=13 应答经注入的回调取得,不再依赖具体 `EventEmitter`;`Session` 合并 `sequence_id`/`seq` 双属性为 `seq`(心跳序列号无历史时发 0 而非 null);移除服务端不会推送的 HEARTBEAT 死代码 handler;`ApiError`/`TokenError` 的错误响应解析合并为 `model.error_parts`
|
||||||
|
- **intents.toml 改为只按大类订阅**:配置项从"`[分组]` 表内逐事件开关"简化为"`分组名 = true/false`"(如 `GROUP_AND_C2C_EVENT = true`)——网关只接受分组位掩码,组内事件本就无法单独订阅,此前的逐事件开关只是"组内任一为 true 即订阅整组"的装饰。破坏性变更:旧格式(表内逐事件开关)不再兼容,与拼错的分组名、非布尔值一样在启动时直接抛 `ValueError`,而非静默少订阅
|
||||||
|
- 形参注入缓存化:handler 的签名与类型标注解析结果(此前每条事件都重新跑 `inspect.signature`/`get_type_hints`)合并缓存,`services` 命中改为每条事件实时判定——先注册 handler、后登记组件的既有装配顺序不受影响
|
||||||
|
|
||||||
### 移除
|
### 移除
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ flowchart TB
|
|||||||
- 内置协议处理器(`ws_protocol.py` / `webhook_protocol.py`):HELLO 鉴权(IDENTIFY/RESUME 自动切换)、apscheduler 定时心跳、READY 会话捕获、INVALID_SESSION 会话失效处理
|
- 内置协议处理器(`ws_protocol.py` / `webhook_protocol.py`):HELLO 鉴权(IDENTIFY/RESUME 自动切换)、apscheduler 定时心跳、READY 会话捕获、INVALID_SESSION 会话失效处理
|
||||||
- 断线自动重连(指数退避 + 心跳看门狗),重连时自动 RESUME 续传
|
- 断线自动重连(指数退避 + 心跳看门狗),重连时自动 RESUME 续传
|
||||||
- Webhook 接入自带 Ed25519 验签、op=13 验证应答与 60s TTL 事件去重
|
- Webhook 接入自带 Ed25519 验签、op=13 验证应答与 60s TTL 事件去重
|
||||||
- 事件订阅由项目根目录的 `intents.toml` 控制,将对应事件改为 `true` 即可
|
- 事件订阅由项目根目录的 `intents.toml` 控制,按大类(Intents 分组)开关,将对应分组改为 `true` 即可
|
||||||
- Markdown 与内嵌按钮一等支持:`post_group_message(..., markdown=..., keyboard=...)` 自动置 msg_type=2,`respond_interaction()` 回应按钮回调
|
- Markdown 与内嵌按钮一等支持:`post_group_message(..., markdown=..., keyboard=...)` 自动置 msg_type=2,`respond_interaction()` 回应按钮回调
|
||||||
- 组件在 `run_loop` 显式装配(无 DI 框架依赖),全 SDK 共享一个 HTTP 连接池
|
- 组件在 `run_loop` 显式装配(无 DI 框架依赖),全 SDK 共享一个 HTTP 连接池
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -181,10 +181,12 @@ async def slow(...): ...
|
|||||||
|
|
||||||
### 事件名 → dataclass 对照
|
### 事件名 → dataclass 对照
|
||||||
|
|
||||||
|
下表左列是 intents.toml 的订阅分组(订阅粒度即分组,组内事件无法单独开关),中列是同时属于该分组、会被一起推送的事件 `t` 名:
|
||||||
|
|
||||||
| intents 分组 | 事件 t 名 | dataclass |
|
| intents 分组 | 事件 t 名 | dataclass |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GROUP_AND_C2C_EVENT | `C2C_MESSAGE_CREATE` | `C2CMessage` |
|
| GROUP_AND_C2C_EVENT | `C2C_MESSAGE_CREATE` | `C2CMessage` |
|
||||||
| | `GROUP_AT_MESSAGE_CREATE` | `GroupAtMessage` |
|
| | `GROUP_AT_MESSAGE_CREATE` / `GROUP_MESSAGE_CREATE` | `GroupAtMessage` |
|
||||||
| | `FRIEND_ADD` / `FRIEND_DEL` | `FriendAdd` / `FriendDel` |
|
| | `FRIEND_ADD` / `FRIEND_DEL` | `FriendAdd` / `FriendDel` |
|
||||||
| | `GROUP_ADD_ROBOT` / `GROUP_DEL_ROBOT` | `GroupAddRobot` / `GroupDelRobot` |
|
| | `GROUP_ADD_ROBOT` / `GROUP_DEL_ROBOT` | `GroupAddRobot` / `GroupDelRobot` |
|
||||||
| | `C2C_MSG_REJECT` / `C2C_MSG_RECEIVE` | `C2CMsgReject` / `C2CMsgReceive` |
|
| | `C2C_MSG_REJECT` / `C2C_MSG_RECEIVE` | `C2CMsgReject` / `C2CMsgReceive` |
|
||||||
@@ -206,11 +208,14 @@ async def slow(...): ...
|
|||||||
|
|
||||||
未收录的事件(如私域 `MESSAGE_CREATE`、`DIRECT_MESSAGE_DELETE`、`GUILD_MEMBER_*`)handler 收到原始 dict;新增事件在 `payloads.py` 加 dataclass 并登记 `EVENT_TYPES` 即可。
|
未收录的事件(如私域 `MESSAGE_CREATE`、`DIRECT_MESSAGE_DELETE`、`GUILD_MEMBER_*`)handler 收到原始 dict;新增事件在 `payloads.py` 加 dataclass 并登记 `EVENT_TYPES` 即可。
|
||||||
|
|
||||||
|
`GROUP_MESSAGE_CREATE` 是群消息**全量模式**:需在开放平台开通"接收所有消息"权限,群里每条消息(不限 @机器人)都推送此事件,与 `GROUP_AT_MESSAGE_CREATE` 共用 `GroupAtMessage`。官方提示同一 msg_id 可能重复推送,业务侧需按 `id` 去重。
|
||||||
|
|
||||||
### 常用 dataclass 字段
|
### 常用 dataclass 字段
|
||||||
|
|
||||||
```python
|
```python
|
||||||
Author # id, user_openid, member_openid, username, bot
|
Author # id, user_openid, member_openid, username, bot
|
||||||
GroupAtMessage # id, content, group_openid, author, timestamp
|
GroupAtMessage # id, content, group_openid, author, timestamp,
|
||||||
|
# message_type, message_scene, attachments, mentions, ark_data, msg_elements
|
||||||
C2CMessage # id, content, author, attachments, timestamp
|
C2CMessage # id, content, author, attachments, timestamp
|
||||||
AtMessage # id, content, channel_id, guild_id, author, member, timestamp
|
AtMessage # id, content, channel_id, guild_id, author, member, timestamp
|
||||||
DirectMessage # 同 AtMessage
|
DirectMessage # 同 AtMessage
|
||||||
|
|||||||
+12
-4
@@ -42,7 +42,15 @@ WEBHOOK_PATH=/qqbot/webhook
|
|||||||
|
|
||||||
## 事件订阅(intents.toml)
|
## 事件订阅(intents.toml)
|
||||||
|
|
||||||
SDK 启动时读取 `intents.toml` 计算订阅掩码,把想收的事件改成 `true` 即可。分组与特权要求:
|
SDK 启动时读取 `intents.toml` 计算订阅掩码。订阅粒度就是下面表格里的大类(Intents 分组),把某组设为 `true` 即订阅该组下的全部事件:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
GROUP_AND_C2C_EVENT = true # 群/单聊消息与关注事件
|
||||||
|
INTERACTION = true # 互动事件(卡片按钮回调)
|
||||||
|
GUILDS = false # 频道/子频道增删改
|
||||||
|
```
|
||||||
|
|
||||||
|
QQ 网关只认分组位掩码,**组内单个事件无法单独开关**——能收到哪些事件由订阅了哪些分组决定(各组包含哪些事件见 [API.md](API.md#事件名--dataclass-对照))。分组与特权要求:
|
||||||
|
|
||||||
| 分组 | 位掩码 | 说明 |
|
| 分组 | 位掩码 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -58,7 +66,7 @@ SDK 启动时读取 `intents.toml` 计算订阅掩码,把想收的事件改成
|
|||||||
| `AUDIO_ACTION` | 1 << 29 | 音频播放/上麦下麦 |
|
| `AUDIO_ACTION` | 1 << 29 | 音频播放/上麦下麦 |
|
||||||
| `PUBLIC_GUILD_MESSAGES` | 1 << 30 | 频道公域消息(AT_MESSAGE_CREATE) |
|
| `PUBLIC_GUILD_MESSAGES` | 1 << 30 | 频道公域消息(AT_MESSAGE_CREATE) |
|
||||||
|
|
||||||
事件名到 payload dataclass 的完整对照见 [API.md](API.md#事件-payload)。
|
分组名即 `Intent` 枚举成员名。配置只接受 `分组名 = true/false`;拼错的分组名、非布尔值,以及旧版"表内逐事件开关"的写法都会在启动时直接抛 `ValueError`(与其静默少订阅,不如启动即报错)。文件缺失则告警并按不订阅任何事件启动。
|
||||||
|
|
||||||
## 接入方式选择
|
## 接入方式选择
|
||||||
|
|
||||||
@@ -114,7 +122,7 @@ handler 参数按标注解析的完整规则:
|
|||||||
|
|
||||||
- 业务事件(op=0)处理器的**返回值不会回流**,回复消息请在 handler 里显式调 API。
|
- 业务事件(op=0)处理器的**返回值不会回流**,回复消息请在 handler 里显式调 API。
|
||||||
- 单个 handler 抛异常只记录日志,不影响同事件其他 handler 和主循环。
|
- 单个 handler 抛异常只记录日志,不影响同事件其他 handler 和主循环。
|
||||||
- 未订阅(intents.toml 为 `false`)的事件不会到达 handler。
|
- 所在分组未订阅(intents.toml 中为 `false`)的事件不会到达 handler。
|
||||||
- 自定义组件也能注入:`ee.services[MyService] = MyService(...)` 登记后,handler 形参标注 `MyService` 即可拿到实例。
|
- 自定义组件也能注入:`ee.services[MyService] = MyService(...)` 登记后,handler 形参标注 `MyService` 即可拿到实例。
|
||||||
|
|
||||||
### 后台并发 handler
|
### 后台并发 handler
|
||||||
@@ -228,6 +236,6 @@ uv run python main.py
|
|||||||
- **启动后频繁重连 / READY 收不到**:QQ 网关接口有频率限制(code 100017),连续重启需间隔约 1 分钟;SDK 已内置退避,等即可。
|
- **启动后频繁重连 / READY 收不到**:QQ 网关接口有频率限制(code 100017),连续重启需间隔约 1 分钟;SDK 已内置退避,等即可。
|
||||||
- **`ApiError`**:HTTP ≥400 时抛出,`e.status`/`e.code`/`e.message` 可用于排查(如被动回复超时、msg_seq 重复、内容审核不通过)。
|
- **`ApiError`**:HTTP ≥400 时抛出,`e.status`/`e.code`/`e.message` 可用于排查(如被动回复超时、msg_seq 重复、内容审核不通过)。
|
||||||
- **日志出现 `TokenError: 获取 access_token 失败`**:appid/secret 配置错误或被平台拒绝,`code`/`message` 就是 QQ 返回的真实原因,对照官方错误码排查即可。
|
- **日志出现 `TokenError: 获取 access_token 失败`**:appid/secret 配置错误或被平台拒绝,`code`/`message` 就是 QQ 返回的真实原因,对照官方错误码排查即可。
|
||||||
- **handler 没被触发**:检查 intents.toml 对应事件是否为 `true`;群/单聊事件需开通对应能力;频道消息事件区分公私域。
|
- **handler 没被触发**:检查 intents.toml 中该事件所属分组是否为 `true`;群/单聊事件需开通对应能力;频道消息事件区分公私域。
|
||||||
- **想收原始数据**:handler 裸参数或标 `Event`,`event.raw` 是完整线上 payload(含 op/t/d)。
|
- **想收原始数据**:handler 裸参数或标 `Event`,`event.raw` 是完整线上 payload(含 op/t/d)。
|
||||||
- **日志**:SDK 用 loguru 输出中文日志,连接、重连、API 失败、handler 异常均有记录,可直接用 loguru 配置格式与落盘。
|
- **日志**:SDK 用 loguru 输出中文日志,连接、重连、API 失败、handler 异常均有记录,可直接用 loguru 配置格式与落盘。
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
## 运行前提
|
## 运行前提
|
||||||
|
|
||||||
1. 根目录 `.env` 配好 `APPID`/`APPSECRET`(`webhook_bot` 需另设 `CONNECTER=webhook`);
|
1. 根目录 `.env` 配好 `APPID`/`APPSECRET`(`webhook_bot` 需另设 `CONNECTER=webhook`);
|
||||||
2. `intents.toml` 开启对应事件分组(`group_bot` 需 `GROUP_AND_C2C_EVENT` 与 `INTERACTION`,`webhook_bot` 需 `GROUP_AND_C2C_EVENT`);
|
2. `intents.toml` 订阅对应分组(`group_bot` 需 `GROUP_AND_C2C_EVENT` 与 `INTERACTION`,`webhook_bot` 需 `GROUP_AND_C2C_EVENT`;仓库默认配置已满足两者);
|
||||||
3. `group_bot` 要求机器人已开通群聊能力并拉入测试群;`webhook_bot` 要求公网 HTTPS 部署(平台仅支持 80/443/8080/8443 端口,本地调试可用内网穿透)。
|
3. `group_bot` 要求机器人已开通群聊能力并拉入测试群;`webhook_bot` 要求公网 HTTPS 部署(平台仅支持 80/443/8080/8443 端口,本地调试可用内网穿透)。
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
运行前提:
|
运行前提:
|
||||||
1. 项目根目录 .env 配好 APPID/APPSECRET(CONNECTER 用默认 websocket);
|
1. 项目根目录 .env 配好 APPID/APPSECRET(CONNECTER 用默认 websocket);
|
||||||
2. intents.toml 开启 GROUP_AND_C2C_EVENT 与 INTERACTION 分组的事件;
|
2. intents.toml 订阅 GROUP_AND_C2C_EVENT 与 INTERACTION 两个分组(仓库默认已开启);
|
||||||
3. 机器人已在开放平台开通群聊能力,并被拉入测试群。
|
3. 机器人已在开放平台开通群聊能力,并被拉入测试群。
|
||||||
|
|
||||||
运行:uv run python examples/group_bot/main.py
|
运行:uv run python examples/group_bot/main.py
|
||||||
@@ -30,7 +30,10 @@ async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None:
|
|||||||
return
|
return
|
||||||
upload = await api.upload_group_file(msg.group_openid, file_type=1, url=url)
|
upload = await api.upload_group_file(msg.group_openid, file_type=1, url=url)
|
||||||
await api.post_group_message(
|
await api.post_group_message(
|
||||||
msg.group_openid, content="图来了", msg_id=msg.id, media=upload,
|
msg.group_openid,
|
||||||
|
content="图来了",
|
||||||
|
msg_id=msg.id,
|
||||||
|
media=upload,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -49,7 +52,9 @@ async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None:
|
|||||||
|
|
||||||
# 普通文本:带 msg_id 被动回复(5 分钟内有效,同一条消息多次回复递增 msg_seq)
|
# 普通文本:带 msg_id 被动回复(5 分钟内有效,同一条消息多次回复递增 msg_seq)
|
||||||
await api.post_group_message(
|
await api.post_group_message(
|
||||||
msg.group_openid, content=f"你说:{msg.content}", msg_id=msg.id,
|
msg.group_openid,
|
||||||
|
content=f"你说:{msg.content}",
|
||||||
|
msg_id=msg.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -62,7 +67,8 @@ async def on_button_click(inter: Interaction, event: Event, api: BotApi) -> None
|
|||||||
# 被动回复的 event_id 是网关帧最外层的 id(形如 INTERACTION_CREATE:uuid),
|
# 被动回复的 event_id 是网关帧最外层的 id(形如 INTERACTION_CREATE:uuid),
|
||||||
# 不是事件体里的裸 UUID——官方文档点名的常见坑
|
# 不是事件体里的裸 UUID——官方文档点名的常见坑
|
||||||
await api.post_group_message(
|
await api.post_group_message(
|
||||||
inter.group_openid, content=f"你点了:{inter.button_data}",
|
inter.group_openid,
|
||||||
|
content=f"你点了:{inter.button_data}",
|
||||||
event_id=event.event_id,
|
event_id=event.event_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,7 +82,9 @@ async def on_robot_added(event: Event, api: BotApi) -> None:
|
|||||||
logger.info(f"机器人加入群聊 {group_openid}(无事件 ID,跳过欢迎语)")
|
logger.info(f"机器人加入群聊 {group_openid}(无事件 ID,跳过欢迎语)")
|
||||||
return
|
return
|
||||||
await api.post_group_message(
|
await api.post_group_message(
|
||||||
group_openid, content="大家好,我是本群机器人,@我即可体验~", event_id=event_id,
|
group_openid,
|
||||||
|
content="大家好,我是本群机器人,@我即可体验~",
|
||||||
|
event_id=event_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -86,7 +94,9 @@ async def on_c2c_message(msg: C2CMessage, api: BotApi) -> None:
|
|||||||
if not (user_openid and msg.id):
|
if not (user_openid and msg.id):
|
||||||
return
|
return
|
||||||
# 单聊被动回复 60 分钟内有效,同一 msg_id 最多回复 4 次
|
# 单聊被动回复 60 分钟内有效,同一 msg_id 最多回复 4 次
|
||||||
await api.post_c2c_message(user_openid, content=f"你说:{msg.content}", msg_id=msg.id)
|
await api.post_c2c_message(
|
||||||
|
user_openid, content=f"你说:{msg.content}", msg_id=msg.id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@ee.on("FRIEND_ADD")
|
@ee.on("FRIEND_ADD")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
运行前提:
|
运行前提:
|
||||||
1. 项目根目录 .env 配好 APPID/APPSECRET,并设 CONNECTER=webhook;
|
1. 项目根目录 .env 配好 APPID/APPSECRET,并设 CONNECTER=webhook;
|
||||||
2. intents.toml 开启 GROUP_AT_MESSAGE_CREATE;
|
2. intents.toml 订阅 GROUP_AND_C2C_EVENT 分组(仓库默认已开启);
|
||||||
3. 部署在公网 HTTPS 环境(平台仅支持 80/443/8080/8443 端口,
|
3. 部署在公网 HTTPS 环境(平台仅支持 80/443/8080/8443 端口,
|
||||||
本地调试可内网穿透),回调地址填
|
本地调试可内网穿透),回调地址填
|
||||||
https://<你的域名>:<端口><WEBHOOK_PATH>;
|
https://<你的域名>:<端口><WEBHOOK_PATH>;
|
||||||
@@ -23,7 +23,9 @@ async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None:
|
|||||||
if not (msg.group_openid and msg.id):
|
if not (msg.group_openid and msg.id):
|
||||||
return
|
return
|
||||||
await api.post_group_message(
|
await api.post_group_message(
|
||||||
msg.group_openid, content=f"收到:{msg.content}", msg_id=msg.id,
|
msg.group_openid,
|
||||||
|
content=f"收到:{msg.content}",
|
||||||
|
msg_id=msg.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+15
-77
@@ -1,80 +1,18 @@
|
|||||||
# =============================================
|
# =============================================
|
||||||
# 事件监听配置(TOML)
|
# 事件订阅配置(TOML)
|
||||||
# 修改 true 表示监听该事件,false 表示不监听
|
# 订阅粒度就是下面的大类(Intents 分组):true 订阅该分组,false 不订阅。
|
||||||
# 分组名与位掩码对应关系见注释
|
# QQ 网关只认分组位掩码,组内单个事件无法单独开关,
|
||||||
|
# 各分组包含哪些事件见 docs/API.md 的「事件名 → dataclass 对照」。
|
||||||
# =============================================
|
# =============================================
|
||||||
|
|
||||||
# GUILDS (1 << 0)
|
GUILDS = false # (1 << 0) 频道/子频道增删改
|
||||||
[GUILDS]
|
GUILD_MEMBERS = false # (1 << 1) 频道成员增删改
|
||||||
GUILD_CREATE = false # 当机器人加入新guild时
|
GUILD_MESSAGES = false # (1 << 9) 频道全量消息,仅 *私域* 机器人
|
||||||
GUILD_UPDATE = false # 当guild资料发生变更时
|
GUILD_MESSAGE_REACTIONS = false # (1 << 10) 频道表情表态
|
||||||
GUILD_DELETE = false # 当机器人退出guild时
|
DIRECT_MESSAGE = false # (1 << 12) 频道私信
|
||||||
CHANNEL_CREATE = false # 当channel被创建时
|
GROUP_AND_C2C_EVENT = true # (1 << 25) 群/单聊消息、关注与主动消息开关事件
|
||||||
CHANNEL_UPDATE = false # 当channel被更新时
|
INTERACTION = true # (1 << 26) 互动事件(卡片按钮回调)
|
||||||
CHANNEL_DELETE = false # 当channel被删除时
|
MESSAGE_AUDIT = false # (1 << 27) 消息审核结果
|
||||||
|
FORUMS_EVENT = false # (1 << 28) 论坛事件,仅 *私域* 机器人
|
||||||
# GUILD_MEMBERS (1 << 1)
|
AUDIO_ACTION = false # (1 << 29) 音频播放/上麦下麦
|
||||||
[GUILD_MEMBERS]
|
PUBLIC_GUILD_MESSAGES = false # (1 << 30) 频道公域消息(@机器人)
|
||||||
GUILD_MEMBER_ADD = false # 当成员加入时
|
|
||||||
GUILD_MEMBER_UPDATE = false # 当成员资料变更时
|
|
||||||
GUILD_MEMBER_REMOVE = false # 当成员被移除时
|
|
||||||
|
|
||||||
# GUILD_MESSAGES (1 << 9) 消息事件,仅 *私域* 机器人能够设置此 intents。
|
|
||||||
[GUILD_MESSAGES]
|
|
||||||
MESSAGE_CREATE = false # 发送消息事件(所有消息)
|
|
||||||
MESSAGE_DELETE = false # 删除(撤回)消息事件
|
|
||||||
|
|
||||||
# GUILD_MESSAGE_REACTIONS (1 << 10)
|
|
||||||
[GUILD_MESSAGE_REACTIONS]
|
|
||||||
MESSAGE_REACTION_ADD = false # 为消息添加表情表态
|
|
||||||
MESSAGE_REACTION_REMOVE = false # 为消息删除表情表态
|
|
||||||
|
|
||||||
# DIRECT_MESSAGE (1 << 12)
|
|
||||||
[DIRECT_MESSAGE]
|
|
||||||
DIRECT_MESSAGE_CREATE = false # 当收到用户发给机器人的私信消息时
|
|
||||||
DIRECT_MESSAGE_DELETE = false # 删除(撤回)消息事件
|
|
||||||
|
|
||||||
# GROUP_AND_C2C_EVENT (1 << 25)
|
|
||||||
[GROUP_AND_C2C_EVENT]
|
|
||||||
C2C_MESSAGE_CREATE = true # 用户单聊发消息给机器人时候
|
|
||||||
FRIEND_ADD = true # 用户添加使用机器人
|
|
||||||
FRIEND_DEL = true # 用户删除机器人
|
|
||||||
C2C_MSG_REJECT = true # 用户在机器人资料卡手动关闭"主动消息"推送
|
|
||||||
C2C_MSG_RECEIVE = true # 用户在机器人资料卡手动开启"主动消息"推送
|
|
||||||
GROUP_AT_MESSAGE_CREATE = true # 用户在群里@机器人时收到的消息
|
|
||||||
GROUP_ADD_ROBOT = true # 机器人被添加到群聊
|
|
||||||
GROUP_DEL_ROBOT = true # 机器人被移出群聊
|
|
||||||
GROUP_MSG_REJECT = true # 群管理员主动关闭通知
|
|
||||||
GROUP_MSG_RECEIVE = true # 群管理员主动开启通知
|
|
||||||
|
|
||||||
# INTERACTION (1 << 26)
|
|
||||||
[INTERACTION]
|
|
||||||
INTERACTION_CREATE = true # 互动事件创建时(音乐插件的卡片按钮回调依赖)
|
|
||||||
|
|
||||||
# MESSAGE_AUDIT (1 << 27)
|
|
||||||
[MESSAGE_AUDIT]
|
|
||||||
MESSAGE_AUDIT_PASS = false # 消息审核通过
|
|
||||||
MESSAGE_AUDIT_REJECT = false # 消息审核不通过
|
|
||||||
|
|
||||||
# FORUMS_EVENT (1 << 28) 论坛事件,仅 *私域* 机器人能够设置此 intents。
|
|
||||||
[FORUMS_EVENT]
|
|
||||||
FORUM_THREAD_CREATE = false # 当用户创建主题时
|
|
||||||
FORUM_THREAD_UPDATE = false # 当用户更新主题时
|
|
||||||
FORUM_THREAD_DELETE = false # 当用户删除主题时
|
|
||||||
FORUM_POST_CREATE = false # 当用户创建帖子时
|
|
||||||
FORUM_POST_DELETE = false # 当用户删除帖子时
|
|
||||||
FORUM_REPLY_CREATE = false # 当用户回复评论时
|
|
||||||
FORUM_REPLY_DELETE = false # 当用户回复评论时
|
|
||||||
FORUM_PUBLISH_AUDIT_RESULT = false # 当用户发表审核通过时
|
|
||||||
|
|
||||||
# AUDIO_ACTION (1 << 29)
|
|
||||||
[AUDIO_ACTION]
|
|
||||||
AUDIO_START = false # 音频开始播放时
|
|
||||||
AUDIO_FINISH = false # 音频播放结束时
|
|
||||||
AUDIO_ON_MIC = false # 上麦时
|
|
||||||
AUDIO_OFF_MIC = false # 下麦时
|
|
||||||
|
|
||||||
# PUBLIC_GUILD_MESSAGES (1 << 30) 消息事件,此为公域的消息事件
|
|
||||||
[PUBLIC_GUILD_MESSAGES]
|
|
||||||
AT_MESSAGE_CREATE = false # 当收到@机器人的消息时
|
|
||||||
PUBLIC_MESSAGE_DELETE = false # 当频道的消息被删除时
|
|
||||||
+13
-6
@@ -45,7 +45,10 @@ class EventEmitter:
|
|||||||
self._queue = queue if queue is not None else EventQueue()
|
self._queue = queue if queue is not None else EventQueue()
|
||||||
self.services: dict[type, Any] = {}
|
self.services: dict[type, Any] = {}
|
||||||
self._protocol: BaseProtocol | None = None
|
self._protocol: BaseProtocol | None = None
|
||||||
self._hints: dict[Callable[..., Any], dict[str, Any]] = {}
|
# handler → (类型标注, 形参名):签名解析较贵,缓存后每条事件只查表
|
||||||
|
self._signatures: dict[
|
||||||
|
Callable[..., Any], tuple[dict[str, Any], tuple[str, ...]]
|
||||||
|
] = {}
|
||||||
self._background: set[Callable[..., Any]] = set()
|
self._background: set[Callable[..., Any]] = set()
|
||||||
self._bg_tasks: set[asyncio.Task[None]] = set()
|
self._bg_tasks: set[asyncio.Task[None]] = set()
|
||||||
|
|
||||||
@@ -194,13 +197,17 @@ class EventEmitter:
|
|||||||
封装,`services` 登记的组件类型 → 实例,其余(含无标注)→ 原始 d
|
封装,`services` 登记的组件类型 → 实例,其余(含无标注)→ 原始 d
|
||||||
(Event.data 是 dict 化视图,标量 d 如 INVALID_SESSION 的 true/false
|
(Event.data 是 dict 化视图,标量 d 如 INVALID_SESSION 的 true/false
|
||||||
必须走这里才能拿到原值)。"""
|
必须走这里才能拿到原值)。"""
|
||||||
hints = self._hints.get(fn)
|
cached = self._signatures.get(fn)
|
||||||
if hints is None:
|
if cached is None:
|
||||||
hints = get_type_hints(fn)
|
cached = (
|
||||||
self._hints[fn] = hints
|
get_type_hints(fn),
|
||||||
|
tuple(inspect.signature(fn).parameters),
|
||||||
|
)
|
||||||
|
self._signatures[fn] = cached
|
||||||
|
hints, names = cached
|
||||||
|
|
||||||
args: list[Any] = []
|
args: list[Any] = []
|
||||||
for name in inspect.signature(fn).parameters:
|
for name in names:
|
||||||
hint = hints.get(name)
|
hint = hints.get(name)
|
||||||
if hint in PAYLOAD_TYPES:
|
if hint in PAYLOAD_TYPES:
|
||||||
args.append(event.typed)
|
args.append(event.typed)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class MissingType:
|
|||||||
|
|
||||||
|
|
||||||
NOT_SET = MissingType()
|
NOT_SET = MissingType()
|
||||||
type NoneType = None | MissingType
|
type NoneType = MissingType | None
|
||||||
|
|
||||||
type DispatchData = Any
|
type DispatchData = Any
|
||||||
|
|
||||||
|
|||||||
@@ -33,13 +33,24 @@ class Ready:
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class GroupAtMessage:
|
class GroupAtMessage:
|
||||||
"""群里 @机器人的消息(GROUP_AT_MESSAGE_CREATE)。"""
|
"""群消息:GROUP_AT_MESSAGE_CREATE(@机器人)与 GROUP_MESSAGE_CREATE
|
||||||
|
(全量模式,需在开放平台开通"接收所有消息",不限 @机器人)共用,
|
||||||
|
两事件字段完全一致。
|
||||||
|
|
||||||
|
官方提示同一 msg_id 可能重复推送,业务侧需按 id 去重。
|
||||||
|
"""
|
||||||
|
|
||||||
id: str | None = None
|
id: str | None = None
|
||||||
content: str | None = None
|
content: str | None = None
|
||||||
group_openid: str | None = None
|
group_openid: str | None = None
|
||||||
author: Author | None = None
|
author: Author | None = None
|
||||||
timestamp: str | None = None
|
timestamp: str | None = None
|
||||||
|
message_type: int | None = None
|
||||||
|
message_scene: dict | None = None
|
||||||
|
attachments: list[dict] | None = None
|
||||||
|
mentions: list[dict] | None = None
|
||||||
|
ark_data: dict | None = None
|
||||||
|
msg_elements: list[dict] | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def user_openid(self) -> str | None:
|
def user_openid(self) -> str | None:
|
||||||
@@ -277,6 +288,7 @@ class AudioAction:
|
|||||||
EVENT_TYPES: dict[str, type] = {
|
EVENT_TYPES: dict[str, type] = {
|
||||||
"READY": Ready,
|
"READY": Ready,
|
||||||
"GROUP_AT_MESSAGE_CREATE": GroupAtMessage,
|
"GROUP_AT_MESSAGE_CREATE": GroupAtMessage,
|
||||||
|
"GROUP_MESSAGE_CREATE": GroupAtMessage,
|
||||||
"C2C_MESSAGE_CREATE": C2CMessage,
|
"C2C_MESSAGE_CREATE": C2CMessage,
|
||||||
"FRIEND_ADD": FriendAdd,
|
"FRIEND_ADD": FriendAdd,
|
||||||
"FRIEND_DEL": FriendDel,
|
"FRIEND_DEL": FriendDel,
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ class EventQueue:
|
|||||||
self.reply_queue: Queue[Payload] = Queue()
|
self.reply_queue: Queue[Payload] = Queue()
|
||||||
|
|
||||||
async def put_event(self, item: Payload) -> None:
|
async def put_event(self, item: Payload) -> None:
|
||||||
return await self.msg_queue.put(item)
|
await self.msg_queue.put(item)
|
||||||
|
|
||||||
async def get_event(self) -> Payload:
|
async def get_event(self) -> Payload:
|
||||||
return await self.msg_queue.get()
|
return await self.msg_queue.get()
|
||||||
|
|
||||||
async def put_reply(self, item: Payload) -> None:
|
async def put_reply(self, item: Payload) -> None:
|
||||||
return await self.reply_queue.put(item)
|
await self.reply_queue.put(item)
|
||||||
|
|
||||||
async def get_reply(self) -> Payload:
|
async def get_reply(self) -> Payload:
|
||||||
return await self.reply_queue.get()
|
return await self.reply_queue.get()
|
||||||
@@ -29,6 +29,12 @@ INTENT_GROUPS: dict[str, Intent] = {name: Intent[name] for name in Intent.__memb
|
|||||||
|
|
||||||
|
|
||||||
def get_intents(path: str) -> int:
|
def get_intents(path: str) -> int:
|
||||||
|
"""按 Intents 大类(分组)读取订阅掩码:分组为 true 即 OR 该位。
|
||||||
|
|
||||||
|
配置只接受 `分组名 = true/false`。网关只认分组位掩码,组内事件无法
|
||||||
|
单独订阅,因此旧版"`[分组]` 表内逐事件开关"的写法、拼错的分组名与
|
||||||
|
非布尔值一律抛 ValueError——静默少订阅比启动即报错更难排查。
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
config = load(f)
|
config = load(f)
|
||||||
@@ -37,12 +43,20 @@ def get_intents(path: str) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
intents = 0
|
intents = 0
|
||||||
for group, events in config.items():
|
for group, enabled in config.items():
|
||||||
if intent := INTENT_GROUPS.get(group):
|
intent = INTENT_GROUPS.get(group)
|
||||||
if isinstance(events, dict) and any(events.values()):
|
if intent is None:
|
||||||
|
raise ValueError(f"未知的 intents 分组: {group}")
|
||||||
|
if isinstance(enabled, dict):
|
||||||
|
raise ValueError(
|
||||||
|
f"intents 分组 {group} 不支持组内事件开关,请改为 {group} = true/false"
|
||||||
|
)
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
raise ValueError(
|
||||||
|
f"intents 分组 {group} 的值应为 true/false,实际为 {enabled!r}"
|
||||||
|
)
|
||||||
|
if enabled:
|
||||||
intents |= intent
|
intents |= intent
|
||||||
else:
|
|
||||||
logger.warning(f"未知的 intents 分组: {group}")
|
|
||||||
return intents
|
return intents
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,32 @@ async def test_unregistered_annotation_falls_back_to_raw_dict():
|
|||||||
assert received == [{"session_id": "s1"}]
|
assert received == [{"session_id": "s1"}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_registered_after_handler_still_injects():
|
||||||
|
"""回归:形参解析被缓存(签名+标注),但 services 命中必须在每条事件时判定。
|
||||||
|
|
||||||
|
先注册 handler、后登记组件是 run_loop 里的真实时序(用户 handler 在
|
||||||
|
main() 之前注册);若缓存把"已解析好的实参"也存下来,这里会永远拿不到
|
||||||
|
实例。同一 handler 连发两条也一并覆盖缓存复用路径。
|
||||||
|
"""
|
||||||
|
emitter = EventEmitter()
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
pass
|
||||||
|
|
||||||
|
store = Store()
|
||||||
|
got: list[Store] = []
|
||||||
|
|
||||||
|
@emitter.on("READY")
|
||||||
|
async def handler(st: Store):
|
||||||
|
got.append(st)
|
||||||
|
|
||||||
|
emitter.services[Store] = store # 登记晚于 handler 注册
|
||||||
|
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
|
||||||
|
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
|
||||||
|
assert got == [store, store]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scalar_d_falls_back_to_raw_value():
|
async def test_scalar_d_falls_back_to_raw_value():
|
||||||
"""回归:op=9 INVALID_SESSION 的 d 是布尔值,DI 回退必须传原始 d。
|
"""回归:op=9 INVALID_SESSION 的 d 是布尔值,DI 回退必须传原始 d。
|
||||||
|
|||||||
+23
-7
@@ -23,20 +23,36 @@ def test_missing_file_returns_zero(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_enabled_group_sets_bit(tmp_path):
|
def test_enabled_group_sets_bit(tmp_path):
|
||||||
path = write(tmp_path, "[GROUP_AND_C2C_EVENT]\nC2C_MESSAGE_CREATE = true\n")
|
path = write(tmp_path, "GROUP_AND_C2C_EVENT = true\n")
|
||||||
assert get_intents(path) & (1 << 25)
|
assert get_intents(path) & (1 << 25)
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_enabled_groups_are_subscribed(tmp_path):
|
||||||
|
path = write(tmp_path, "GUILDS = true\nINTERACTION = false\nAUDIO_ACTION = true\n")
|
||||||
|
assert get_intents(path) == (1 << 0) | (1 << 29)
|
||||||
|
|
||||||
|
|
||||||
def test_all_disabled_returns_zero(tmp_path):
|
def test_all_disabled_returns_zero(tmp_path):
|
||||||
path = write(tmp_path, "[GUILDS]\nGUILD_CREATE = false\n")
|
path = write(tmp_path, "GUILDS = false\nFORUMS_EVENT = false\n")
|
||||||
assert get_intents(path) == 0
|
assert get_intents(path) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_group_is_skipped(tmp_path):
|
def test_unknown_group_raises(tmp_path):
|
||||||
path = write(
|
path = write(tmp_path, "NOT_A_GROUP = true\n")
|
||||||
tmp_path, "[NOT_A_GROUP]\nFOO = true\n\n[GUILDS]\nGUILD_CREATE = true\n"
|
with pytest.raises(ValueError, match="NOT_A_GROUP"):
|
||||||
)
|
get_intents(path)
|
||||||
assert get_intents(path) == (1 << 0)
|
|
||||||
|
|
||||||
|
def test_non_bool_value_raises(tmp_path):
|
||||||
|
path = write(tmp_path, 'GUILDS = "yes"\n')
|
||||||
|
with pytest.raises(ValueError, match="true/false"):
|
||||||
|
get_intents(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_per_event_switches_are_rejected(tmp_path):
|
||||||
|
path = write(tmp_path, "[GROUP_AND_C2C_EVENT]\nC2C_MESSAGE_CREATE = true\n")
|
||||||
|
with pytest.raises(ValueError, match="组内事件开关"):
|
||||||
|
get_intents(path)
|
||||||
|
|
||||||
|
|
||||||
def test_config_load_defaults():
|
def test_config_load_defaults():
|
||||||
|
|||||||
@@ -28,6 +28,53 @@ def test_parse_group_at_message():
|
|||||||
assert msg.author is not None and msg.author.member_openid == "M1"
|
assert msg.author is not None and msg.author.member_openid == "M1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_group_full_message():
|
||||||
|
msg = parse_event(
|
||||||
|
"GROUP_MESSAGE_CREATE",
|
||||||
|
{
|
||||||
|
"author": {"member_openid": "M2"},
|
||||||
|
"content": "大家好",
|
||||||
|
"group_openid": "G1",
|
||||||
|
"id": "msgid-2",
|
||||||
|
"timestamp": "2026-01-01",
|
||||||
|
"message_type": 0,
|
||||||
|
"message_scene": {"unknown": False},
|
||||||
|
"attachments": [{"content_type": 1, "url": "https://x/img.png"}],
|
||||||
|
"mentions": [{"id": "U1"}],
|
||||||
|
"msg_elements": [{"type": 1, "text_element": {"content": "大家好"}}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert isinstance(msg, GroupAtMessage)
|
||||||
|
assert msg.group_openid == "G1"
|
||||||
|
assert msg.user_openid == "M2"
|
||||||
|
assert msg.message_type == 0
|
||||||
|
assert msg.message_scene == {"unknown": False}
|
||||||
|
assert msg.attachments == [{"content_type": 1, "url": "https://x/img.png"}]
|
||||||
|
assert msg.mentions == [{"id": "U1"}]
|
||||||
|
assert msg.msg_elements == [{"type": 1, "text_element": {"content": "大家好"}}]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_full_message_reaches_typed_handler():
|
||||||
|
queue = EventQueue()
|
||||||
|
emitter = EventEmitter(queue)
|
||||||
|
received: list[GroupAtMessage] = []
|
||||||
|
|
||||||
|
@emitter.on("GROUP_MESSAGE_CREATE")
|
||||||
|
async def handler(msg: GroupAtMessage):
|
||||||
|
received.append(msg)
|
||||||
|
|
||||||
|
await emitter.emit(
|
||||||
|
{
|
||||||
|
"op": Opcode.DISPATCH,
|
||||||
|
"t": "GROUP_MESSAGE_CREATE",
|
||||||
|
"d": {"content": "hi", "group_openid": "G1"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0].content == "hi"
|
||||||
|
assert received[0].group_openid == "G1"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_c2c_message():
|
def test_parse_c2c_message():
|
||||||
msg = parse_event(
|
msg = parse_event(
|
||||||
"C2C_MESSAGE_CREATE",
|
"C2C_MESSAGE_CREATE",
|
||||||
|
|||||||
Reference in new issue
Block a user