ErisPulse-QQBotAdapter 1.0.0__tar.gz → 4.0.0__tar.gz
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.
- {erispulse_qqbotadapter-1.0.0 → erispulse_qqbotadapter-4.0.0}/ErisPulse_QQBotAdapter.egg-info/PKG-INFO +1 -2
- {erispulse_qqbotadapter-1.0.0 → erispulse_qqbotadapter-4.0.0}/ErisPulse_QQBotAdapter.egg-info/SOURCES.txt +0 -1
- {erispulse_qqbotadapter-1.0.0 → erispulse_qqbotadapter-4.0.0}/PKG-INFO +1 -2
- {erispulse_qqbotadapter-1.0.0 → erispulse_qqbotadapter-4.0.0}/QQBotAdapter/Core.py +262 -152
- {erispulse_qqbotadapter-1.0.0 → erispulse_qqbotadapter-4.0.0}/QQBotAdapter/WebSocket.py +37 -26
- {erispulse_qqbotadapter-1.0.0 → erispulse_qqbotadapter-4.0.0}/pyproject.toml +6 -4
- erispulse_qqbotadapter-1.0.0/ErisPulse_QQBotAdapter.egg-info/requires.txt +0 -1
- {erispulse_qqbotadapter-1.0.0 → erispulse_qqbotadapter-4.0.0}/ErisPulse_QQBotAdapter.egg-info/dependency_links.txt +0 -0
- {erispulse_qqbotadapter-1.0.0 → erispulse_qqbotadapter-4.0.0}/ErisPulse_QQBotAdapter.egg-info/entry_points.txt +0 -0
- {erispulse_qqbotadapter-1.0.0 → erispulse_qqbotadapter-4.0.0}/ErisPulse_QQBotAdapter.egg-info/top_level.txt +0 -0
- {erispulse_qqbotadapter-1.0.0 → erispulse_qqbotadapter-4.0.0}/QQBotAdapter/Converter.py +0 -0
- {erispulse_qqbotadapter-1.0.0 → erispulse_qqbotadapter-4.0.0}/QQBotAdapter/__init__.py +0 -0
- {erispulse_qqbotadapter-1.0.0 → erispulse_qqbotadapter-4.0.0}/README.md +0 -0
- {erispulse_qqbotadapter-1.0.0 → erispulse_qqbotadapter-4.0.0}/setup.cfg +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ErisPulse-QQBotAdapter
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.0
|
|
4
4
|
Summary: ErisPulse的QQ官方机器人适配模块
|
|
5
5
|
Author-email: wsu2059 <wsu2059@qq.com>
|
|
6
6
|
Project-URL: homepage, https://github.com/ErisPulse/ErisPulse-QQBotAdapter
|
|
7
7
|
Requires-Python: >=3.10
|
|
8
8
|
Description-Content-Type: text/markdown
|
|
9
|
-
Requires-Dist: aiohttp>=3.8.0
|
|
10
9
|
|
|
11
10
|
# QQBotAdapter 模块文档
|
|
12
11
|
|
|
@@ -4,7 +4,6 @@ ErisPulse_QQBotAdapter.egg-info/PKG-INFO
|
|
|
4
4
|
ErisPulse_QQBotAdapter.egg-info/SOURCES.txt
|
|
5
5
|
ErisPulse_QQBotAdapter.egg-info/dependency_links.txt
|
|
6
6
|
ErisPulse_QQBotAdapter.egg-info/entry_points.txt
|
|
7
|
-
ErisPulse_QQBotAdapter.egg-info/requires.txt
|
|
8
7
|
ErisPulse_QQBotAdapter.egg-info/top_level.txt
|
|
9
8
|
QQBotAdapter/Converter.py
|
|
10
9
|
QQBotAdapter/Core.py
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ErisPulse-QQBotAdapter
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.0
|
|
4
4
|
Summary: ErisPulse的QQ官方机器人适配模块
|
|
5
5
|
Author-email: wsu2059 <wsu2059@qq.com>
|
|
6
6
|
Project-URL: homepage, https://github.com/ErisPulse/ErisPulse-QQBotAdapter
|
|
7
7
|
Requires-Python: >=3.10
|
|
8
8
|
Description-Content-Type: text/markdown
|
|
9
|
-
Requires-Dist: aiohttp>=3.8.0
|
|
10
9
|
|
|
11
10
|
# QQBotAdapter 模块文档
|
|
12
11
|
|
|
@@ -1,21 +1,89 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
import aiohttp
|
|
3
2
|
import json
|
|
4
|
-
import
|
|
5
|
-
from
|
|
3
|
+
import time as _time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Dict, Any, Optional
|
|
6
|
+
|
|
6
7
|
from ErisPulse import sdk
|
|
7
|
-
from ErisPulse.Core import
|
|
8
|
+
from ErisPulse.Core import client
|
|
9
|
+
from ErisPulse.runtime.config_schema import BotAccountConfig
|
|
10
|
+
|
|
8
11
|
from .Converter import QQBotConverter
|
|
9
12
|
from .WebSocket import QQBotWebSocket
|
|
10
13
|
|
|
11
14
|
|
|
15
|
+
@dataclass
|
|
16
|
+
class QQBotConfig(BotAccountConfig):
|
|
17
|
+
"""QQBot 账户配置"""
|
|
18
|
+
|
|
19
|
+
appid: str = field(
|
|
20
|
+
default="",
|
|
21
|
+
metadata={
|
|
22
|
+
"description": "QQ机器人应用ID",
|
|
23
|
+
"required": True,
|
|
24
|
+
"webui": {"widget": "text", "group": "basic", "order": 1},
|
|
25
|
+
},
|
|
26
|
+
)
|
|
27
|
+
secret: str = field(
|
|
28
|
+
default="",
|
|
29
|
+
metadata={
|
|
30
|
+
"description": "QQ机器人客户端密钥",
|
|
31
|
+
"required": True,
|
|
32
|
+
"secret": True,
|
|
33
|
+
"webui": {"widget": "password", "group": "basic", "order": 2},
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
sandbox: bool = field(
|
|
37
|
+
default=False,
|
|
38
|
+
metadata={
|
|
39
|
+
"description": "是否使用沙盒环境",
|
|
40
|
+
"webui": {"widget": "switch", "group": "connection", "order": 3},
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class QQBotAdapterConfig:
|
|
47
|
+
"""QQBot 适配器全局配置"""
|
|
48
|
+
|
|
49
|
+
intents: str = field(
|
|
50
|
+
default="[1, 30, 25]",
|
|
51
|
+
metadata={
|
|
52
|
+
"description": "订阅的 intents 位列表(JSON格式)",
|
|
53
|
+
"webui": {"widget": "text", "group": "connection", "order": 1},
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
gateway_url: str = field(
|
|
57
|
+
default="wss://api.sgroup.qq.com/websocket/",
|
|
58
|
+
metadata={
|
|
59
|
+
"description": "WebSocket 网关地址",
|
|
60
|
+
"webui": {"widget": "text", "group": "connection", "order": 2},
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
12
65
|
class QQBotAdapter(sdk.BaseAdapter):
|
|
66
|
+
"""
|
|
67
|
+
QQ官方机器人适配器
|
|
68
|
+
|
|
69
|
+
通过 WebSocket 长连接接收事件,整合群聊、私聊、频道等多种场景
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
_platform = "qqbot"
|
|
73
|
+
AccountConfigClass = QQBotConfig
|
|
74
|
+
ConfigClass = QQBotAdapterConfig
|
|
75
|
+
|
|
13
76
|
class Send(sdk.BaseAdapter.Send):
|
|
77
|
+
|
|
14
78
|
def __init__(self, adapter, target_type=None, target_id=None, account_id=None):
|
|
15
79
|
super().__init__(adapter, target_type, target_id, account_id)
|
|
16
|
-
self.
|
|
17
|
-
|
|
18
|
-
|
|
80
|
+
self._keyboard = None
|
|
81
|
+
|
|
82
|
+
def Keyboard(self, keyboard: dict):
|
|
83
|
+
self._keyboard = keyboard
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def _reset_modifiers(self):
|
|
19
87
|
self._keyboard = None
|
|
20
88
|
|
|
21
89
|
def Text(self, text: str):
|
|
@@ -24,6 +92,18 @@ class QQBotAdapter(sdk.BaseAdapter):
|
|
|
24
92
|
def Image(self, file: bytes | str):
|
|
25
93
|
return self.Raw_ob12([{"type": "image", "data": {"file": file}}])
|
|
26
94
|
|
|
95
|
+
def Audio(self, file: bytes | str):
|
|
96
|
+
return self.Raw_ob12([{"type": "audio", "data": {"file": file}}])
|
|
97
|
+
|
|
98
|
+
def Voice(self, file: bytes | str):
|
|
99
|
+
return self.Audio(file)
|
|
100
|
+
|
|
101
|
+
def Video(self, file: bytes | str):
|
|
102
|
+
return self.Raw_ob12([{"type": "video", "data": {"file": file}}])
|
|
103
|
+
|
|
104
|
+
def File(self, file: bytes | str):
|
|
105
|
+
return self.Raw_ob12([{"type": "file", "data": {"file": file}}])
|
|
106
|
+
|
|
27
107
|
def Markdown(self, content: str):
|
|
28
108
|
return self.Raw_ob12([{"type": "markdown", "data": {"content": content}}])
|
|
29
109
|
|
|
@@ -33,32 +113,10 @@ class QQBotAdapter(sdk.BaseAdapter):
|
|
|
33
113
|
def Embed(self, embed_data: dict):
|
|
34
114
|
return self.Raw_ob12([{"type": "embed", "data": embed_data}])
|
|
35
115
|
|
|
36
|
-
def
|
|
37
|
-
self._keyboard = keyboard
|
|
38
|
-
return self
|
|
39
|
-
|
|
40
|
-
def Reply(self, message_id: str) -> "Send":
|
|
41
|
-
self._reply_message_id = message_id
|
|
42
|
-
return self
|
|
43
|
-
|
|
44
|
-
def At(self, user_id: str) -> "Send":
|
|
45
|
-
self._at_user_ids.append(user_id)
|
|
46
|
-
return self
|
|
47
|
-
|
|
48
|
-
def AtAll(self) -> "Send":
|
|
49
|
-
self._at_all = True
|
|
50
|
-
return self
|
|
51
|
-
|
|
52
|
-
def _reset_modifiers(self):
|
|
53
|
-
self._at_user_ids = []
|
|
54
|
-
self._at_all = False
|
|
55
|
-
self._reply_message_id = None
|
|
56
|
-
self._keyboard = None
|
|
57
|
-
|
|
58
|
-
def Raw_ob12(self, message: List[Dict], **kwargs):
|
|
116
|
+
def Raw_ob12(self, message, **kwargs):
|
|
59
117
|
return asyncio.create_task(self._do_send_raw_ob12(message, **kwargs))
|
|
60
118
|
|
|
61
|
-
async def _do_send_raw_ob12(self, message
|
|
119
|
+
async def _do_send_raw_ob12(self, message, **kwargs):
|
|
62
120
|
text_parts = []
|
|
63
121
|
media_info = None
|
|
64
122
|
msg_type = 0
|
|
@@ -98,6 +156,12 @@ class QQBotAdapter(sdk.BaseAdapter):
|
|
|
98
156
|
media_info = await self._adapter._upload_media(file, self._target_type, self._target_id, 4)
|
|
99
157
|
if media_info:
|
|
100
158
|
msg_type = 7
|
|
159
|
+
elif seg_type == "audio":
|
|
160
|
+
file = data.get("file", data.get("url", ""))
|
|
161
|
+
if isinstance(file, (bytes, str)) and file:
|
|
162
|
+
media_info = await self._adapter._upload_media(file, self._target_type, self._target_id, 3)
|
|
163
|
+
if media_info:
|
|
164
|
+
msg_type = 7
|
|
101
165
|
elif seg_type == "markdown":
|
|
102
166
|
msg_type = 2
|
|
103
167
|
markdown_data = data
|
|
@@ -107,6 +171,10 @@ class QQBotAdapter(sdk.BaseAdapter):
|
|
|
107
171
|
elif seg_type == "embed":
|
|
108
172
|
msg_type = 4
|
|
109
173
|
embed_data = data
|
|
174
|
+
elif seg_type == "mention":
|
|
175
|
+
uid = data.get("user_id", "")
|
|
176
|
+
if uid:
|
|
177
|
+
text_parts.append(f"<@{uid}>")
|
|
110
178
|
|
|
111
179
|
for uid in self._at_user_ids:
|
|
112
180
|
text_parts.append(f"<@{uid}>")
|
|
@@ -134,14 +202,7 @@ class QQBotAdapter(sdk.BaseAdapter):
|
|
|
134
202
|
endpoint = self._get_send_endpoint()
|
|
135
203
|
if not endpoint:
|
|
136
204
|
self._reset_modifiers()
|
|
137
|
-
return
|
|
138
|
-
"status": "failed",
|
|
139
|
-
"retcode": 10003,
|
|
140
|
-
"data": None,
|
|
141
|
-
"message_id": "",
|
|
142
|
-
"message": "无法确定发送目标",
|
|
143
|
-
"qqbot_raw": None,
|
|
144
|
-
}
|
|
205
|
+
return self._adapter.make_error(retcode=10003, message="无法确定发送目标")
|
|
145
206
|
|
|
146
207
|
self._reset_modifiers()
|
|
147
208
|
return await self._adapter.call_api(endpoint=endpoint, **params)
|
|
@@ -157,77 +218,153 @@ class QQBotAdapter(sdk.BaseAdapter):
|
|
|
157
218
|
return f"/dms/{self._target_id}/messages"
|
|
158
219
|
return None
|
|
159
220
|
|
|
160
|
-
def
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
221
|
+
def _get_config_key(self) -> str:
|
|
222
|
+
return "QQBot_Adapter"
|
|
223
|
+
|
|
224
|
+
def _load_accounts(self) -> dict:
|
|
225
|
+
from ErisPulse.runtime.config_schema import dict_to_dataclass
|
|
226
|
+
from ErisPulse.Core.config import config as config_mgr
|
|
227
|
+
|
|
228
|
+
key = f"{self._get_config_key()}.accounts"
|
|
229
|
+
data = config_mgr.getConfig(key)
|
|
230
|
+
|
|
231
|
+
if not data:
|
|
232
|
+
old_config = config_mgr.getConfig(self._get_config_key())
|
|
233
|
+
if old_config:
|
|
234
|
+
self.logger.info("检测到旧格式配置,迁移到新格式")
|
|
235
|
+
data = {
|
|
236
|
+
"default": {
|
|
237
|
+
"appid": old_config.get("appid", ""),
|
|
238
|
+
"secret": old_config.get("secret", ""),
|
|
239
|
+
"sandbox": old_config.get("sandbox", False),
|
|
240
|
+
"enabled": True,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
try:
|
|
244
|
+
config_mgr.setConfig(key, data)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
self.logger.error(f"保存迁移配置失败: {e}")
|
|
247
|
+
else:
|
|
248
|
+
self.logger.info("未找到配置,创建默认配置模板")
|
|
249
|
+
data = {
|
|
250
|
+
"default": {
|
|
251
|
+
"appid": "YOUR_APPID",
|
|
252
|
+
"secret": "YOUR_CLIENT_SECRET",
|
|
253
|
+
"sandbox": False,
|
|
254
|
+
"enabled": True,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
try:
|
|
258
|
+
config_mgr.setConfig(key, data)
|
|
259
|
+
self.logger.warning("QQBot适配器配置不存在,已自动创建默认配置")
|
|
260
|
+
except Exception as e:
|
|
261
|
+
self.logger.error(f"保存默认配置失败: {e}")
|
|
262
|
+
|
|
263
|
+
accounts = {}
|
|
264
|
+
for name, account_data in data.items():
|
|
265
|
+
if not isinstance(account_data, dict):
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
appid = account_data.get("appid", "")
|
|
269
|
+
secret = account_data.get("secret", "")
|
|
270
|
+
|
|
271
|
+
if appid in ("YOUR_APPID", "") and secret in ("YOUR_CLIENT_SECRET", ""):
|
|
272
|
+
self.logger.warning(f"跳过模板账户: {name}")
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
instance = dict_to_dataclass(QQBotConfig, account_data)
|
|
276
|
+
instance.name = name
|
|
277
|
+
accounts[name] = instance
|
|
278
|
+
|
|
279
|
+
return accounts
|
|
280
|
+
|
|
281
|
+
def __init__(self, sdk_instance=None):
|
|
282
|
+
super().__init__(sdk_instance)
|
|
283
|
+
|
|
284
|
+
self.adapter = sdk.adapter
|
|
167
285
|
self._access_token = None
|
|
168
286
|
self._token_expires = 0
|
|
169
287
|
self.bot_id = ""
|
|
170
|
-
self.session: Optional[aiohttp.ClientSession] = None
|
|
171
288
|
self.ws_client: Optional[QQBotWebSocket] = None
|
|
172
289
|
self._heartbeat_meta_task: Optional[asyncio.Task] = None
|
|
173
290
|
self._pending_msg_ids: Dict[str, str] = {}
|
|
174
291
|
|
|
292
|
+
self._active_account_name = None
|
|
293
|
+
self._active_account = None
|
|
294
|
+
|
|
175
295
|
converter = QQBotConverter(bot_id_getter=lambda: self.bot_id)
|
|
176
296
|
self.convert = converter.convert
|
|
177
297
|
|
|
178
|
-
def
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
"
|
|
183
|
-
"
|
|
184
|
-
"sandbox": False,
|
|
185
|
-
"intents": [1, 30, 25],
|
|
298
|
+
def _get_active_config(self) -> dict:
|
|
299
|
+
if self._active_account:
|
|
300
|
+
return {
|
|
301
|
+
"appid": self._active_account.appid,
|
|
302
|
+
"secret": self._active_account.secret,
|
|
303
|
+
"sandbox": self._active_account.sandbox,
|
|
186
304
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
305
|
+
return {}
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def config(self):
|
|
309
|
+
return self._get_active_config()
|
|
310
|
+
|
|
311
|
+
@config.setter
|
|
312
|
+
def config(self, value):
|
|
313
|
+
pass
|
|
194
314
|
|
|
195
315
|
def _get_base_url(self) -> str:
|
|
196
|
-
|
|
316
|
+
sandbox = self._active_account.sandbox if self._active_account else False
|
|
317
|
+
if sandbox:
|
|
197
318
|
return "https://sandbox.api.sgroup.qq.com"
|
|
198
319
|
return "https://api.sgroup.qq.com"
|
|
199
320
|
|
|
200
321
|
def _get_intents_value(self) -> int:
|
|
201
|
-
|
|
322
|
+
adapter_config = self._config_instance
|
|
323
|
+
if adapter_config:
|
|
324
|
+
import json as _json
|
|
325
|
+
try:
|
|
326
|
+
intents = _json.loads(adapter_config.intents)
|
|
327
|
+
except Exception:
|
|
328
|
+
intents = [1, 30, 25]
|
|
329
|
+
else:
|
|
330
|
+
intents = [1, 30, 25]
|
|
331
|
+
|
|
202
332
|
value = 0
|
|
203
333
|
for intent in intents:
|
|
204
334
|
value |= (1 << intent)
|
|
205
335
|
return value
|
|
206
336
|
|
|
337
|
+
def _get_gateway_url(self) -> str:
|
|
338
|
+
adapter_config = self._config_instance
|
|
339
|
+
if adapter_config and adapter_config.gateway_url:
|
|
340
|
+
return adapter_config.gateway_url
|
|
341
|
+
return "wss://api.sgroup.qq.com/websocket/"
|
|
342
|
+
|
|
207
343
|
async def _ensure_token(self) -> str:
|
|
208
|
-
import time as _time
|
|
209
344
|
if self._access_token and _time.time() < self._token_expires - 120:
|
|
210
345
|
return self._access_token
|
|
211
346
|
await self._refresh_token()
|
|
212
347
|
return self._access_token
|
|
213
348
|
|
|
214
349
|
async def _refresh_token(self):
|
|
215
|
-
import time as _time
|
|
216
350
|
url = "https://bots.qq.com/app/getAppAccessToken"
|
|
217
|
-
payload = {
|
|
351
|
+
payload = {
|
|
352
|
+
"appId": self._active_account.appid,
|
|
353
|
+
"clientSecret": self._active_account.secret,
|
|
354
|
+
}
|
|
218
355
|
try:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
356
|
+
resp = await client.post(url, json=payload)
|
|
357
|
+
data = await resp.json()
|
|
358
|
+
self._access_token = data.get("access_token", "")
|
|
359
|
+
expires_in = int(data.get("expires_in", 7200))
|
|
360
|
+
self._token_expires = _time.time() + expires_in
|
|
361
|
+
self.logger.debug("Access Token 已获取/刷新")
|
|
225
362
|
except Exception as e:
|
|
226
363
|
self.logger.error(f"获取 Access Token 失败: {e}")
|
|
227
364
|
raise
|
|
228
365
|
|
|
229
366
|
async def _upload_media(self, file, target_type: str, target_id: str, file_type: int) -> Optional[str]:
|
|
230
|
-
url =
|
|
367
|
+
url = self._get_base_url()
|
|
231
368
|
if target_type == "user":
|
|
232
369
|
url += f"/v2/users/{target_id}/files"
|
|
233
370
|
elif target_type == "group":
|
|
@@ -261,73 +398,60 @@ class QQBotAdapter(sdk.BaseAdapter):
|
|
|
261
398
|
return None
|
|
262
399
|
|
|
263
400
|
try:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
401
|
+
resp = await client.post(url, json=payload, headers=headers)
|
|
402
|
+
data = await resp.json()
|
|
403
|
+
self.logger.debug(f"上传媒体响应: {data}")
|
|
404
|
+
file_info = data.get("file_info", "")
|
|
405
|
+
if not file_info:
|
|
406
|
+
self.logger.error(f"上传媒体失败: {data}")
|
|
407
|
+
return None
|
|
408
|
+
return file_info
|
|
272
409
|
except Exception as e:
|
|
273
410
|
self.logger.error(f"上传媒体异常: {e}")
|
|
274
411
|
return None
|
|
275
412
|
|
|
276
|
-
async def call_api(self, endpoint: str, **params):
|
|
413
|
+
async def call_api(self, endpoint: str, _account_id: str = None, **params):
|
|
277
414
|
token = await self._ensure_token()
|
|
278
415
|
url = f"{self._get_base_url()}{endpoint}"
|
|
279
416
|
headers = {"Authorization": f"QQBot {token}", "Content-Type": "application/json"}
|
|
280
417
|
|
|
281
418
|
try:
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
"
|
|
309
|
-
|
|
419
|
+
resp = await client.post(url, json=params, headers=headers)
|
|
420
|
+
raw_response = await resp.json()
|
|
421
|
+
|
|
422
|
+
self.logger.debug(f"QQBot API 请求: {url}")
|
|
423
|
+
self.logger.debug(f"QQBot API 响应: {raw_response}")
|
|
424
|
+
|
|
425
|
+
if not isinstance(raw_response, dict):
|
|
426
|
+
return self.make_error(
|
|
427
|
+
retcode=34000,
|
|
428
|
+
message=f"API 返回了意外格式: {type(raw_response)}",
|
|
429
|
+
raw=raw_response,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
code = raw_response.get("code", 0)
|
|
433
|
+
success = 200 <= resp.status < 300 and code == 0
|
|
434
|
+
msg_id = raw_response.get("id", raw_response.get("data", {}).get("id", ""))
|
|
435
|
+
|
|
436
|
+
if success:
|
|
437
|
+
return self.make_response(
|
|
438
|
+
data=raw_response.get("data", raw_response),
|
|
439
|
+
message_id=str(msg_id) if msg_id else "",
|
|
440
|
+
raw=raw_response,
|
|
441
|
+
)
|
|
442
|
+
else:
|
|
443
|
+
return self.make_error(
|
|
444
|
+
retcode=code or 34000,
|
|
445
|
+
message=raw_response.get("message", "Unknown QQBot API error"),
|
|
446
|
+
raw=raw_response,
|
|
447
|
+
)
|
|
310
448
|
|
|
311
449
|
except asyncio.TimeoutError:
|
|
312
450
|
self.logger.error(f"QQBot API 请求超时: {endpoint}")
|
|
313
|
-
return
|
|
314
|
-
"status": "failed",
|
|
315
|
-
"retcode": 32000,
|
|
316
|
-
"data": None,
|
|
317
|
-
"message_id": "",
|
|
318
|
-
"message": "请求超时",
|
|
319
|
-
"qqbot_raw": None,
|
|
320
|
-
}
|
|
451
|
+
return self.make_error(retcode=32000, message="请求超时")
|
|
321
452
|
except Exception as e:
|
|
322
453
|
self.logger.error(f"调用 QQBot API 失败: {e}")
|
|
323
|
-
return {
|
|
324
|
-
"status": "failed",
|
|
325
|
-
"retcode": 33000,
|
|
326
|
-
"data": None,
|
|
327
|
-
"message_id": "",
|
|
328
|
-
"message": f"API调用失败: {str(e)}",
|
|
329
|
-
"qqbot_raw": None,
|
|
330
|
-
}
|
|
454
|
+
return self.make_error(retcode=33000, message=f"API调用失败: {str(e)}")
|
|
331
455
|
|
|
332
456
|
def _store_event_msg_id(self, event: Dict):
|
|
333
457
|
if event.get("type") != "message":
|
|
@@ -348,37 +472,31 @@ class QQBotAdapter(sdk.BaseAdapter):
|
|
|
348
472
|
self._pending_msg_ids[f"channel:{channel_id}"] = msg_id
|
|
349
473
|
|
|
350
474
|
async def _on_connect(self):
|
|
351
|
-
await self.
|
|
352
|
-
"type": "meta",
|
|
353
|
-
"detail_type": "connect",
|
|
354
|
-
"platform": "qqbot",
|
|
355
|
-
"self": {"platform": "qqbot", "user_id": self.bot_id},
|
|
356
|
-
})
|
|
357
|
-
|
|
475
|
+
await self.emit_meta("connect", self.bot_id)
|
|
358
476
|
self._heartbeat_meta_task = asyncio.create_task(self._heartbeat_meta_loop())
|
|
359
477
|
|
|
360
478
|
async def _heartbeat_meta_loop(self):
|
|
361
479
|
try:
|
|
362
480
|
while True:
|
|
363
481
|
await asyncio.sleep(30)
|
|
364
|
-
await self.
|
|
365
|
-
"type": "meta",
|
|
366
|
-
"detail_type": "heartbeat",
|
|
367
|
-
"platform": "qqbot",
|
|
368
|
-
"self": {"platform": "qqbot", "user_id": self.bot_id},
|
|
369
|
-
})
|
|
482
|
+
await self.emit_meta("heartbeat", self.bot_id)
|
|
370
483
|
except asyncio.CancelledError:
|
|
371
484
|
pass
|
|
372
485
|
|
|
373
486
|
async def start(self):
|
|
374
|
-
|
|
487
|
+
enabled = self.enabled_accounts
|
|
488
|
+
if not enabled:
|
|
489
|
+
self.logger.warning("没有找到启用的账户配置")
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
first_name = next(iter(enabled))
|
|
493
|
+
self._active_account_name = first_name
|
|
494
|
+
self._active_account = enabled[first_name]
|
|
375
495
|
|
|
376
496
|
try:
|
|
377
497
|
await self._ensure_token()
|
|
378
498
|
except Exception as e:
|
|
379
499
|
self.logger.error(f"初始化 Token 失败: {e}")
|
|
380
|
-
if self.session:
|
|
381
|
-
await self.session.close()
|
|
382
500
|
raise
|
|
383
501
|
|
|
384
502
|
self.ws_client = QQBotWebSocket(self)
|
|
@@ -388,12 +506,7 @@ class QQBotAdapter(sdk.BaseAdapter):
|
|
|
388
506
|
|
|
389
507
|
async def shutdown(self):
|
|
390
508
|
if self.bot_id:
|
|
391
|
-
await self.
|
|
392
|
-
"type": "meta",
|
|
393
|
-
"detail_type": "disconnect",
|
|
394
|
-
"platform": "qqbot",
|
|
395
|
-
"self": {"platform": "qqbot", "user_id": self.bot_id},
|
|
396
|
-
})
|
|
509
|
+
await self.emit_meta("disconnect", self.bot_id)
|
|
397
510
|
|
|
398
511
|
if self._heartbeat_meta_task:
|
|
399
512
|
self._heartbeat_meta_task.cancel()
|
|
@@ -407,7 +520,4 @@ class QQBotAdapter(sdk.BaseAdapter):
|
|
|
407
520
|
await self.ws_client.close()
|
|
408
521
|
self.ws_client = None
|
|
409
522
|
|
|
410
|
-
if self.session:
|
|
411
|
-
await self.session.close()
|
|
412
|
-
self.session = None
|
|
413
523
|
self.logger.info("QQBot 适配器已关闭")
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import json
|
|
3
3
|
import time
|
|
4
|
-
import aiohttp
|
|
5
4
|
from typing import Optional
|
|
6
5
|
|
|
6
|
+
from ErisPulse.Core import client
|
|
7
|
+
from ErisPulse.Core.Bases.websocket import WSMessage
|
|
8
|
+
|
|
7
9
|
|
|
8
10
|
class QQBotWebSocket:
|
|
9
11
|
OP_HELLO = 10
|
|
@@ -17,8 +19,7 @@ class QQBotWebSocket:
|
|
|
17
19
|
|
|
18
20
|
def __init__(self, adapter):
|
|
19
21
|
self.adapter = adapter
|
|
20
|
-
self.
|
|
21
|
-
self.ws: Optional[aiohttp.ClientWebSocketResponse] = None
|
|
22
|
+
self.ws = None
|
|
22
23
|
self.heartbeat_interval = 45000
|
|
23
24
|
self.heartbeat_task: Optional[asyncio.Task] = None
|
|
24
25
|
self.listen_task: Optional[asyncio.Task] = None
|
|
@@ -28,34 +29,38 @@ class QQBotWebSocket:
|
|
|
28
29
|
self._token_refresh_task: Optional[asyncio.Task] = None
|
|
29
30
|
self._reconnect_count = 0
|
|
30
31
|
self._max_reconnect = 50
|
|
32
|
+
self._closing = False
|
|
31
33
|
|
|
32
34
|
async def connect(self):
|
|
33
|
-
self.session = aiohttp.ClientSession()
|
|
34
35
|
gateway_url = self.adapter.config.get("gateway_url", "wss://api.sgroup.qq.com/websocket/")
|
|
35
36
|
await self._connect(gateway_url)
|
|
36
37
|
|
|
37
38
|
async def _connect(self, url: str):
|
|
38
39
|
try:
|
|
39
|
-
self.ws = await
|
|
40
|
+
self.ws = await client.ws_connect(url)
|
|
40
41
|
self.adapter.logger.info("WebSocket 已连接到 QQBot 网关")
|
|
41
42
|
self._connected = True
|
|
42
43
|
|
|
43
|
-
msg = await self.ws.
|
|
44
|
-
if msg.
|
|
45
|
-
data = msg.
|
|
46
|
-
|
|
44
|
+
msg = await self.ws.receive()
|
|
45
|
+
if msg.type == WSMessage.TEXT:
|
|
46
|
+
data = json.loads(msg.data)
|
|
47
|
+
if data.get("op") == self.OP_HELLO:
|
|
48
|
+
inner = data.get("d", {})
|
|
49
|
+
self.heartbeat_interval = inner.get("heartbeat_interval", 45000)
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
if self.session_id and self.seq is not None:
|
|
52
|
+
await self._resume()
|
|
53
|
+
else:
|
|
54
|
+
await self._identify()
|
|
52
55
|
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
57
|
+
self.listen_task = asyncio.create_task(self._listen())
|
|
55
58
|
|
|
56
|
-
|
|
59
|
+
self._reconnect_count = 0
|
|
60
|
+
else:
|
|
61
|
+
self.adapter.logger.error(f"未收到 Hello 消息: {data}")
|
|
57
62
|
else:
|
|
58
|
-
self.adapter.logger.error(f"未收到 Hello 消息: {msg}")
|
|
63
|
+
self.adapter.logger.error(f"未收到 Hello 消息: {msg.type}")
|
|
59
64
|
|
|
60
65
|
except Exception as e:
|
|
61
66
|
self.adapter.logger.error(f"WebSocket 连接失败: {e}")
|
|
@@ -108,11 +113,12 @@ class QQBotWebSocket:
|
|
|
108
113
|
|
|
109
114
|
async def _listen(self):
|
|
110
115
|
try:
|
|
111
|
-
|
|
112
|
-
|
|
116
|
+
while self._connected:
|
|
117
|
+
msg = await self.ws.receive()
|
|
118
|
+
if msg.type == WSMessage.TEXT:
|
|
113
119
|
data = json.loads(msg.data)
|
|
114
120
|
await self._handle_message(data)
|
|
115
|
-
elif msg.type in (
|
|
121
|
+
elif msg.type in (WSMessage.CLOSE, WSMessage.ERROR):
|
|
116
122
|
self.adapter.logger.warning(f"WebSocket 关闭: {msg.type}")
|
|
117
123
|
break
|
|
118
124
|
except asyncio.CancelledError:
|
|
@@ -121,7 +127,7 @@ class QQBotWebSocket:
|
|
|
121
127
|
self.adapter.logger.error(f"监听异常: {e}")
|
|
122
128
|
finally:
|
|
123
129
|
self._connected = False
|
|
124
|
-
if not self.listen_task or not self.listen_task.cancelled():
|
|
130
|
+
if not self._closing and (not self.listen_task or not self.listen_task.cancelled()):
|
|
125
131
|
await self._reconnect()
|
|
126
132
|
|
|
127
133
|
async def _handle_message(self, data: dict):
|
|
@@ -138,6 +144,8 @@ class QQBotWebSocket:
|
|
|
138
144
|
self.session_id = d.get("session_id") if d else None
|
|
139
145
|
user = d.get("user", {}) if d else {}
|
|
140
146
|
self.adapter.bot_id = str(user.get("id", ""))
|
|
147
|
+
if self.adapter._active_account:
|
|
148
|
+
self.adapter._active_account.bot_id = self.adapter.bot_id
|
|
141
149
|
self.adapter.logger.info(f"QQBot 就绪, bot_id: {self.adapter.bot_id}")
|
|
142
150
|
await self.adapter._on_connect()
|
|
143
151
|
elif t == "RESUMED":
|
|
@@ -147,8 +155,7 @@ class QQBotWebSocket:
|
|
|
147
155
|
onebot_event = self.adapter.convert(d, t or "")
|
|
148
156
|
if onebot_event:
|
|
149
157
|
self.adapter._store_event_msg_id(onebot_event)
|
|
150
|
-
|
|
151
|
-
logger.debug(f"收到事件: {onebot_event}")
|
|
158
|
+
self.adapter.logger.debug(f"收到事件: {onebot_event}")
|
|
152
159
|
await self.adapter.sdk.adapter.emit(onebot_event)
|
|
153
160
|
|
|
154
161
|
elif op == self.OP_HEARTBEAT_ACK:
|
|
@@ -169,6 +176,9 @@ class QQBotWebSocket:
|
|
|
169
176
|
self.heartbeat_interval = data_inner.get("heartbeat_interval", 45000)
|
|
170
177
|
|
|
171
178
|
async def _reconnect(self):
|
|
179
|
+
if self._closing:
|
|
180
|
+
return
|
|
181
|
+
|
|
172
182
|
if self.heartbeat_task:
|
|
173
183
|
self.heartbeat_task.cancel()
|
|
174
184
|
try:
|
|
@@ -186,6 +196,9 @@ class QQBotWebSocket:
|
|
|
186
196
|
self.adapter.logger.warning(f"{wait_time}秒后尝试第 {self._reconnect_count} 次重连")
|
|
187
197
|
await asyncio.sleep(wait_time)
|
|
188
198
|
|
|
199
|
+
if self._closing:
|
|
200
|
+
return
|
|
201
|
+
|
|
189
202
|
try:
|
|
190
203
|
if self.ws and not self.ws.closed:
|
|
191
204
|
await self.ws.close()
|
|
@@ -210,6 +223,7 @@ class QQBotWebSocket:
|
|
|
210
223
|
self.adapter.logger.error(f"Token 刷新异常: {e}")
|
|
211
224
|
|
|
212
225
|
async def close(self):
|
|
226
|
+
self._closing = True
|
|
213
227
|
self._connected = False
|
|
214
228
|
|
|
215
229
|
if self._token_refresh_task:
|
|
@@ -238,8 +252,5 @@ class QQBotWebSocket:
|
|
|
238
252
|
|
|
239
253
|
if self.ws and not self.ws.closed:
|
|
240
254
|
await self.ws.close()
|
|
241
|
-
if self.session:
|
|
242
|
-
await self.session.close()
|
|
243
|
-
self.session = None
|
|
244
255
|
|
|
245
256
|
self.adapter.logger.info("WebSocket 连接已关闭")
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ErisPulse-QQBotAdapter"
|
|
3
|
-
version = "
|
|
3
|
+
version = "4.0.0"
|
|
4
4
|
description = "ErisPulse的QQ官方机器人适配模块"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
7
|
license = { file = "LICENSE" }
|
|
8
8
|
authors = [ { name = "wsu2059", email = "wsu2059@qq.com" } ]
|
|
9
|
-
dependencies = [
|
|
10
|
-
"aiohttp>=3.8.0"
|
|
11
|
-
]
|
|
9
|
+
dependencies = []
|
|
12
10
|
|
|
13
11
|
[project.urls]
|
|
14
12
|
"homepage" = "https://github.com/ErisPulse/ErisPulse-QQBotAdapter"
|
|
@@ -19,3 +17,7 @@ qq = "QQBotAdapter:QQBotAdapter"
|
|
|
19
17
|
|
|
20
18
|
[tool.setuptools.packages.find]
|
|
21
19
|
include = [ "QQBotAdapter" ]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
23
|
+
build-backend = "setuptools.build_meta"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
aiohttp>=3.8.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|