qbotsdk 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qbotsdk/__init__.py +76 -0
- qbotsdk/adapter/__init__.py +9 -0
- qbotsdk/adapter/base.py +35 -0
- qbotsdk/adapter/webhook.py +78 -0
- qbotsdk/adapter/websocket.py +65 -0
- qbotsdk/api/__init__.py +46 -0
- qbotsdk/api/_parse.py +29 -0
- qbotsdk/api/channel.py +136 -0
- qbotsdk/api/group.py +184 -0
- qbotsdk/api/guild.py +73 -0
- qbotsdk/api/interaction.py +34 -0
- qbotsdk/api/message.py +55 -0
- qbotsdk/api/reaction.py +125 -0
- qbotsdk/api/user.py +57 -0
- qbotsdk/api/user_message.py +115 -0
- qbotsdk/bot.py +136 -0
- qbotsdk/constant.py +10 -0
- qbotsdk/exceptions.py +195 -0
- qbotsdk/gateway/__init__.py +61 -0
- qbotsdk/gateway/connection.py +316 -0
- qbotsdk/gateway/dispatcher.py +142 -0
- qbotsdk/gateway/handler.py +81 -0
- qbotsdk/gateway/payload.py +198 -0
- qbotsdk/gateway/session.py +59 -0
- qbotsdk/gateway/webhook.py +66 -0
- qbotsdk/gateway/webhook_server.py +171 -0
- qbotsdk/model/__init__.py +145 -0
- qbotsdk/model/base.py +9 -0
- qbotsdk/model/channel.py +74 -0
- qbotsdk/model/draft.py +234 -0
- qbotsdk/model/event.py +410 -0
- qbotsdk/model/group.py +119 -0
- qbotsdk/model/guild.py +29 -0
- qbotsdk/model/keyboard.py +112 -0
- qbotsdk/model/media.py +43 -0
- qbotsdk/model/member.py +27 -0
- qbotsdk/model/message.py +79 -0
- qbotsdk/model/reaction.py +65 -0
- qbotsdk/model/role.py +31 -0
- qbotsdk/model/scene.py +98 -0
- qbotsdk/model/token.py +9 -0
- qbotsdk/model/user.py +63 -0
- qbotsdk/module/__init__.py +1 -0
- qbotsdk/module/http_client.py +372 -0
- qbotsdk/module/pagination.py +74 -0
- qbotsdk/module/rate_limit.py +101 -0
- qbotsdk/module/token_manager.py +151 -0
- qbotsdk/options.py +43 -0
- qbotsdk/py.typed +1 -0
- qbotsdk/storage/__init__.py +28 -0
- qbotsdk/storage/base.py +42 -0
- qbotsdk/storage/file.py +142 -0
- qbotsdk/storage/memory.py +25 -0
- qbotsdk/storage/redis.py +219 -0
- qbotsdk-0.6.0.dist-info/METADATA +250 -0
- qbotsdk-0.6.0.dist-info/RECORD +59 -0
- qbotsdk-0.6.0.dist-info/WHEEL +4 -0
- qbotsdk-0.6.0.dist-info/entry_points.txt +4 -0
- qbotsdk-0.6.0.dist-info/licenses/LICENSE +201 -0
qbotsdk/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Copyright (c) 2026-present Half_nothing.
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
from .adapter import Adapter, AdapterContext, WebhookAdapter, WebSocketAdapter
|
|
8
|
+
from .api import ApiClient
|
|
9
|
+
from .bot import Bot
|
|
10
|
+
from .exceptions import APIError, ErrorCode, QQBotError, RateLimitExceededError, TransportError
|
|
11
|
+
from .gateway import WebhookServer, build_webhook_handler
|
|
12
|
+
from .model import (
|
|
13
|
+
Channel,
|
|
14
|
+
ChannelMessageCreate,
|
|
15
|
+
ChannelMessageDraft,
|
|
16
|
+
Group,
|
|
17
|
+
GroupBotState,
|
|
18
|
+
GroupMember,
|
|
19
|
+
GroupMessageDraft,
|
|
20
|
+
GroupMessageReceipt,
|
|
21
|
+
GroupMuteState,
|
|
22
|
+
GroupUser,
|
|
23
|
+
Guild,
|
|
24
|
+
GuildUser,
|
|
25
|
+
InteractionCreate,
|
|
26
|
+
Member,
|
|
27
|
+
Message,
|
|
28
|
+
Role,
|
|
29
|
+
User,
|
|
30
|
+
)
|
|
31
|
+
from .options import ClientOptions
|
|
32
|
+
from .storage import FileSessionStorage, MemorySessionStorage, SessionData, SessionStorage
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
__version__ = version("qbotsdk")
|
|
36
|
+
except PackageNotFoundError: # pragma: no cover
|
|
37
|
+
__version__ = "0.0.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"APIError",
|
|
41
|
+
"Adapter",
|
|
42
|
+
"AdapterContext",
|
|
43
|
+
"ApiClient",
|
|
44
|
+
"Bot",
|
|
45
|
+
"Channel",
|
|
46
|
+
"ChannelMessageCreate",
|
|
47
|
+
"ChannelMessageDraft",
|
|
48
|
+
"ClientOptions",
|
|
49
|
+
"ErrorCode",
|
|
50
|
+
"FileSessionStorage",
|
|
51
|
+
"Group",
|
|
52
|
+
"GroupBotState",
|
|
53
|
+
"GroupMember",
|
|
54
|
+
"GroupMessageDraft",
|
|
55
|
+
"GroupMessageReceipt",
|
|
56
|
+
"GroupMuteState",
|
|
57
|
+
"GroupUser",
|
|
58
|
+
"Guild",
|
|
59
|
+
"GuildUser",
|
|
60
|
+
"InteractionCreate",
|
|
61
|
+
"Member",
|
|
62
|
+
"MemorySessionStorage",
|
|
63
|
+
"Message",
|
|
64
|
+
"QQBotError",
|
|
65
|
+
"RateLimitExceededError",
|
|
66
|
+
"Role",
|
|
67
|
+
"SessionData",
|
|
68
|
+
"SessionStorage",
|
|
69
|
+
"TransportError",
|
|
70
|
+
"User",
|
|
71
|
+
"WebSocketAdapter",
|
|
72
|
+
"WebhookAdapter",
|
|
73
|
+
"WebhookServer",
|
|
74
|
+
"__version__",
|
|
75
|
+
"build_webhook_handler",
|
|
76
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Copyright (c) 2026-present Half_nothing.
|
|
2
|
+
|
|
3
|
+
"""适配器:核心不内置任何接入方式, 使用者显式选择或自己实现。"""
|
|
4
|
+
|
|
5
|
+
from .base import Adapter, AdapterContext
|
|
6
|
+
from .webhook import WebhookAdapter
|
|
7
|
+
from .websocket import WebSocketAdapter
|
|
8
|
+
|
|
9
|
+
__all__ = ["Adapter", "AdapterContext", "WebSocketAdapter", "WebhookAdapter"]
|
qbotsdk/adapter/base.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Copyright (c) 2026-present Half_nothing.
|
|
2
|
+
|
|
3
|
+
"""适配器抽象:事件从哪里来由使用者决定, 核心只认这个接口。"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from qbotsdk.api import ApiClient
|
|
13
|
+
from qbotsdk.gateway.dispatcher import Dispatcher
|
|
14
|
+
from qbotsdk.module.http_client import HTTPClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class AdapterContext:
|
|
19
|
+
"""适配器启动时能拿到的东西, 由 Bot.start() 组装。"""
|
|
20
|
+
|
|
21
|
+
http: HTTPClient
|
|
22
|
+
api: ApiClient
|
|
23
|
+
dispatcher: Dispatcher
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Adapter(ABC):
|
|
27
|
+
"""接入方式的公共接口。"""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
async def start(self, context: AdapterContext) -> None:
|
|
31
|
+
"""开始收事件并立即返回, 后台任务由适配器自己持有。"""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
async def stop(self) -> None:
|
|
35
|
+
"""停止收事件并释放资源, 允许重复调用。"""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Copyright (c) 2026-present Half_nothing.
|
|
2
|
+
|
|
3
|
+
"""Webhook 适配器:由平台回调推事件, 自己不再连网关。"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from ssl import SSLContext
|
|
8
|
+
|
|
9
|
+
from qbotsdk.gateway.webhook_server import WebhookServer
|
|
10
|
+
|
|
11
|
+
from .base import Adapter, AdapterContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WebhookAdapter(Adapter):
|
|
15
|
+
"""把 HTTP 回调收进同一个分发器, 与Websocket二选一。"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
app_secret: str,
|
|
20
|
+
*,
|
|
21
|
+
host: str = "0.0.0.0",
|
|
22
|
+
port: int = 8443,
|
|
23
|
+
path: str = "/qbotsdk",
|
|
24
|
+
certfile: str | None = None,
|
|
25
|
+
keyfile: str | None = None,
|
|
26
|
+
ssl_context: SSLContext | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""记录监听参数, 真正监听发生在 start()。
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
app_secret: 机器人密钥, 用于验签与 op 13 应答。
|
|
32
|
+
host: 监听地址。
|
|
33
|
+
port: 监听端口, 0 表示让操作系统挑一个空闲端口。
|
|
34
|
+
path: 回调路径, 管理端填的地址要与之对应。
|
|
35
|
+
certfile: 证书链路径, 必须与 keyfile 成对出现。
|
|
36
|
+
keyfile: 私钥路径。
|
|
37
|
+
ssl_context: 现成的 SSLContext, 与 certfile/keyfile 互斥。
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
self._app_secret = app_secret
|
|
41
|
+
self._host = host
|
|
42
|
+
self._port = port
|
|
43
|
+
self._path = path
|
|
44
|
+
self._certfile = certfile
|
|
45
|
+
self._keyfile = keyfile
|
|
46
|
+
self._ssl_context = ssl_context
|
|
47
|
+
self._server: WebhookServer | None = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def port(self) -> int:
|
|
51
|
+
"""实际监听的端口, 传 0 时要等 start() 之后才有值。"""
|
|
52
|
+
|
|
53
|
+
if self._server is not None:
|
|
54
|
+
return self._server.port
|
|
55
|
+
return self._port
|
|
56
|
+
|
|
57
|
+
async def start(self, context: AdapterContext) -> None:
|
|
58
|
+
if self._server is not None:
|
|
59
|
+
raise RuntimeError("webhook adapter is already started")
|
|
60
|
+
|
|
61
|
+
server = WebhookServer(
|
|
62
|
+
context.dispatcher,
|
|
63
|
+
self._app_secret,
|
|
64
|
+
host=self._host,
|
|
65
|
+
port=self._port,
|
|
66
|
+
path=self._path,
|
|
67
|
+
certfile=self._certfile,
|
|
68
|
+
keyfile=self._keyfile,
|
|
69
|
+
ssl_context=self._ssl_context,
|
|
70
|
+
)
|
|
71
|
+
await server.start()
|
|
72
|
+
self._server = server
|
|
73
|
+
self._port = server.port
|
|
74
|
+
|
|
75
|
+
async def stop(self) -> None:
|
|
76
|
+
if self._server is not None:
|
|
77
|
+
await self._server.close()
|
|
78
|
+
self._server = None
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Copyright (c) 2026-present Half_nothing.
|
|
2
|
+
|
|
3
|
+
"""WebSocket 适配器:连上网关、维持心跳、断线续连。"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from asyncio import Task, create_task, gather
|
|
8
|
+
|
|
9
|
+
from qbotsdk.gateway.connection import GatewayConnection
|
|
10
|
+
from qbotsdk.gateway.payload import Intents
|
|
11
|
+
from qbotsdk.storage import SessionStorage
|
|
12
|
+
|
|
13
|
+
from .base import Adapter, AdapterContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WebSocketAdapter(Adapter):
|
|
17
|
+
"""用Websocket收事件。"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
intents: int | None = None,
|
|
23
|
+
shard: tuple[int, int] = (0, 1),
|
|
24
|
+
session_storage: SessionStorage | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""记录连接参数, 真正连接发生在 start()。
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
intents: 订阅的事件位, 不传用 Intents.DEFAULT。
|
|
30
|
+
shard: (分片序号, 分片总数), 单连接保持默认即可。
|
|
31
|
+
session_storage: 会话存储, 不传则用内存实现。
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
self._intents = intents if intents is not None else Intents.DEFAULT
|
|
35
|
+
self._shard = shard
|
|
36
|
+
self._storage = session_storage
|
|
37
|
+
self._connection: GatewayConnection | None = None
|
|
38
|
+
self._tasks: set[Task[None]] = set()
|
|
39
|
+
|
|
40
|
+
async def start(self, context: AdapterContext) -> None:
|
|
41
|
+
if self._connection is not None:
|
|
42
|
+
raise RuntimeError("websocket adapter is already started")
|
|
43
|
+
|
|
44
|
+
connection = GatewayConnection(
|
|
45
|
+
context.http,
|
|
46
|
+
options=context.http.options,
|
|
47
|
+
intents=self._intents,
|
|
48
|
+
shard=self._shard,
|
|
49
|
+
session_storage=self._storage,
|
|
50
|
+
)
|
|
51
|
+
self._connection = connection
|
|
52
|
+
self._tasks = {
|
|
53
|
+
create_task(connection.run()),
|
|
54
|
+
create_task(context.dispatcher.run(connection.events)),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async def stop(self) -> None:
|
|
58
|
+
if self._connection is not None:
|
|
59
|
+
await self._connection.close()
|
|
60
|
+
for task in list(self._tasks):
|
|
61
|
+
task.cancel()
|
|
62
|
+
if self._tasks:
|
|
63
|
+
await gather(*self._tasks, return_exceptions=True)
|
|
64
|
+
self._tasks.clear()
|
|
65
|
+
self._connection = None
|
qbotsdk/api/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Copyright (c) 2026-present Half_nothing.
|
|
2
|
+
|
|
3
|
+
"""资源层,每个资源只负责拼路径、调 HTTPClient、把返回值转模型。"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from qbotsdk.module.http_client import HTTPClient
|
|
8
|
+
from qbotsdk.module.rate_limit import RateLimiter
|
|
9
|
+
|
|
10
|
+
from .channel import ChannelResource
|
|
11
|
+
from .group import GroupResource
|
|
12
|
+
from .guild import GuildResource
|
|
13
|
+
from .interaction import InteractionResource
|
|
14
|
+
from .message import MessageResource
|
|
15
|
+
from .reaction import ReactionResource
|
|
16
|
+
from .user import UserResource
|
|
17
|
+
from .user_message import UserMessageResource
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ApiClient",
|
|
21
|
+
"ChannelResource",
|
|
22
|
+
"GroupResource",
|
|
23
|
+
"GuildResource",
|
|
24
|
+
"InteractionResource",
|
|
25
|
+
"MessageResource",
|
|
26
|
+
"ReactionResource",
|
|
27
|
+
"UserMessageResource",
|
|
28
|
+
"UserResource",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ApiClient:
|
|
33
|
+
"""八个资源域的聚合入口,由 `Bot` 持有并暴露为 `bot.api`。"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
36
|
+
options = http.options
|
|
37
|
+
# 关掉限流就整层不建桶, 资源层拿到的 limiter 为 None
|
|
38
|
+
limiter = RateLimiter(options.rate_limits) if options.respect_rate_limits else None
|
|
39
|
+
self.user = UserResource(http)
|
|
40
|
+
self.guild = GuildResource(http)
|
|
41
|
+
self.group = GroupResource(http, limiter=limiter)
|
|
42
|
+
self.channel = ChannelResource(http)
|
|
43
|
+
self.message = MessageResource(http)
|
|
44
|
+
self.user_message = UserMessageResource(http, limiter=limiter)
|
|
45
|
+
self.interaction = InteractionResource(http)
|
|
46
|
+
self.reaction = ReactionResource(http)
|
qbotsdk/api/_parse.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Copyright (c) 2026-present Half_nothing.
|
|
2
|
+
|
|
3
|
+
"""响应形状校验。
|
|
4
|
+
资源层只认对象与数组,形状不符说明接口契约变了,直接抛TransportError并带上 path。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from qbotsdk.exceptions import TransportError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def expect_object(payload: object, path: str) -> dict[str, object]:
|
|
13
|
+
if not isinstance(payload, dict):
|
|
14
|
+
raise TransportError(
|
|
15
|
+
f"expected a JSON object from {path}, got {type(payload).__name__}",
|
|
16
|
+
method="GET",
|
|
17
|
+
path=path,
|
|
18
|
+
)
|
|
19
|
+
return payload
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def expect_list(payload: object, path: str) -> list[object]:
|
|
23
|
+
if not isinstance(payload, list):
|
|
24
|
+
raise TransportError(
|
|
25
|
+
f"expected a JSON array from {path}, got {type(payload).__name__}",
|
|
26
|
+
method="GET",
|
|
27
|
+
path=path,
|
|
28
|
+
)
|
|
29
|
+
return payload
|
qbotsdk/api/channel.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Copyright (c) 2026-present Half_nothing.
|
|
2
|
+
|
|
3
|
+
"""子频道域接口。"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from aiohttp import FormData
|
|
10
|
+
|
|
11
|
+
from qbotsdk.model.channel import Channel
|
|
12
|
+
from qbotsdk.model.draft import ChannelMessageDraft, MessageMarkdown, MessageReference
|
|
13
|
+
from qbotsdk.model.message import Message
|
|
14
|
+
from qbotsdk.module.http_client import HTTPClient
|
|
15
|
+
|
|
16
|
+
from ._parse import expect_object
|
|
17
|
+
|
|
18
|
+
# 官方支持 multipart 直传时就这几种图片类型, 其余按二进制流发
|
|
19
|
+
_CONTENT_TYPES: dict[str, str] = {
|
|
20
|
+
".png": "image/png",
|
|
21
|
+
".jpg": "image/jpeg",
|
|
22
|
+
".jpeg": "image/jpeg",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _content_type(file_name: str) -> str:
|
|
27
|
+
return _CONTENT_TYPES.get(Path(file_name).suffix.lower(), "application/octet-stream")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ChannelResource:
|
|
31
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
32
|
+
self._http = http
|
|
33
|
+
|
|
34
|
+
async def get(self, channel_id: str) -> Channel:
|
|
35
|
+
"""子频道详情。"""
|
|
36
|
+
|
|
37
|
+
path = f"/channels/{channel_id}"
|
|
38
|
+
return Channel.model_validate(expect_object(await self._http.get(path), path))
|
|
39
|
+
|
|
40
|
+
async def send_message(
|
|
41
|
+
self,
|
|
42
|
+
channel_id: str,
|
|
43
|
+
*,
|
|
44
|
+
content: str | None = None,
|
|
45
|
+
markdown: MessageMarkdown | None = None,
|
|
46
|
+
image: str | None = None,
|
|
47
|
+
file_image: bytes | None = None,
|
|
48
|
+
file_name: str = "image.png",
|
|
49
|
+
reply_to: str | None = None,
|
|
50
|
+
event_id: str | None = None,
|
|
51
|
+
) -> Message:
|
|
52
|
+
"""发子频道消息。
|
|
53
|
+
|
|
54
|
+
每个子频道主动与被动合计每秒最多5条
|
|
55
|
+
主动消息每个子频道每天 20 条、每天最多 2 个子频道
|
|
56
|
+
超限返回 `40034100` 等错误码
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
channel_id: 频道标识
|
|
60
|
+
content: 纯文本内容
|
|
61
|
+
markdown: markdown格式的内容
|
|
62
|
+
image: 传URL由平台转存, 与file_image参数互斥
|
|
63
|
+
file_image: 传字节走multipart, 与image参数互斥
|
|
64
|
+
file_name: 文件名
|
|
65
|
+
reply_to: 被动回复的msg_id, 有效期5分钟
|
|
66
|
+
event_id: 消息事件ID
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
if image is not None and file_image is not None:
|
|
70
|
+
raise ValueError("image and file_image are mutually exclusive")
|
|
71
|
+
|
|
72
|
+
path = f"/channels/{channel_id}/messages"
|
|
73
|
+
reference = MessageReference(message_id=reply_to) if reply_to is not None else None
|
|
74
|
+
|
|
75
|
+
if file_image is not None:
|
|
76
|
+
# Markdown 消息不带 content, 同传时以 markdown 为准
|
|
77
|
+
text = None if markdown is not None else content
|
|
78
|
+
file_bytes: bytes = file_image
|
|
79
|
+
|
|
80
|
+
def build_form() -> FormData:
|
|
81
|
+
form = FormData()
|
|
82
|
+
if text is not None:
|
|
83
|
+
form.add_field("content", text)
|
|
84
|
+
if image is not None:
|
|
85
|
+
form.add_field("image", image)
|
|
86
|
+
if event_id is not None:
|
|
87
|
+
form.add_field("event_id", event_id)
|
|
88
|
+
if markdown is not None:
|
|
89
|
+
form.add_field("markdown", _as_form_json(markdown))
|
|
90
|
+
if reference is not None:
|
|
91
|
+
form.add_field("message_reference", _as_form_json(reference))
|
|
92
|
+
form.add_field(
|
|
93
|
+
"file_image",
|
|
94
|
+
file_bytes,
|
|
95
|
+
filename=file_name,
|
|
96
|
+
content_type=_content_type(file_name),
|
|
97
|
+
)
|
|
98
|
+
return form
|
|
99
|
+
|
|
100
|
+
data = await self._http.post_form(path, form=build_form)
|
|
101
|
+
else:
|
|
102
|
+
draft = ChannelMessageDraft(
|
|
103
|
+
content=None if markdown is not None else content,
|
|
104
|
+
markdown=markdown,
|
|
105
|
+
image=image,
|
|
106
|
+
event_id=event_id,
|
|
107
|
+
message_reference=reference,
|
|
108
|
+
)
|
|
109
|
+
data = await self._http.post(
|
|
110
|
+
path,
|
|
111
|
+
json=draft.model_dump(by_alias=True, exclude_none=True, mode="json"),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return Message.model_validate(expect_object(data, path))
|
|
115
|
+
|
|
116
|
+
async def recall_message(
|
|
117
|
+
self,
|
|
118
|
+
channel_id: str,
|
|
119
|
+
message_id: str,
|
|
120
|
+
*,
|
|
121
|
+
hide_tip: bool = False,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""撤回消息, 发送后 2 分钟内有效。hidetip 是 query, 不是包体。"""
|
|
124
|
+
|
|
125
|
+
path = f"/channels/{channel_id}/messages/{message_id}"
|
|
126
|
+
await self._http.request(
|
|
127
|
+
"DELETE",
|
|
128
|
+
path,
|
|
129
|
+
params={"hidetip": "true" if hide_tip else "false"},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _as_form_json(model: MessageMarkdown | MessageReference) -> str:
|
|
134
|
+
"""multipart 里的嵌套对象要序列化成字符串字段。"""
|
|
135
|
+
|
|
136
|
+
return model.model_dump_json(by_alias=True, exclude_none=True, ensure_ascii=False)
|
qbotsdk/api/group.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Copyright (c) 2026-present Half_nothing.
|
|
2
|
+
|
|
3
|
+
"""群组(QQ 群)域接口,读写。"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
|
|
9
|
+
from aiohttp import FormData
|
|
10
|
+
|
|
11
|
+
from qbotsdk.model.draft import GroupMessageDraft, MessageMarkdown
|
|
12
|
+
from qbotsdk.model.group import Group, GroupBotState, GroupMember, GroupMembersPage, GroupMuteState
|
|
13
|
+
from qbotsdk.model.keyboard import Keyboard
|
|
14
|
+
from qbotsdk.model.media import FileType, MediaInfo, UploadedFile
|
|
15
|
+
from qbotsdk.model.message import GroupMessageReceipt
|
|
16
|
+
from qbotsdk.module.http_client import HTTPClient
|
|
17
|
+
from qbotsdk.module.pagination import paginate
|
|
18
|
+
from qbotsdk.module.rate_limit import RateLimiter
|
|
19
|
+
|
|
20
|
+
from ._parse import expect_object
|
|
21
|
+
|
|
22
|
+
# 二进制直传上限,官方文档对图片/语音写 20MB、视频 30MB、文件 200MB
|
|
23
|
+
# 但 multipart 直传字段本身限制 5MB,超过要走 url 或分片上传。
|
|
24
|
+
_MAX_UPLOAD_BYTES = 5 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GroupResource:
|
|
28
|
+
def __init__(self, http: HTTPClient, *, limiter: RateLimiter | None = None) -> None:
|
|
29
|
+
self._http = http
|
|
30
|
+
self._limiter = limiter
|
|
31
|
+
|
|
32
|
+
async def get(self, group_openid: str) -> Group:
|
|
33
|
+
"""群基本信息,频率限制 30 QPM。"""
|
|
34
|
+
|
|
35
|
+
path = f"/v2/groups/{group_openid}/info"
|
|
36
|
+
return Group.model_validate(expect_object(await self._http.get(path), path))
|
|
37
|
+
|
|
38
|
+
async def bot_state(self, group_openid: str) -> GroupBotState:
|
|
39
|
+
"""机器人在群内的状态:是否收主动推送、接收消息范围、自身角色。"""
|
|
40
|
+
|
|
41
|
+
path = f"/v2/groups/{group_openid}/bot_state"
|
|
42
|
+
return GroupBotState.model_validate(expect_object(await self._http.get(path), path))
|
|
43
|
+
|
|
44
|
+
async def members(self, group_openid: str, *, cursor: str = "") -> GroupMembersPage:
|
|
45
|
+
"""
|
|
46
|
+
群成员分页,每次最多 30 条。
|
|
47
|
+
|
|
48
|
+
`cursor` 首次不传,后续传上一页的 `next_cursor`;
|
|
49
|
+
返回整页而不是列表,因为末页标志 `next_cursor` 与成员在同一层。
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
path = f"/v2/groups/{group_openid}/members"
|
|
53
|
+
params = {"cursor": cursor} if cursor else None
|
|
54
|
+
payload = await self._http.get(path, params=params)
|
|
55
|
+
return GroupMembersPage.model_validate(expect_object(payload, path))
|
|
56
|
+
|
|
57
|
+
async def member(self, group_openid: str, member_openid: str) -> GroupMember:
|
|
58
|
+
"""单个群成员。"""
|
|
59
|
+
|
|
60
|
+
path = f"/v2/groups/{group_openid}/members/{member_openid}"
|
|
61
|
+
return GroupMember.model_validate(expect_object(await self._http.get(path), path))
|
|
62
|
+
|
|
63
|
+
async def mute_setting(self, group_openid: str) -> GroupMuteState:
|
|
64
|
+
"""群禁言状态,机器人为群管理员时才有权限(错误码 11253)。"""
|
|
65
|
+
|
|
66
|
+
path = f"/v2/groups/{group_openid}/restrict_chat_setting"
|
|
67
|
+
return GroupMuteState.model_validate(expect_object(await self._http.get(path), path))
|
|
68
|
+
|
|
69
|
+
async def iter_members(self, group_openid: str) -> AsyncIterator[GroupMember]:
|
|
70
|
+
"""按 `next_cursor` 翻页遍历群成员,末页或空页结束。"""
|
|
71
|
+
|
|
72
|
+
state = {"next": ""}
|
|
73
|
+
|
|
74
|
+
async def fetch_page(cursor: str | None) -> list[GroupMember]:
|
|
75
|
+
page = await self.members(group_openid, cursor=cursor or "")
|
|
76
|
+
state["next"] = page.next_cursor
|
|
77
|
+
return page.members
|
|
78
|
+
|
|
79
|
+
def cursor_of(page: list[GroupMember]) -> str | None:
|
|
80
|
+
return state["next"] or None
|
|
81
|
+
|
|
82
|
+
async for member in paginate(fetch_page, cursor_of=cursor_of):
|
|
83
|
+
yield member
|
|
84
|
+
|
|
85
|
+
async def send_message(
|
|
86
|
+
self,
|
|
87
|
+
group_openid: str,
|
|
88
|
+
*,
|
|
89
|
+
content: str | None = None,
|
|
90
|
+
markdown: MessageMarkdown | None = None,
|
|
91
|
+
media: MediaInfo | None = None,
|
|
92
|
+
keyboard: Keyboard | None = None,
|
|
93
|
+
reply_to: str | None = None,
|
|
94
|
+
event_id: str | None = None,
|
|
95
|
+
msg_seq: int | None = None,
|
|
96
|
+
) -> GroupMessageReceipt:
|
|
97
|
+
"""发群消息。
|
|
98
|
+
|
|
99
|
+
被动回复有效期为 5 分钟, 每条消息最多回 5 次。
|
|
100
|
+
同一个 msg_id + msg_seq 重复发送会被拒
|
|
101
|
+
要回第二次请显式传 msg_seq=2
|
|
102
|
+
|
|
103
|
+
keyboard 只在 Markdown 消息上渲染: 只传 content + keyboard 时,
|
|
104
|
+
content 会被自动搬进 markdown 并把 msg_type 置 2。
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
path = f"/v2/groups/{group_openid}/messages"
|
|
108
|
+
if self._limiter is not None:
|
|
109
|
+
self._limiter.check("group", group_openid)
|
|
110
|
+
if reply_to is not None and msg_seq is None:
|
|
111
|
+
msg_seq = 1
|
|
112
|
+
draft = GroupMessageDraft(
|
|
113
|
+
content=content,
|
|
114
|
+
markdown=markdown,
|
|
115
|
+
media=media,
|
|
116
|
+
keyboard=keyboard,
|
|
117
|
+
msg_id=reply_to,
|
|
118
|
+
event_id=event_id,
|
|
119
|
+
msg_seq=msg_seq,
|
|
120
|
+
)
|
|
121
|
+
payload = await self._http.post(
|
|
122
|
+
path,
|
|
123
|
+
json=draft.model_dump(by_alias=True, exclude_none=True, mode="json"),
|
|
124
|
+
)
|
|
125
|
+
return GroupMessageReceipt.model_validate(expect_object(payload, path))
|
|
126
|
+
|
|
127
|
+
async def recall_message(self, group_openid: str, message_id: str) -> None:
|
|
128
|
+
"""撤回群消息, 发送后 2 分钟内有效, 接口限制 10 QPS。"""
|
|
129
|
+
|
|
130
|
+
path = f"/v2/groups/{group_openid}/messages/{message_id}"
|
|
131
|
+
await self._http.request("DELETE", path)
|
|
132
|
+
|
|
133
|
+
async def upload_file(
|
|
134
|
+
self,
|
|
135
|
+
group_openid: str,
|
|
136
|
+
file_type: FileType,
|
|
137
|
+
/,
|
|
138
|
+
*,
|
|
139
|
+
url: str | None = None,
|
|
140
|
+
file_data: bytes | None = None,
|
|
141
|
+
file_name: str | None = None,
|
|
142
|
+
srv_send_msg: bool = False,
|
|
143
|
+
) -> UploadedFile:
|
|
144
|
+
"""上传群富媒体(图片 / 视频 / 语音 / 文件)。
|
|
145
|
+
|
|
146
|
+
url 与 file_data 二选一,前者交给服务端下载,后者按 multipart 直传,上限 5MB。
|
|
147
|
+
返回的 file_info 直接填进 send_message 的 media。
|
|
148
|
+
srv_send_msg=True 时服务端上传后立即发送,会占用主动消息频次。
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
if (url is None) == (file_data is None):
|
|
152
|
+
raise ValueError("upload_file needs exactly one of url or file_data")
|
|
153
|
+
|
|
154
|
+
path = f"/v2/groups/{group_openid}/files"
|
|
155
|
+
|
|
156
|
+
if url is not None:
|
|
157
|
+
payload = await self._http.post(
|
|
158
|
+
path,
|
|
159
|
+
json={
|
|
160
|
+
"file_type": int(file_type),
|
|
161
|
+
"url": url,
|
|
162
|
+
"srv_send_msg": srv_send_msg,
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
return UploadedFile.model_validate(expect_object(payload, path))
|
|
166
|
+
|
|
167
|
+
assert file_data is not None # 由上面的二选一校验保证
|
|
168
|
+
if len(file_data) > _MAX_UPLOAD_BYTES:
|
|
169
|
+
raise ValueError("file_data must not exceed 5 MB, use url instead")
|
|
170
|
+
if file_name is None:
|
|
171
|
+
raise ValueError("file_data needs file_name so the server can infer the type")
|
|
172
|
+
|
|
173
|
+
name = file_name
|
|
174
|
+
|
|
175
|
+
def build_form() -> FormData:
|
|
176
|
+
form = FormData()
|
|
177
|
+
form.add_field("file_type", str(int(file_type)))
|
|
178
|
+
form.add_field("srv_send_msg", "true" if srv_send_msg else "false")
|
|
179
|
+
form.add_field("file_data", file_data, filename=name)
|
|
180
|
+
form.add_field("file_name", name)
|
|
181
|
+
return form
|
|
182
|
+
|
|
183
|
+
payload = await self._http.post_form(path, form=build_form)
|
|
184
|
+
return UploadedFile.model_validate(expect_object(payload, path))
|