From 7e8794bf5f3ce73346d123933c6f59c0aaf3b915 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:07:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20intents=20=E6=8E=A9=E7=A0=81=E7=9B=B4?= =?UTF-8?q?=E4=BC=A0=E2=80=94=E2=80=94Bot(intents=3D)=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E4=B8=8E=20INTENTS=20=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=85=88=E4=BA=8E=20intents.toml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intent 枚举(IntFlag)提升到顶层导出可直接 OR;掩码含未收录位仅告警不拦截。 --- docs/api/core.md | 3 ++- docs/guide/config.md | 20 +++++++++++++++ src/qqbotsdk/__init__.py | 2 ++ src/qqbotsdk/bot.py | 8 +++++- src/qqbotsdk/config.py | 12 +++++++++ src/qqbotsdk/ws_protocol.py | 19 +++++++++++++- tests/test_bot.py | 21 +++++++++++++++ tests/test_intents.py | 51 ++++++++++++++++++++++++++++++++++++- tests/test_route.py | 15 ++++++++++- 9 files changed, 146 insertions(+), 5 deletions(-) diff --git a/docs/api/core.md b/docs/api/core.md index 8b3fa0e..78cc4b4 100644 --- a/docs/api/core.md +++ b/docs/api/core.md @@ -9,6 +9,7 @@ from qqbotsdk import ( Bot, # 门面:on/run/start/call_api/api/services EventEmitter, EventQueue, # 分发层 + Intent, # 订阅分组位掩码(IntFlag,可 OR 出掩码传 Bot(intents=…)) BaseProtocol, # 协议处理器接口(自定义接入方式时继承) Config, Connecter, # 配置与连接器接口 ) @@ -44,7 +45,7 @@ bot.run() # 同步入口:阻塞运行,Ctrl+C 优雅 | 成员 | 说明 | |---|---| -| `Bot(config=None)` | 不传 config 时 `Config.load()` 读环境变量;缺 `APPID`/`APPSECRET` 构造即抛 `ValueError` | +| `Bot(config=None, *, intents=None)` | 不传 config 时 `Config.load()` 读环境变量;缺 `APPID`/`APPSECRET` 构造即抛 `ValueError`。`intents` 直传订阅掩码(可用 `Intent` 枚举 OR 出来),优先级 `intents` 参数 > `INTENTS` 环境变量 > intents.toml,详见[配置与订阅](/docs/guide/config.md#掩码直传) | | `bot.on(event, fn=None, *, background=False)` | 注册 handler;`event` 为事件 `t` 名或 `Opcode`,装饰器/直传皆可 | | `bot.run()` | 同步入口:`asyncio.run(start())`,`KeyboardInterrupt` 记日志退出 | | `await bot.start()` | 异步入口:装配组件并常驻运行,供嵌入外部 asyncio 应用 | diff --git a/docs/guide/config.md b/docs/guide/config.md index 034af2c..1da7cce 100644 --- a/docs/guide/config.md +++ b/docs/guide/config.md @@ -14,6 +14,7 @@ CONNECTER=websocket # websocket 或 webhook,默认 websocket BASE_URL=https://api.bot.qq.com # 可选,官方 API 地址 TIMEOUT=5000 # 可选,HTTP 请求超时(毫秒) INTENTS_FILE=intents.toml # 可选,事件订阅配置文件路径 +INTENTS= # 可选,订阅掩码直传(十进制或 0x 十六进制),优先于 intents.toml WEBHOOK_HOST=0.0.0.0 # 以下仅 webhook 接入时生效 WEBHOOK_PORT=8080 @@ -28,6 +29,7 @@ WEBHOOK_PATH=/qqbot/webhook | `base_url` | `BASE_URL` | `https://api.bot.qq.com` | 官方 API 根地址 | | `timeout` | `TIMEOUT` | `5000`(毫秒) | 转为 aiohttp `ClientTimeout(total=…)` | | `intents_file` | `INTENTS_FILE` | `intents.toml` | 事件订阅配置路径 | +| `intents` | `INTENTS` | 空 | 订阅掩码直传,见[掩码直传](#掩码直传) | | `webhook_host` | `WEBHOOK_HOST` | `0.0.0.0` | webhook 监听地址 | | `webhook_port` | `WEBHOOK_PORT` | `8080` | webhook 监听端口 | | `webhook_path` | `WEBHOOK_PATH` | `/qqbot/webhook` | webhook 回调路径 | @@ -62,6 +64,24 @@ QQ 网关只认分组位掩码,**组内单个事件无法单独开关**—— 分组名即 `Intent` 枚举成员名。配置只接受 `分组名 = true/false`;拼错的分组名、非布尔值,以及旧版"表内逐事件开关"的写法都会在启动时直接抛 `ValueError`(与其静默少订阅,不如启动即报错)。文件缺失则告警并按不订阅任何事件启动。 +## 掩码直传 + +不想用配置文件的,可以直接传订阅掩码,三种入口按优先级取先命中者: + +1. **`Bot` 参数**(推荐写法,`Intent` 是 `IntFlag` 可直接 OR): + + ```python + from qqbotsdk import Bot, Intent + + bot = Bot(intents=Intent.GROUP_AND_C2C_EVENT | Intent.INTERACTION) + ``` + +2. **`INTENTS` 环境变量**:十进制或 `0x` 十六进制字符串,如 `INTENTS=1073741824` 或 `INTENTS=0x40000000`; + +3. 都没给才回落 `intents.toml`。 + +掩码原样下发给网关,含 `Intent` 枚举未收录的位时仅告警不拦截(平台可能新增 SDK 尚未收录的分组)。`Intent` 从包顶层导出(`from qqbotsdk import Intent`),各位值见上面的分组表。 + ## 接入方式选择 | | WebSocket | Webhook | diff --git a/src/qqbotsdk/__init__.py b/src/qqbotsdk/__init__.py index cf6aae8..dfa8ba1 100644 --- a/src/qqbotsdk/__init__.py +++ b/src/qqbotsdk/__init__.py @@ -13,6 +13,7 @@ from .config import Config as Config from .connecter import Connecter as Connecter from .connecter import WebhookConnecter, WebsocketConnecter from .emitter import EventEmitter as EventEmitter +from .model import Intent as Intent from .protocol import BaseProtocol as BaseProtocol from .queue import EventQueue as EventQueue from .session import Session as Session @@ -31,6 +32,7 @@ __all__ = [ "Connecter", "EventEmitter", "EventQueue", + "Intent", "Session", "WebhookConnecter", "WebhookProtocol", diff --git a/src/qqbotsdk/bot.py b/src/qqbotsdk/bot.py index b56a1ac..3022df4 100644 --- a/src/qqbotsdk/bot.py +++ b/src/qqbotsdk/bot.py @@ -8,6 +8,7 @@ start() 的装配里,handler 经形参标注拿所需组件(含 Bot 自身 import asyncio from collections.abc import Callable +from dataclasses import replace from typing import Any, overload from aiohttp import ClientSession @@ -26,8 +27,13 @@ from .ws_protocol import WebsocketProtocol class Bot: - def __init__(self, config: Config | None = None) -> None: + def __init__( + self, config: Config | None = None, *, intents: int | None = None + ) -> None: self._config = config if config is not None else Config.load() + # 掩码直传(可用 Intent 枚举 OR 出来),优先级高于 INTENTS 环境变量与 intents.toml + if intents is not None: + self._config = replace(self._config, intents=intents) self._emitter = EventEmitter() self._http: ClientSession | None = None self._api: BotApi | None = None diff --git a/src/qqbotsdk/config.py b/src/qqbotsdk/config.py index 26bdeb3..3a93861 100644 --- a/src/qqbotsdk/config.py +++ b/src/qqbotsdk/config.py @@ -26,6 +26,7 @@ class Config: webhook_host: str webhook_port: int webhook_path: str + intents: int | None = None @classmethod def load(cls) -> "Config": @@ -34,6 +35,16 @@ class Config: raise ValueError(f"未知的接入方式: {connecter}") # TIMEOUT 以毫秒配置 timeout = float(os.getenv("TIMEOUT", "5000")) / 1000 + intents_raw = os.getenv("INTENTS", "") + if intents_raw: + try: + intents = int(intents_raw, 0) # 十进制或 0x 十六进制 + except ValueError: + raise ValueError( + f"INTENTS 应为十进制或 0x 十六进制掩码,实际: {intents_raw!r}" + ) from None + else: + intents = None return cls( app_id=_required_env("APPID"), app_secret=_required_env("APPSECRET"), @@ -44,4 +55,5 @@ class Config: webhook_host=os.getenv("WEBHOOK_HOST", "0.0.0.0"), webhook_port=int(os.getenv("WEBHOOK_PORT", "8080")), webhook_path=os.getenv("WEBHOOK_PATH", "/qqbot/webhook"), + intents=intents, ) diff --git a/src/qqbotsdk/ws_protocol.py b/src/qqbotsdk/ws_protocol.py index c5103b1..1d02c25 100644 --- a/src/qqbotsdk/ws_protocol.py +++ b/src/qqbotsdk/ws_protocol.py @@ -50,6 +50,23 @@ def get_intents(path: str) -> int: return intents +_KNOWN_INTENTS_MASK = sum(intent.value for intent in Intent) + + +def resolve_intents(config: Config) -> int: + """掩码优先于 intents.toml:Config.intents(Bot 参数或 INTENTS 环境变量) + 已给则原样下发并校验未知位,未给才回落文件。""" + if config.intents is None: + return get_intents(config.intents_file) + unknown = config.intents & ~_KNOWN_INTENTS_MASK + if unknown: + # 平台可能新增 SDK 尚未收录的分组,未知位只告警不拦截 + logger.warning( + f"intents 掩码含未知位 {unknown:#x}(当前 Intent 枚举未收录),将原样下发" + ) + return config.intents + + class WebsocketProtocol(BaseProtocol): def __init__(self, config: Config, session: Session, token: AccessToken) -> None: self._config = config @@ -87,7 +104,7 @@ class WebsocketProtocol(BaseProtocol): op = Opcode.IDENTIFY d = { "token": f"QQBot {access_token}", - "intents": get_intents(self._config.intents_file), + "intents": resolve_intents(self._config), "shard": (0, 1), "properties": {}, } diff --git a/tests/test_bot.py b/tests/test_bot.py index 96003b1..c0c291d 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -10,12 +10,15 @@ from qqbotsdk import ( Config, EventEmitter, EventQueue, + Intent, Session, WebhookConnecter, WebsocketConnecter, ) from qqbotsdk.payloads import GroupAtMessage +intent_mask = (1 << 25) | (1 << 26) + @pytest.fixture def env(monkeypatch: pytest.MonkeyPatch) -> None: @@ -29,6 +32,24 @@ def test_construction_loads_config(env: None) -> None: assert bot.config.connecter == "websocket" +def test_intents_param_overrides_env( + env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("INTENTS", str(1 << 0)) + bot = Bot(intents=intent_mask) + assert bot.config.intents == intent_mask # Bot 参数 > INTENTS 环境变量 + + +def test_intents_env_picked_up(env: None, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("INTENTS", str(intent_mask)) + assert Bot().config.intents == intent_mask + + +def test_intents_accepts_intent_flag(env: None) -> None: + bot = Bot(intents=Intent.GROUP_AND_C2C_EVENT | Intent.INTERACTION) + assert bot.config.intents == (1 << 25) | (1 << 26) + + def test_missing_credentials_raises(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("APPID", raising=False) monkeypatch.delenv("APPSECRET", raising=False) diff --git a/tests/test_intents.py b/tests/test_intents.py index 5ea5b87..d9871f2 100644 --- a/tests/test_intents.py +++ b/tests/test_intents.py @@ -1,7 +1,7 @@ import pytest from qqbotsdk.config import Config -from qqbotsdk.ws_protocol import get_intents +from qqbotsdk.ws_protocol import get_intents, resolve_intents @pytest.fixture(autouse=True) @@ -74,3 +74,52 @@ def test_config_rejects_unknown_connecter(monkeypatch): monkeypatch.setenv("CONNECTER", "carrier-pigeon") with pytest.raises(ValueError, match="carrier-pigeon"): Config.load() + + +# --- 掩码直传:Config.intents(INTENTS 环境变量 / Bot 参数)优先于 intents.toml --- + + +def test_config_intents_env_decimal(monkeypatch): + monkeypatch.setenv("INTENTS", str((1 << 25) | (1 << 26))) + assert Config.load().intents == (1 << 25) | (1 << 26) + + +def test_config_intents_env_hex(monkeypatch): + monkeypatch.setenv("INTENTS", "0x4000000") + assert Config.load().intents == 1 << 26 + + +def test_config_intents_env_invalid(monkeypatch): + monkeypatch.setenv("INTENTS", "abc") + with pytest.raises(ValueError, match="INTENTS"): + Config.load() + + +def make_config(intents: int | None, path: str) -> Config: + return Config( + app_id="app", + app_secret="secret", + base_url="https://api.bot.qq.com", + timeout=None, # type: ignore[arg-type] + connecter="websocket", + intents_file=path, + webhook_host="0.0.0.0", + webhook_port=8080, + webhook_path="/x", + intents=intents, + ) + + +def test_resolve_intents_mask_wins_over_file(tmp_path): + path = write(tmp_path, "GUILDS = true\n") + assert resolve_intents(make_config(1 << 26, path)) == 1 << 26 + + +def test_resolve_intents_falls_back_to_file(tmp_path): + path = write(tmp_path, "GUILDS = true\nAUDIO_ACTION = true\n") + assert resolve_intents(make_config(None, path)) == (1 << 0) | (1 << 29) + + +def test_resolve_intents_unknown_bits_pass_through(tmp_path): + mask = (1 << 26) | (1 << 5) # 1<<5 未收录进 Intent 枚举 + assert resolve_intents(make_config(mask, str(tmp_path / "nope.toml"))) == mask diff --git a/tests/test_route.py b/tests/test_route.py index 2dce242..959b5a0 100644 --- a/tests/test_route.py +++ b/tests/test_route.py @@ -17,7 +17,9 @@ class FakeToken: return "tok" -def make_protocol(session: Session | None = None) -> tuple[WebsocketProtocol, Session]: +def make_protocol( + session: Session | None = None, intents: int | None = None +) -> tuple[WebsocketProtocol, Session]: session = session or Session() config = Config( app_id="app", @@ -29,6 +31,7 @@ def make_protocol(session: Session | None = None) -> tuple[WebsocketProtocol, Se webhook_host="0.0.0.0", webhook_port=8080, webhook_path="/x", + intents=intents, ) return WebsocketProtocol(config, session, FakeToken()), session # type: ignore[arg-type] @@ -45,6 +48,16 @@ async def test_hello_without_session_identifies(): assert reply["d"]["shard"] == (0, 1) +@pytest.mark.asyncio +async def test_identify_carries_intents_mask(): + mask = (1 << 25) | (1 << 26) + protocol, _ = make_protocol(intents=mask) + reply = await protocol.on_frame({"op": Opcode.HELLO, "d": {}}) + assert reply is not None + assert reply["op"] == Opcode.IDENTIFY + assert reply["d"]["intents"] == mask + + @pytest.mark.asyncio async def test_hello_with_session_resumes(): protocol, session = make_protocol()