diff --git a/CHANGELOG.md b/CHANGELOG.md index c528f8c..1983f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - 按钮快捷构造与点击处理:`button()`/`keyboard()` 生成内嵌按钮(免手写嵌套 dict);`Interaction` 新增 `button_data` 属性(被点按钮的 action.data);examples/group_bot 补 `/btn` 卡片与 `INTERACTION_CREATE` 回调示例 - handler 后台并发:`@ee.on(..., background=True)` 把 handler 放进后台任务,不阻塞事件流(同事件内失去先后保证,返回值不回流);`run_loop` 退出经 `emitter.close()` 统一取消在飞任务 - 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` 示例索引 ### 变更 @@ -23,6 +24,8 @@ - 重连退避只在连接稳定存活(≥60s)后才重置归一,抖动的服务端不再被 1s 间隔反复探测 - `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` +- **intents.toml 改为只按大类订阅**:配置项从"`[分组]` 表内逐事件开关"简化为"`分组名 = true/false`"(如 `GROUP_AND_C2C_EVENT = true`)——网关只接受分组位掩码,组内事件本就无法单独订阅,此前的逐事件开关只是"组内任一为 true 即订阅整组"的装饰。破坏性变更:旧格式(表内逐事件开关)不再兼容,与拼错的分组名、非布尔值一样在启动时直接抛 `ValueError`,而非静默少订阅 +- 形参注入缓存化:handler 的签名与类型标注解析结果(此前每条事件都重新跑 `inspect.signature`/`get_type_hints`)合并缓存,`services` 命中改为每条事件实时判定——先注册 handler、后登记组件的既有装配顺序不受影响 ### 移除 diff --git a/README.md b/README.md index 7f4f230..edf3dd6 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ flowchart TB - 内置协议处理器(`ws_protocol.py` / `webhook_protocol.py`):HELLO 鉴权(IDENTIFY/RESUME 自动切换)、apscheduler 定时心跳、READY 会话捕获、INVALID_SESSION 会话失效处理 - 断线自动重连(指数退避 + 心跳看门狗),重连时自动 RESUME 续传 - Webhook 接入自带 Ed25519 验签、op=13 验证应答与 60s TTL 事件去重 -- 事件订阅由项目根目录的 `intents.toml` 控制,将对应事件改为 `true` 即可 +- 事件订阅由项目根目录的 `intents.toml` 控制,按大类(Intents 分组)开关,将对应分组改为 `true` 即可 - Markdown 与内嵌按钮一等支持:`post_group_message(..., markdown=..., keyboard=...)` 自动置 msg_type=2,`respond_interaction()` 回应按钮回调 - 组件在 `run_loop` 显式装配(无 DI 框架依赖),全 SDK 共享一个 HTTP 连接池 diff --git a/docs/API.md b/docs/API.md index 2b4993e..ddb0400 100644 --- a/docs/API.md +++ b/docs/API.md @@ -181,10 +181,12 @@ async def slow(...): ... ### 事件名 → dataclass 对照 +下表左列是 intents.toml 的订阅分组(订阅粒度即分组,组内事件无法单独开关),中列是同时属于该分组、会被一起推送的事件 `t` 名: + | intents 分组 | 事件 t 名 | dataclass | |---|---|---| | 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` | | | `GROUP_ADD_ROBOT` / `GROUP_DEL_ROBOT` | `GroupAddRobot` / `GroupDelRobot` | | | `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` 即可。 +`GROUP_MESSAGE_CREATE` 是群消息**全量模式**:需在开放平台开通"接收所有消息"权限,群里每条消息(不限 @机器人)都推送此事件,与 `GROUP_AT_MESSAGE_CREATE` 共用 `GroupAtMessage`。官方提示同一 msg_id 可能重复推送,业务侧需按 `id` 去重。 + ### 常用 dataclass 字段 ```python 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 AtMessage # id, content, channel_id, guild_id, author, member, timestamp DirectMessage # 同 AtMessage diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 268e40a..e669965 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -42,7 +42,15 @@ WEBHOOK_PATH=/qqbot/webhook ## 事件订阅(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 | 音频播放/上麦下麦 | | `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。 - 单个 handler 抛异常只记录日志,不影响同事件其他 handler 和主循环。 -- 未订阅(intents.toml 为 `false`)的事件不会到达 handler。 +- 所在分组未订阅(intents.toml 中为 `false`)的事件不会到达 handler。 - 自定义组件也能注入:`ee.services[MyService] = MyService(...)` 登记后,handler 形参标注 `MyService` 即可拿到实例。 ### 后台并发 handler @@ -228,6 +236,6 @@ uv run python main.py - **启动后频繁重连 / READY 收不到**:QQ 网关接口有频率限制(code 100017),连续重启需间隔约 1 分钟;SDK 已内置退避,等即可。 - **`ApiError`**:HTTP ≥400 时抛出,`e.status`/`e.code`/`e.message` 可用于排查(如被动回复超时、msg_seq 重复、内容审核不通过)。 - **日志出现 `TokenError: 获取 access_token 失败`**:appid/secret 配置错误或被平台拒绝,`code`/`message` 就是 QQ 返回的真实原因,对照官方错误码排查即可。 -- **handler 没被触发**:检查 intents.toml 对应事件是否为 `true`;群/单聊事件需开通对应能力;频道消息事件区分公私域。 +- **handler 没被触发**:检查 intents.toml 中该事件所属分组是否为 `true`;群/单聊事件需开通对应能力;频道消息事件区分公私域。 - **想收原始数据**:handler 裸参数或标 `Event`,`event.raw` 是完整线上 payload(含 op/t/d)。 - **日志**:SDK 用 loguru 输出中文日志,连接、重连、API 失败、handler 异常均有记录,可直接用 loguru 配置格式与落盘。 diff --git a/examples/README.md b/examples/README.md index b60acd6..885e28c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,7 +10,7 @@ ## 运行前提 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 端口,本地调试可用内网穿透)。 ## 运行 diff --git a/examples/group_bot/main.py b/examples/group_bot/main.py index 518312e..667eca8 100644 --- a/examples/group_bot/main.py +++ b/examples/group_bot/main.py @@ -2,7 +2,7 @@ 运行前提: 1. 项目根目录 .env 配好 APPID/APPSECRET(CONNECTER 用默认 websocket); -2. intents.toml 开启 GROUP_AND_C2C_EVENT 与 INTERACTION 分组的事件; +2. intents.toml 订阅 GROUP_AND_C2C_EVENT 与 INTERACTION 两个分组(仓库默认已开启); 3. 机器人已在开放平台开通群聊能力,并被拉入测试群。 运行:uv run python examples/group_bot/main.py @@ -30,7 +30,10 @@ async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None: return upload = await api.upload_group_file(msg.group_openid, file_type=1, url=url) 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 @@ -49,7 +52,9 @@ async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None: # 普通文本:带 msg_id 被动回复(5 分钟内有效,同一条消息多次回复递增 msg_seq) 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), # 不是事件体里的裸 UUID——官方文档点名的常见坑 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, ) @@ -76,7 +82,9 @@ async def on_robot_added(event: Event, api: BotApi) -> None: logger.info(f"机器人加入群聊 {group_openid}(无事件 ID,跳过欢迎语)") return 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): return # 单聊被动回复 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") diff --git a/examples/webhook_bot/main.py b/examples/webhook_bot/main.py index 305784d..33813f0 100644 --- a/examples/webhook_bot/main.py +++ b/examples/webhook_bot/main.py @@ -2,7 +2,7 @@ 运行前提: 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 端口, 本地调试可内网穿透),回调地址填 https://<你的域名>:<端口>; @@ -23,7 +23,9 @@ async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None: if not (msg.group_openid and msg.id): return 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, ) diff --git a/intents.toml b/intents.toml index f225d9a..b7cfc83 100644 --- a/intents.toml +++ b/intents.toml @@ -1,80 +1,18 @@ # ============================================= -# 事件监听配置(TOML) -# 修改 true 表示监听该事件,false 表示不监听 -# 分组名与位掩码对应关系见注释 +# 事件订阅配置(TOML) +# 订阅粒度就是下面的大类(Intents 分组):true 订阅该分组,false 不订阅。 +# QQ 网关只认分组位掩码,组内单个事件无法单独开关, +# 各分组包含哪些事件见 docs/API.md 的「事件名 → dataclass 对照」。 # ============================================= -# GUILDS (1 << 0) -[GUILDS] -GUILD_CREATE = false # 当机器人加入新guild时 -GUILD_UPDATE = false # 当guild资料发生变更时 -GUILD_DELETE = false # 当机器人退出guild时 -CHANNEL_CREATE = false # 当channel被创建时 -CHANNEL_UPDATE = false # 当channel被更新时 -CHANNEL_DELETE = false # 当channel被删除时 - -# GUILD_MEMBERS (1 << 1) -[GUILD_MEMBERS] -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 # 当频道的消息被删除时 +GUILDS = false # (1 << 0) 频道/子频道增删改 +GUILD_MEMBERS = false # (1 << 1) 频道成员增删改 +GUILD_MESSAGES = false # (1 << 9) 频道全量消息,仅 *私域* 机器人 +GUILD_MESSAGE_REACTIONS = false # (1 << 10) 频道表情表态 +DIRECT_MESSAGE = false # (1 << 12) 频道私信 +GROUP_AND_C2C_EVENT = true # (1 << 25) 群/单聊消息、关注与主动消息开关事件 +INTERACTION = true # (1 << 26) 互动事件(卡片按钮回调) +MESSAGE_AUDIT = false # (1 << 27) 消息审核结果 +FORUMS_EVENT = false # (1 << 28) 论坛事件,仅 *私域* 机器人 +AUDIO_ACTION = false # (1 << 29) 音频播放/上麦下麦 +PUBLIC_GUILD_MESSAGES = false # (1 << 30) 频道公域消息(@机器人) diff --git a/src/qqbotsdk/emitter.py b/src/qqbotsdk/emitter.py index 9acfaa9..03f0ed4 100644 --- a/src/qqbotsdk/emitter.py +++ b/src/qqbotsdk/emitter.py @@ -45,7 +45,10 @@ class EventEmitter: self._queue = queue if queue is not None else EventQueue() self.services: dict[type, Any] = {} 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._bg_tasks: set[asyncio.Task[None]] = set() @@ -194,13 +197,17 @@ class EventEmitter: 封装,`services` 登记的组件类型 → 实例,其余(含无标注)→ 原始 d (Event.data 是 dict 化视图,标量 d 如 INVALID_SESSION 的 true/false 必须走这里才能拿到原值)。""" - hints = self._hints.get(fn) - if hints is None: - hints = get_type_hints(fn) - self._hints[fn] = hints + cached = self._signatures.get(fn) + if cached is None: + cached = ( + get_type_hints(fn), + tuple(inspect.signature(fn).parameters), + ) + self._signatures[fn] = cached + hints, names = cached args: list[Any] = [] - for name in inspect.signature(fn).parameters: + for name in names: hint = hints.get(name) if hint in PAYLOAD_TYPES: args.append(event.typed) diff --git a/src/qqbotsdk/model.py b/src/qqbotsdk/model.py index 0bc2d5b..1fb28de 100644 --- a/src/qqbotsdk/model.py +++ b/src/qqbotsdk/model.py @@ -17,7 +17,7 @@ class MissingType: NOT_SET = MissingType() -type NoneType = None | MissingType +type NoneType = MissingType | None type DispatchData = Any diff --git a/src/qqbotsdk/payloads.py b/src/qqbotsdk/payloads.py index 387e74a..a69de80 100644 --- a/src/qqbotsdk/payloads.py +++ b/src/qqbotsdk/payloads.py @@ -33,13 +33,24 @@ class Ready: @dataclass(slots=True) class GroupAtMessage: - """群里 @机器人的消息(GROUP_AT_MESSAGE_CREATE)。""" + """群消息:GROUP_AT_MESSAGE_CREATE(@机器人)与 GROUP_MESSAGE_CREATE + (全量模式,需在开放平台开通"接收所有消息",不限 @机器人)共用, + 两事件字段完全一致。 + + 官方提示同一 msg_id 可能重复推送,业务侧需按 id 去重。 + """ id: str | None = None content: str | None = None group_openid: str | None = None author: Author | 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 def user_openid(self) -> str | None: @@ -277,6 +288,7 @@ class AudioAction: EVENT_TYPES: dict[str, type] = { "READY": Ready, "GROUP_AT_MESSAGE_CREATE": GroupAtMessage, + "GROUP_MESSAGE_CREATE": GroupAtMessage, "C2C_MESSAGE_CREATE": C2CMessage, "FRIEND_ADD": FriendAdd, "FRIEND_DEL": FriendDel, diff --git a/src/qqbotsdk/queue.py b/src/qqbotsdk/queue.py index 1d5646b..f47dc01 100644 --- a/src/qqbotsdk/queue.py +++ b/src/qqbotsdk/queue.py @@ -13,13 +13,13 @@ class EventQueue: self.reply_queue: Queue[Payload] = Queue() 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: return await self.msg_queue.get() 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: return await self.reply_queue.get() diff --git a/src/qqbotsdk/ws_protocol.py b/src/qqbotsdk/ws_protocol.py index 7b3a331..0d92384 100644 --- a/src/qqbotsdk/ws_protocol.py +++ b/src/qqbotsdk/ws_protocol.py @@ -29,6 +29,12 @@ INTENT_GROUPS: dict[str, Intent] = {name: Intent[name] for name in Intent.__memb def get_intents(path: str) -> int: + """按 Intents 大类(分组)读取订阅掩码:分组为 true 即 OR 该位。 + + 配置只接受 `分组名 = true/false`。网关只认分组位掩码,组内事件无法 + 单独订阅,因此旧版"`[分组]` 表内逐事件开关"的写法、拼错的分组名与 + 非布尔值一律抛 ValueError——静默少订阅比启动即报错更难排查。 + """ try: with open(path, "rb") as f: config = load(f) @@ -37,12 +43,20 @@ def get_intents(path: str) -> int: return 0 intents = 0 - for group, events in config.items(): - if intent := INTENT_GROUPS.get(group): - if isinstance(events, dict) and any(events.values()): - intents |= intent - else: - logger.warning(f"未知的 intents 分组: {group}") + for group, enabled in config.items(): + intent = INTENT_GROUPS.get(group) + 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 return intents diff --git a/tests/test_emitter.py b/tests/test_emitter.py index 160422f..9a1e74f 100644 --- a/tests/test_emitter.py +++ b/tests/test_emitter.py @@ -99,6 +99,32 @@ async def test_unregistered_annotation_falls_back_to_raw_dict(): 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 async def test_scalar_d_falls_back_to_raw_value(): """回归:op=9 INVALID_SESSION 的 d 是布尔值,DI 回退必须传原始 d。 diff --git a/tests/test_intents.py b/tests/test_intents.py index 050eadf..fe167da 100644 --- a/tests/test_intents.py +++ b/tests/test_intents.py @@ -23,20 +23,36 @@ def test_missing_file_returns_zero(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) +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): - path = write(tmp_path, "[GUILDS]\nGUILD_CREATE = false\n") + path = write(tmp_path, "GUILDS = false\nFORUMS_EVENT = false\n") assert get_intents(path) == 0 -def test_unknown_group_is_skipped(tmp_path): - path = write( - tmp_path, "[NOT_A_GROUP]\nFOO = true\n\n[GUILDS]\nGUILD_CREATE = true\n" - ) - assert get_intents(path) == (1 << 0) +def test_unknown_group_raises(tmp_path): + path = write(tmp_path, "NOT_A_GROUP = true\n") + with pytest.raises(ValueError, match="NOT_A_GROUP"): + get_intents(path) + + +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(): diff --git a/tests/test_payloads.py b/tests/test_payloads.py index 3a69214..4afd215 100644 --- a/tests/test_payloads.py +++ b/tests/test_payloads.py @@ -28,6 +28,53 @@ def test_parse_group_at_message(): 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(): msg = parse_event( "C2C_MESSAGE_CREATE",