qqbotsdk-py 0.1.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.
- qqbotsdk/__init__.py +83 -0
- qqbotsdk/api/__init__.py +39 -0
- qqbotsdk/api/audio.py +45 -0
- qqbotsdk/api/channel.py +175 -0
- qqbotsdk/api/core.py +112 -0
- qqbotsdk/api/forum.py +59 -0
- qqbotsdk/api/group.py +265 -0
- qqbotsdk/api/guild.py +369 -0
- qqbotsdk/api/panel.py +137 -0
- qqbotsdk/api/v2.py +407 -0
- qqbotsdk/config.py +47 -0
- qqbotsdk/connecter.py +345 -0
- qqbotsdk/crypto.py +41 -0
- qqbotsdk/emitter.py +173 -0
- qqbotsdk/events.py +55 -0
- qqbotsdk/model.py +103 -0
- qqbotsdk/payloads.py +431 -0
- qqbotsdk/protocol.py +24 -0
- qqbotsdk/queue.py +22 -0
- qqbotsdk/session.py +19 -0
- qqbotsdk/token.py +80 -0
- qqbotsdk/webhook_protocol.py +33 -0
- qqbotsdk/ws_protocol.py +108 -0
- qqbotsdk_py-0.1.0.dist-info/METADATA +147 -0
- qqbotsdk_py-0.1.0.dist-info/RECORD +28 -0
- qqbotsdk_py-0.1.0.dist-info/WHEEL +4 -0
- qqbotsdk_py-0.1.0.dist-info/entry_points.txt +3 -0
- qqbotsdk_py-0.1.0.dist-info/licenses/LICENSE +21 -0
qqbotsdk/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""qqbotsdk:QQ 官方机器人 SDK 入口。
|
|
2
|
+
|
|
3
|
+
用法:构造 `EventEmitter()` 注册 handler,交给 `main(ee)`
|
|
4
|
+
启动;连接方式由 .env 的 CONNECTER 决定(websocket / webhook)。
|
|
5
|
+
架构详情见 docs/ARCHITECTURE.md。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
from aiohttp import ClientSession
|
|
11
|
+
from dotenv import load_dotenv
|
|
12
|
+
from loguru import logger
|
|
13
|
+
|
|
14
|
+
from .api import BotApi
|
|
15
|
+
from .config import Config
|
|
16
|
+
from .connecter import Connecter as Connecter
|
|
17
|
+
from .connecter import WebhookConnecter, WebsocketConnecter
|
|
18
|
+
from .emitter import EventEmitter as EventEmitter
|
|
19
|
+
from .protocol import BaseProtocol as BaseProtocol
|
|
20
|
+
from .queue import EventQueue as EventQueue
|
|
21
|
+
from .session import Session
|
|
22
|
+
from .token import AccessToken
|
|
23
|
+
from .webhook_protocol import WebhookProtocol
|
|
24
|
+
from .ws_protocol import WebsocketProtocol
|
|
25
|
+
|
|
26
|
+
load_dotenv()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def run_loop(emitter: EventEmitter | None = None) -> None:
|
|
30
|
+
"""启动 SDK。可传入外部构造的 EventEmitter 以注册 handler;不传时自建。
|
|
31
|
+
|
|
32
|
+
全部组件在此显式装配,并按类型登记到 emitter.services 供 handler
|
|
33
|
+
参数注入;共享的 HTTP 连接池随 run_loop 结束释放。
|
|
34
|
+
"""
|
|
35
|
+
if emitter is None:
|
|
36
|
+
emitter = EventEmitter()
|
|
37
|
+
|
|
38
|
+
config = Config.load()
|
|
39
|
+
http = ClientSession(timeout=config.timeout)
|
|
40
|
+
session = Session()
|
|
41
|
+
token = AccessToken(config, http)
|
|
42
|
+
api = BotApi(config, http, token)
|
|
43
|
+
emitter.services.update(
|
|
44
|
+
{
|
|
45
|
+
EventEmitter: emitter,
|
|
46
|
+
EventQueue: emitter.queue,
|
|
47
|
+
Config: config,
|
|
48
|
+
Session: session,
|
|
49
|
+
ClientSession: http,
|
|
50
|
+
AccessToken: token,
|
|
51
|
+
BotApi: api,
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# 各接入方式配自己的协议处理器:协议帧由适配器就近处理,只有业务事件
|
|
56
|
+
# (op=0)入队交给 emitter 分发(见 protocol.py / connecter.py)
|
|
57
|
+
match config.connecter:
|
|
58
|
+
case "websocket":
|
|
59
|
+
protocol = WebsocketProtocol(config, session, token)
|
|
60
|
+
connecter = WebsocketConnecter(
|
|
61
|
+
config, http, token, session, emitter.queue, protocol
|
|
62
|
+
)
|
|
63
|
+
case "webhook":
|
|
64
|
+
protocol = WebhookProtocol(config)
|
|
65
|
+
connecter = WebhookConnecter(config, emitter.queue, protocol)
|
|
66
|
+
case unknown:
|
|
67
|
+
raise ValueError(f"未知的 CONNECTER: {unknown}")
|
|
68
|
+
|
|
69
|
+
logger.info(f"qqbotsdk 启动,连接方式: {config.connecter}")
|
|
70
|
+
try:
|
|
71
|
+
await asyncio.gather(emitter.dispatch(), connecter.run())
|
|
72
|
+
finally:
|
|
73
|
+
# 在飞的后台 handler 先于连接池收尾,避免用到已关闭的 ClientSession
|
|
74
|
+
await emitter.close()
|
|
75
|
+
# 全 SDK 共享一个连接池,统一在此释放
|
|
76
|
+
await http.close()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(emitter: EventEmitter | None = None) -> None:
|
|
80
|
+
try:
|
|
81
|
+
asyncio.run(run_loop(emitter))
|
|
82
|
+
except KeyboardInterrupt:
|
|
83
|
+
logger.info("qqbotsdk 已退出")
|
qqbotsdk/api/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""OpenAPI 客户端:BotApi 按域组合——v2 群/单聊、频道消息、频道管理、
|
|
2
|
+
指令面板(斜线指令)与全局菜单、群管理、音频、论坛。
|
|
3
|
+
|
|
4
|
+
各域模块是方法集(继承 BaseApi 共享请求内核:鉴权头注入、429 自动
|
|
5
|
+
重试、401 刷新 token 重试、统一 ApiError),方法名与拆分前完全一致。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .audio import AudioApi
|
|
9
|
+
from .channel import ChannelApi
|
|
10
|
+
from .core import ApiError
|
|
11
|
+
from .core import BaseApi as BaseApi
|
|
12
|
+
from .forum import ForumApi
|
|
13
|
+
from .group import GroupApi
|
|
14
|
+
from .guild import GuildApi
|
|
15
|
+
from .panel import PanelApi
|
|
16
|
+
from .v2 import V2Api, button, keyboard
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BotApi(V2Api, ChannelApi, GuildApi, PanelApi, GroupApi, AudioApi, ForumApi):
|
|
20
|
+
"""通用 REST 封装;具体业务接口见各域方法集。
|
|
21
|
+
|
|
22
|
+
BaseApi(请求内核)经域方法集继承自然落在 MRO 末尾,
|
|
23
|
+
构造签名 (config, http, token) 由它提供。
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ApiError",
|
|
29
|
+
"AudioApi",
|
|
30
|
+
"BotApi",
|
|
31
|
+
"ChannelApi",
|
|
32
|
+
"ForumApi",
|
|
33
|
+
"GroupApi",
|
|
34
|
+
"GuildApi",
|
|
35
|
+
"PanelApi",
|
|
36
|
+
"V2Api",
|
|
37
|
+
"button",
|
|
38
|
+
"keyboard",
|
|
39
|
+
]
|
qqbotsdk/api/audio.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""音频方法集(仅音频类机器人):子频道播放控制与上/下麦。
|
|
2
|
+
|
|
3
|
+
需联系平台开通权限后调用;控制与上/下麦均触发 AUDIO_ACTION 事件。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .core import BaseApi
|
|
9
|
+
|
|
10
|
+
# 播放状态(AudioControl.status)
|
|
11
|
+
AUDIO_STATUS_START = 0
|
|
12
|
+
AUDIO_STATUS_PAUSE = 1
|
|
13
|
+
AUDIO_STATUS_RESUME = 2
|
|
14
|
+
AUDIO_STATUS_STOP = 3
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AudioApi(BaseApi):
|
|
18
|
+
"""音频方法集;请求内核(鉴权/重试/错误)由 BaseApi 提供。"""
|
|
19
|
+
|
|
20
|
+
async def control_channel_audio(
|
|
21
|
+
self,
|
|
22
|
+
channel_id: str,
|
|
23
|
+
audio_url: str | None = None,
|
|
24
|
+
text: str | None = None,
|
|
25
|
+
status: int = AUDIO_STATUS_START,
|
|
26
|
+
) -> dict:
|
|
27
|
+
"""控制子频道音频播放(POST /channels/{channel_id}/audio)。
|
|
28
|
+
|
|
29
|
+
status 为 START/PAUSE/RESUME/STOP(AUDIO_STATUS_* 常量);
|
|
30
|
+
audio_url 与状态文本 text 仅 START 时传。
|
|
31
|
+
"""
|
|
32
|
+
body: dict[str, Any] = {"status": status}
|
|
33
|
+
if audio_url is not None:
|
|
34
|
+
body["audio_url"] = audio_url
|
|
35
|
+
if text is not None:
|
|
36
|
+
body["text"] = text
|
|
37
|
+
return await self.post(f"/channels/{channel_id}/audio", json=body)
|
|
38
|
+
|
|
39
|
+
async def put_channel_mic(self, channel_id: str) -> dict:
|
|
40
|
+
"""机器人上麦(PUT /channels/{channel_id}/mic)。"""
|
|
41
|
+
return await self.put(f"/channels/{channel_id}/mic")
|
|
42
|
+
|
|
43
|
+
async def delete_channel_mic(self, channel_id: str) -> dict:
|
|
44
|
+
"""机器人下麦(DELETE /channels/{channel_id}/mic)。"""
|
|
45
|
+
return await self.delete(f"/channels/{channel_id}/mic")
|
qqbotsdk/api/channel.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""频道消息方法集(channel_id 接入面):发送、撤回、表情表态、
|
|
2
|
+
子频道权限与频道私信。"""
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .core import BaseApi
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _message_body(
|
|
10
|
+
content: str,
|
|
11
|
+
msg_id: str | None,
|
|
12
|
+
msg_seq: int,
|
|
13
|
+
image: str | None,
|
|
14
|
+
extra: dict[str, Any],
|
|
15
|
+
) -> dict[str, Any]:
|
|
16
|
+
"""频道系消息(频道/私信)的请求体:msg_seq 必带,其余按需。"""
|
|
17
|
+
body: dict[str, Any] = {"msg_seq": msg_seq}
|
|
18
|
+
if content:
|
|
19
|
+
body["content"] = content
|
|
20
|
+
if msg_id:
|
|
21
|
+
body["msg_id"] = msg_id
|
|
22
|
+
if image:
|
|
23
|
+
body["image"] = image
|
|
24
|
+
body.update(extra)
|
|
25
|
+
return body
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ChannelApi(BaseApi):
|
|
29
|
+
"""频道消息方法集;请求内核(鉴权/重试/错误)由 BaseApi 提供。"""
|
|
30
|
+
|
|
31
|
+
async def post_channel_message(
|
|
32
|
+
self,
|
|
33
|
+
channel_id: str,
|
|
34
|
+
content: str = "",
|
|
35
|
+
msg_id: str | None = None,
|
|
36
|
+
msg_seq: int = 1,
|
|
37
|
+
image: str | None = None,
|
|
38
|
+
**extra: Any,
|
|
39
|
+
) -> dict:
|
|
40
|
+
"""发频道消息(POST /channels/{channel_id}/messages)。
|
|
41
|
+
|
|
42
|
+
image 为图片 URL;其余字段(embed/ark/markdown 等)经 extra 透传。
|
|
43
|
+
"""
|
|
44
|
+
body = _message_body(content, msg_id, msg_seq, image, extra)
|
|
45
|
+
return await self.post(f"/channels/{channel_id}/messages", json=body)
|
|
46
|
+
|
|
47
|
+
async def withdraw_channel_message(
|
|
48
|
+
self, channel_id: str, message_id: str, hide_tip: bool = False
|
|
49
|
+
) -> dict:
|
|
50
|
+
"""撤回频道消息(DELETE /channels/{channel_id}/messages/{message_id})。
|
|
51
|
+
|
|
52
|
+
hide_tip 控制是否隐藏撤回提示小灰条(官方参数名为 hidetip);
|
|
53
|
+
仅私域机器人可用。
|
|
54
|
+
"""
|
|
55
|
+
return await self.delete(
|
|
56
|
+
f"/channels/{channel_id}/messages/{message_id}",
|
|
57
|
+
params={"hidetip": str(hide_tip).lower()},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def put_channel_reaction(
|
|
61
|
+
self, channel_id: str, message_id: str, emoji_type: int, emoji_id: str
|
|
62
|
+
) -> dict:
|
|
63
|
+
"""对频道消息表态(PUT .../reactions/{type}/{emoji_id})。
|
|
64
|
+
|
|
65
|
+
emoji_type=1 为系统表情,emoji_id 为系统表情序号(字符串)。
|
|
66
|
+
"""
|
|
67
|
+
return await self.put(
|
|
68
|
+
f"/channels/{channel_id}/messages/{message_id}"
|
|
69
|
+
f"/reactions/{emoji_type}/{emoji_id}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
async def delete_channel_reaction(
|
|
73
|
+
self, channel_id: str, message_id: str, emoji_type: int, emoji_id: str
|
|
74
|
+
) -> dict:
|
|
75
|
+
"""删除自己对该消息的表态(DELETE .../reactions/{type}/{emoji_id})。"""
|
|
76
|
+
return await self.delete(
|
|
77
|
+
f"/channels/{channel_id}/messages/{message_id}"
|
|
78
|
+
f"/reactions/{emoji_type}/{emoji_id}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# ---------- 子频道权限 ----------
|
|
82
|
+
|
|
83
|
+
async def get_channel_permissions(self, channel_id: str, user_id: str) -> dict:
|
|
84
|
+
"""获取子频道指定用户的权限(GET .../members/{user_id}/permissions)。
|
|
85
|
+
|
|
86
|
+
返回 permissions 字符串形式的权限位图(各权限位含义见官方文档)。
|
|
87
|
+
"""
|
|
88
|
+
return await self.get(f"/channels/{channel_id}/members/{user_id}/permissions")
|
|
89
|
+
|
|
90
|
+
async def get_channel_role_permissions(self, channel_id: str, role_id: str) -> dict:
|
|
91
|
+
"""获取子频道指定身份组的权限(GET .../roles/{role_id}/permissions)。"""
|
|
92
|
+
return await self.get(f"/channels/{channel_id}/roles/{role_id}/permissions")
|
|
93
|
+
|
|
94
|
+
async def update_channel_permissions(
|
|
95
|
+
self,
|
|
96
|
+
channel_id: str,
|
|
97
|
+
user_id: str,
|
|
98
|
+
*,
|
|
99
|
+
add: str | None = None,
|
|
100
|
+
remove: str | None = None,
|
|
101
|
+
) -> dict:
|
|
102
|
+
"""修改子频道指定用户的权限(PUT .../members/{user_id}/permissions)。
|
|
103
|
+
|
|
104
|
+
add/remove 为字符串形式的权限位图(要赋予/删除的权限位),
|
|
105
|
+
至少传一个;需要管理员权限。
|
|
106
|
+
"""
|
|
107
|
+
body: dict[str, Any] = {}
|
|
108
|
+
if add is not None:
|
|
109
|
+
body["add"] = add
|
|
110
|
+
if remove is not None:
|
|
111
|
+
body["remove"] = remove
|
|
112
|
+
return await self.put(
|
|
113
|
+
f"/channels/{channel_id}/members/{user_id}/permissions", json=body
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def update_channel_role_permissions(
|
|
117
|
+
self,
|
|
118
|
+
channel_id: str,
|
|
119
|
+
role_id: str,
|
|
120
|
+
*,
|
|
121
|
+
add: str | None = None,
|
|
122
|
+
remove: str | None = None,
|
|
123
|
+
) -> dict:
|
|
124
|
+
"""修改子频道指定身份组的权限(PUT .../roles/{role_id}/permissions)。"""
|
|
125
|
+
body: dict[str, Any] = {}
|
|
126
|
+
if add is not None:
|
|
127
|
+
body["add"] = add
|
|
128
|
+
if remove is not None:
|
|
129
|
+
body["remove"] = remove
|
|
130
|
+
return await self.put(
|
|
131
|
+
f"/channels/{channel_id}/roles/{role_id}/permissions", json=body
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# ---------- 频道私信 ----------
|
|
135
|
+
|
|
136
|
+
async def create_dms(self, recipient_id: str, source_guild_id: str) -> dict:
|
|
137
|
+
"""创建私信会话(POST /users/@me/dms)。
|
|
138
|
+
|
|
139
|
+
机器人和用户存在共同频道才能创建;返回私信的 guild_id 与
|
|
140
|
+
channel_id,后续发/撤私信用它作路径参数。
|
|
141
|
+
"""
|
|
142
|
+
return await self.post(
|
|
143
|
+
"/users/@me/dms",
|
|
144
|
+
json={"recipient_id": recipient_id, "source_guild_id": source_guild_id},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
async def post_dms_message(
|
|
148
|
+
self,
|
|
149
|
+
guild_id: str,
|
|
150
|
+
content: str = "",
|
|
151
|
+
msg_id: str | None = None,
|
|
152
|
+
msg_seq: int = 1,
|
|
153
|
+
image: str | None = None,
|
|
154
|
+
**extra: Any,
|
|
155
|
+
) -> dict:
|
|
156
|
+
"""发私信消息(POST /dms/{guild_id}/messages)。
|
|
157
|
+
|
|
158
|
+
guild_id 为 create_dms 返回(或 DIRECT_MESSAGE_CREATE 事件携带)
|
|
159
|
+
的私信频道 id;参数与 post_channel_message 一致。私信每天可对单
|
|
160
|
+
用户发 2 条主动消息、累计 200 条,被动消息不限。
|
|
161
|
+
"""
|
|
162
|
+
body = _message_body(content, msg_id, msg_seq, image, extra)
|
|
163
|
+
return await self.post(f"/dms/{guild_id}/messages", json=body)
|
|
164
|
+
|
|
165
|
+
async def withdraw_dms_message(
|
|
166
|
+
self, guild_id: str, message_id: str, hide_tip: bool = False
|
|
167
|
+
) -> dict:
|
|
168
|
+
"""撤回私信消息(DELETE /dms/{guild_id}/messages/{message_id})。私域接口。
|
|
169
|
+
|
|
170
|
+
只能撤回机器人自己发送的私信;hide_tip 隐藏撤回提示小灰条。
|
|
171
|
+
"""
|
|
172
|
+
return await self.delete(
|
|
173
|
+
f"/dms/{guild_id}/messages/{message_id}",
|
|
174
|
+
params={"hidetip": str(hide_tip).lower()},
|
|
175
|
+
)
|
qqbotsdk/api/core.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""OpenAPI 请求内核:鉴权头注入、限流/鉴权失败重试与统一错误。
|
|
2
|
+
|
|
3
|
+
失败语义(官方文档 2026-07 核对):
|
|
4
|
+
- 429 触发令牌桶限流,请求未被服务端处理,重试安全——优先按
|
|
5
|
+
Retry-After 等待(线上见过 "30,120" 这类脏值,解析容错),缺失时
|
|
6
|
+
指数退避,最多重试 `_RETRY_LIMIT` 次,等待时长封顶 `_RETRY_CAP`;
|
|
7
|
+
- 401 多为 token 被服务端提前作废,强制刷新 token 后重试一次;
|
|
8
|
+
- 其余 HTTP ≥400 抛 ApiError,code/message 取自响应体。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import ujson
|
|
15
|
+
from aiohttp import ClientResponse, ClientSession
|
|
16
|
+
from loguru import logger
|
|
17
|
+
|
|
18
|
+
from ..config import Config
|
|
19
|
+
from ..model import error_parts
|
|
20
|
+
from ..token import AccessToken
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApiError(RuntimeError):
|
|
24
|
+
"""OpenAPI 调用失败:HTTP 非 2xx 或业务 code 非 0。"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, status: int, code: Any, message: Any) -> None:
|
|
27
|
+
self.status = status
|
|
28
|
+
self.code = code
|
|
29
|
+
self.message = message
|
|
30
|
+
super().__init__(f"API 调用失败: HTTP {status}, code={code}, message={message}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BaseApi:
|
|
34
|
+
"""REST 请求内核;域方法集(v2/channel/guild)继承它做薄封装。"""
|
|
35
|
+
|
|
36
|
+
# 429 重试次数上限;Retry-After 缺失时的退避基数(秒)与等待封顶
|
|
37
|
+
_RETRY_LIMIT = 3
|
|
38
|
+
_RETRY_BASE = 1.0
|
|
39
|
+
_RETRY_CAP = 30.0
|
|
40
|
+
|
|
41
|
+
def __init__(self, config: Config, http: ClientSession, token: AccessToken) -> None:
|
|
42
|
+
self._config = config
|
|
43
|
+
self._http = http
|
|
44
|
+
self._token = token
|
|
45
|
+
|
|
46
|
+
async def request(self, method: str, path: str, **kwargs) -> Any:
|
|
47
|
+
auth_retried = False
|
|
48
|
+
for attempt in range(self._RETRY_LIMIT + 1):
|
|
49
|
+
access_token = await self._token.get_access_token()
|
|
50
|
+
headers = {"Authorization": f"QQBot {access_token}"}
|
|
51
|
+
async with self._http.request(
|
|
52
|
+
method,
|
|
53
|
+
f"{self._config.base_url}{path}",
|
|
54
|
+
headers=headers,
|
|
55
|
+
**kwargs,
|
|
56
|
+
) as resp:
|
|
57
|
+
# 个别接口 200 响应的 Content-Type 不是 JSON(body 仍是 JSON,
|
|
58
|
+
# 如回应互动返回 200 text/plain + {}),跳过标头校验
|
|
59
|
+
data = await resp.json(loads=ujson.loads, content_type=None)
|
|
60
|
+
if resp.status == 429 and attempt < self._RETRY_LIMIT:
|
|
61
|
+
delay = self._retry_delay(resp, attempt)
|
|
62
|
+
logger.warning(f"{method} {path} 触发频率限制,{delay:.0f}s 后重试")
|
|
63
|
+
await asyncio.sleep(delay)
|
|
64
|
+
continue
|
|
65
|
+
if resp.status == 401 and not auth_retried:
|
|
66
|
+
auth_retried = True
|
|
67
|
+
self._token.invalidate()
|
|
68
|
+
logger.warning(f"{method} {path} 鉴权失效,刷新 token 后重试")
|
|
69
|
+
continue
|
|
70
|
+
if resp.status >= 400:
|
|
71
|
+
logger.warning(f"{method} {path} 失败: {data}")
|
|
72
|
+
raise ApiError(resp.status, *error_parts(data))
|
|
73
|
+
return data
|
|
74
|
+
raise ApiError(429, None, "频率限制重试次数用尽") # 兜底,理论不可达
|
|
75
|
+
|
|
76
|
+
def _retry_delay(self, resp: ClientResponse, attempt: int) -> float:
|
|
77
|
+
raw = resp.headers.get("Retry-After")
|
|
78
|
+
if raw:
|
|
79
|
+
try:
|
|
80
|
+
return min(float(raw.split(",")[0].strip()), self._RETRY_CAP)
|
|
81
|
+
except ValueError:
|
|
82
|
+
pass
|
|
83
|
+
return min(self._RETRY_BASE * 2**attempt, self._RETRY_CAP)
|
|
84
|
+
|
|
85
|
+
# ---------- 通用 ----------
|
|
86
|
+
|
|
87
|
+
async def me(self) -> dict:
|
|
88
|
+
"""机器人自身信息(GET /users/@me)。"""
|
|
89
|
+
return await self.get("/users/@me")
|
|
90
|
+
|
|
91
|
+
async def respond_interaction(self, interaction_id: str, code: int = 0) -> dict:
|
|
92
|
+
"""回应互动事件(PUT /interactions/{interaction_id})。
|
|
93
|
+
|
|
94
|
+
按钮点击(INTERACTION_CREATE type=11)等互动收到后必须回应,
|
|
95
|
+
否则客户端一直 loading 直到超时;同一 id 只能回应一次。
|
|
96
|
+
"""
|
|
97
|
+
return await self.put(f"/interactions/{interaction_id}", json={"code": code})
|
|
98
|
+
|
|
99
|
+
async def get(self, path: str, **kwargs) -> Any:
|
|
100
|
+
return await self.request("GET", path, **kwargs)
|
|
101
|
+
|
|
102
|
+
async def post(self, path: str, **kwargs) -> Any:
|
|
103
|
+
return await self.request("POST", path, **kwargs)
|
|
104
|
+
|
|
105
|
+
async def delete(self, path: str, **kwargs) -> Any:
|
|
106
|
+
return await self.request("DELETE", path, **kwargs)
|
|
107
|
+
|
|
108
|
+
async def put(self, path: str, **kwargs) -> Any:
|
|
109
|
+
return await self.request("PUT", path, **kwargs)
|
|
110
|
+
|
|
111
|
+
async def patch(self, path: str, **kwargs) -> Any:
|
|
112
|
+
return await self.request("PATCH", path, **kwargs)
|
qqbotsdk/api/forum.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""论坛方法集(私域,仅论坛子频道):帖子列表、详情、发帖与删帖。
|
|
2
|
+
|
|
3
|
+
发帖/删帖为异步任务(返回 task_id),结果经 FORUM_* 事件推送;
|
|
4
|
+
content 为 FORMAT_JSON 时参照官方 RichText 结构。仅私域机器人可用。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .core import BaseApi
|
|
10
|
+
|
|
11
|
+
# 帖子内容格式(format)
|
|
12
|
+
FORUM_FORMAT_TEXT = 1
|
|
13
|
+
FORUM_FORMAT_HTML = 2
|
|
14
|
+
FORUM_FORMAT_MARKDOWN = 3
|
|
15
|
+
FORUM_FORMAT_JSON = 4
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ForumApi(BaseApi):
|
|
19
|
+
"""论坛方法集;请求内核(鉴权/重试/错误)由 BaseApi 提供。"""
|
|
20
|
+
|
|
21
|
+
async def get_channel_threads(self, channel_id: str) -> dict:
|
|
22
|
+
"""获取帖子列表(GET /channels/{channel_id}/threads)。
|
|
23
|
+
|
|
24
|
+
返回 {"threads": [...], "is_finish": ...}(1 表示已拉取完毕);
|
|
25
|
+
threads 内 content 为 RichText 结构。
|
|
26
|
+
"""
|
|
27
|
+
return await self.get(f"/channels/{channel_id}/threads")
|
|
28
|
+
|
|
29
|
+
async def get_channel_thread(self, channel_id: str, thread_id: str) -> dict:
|
|
30
|
+
"""获取帖子详情(GET .../threads/{thread_id}),含首楼与回复。"""
|
|
31
|
+
return await self.get(f"/channels/{channel_id}/threads/{thread_id}")
|
|
32
|
+
|
|
33
|
+
async def put_channel_thread(
|
|
34
|
+
self,
|
|
35
|
+
channel_id: str,
|
|
36
|
+
title: str,
|
|
37
|
+
content: str,
|
|
38
|
+
format: int = FORUM_FORMAT_TEXT,
|
|
39
|
+
**extra: Any,
|
|
40
|
+
) -> dict:
|
|
41
|
+
"""发表帖子(PUT /channels/{channel_id}/threads)。
|
|
42
|
+
|
|
43
|
+
format 为 FORUM_FORMAT_* 常量;返回 {"task_id": ...},发表结果
|
|
44
|
+
经 FORUM_THREAD_CREATE 事件推送。
|
|
45
|
+
"""
|
|
46
|
+
body: dict[str, Any] = {
|
|
47
|
+
"title": title,
|
|
48
|
+
"content": content,
|
|
49
|
+
"format": format,
|
|
50
|
+
}
|
|
51
|
+
body.update(extra)
|
|
52
|
+
return await self.put(f"/channels/{channel_id}/threads", json=body)
|
|
53
|
+
|
|
54
|
+
async def delete_channel_thread(self, channel_id: str, thread_id: str) -> dict:
|
|
55
|
+
"""删除帖子(DELETE .../threads/{thread_id})。
|
|
56
|
+
|
|
57
|
+
返回 {"task_id": ...},删除结果经 FORUM_THREAD_DELETE 事件推送。
|
|
58
|
+
"""
|
|
59
|
+
return await self.delete(f"/channels/{channel_id}/threads/{thread_id}")
|