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.
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ErisPulse-QQBotAdapter
3
- Version: 1.0.0
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: 1.0.0
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 uuid
5
- from typing import Dict, List, Any, Optional
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 router
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._at_user_ids = []
17
- self._at_all = False
18
- self._reply_message_id = None
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 Keyboard(self, keyboard: dict):
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: List[Dict], **kwargs):
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 __init__(self, sdk):
161
- super().__init__()
162
- self.sdk = sdk
163
- self.logger = sdk.logger
164
- self.config = self._load_config()
165
- self.appid = self.config.get("appid", "")
166
- self.secret = self.config.get("secret", "")
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 _load_config(self):
179
- config = self.sdk.config.getConfig("QQBot_Adapter")
180
- if not config:
181
- default_config = {
182
- "appid": "YOUR_APPID",
183
- "secret": "YOUR_CLIENT_SECRET",
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
- try:
188
- self.sdk.config.setConfig("QQBot_Adapter", default_config)
189
- self.logger.warning("QQBot适配器配置不存在,已自动创建默认配置")
190
- except Exception as e:
191
- self.logger.error(f"保存默认配置失败: {e}")
192
- return default_config
193
- return config
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
- if self.config.get("sandbox", False):
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
- intents = self.config.get("intents", [1, 30, 25])
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 = {"appId": self.appid, "clientSecret": self.secret}
351
+ payload = {
352
+ "appId": self._active_account.appid,
353
+ "clientSecret": self._active_account.secret,
354
+ }
218
355
  try:
219
- async with self.session.post(url, json=payload) as resp:
220
- data = await resp.json()
221
- self._access_token = data.get("access_token", "")
222
- expires_in = int(data.get("expires_in", 7200))
223
- self._token_expires = _time.time() + expires_in
224
- self.logger.debug("Access Token 已获取/刷新")
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 = f"{self._get_base_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
- async with self.session.post(url, json=payload, headers=headers) as resp:
265
- data = await resp.json()
266
- self.logger.debug(f"上传媒体响应: {data}")
267
- file_info = data.get("file_info", "")
268
- if not file_info:
269
- self.logger.error(f"上传媒体失败: {data}")
270
- return None
271
- return file_info
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
- async with self.session.post(url, json=params, headers=headers) as resp:
283
- raw_response = await resp.json()
284
-
285
- self.logger.debug(f"QQBot API 请求: {url}")
286
- self.logger.debug(f"QQBot API 响应: {raw_response}")
287
-
288
- if not isinstance(raw_response, dict):
289
- return {
290
- "status": "failed",
291
- "retcode": 34000,
292
- "data": None,
293
- "message_id": "",
294
- "message": f"API 返回了意外格式: {type(raw_response)}",
295
- "qqbot_raw": raw_response,
296
- }
297
-
298
- code = raw_response.get("code", 0)
299
- success = 200 <= resp.status < 300 and code == 0
300
- msg_id = raw_response.get("id", raw_response.get("data", {}).get("id", ""))
301
-
302
- return {
303
- "status": "ok" if success else "failed",
304
- "retcode": 0 if success else code or 34000,
305
- "data": raw_response.get("data", raw_response),
306
- "message_id": str(msg_id) if msg_id else "",
307
- "message": "" if success else raw_response.get("message", "Unknown QQBot API error"),
308
- "qqbot_raw": raw_response,
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.sdk.adapter.emit({
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.sdk.adapter.emit({
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
- self.session = aiohttp.ClientSession()
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.sdk.adapter.emit({
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.session: Optional[aiohttp.ClientSession] = None
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 self.session.ws_connect(url)
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.receive_json()
44
- if msg.get("op") == self.OP_HELLO:
45
- data = msg.get("d", {})
46
- self.heartbeat_interval = data.get("heartbeat_interval", 45000)
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
- if self.session_id and self.seq is not None:
49
- await self._resume()
50
- else:
51
- await self._identify()
51
+ if self.session_id and self.seq is not None:
52
+ await self._resume()
53
+ else:
54
+ await self._identify()
52
55
 
53
- self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
54
- self.listen_task = asyncio.create_task(self._listen())
56
+ self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
57
+ self.listen_task = asyncio.create_task(self._listen())
55
58
 
56
- self._reconnect_count = 0
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
- async for msg in self.ws:
112
- if msg.type == aiohttp.WSMsgType.TEXT:
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 (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
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
- from ErisPulse.Core import logger
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 = "1.0.0"
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"