mirror of
https://github.com/snowy-yang/qqbotsdk.git
synced 2026-10-21 13:55:20 +00:00
Compare commits
2
Commits
bcd69099b6
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
321ab70b35 | ||
|
|
7e8794bf5f |
No files matched your search
@@ -5,8 +5,11 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.0] - 2026-10-03
|
||||
|
||||
### 新增
|
||||
|
||||
- **intents 掩码直传**:`Bot(intents=…)` 参数(推荐用顶层导出的 `Intent` 枚举 OR 出掩码)与 `INTENTS` 环境变量(十进制/0x 十六进制),优先级 Bot 参数 > 环境变量 > intents.toml;掩码含未收录位仅告警不拦截
|
||||
- **`Bot` 门面类(新启动方式)**:`bot = Bot()` 构造即读 .env 校验配置,`@bot.on(...)` 注册 handler,`bot.run()` 同步启动;`await bot.start()` 供嵌入外部 asyncio 应用。`bot.call_api(method, path, **kwargs)` 通用 REST 入口(带鉴权/429 重试/401 自愈),`bot.api` 为类型化 API 面,`bot.services` 登记自定义组件;handler 可标注 `bot: Bot` 注入实例
|
||||
- **CI 与发布**:CI 新增构建 job(`uv build` 产出 sdist/wheel 冒烟);新增 `publish.yml`——打 `v*` 标签自动构建并经 Trusted Publishing(OIDC,无需 token)发布到 PyPI
|
||||
- **文档模块化重构**:`docs/GUIDE.md`/`docs/API.md` 拆为 `docs/guide/` 五页(start/config/handlers/messaging/ops)与 `docs/api/` 七页(core/v2/group/guild/panel/audio-forum/events),侧边栏分组折叠,旧链接经 docsify alias 映射;安装指引改为 `pip install qqbotsdk-py`,README 重写为快速开始入口并加 CI/PyPI 徽章
|
||||
@@ -81,4 +84,6 @@
|
||||
- **文档**:使用指南、API 参考、架构说明与可运行示例(`examples/`)
|
||||
- **测试**:纯逻辑单测 + 假网关 WebSocket 端到端 + Webhook 验签/去重端到端
|
||||
|
||||
[Unreleased]: https://github.com/snowy-yang/qqbotsdk/compare/v0.2.0...HEAD
|
||||
[0.2.0]: https://github.com/snowy-yang/qqbotsdk/releases/tag/v0.2.0
|
||||
[0.1.0]: https://github.com/snowy-yang/qqbotsdk/releases/tag/v0.1.0
|
||||
+2
-1
@@ -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 应用 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "qqbotsdk-py"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "QQ 官方机器人 Python SDK(WebSocket / Webhook)"
|
||||
license = "MIT"
|
||||
license-files = ["LICENSE"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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": {},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+50
-1
@@ -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
|
||||
+14
-1
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user