satori-python-adapter-qq 0.3.0__tar.gz → 0.3.2__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.
Files changed (19) hide show
  1. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/.mina/adapter_qq.toml +2 -2
  2. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/PKG-INFO +2 -2
  3. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/pyproject.toml +13 -15
  4. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/src/satori/adapters/qq/main.py +9 -2
  5. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/src/satori/adapters/qq/message.py +114 -14
  6. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/src/satori/adapters/qq/utils.py +22 -2
  7. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/src/satori/adapters/qq/websocket.py +12 -5
  8. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/LICENSE +0 -0
  9. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/README.md +0 -0
  10. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/src/satori/adapters/qq/__init__.py +0 -0
  11. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/src/satori/adapters/qq/api.py +0 -0
  12. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/src/satori/adapters/qq/audit_store.py +0 -0
  13. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/src/satori/adapters/qq/events/__init__.py +0 -0
  14. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/src/satori/adapters/qq/events/base.py +0 -0
  15. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/src/satori/adapters/qq/events/group.py +0 -0
  16. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/src/satori/adapters/qq/events/guild.py +0 -0
  17. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/src/satori/adapters/qq/events/interaction.py +0 -0
  18. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/src/satori/adapters/qq/events/message.py +0 -0
  19. {satori_python_adapter_qq-0.3.0 → satori_python_adapter_qq-0.3.2}/src/satori/adapters/qq/exception.py +0 -0
@@ -1,12 +1,12 @@
1
1
  includes = ["src/satori/adapters/qq"]
2
2
  raw-dependencies = [
3
- "satori-python<1.4.0,>= 1.3.0",
3
+ "satori-python<1.4.0,>= 1.3.3",
4
4
  "cryptography>=46.0.4",
5
5
  ]
6
6
 
7
7
  [project]
8
8
  name = "satori-python-adapter-qq"
9
- version = "0.3.0"
9
+ version = "0.3.2"
10
10
  authors = [
11
11
  {name = "RF-Tar-Railt", email = "rf_tar_railt@qq.com"}
12
12
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: satori-python-adapter-qq
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Satori Protocol SDK for python, adapter for QQ
5
5
  Home-page: https://github.com/RF-Tar-Railt/satori-python
6
6
  Author-Email: RF-Tar-Railt <rf_tar_railt@qq.com>
@@ -17,7 +17,7 @@ Classifier: Operating System :: OS Independent
17
17
  Project-URL: Homepage, https://github.com/RF-Tar-Railt/satori-python
18
18
  Project-URL: Repository, https://github.com/RF-Tar-Railt/satori-python
19
19
  Requires-Python: <4.0,>=3.10
20
- Requires-Dist: satori-python<1.4.0,>=1.3.0
20
+ Requires-Dist: satori-python<1.4.0,>=1.3.3
21
21
  Requires-Dist: cryptography>=46.0.4
22
22
  Description-Content-Type: text/markdown
23
23
 
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "satori-python-adapter-qq"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  authors = [
5
5
  { name = "RF-Tar-Railt", email = "rf_tar_railt@qq.com" },
6
6
  ]
7
7
  dependencies = [
8
- "satori-python<1.4.0,>= 1.3.0",
8
+ "satori-python<1.4.0,>= 1.3.3",
9
9
  "cryptography>=46.0.4",
10
10
  ]
11
11
  description = "Satori Protocol SDK for python, adapter for QQ"
@@ -39,15 +39,14 @@ build-backend = "mina.backend"
39
39
 
40
40
  [dependency-groups]
41
41
  dev = [
42
- "isort>=5.13.2",
43
- "black>=24.4.0",
44
- "ruff>=0.4.1",
42
+ "black>=26.3.0",
43
+ "ruff>=0.15.1",
45
44
  "pre-commit>=3.7.0",
46
- "fix-future-annotations>=0.5.0",
47
45
  "mina-build<0.6,>=0.5.1",
48
46
  "pdm-mina>=0.3.2",
49
47
  "nonechat<0.7.0,>=0.6.0",
50
48
  "uvicorn[standard]>=0.37.0",
49
+ "pydantic>=2.13.1",
51
50
  ]
52
51
 
53
52
  [tool.pdm.build]
@@ -61,7 +60,7 @@ excludes = [
61
60
 
62
61
  [tool.pdm.scripts.format]
63
62
  composite = [
64
- "isort ./src/ ./example/",
63
+ "ruff check --select I --fix ./src/ ./example/",
65
64
  "black ./src/ ./example/",
66
65
  "ruff check",
67
66
  ]
@@ -75,14 +74,6 @@ line-length = 120
75
74
  include = "\\.pyi?$"
76
75
  extend-exclude = ""
77
76
 
78
- [tool.isort]
79
- profile = "black"
80
- line_length = 120
81
- skip_gitignore = true
82
- extra_standard_library = [
83
- "typing_extensions",
84
- ]
85
-
86
77
  [tool.ruff]
87
78
  line-length = 120
88
79
  target-version = "py310"
@@ -92,6 +83,7 @@ exclude = [
92
83
  "exam2.py",
93
84
  "src/satori/_vendor/*",
94
85
  ]
86
+ respect-gitignore = true
95
87
 
96
88
  [tool.ruff.lint]
97
89
  select = [
@@ -111,6 +103,12 @@ ignore = [
111
103
  "T201",
112
104
  ]
113
105
 
106
+ [tool.ruff.lint.isort]
107
+ extra-standard-library = [
108
+ "typing_extensions",
109
+ ]
110
+ force-sort-within-sections = false
111
+
114
112
  [tool.pyright]
115
113
  pythonPlatform = "All"
116
114
  pythonVersion = "3.10"
@@ -318,7 +318,9 @@ class QQBotWebhookAdapter(BaseAdapter):
318
318
  bot_info = await network.call_api("get", "users/@me")
319
319
  user = decode_user(bot_info)
320
320
  user.is_bot = True
321
- login = Login(0, LoginStatus.ONLINE, "qqbot", platform="qq", user=user, features=QQ_FEATURES.copy())
321
+ login = Login(
322
+ sn=0, status=LoginStatus.ONLINE, adapter="qqbot", platform="qq", user=user, features=QQ_FEATURES.copy()
323
+ )
322
324
  previous = next((lg for lg in self.logins if lg.id == login.id and lg.platform == "qq"), None)
323
325
  if previous:
324
326
  previous.user = login.user
@@ -331,7 +333,12 @@ class QQBotWebhookAdapter(BaseAdapter):
331
333
  event_type = EventType.LOGIN_ADDED
332
334
  await self.server.post(Event(event_type, datetime.now(), login))
333
335
  guild_login = Login(
334
- 0, LoginStatus.ONLINE, "qqbot", platform="qqguild", user=user, features=QQ_GUILD_FEATURES.copy()
336
+ sn=0,
337
+ status=LoginStatus.ONLINE,
338
+ adapter="qqbot",
339
+ platform="qqguild",
340
+ user=user,
341
+ features=QQ_GUILD_FEATURES.copy(),
335
342
  )
336
343
  previous = next((lg for lg in self.logins if lg.id == guild_login.id and lg.platform == "qqguild"), None)
337
344
  if previous:
@@ -1,24 +1,26 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import base64
4
5
  import json
5
6
  import random
6
7
  import re
7
8
  from datetime import datetime, timezone
8
- from pathlib import Path
9
+ from hashlib import md5, sha1
9
10
 
10
11
  from loguru import logger
11
12
 
12
- from satori.element import Button, Custom, E, Element, Raw, select, transform
13
+ from satori.element import Custom, E, Element, Raw
13
14
  from satori.model import Login, MessageObject
14
15
  from satori.parser import Element as RawElement
15
- from satori.parser import parse
16
+ from satori.parser import parse, select
16
17
 
17
18
  from ...exception import ActionFailed
18
19
  from .exception import AuditException
19
- from .utils import QQBotNetwork
20
+ from .utils import QQBotNetwork, parse_file_uri
20
21
 
21
22
  _BASE64_RE = re.compile(r"^data:([\w/.+-]+);base64,")
23
+ MAX_FILESIZE_ONCE = 10 * 1024 * 1024 # 10MB
22
24
 
23
25
 
24
26
  def escape(s: str) -> str:
@@ -88,12 +90,12 @@ class QQBotMessageEncoder:
88
90
 
89
91
  async def send(self, content: str):
90
92
  self._raw_content = content
91
- msg = transform(parse(content))
92
- btns = select(msg, Button)
93
- btns = [btn for btn in btns if btn.type != "link" and not btn.id]
93
+ msg = parse(content)
94
+ btns = select(msg, "button")
95
+ btns = [btn for btn in btns if btn.tag() != "link" and not btn.attrs.get("id")]
94
96
  for btn in btns:
95
- btn.id = random.randbytes(8).hex()
96
- await self.render(parse("".join(map(str, msg))))
97
+ btn.attrs["id"] = random.randbytes(8).hex()
98
+ await self.render(msg)
97
99
  await self.flush()
98
100
  return self.results
99
101
 
@@ -230,7 +232,7 @@ class QQGuildMessageEncoder(QQBotMessageEncoder):
230
232
  content_type = b64_match.group(1)
231
233
  filename = f"file.{content_type.split('/')[-1]}"
232
234
  else:
233
- path = Path(url[7:])
235
+ path = parse_file_uri(url)
234
236
  data = path.read_bytes()
235
237
  filename = path.name
236
238
  content_type = None
@@ -301,6 +303,7 @@ class QQGroupMessageEncoder(QQBotMessageEncoder):
301
303
  "event_id": event_id,
302
304
  "msg_id": msg_id,
303
305
  "msg_seq": data["msg_seq"],
306
+ "ref_idx": resp["ext_info"].get("ref_idx"),
304
307
  }
305
308
  self.results.append(MessageObject(resp["id"], self._raw_content, referrer=referrer))
306
309
  except Exception as e:
@@ -315,6 +318,7 @@ class QQGroupMessageEncoder(QQBotMessageEncoder):
315
318
 
316
319
  async def send_file(self, type_: str, attrs: dict) -> dict | None:
317
320
  url = attrs.get("url") or attrs["src"]
321
+ filename = attrs.get("name") or attrs.get("title")
318
322
  is_uri = url.startswith("file://")
319
323
  b64_match = _BASE64_RE.match(url)
320
324
  file_type = 0
@@ -331,13 +335,27 @@ class QQGroupMessageEncoder(QQBotMessageEncoder):
331
335
  "file_type": file_type,
332
336
  "srv_send_msg": False,
333
337
  }
338
+ file_data = None
339
+ raw_data = None
334
340
  if b64_match:
335
- req["file_data"] = url[len(b64_match.group(0)) :]
341
+ file_data = url[len(b64_match.group(0)) :]
342
+ raw_data = base64.b64decode(file_data)
336
343
  elif is_uri:
337
- path = Path(url[7:])
338
- req["file_data"] = base64.b64encode(path.read_bytes()).decode("utf-8")
339
- else:
344
+ path = parse_file_uri(url)
345
+ filename = filename or path.name
346
+ raw_data = path.read_bytes()
347
+ file_data = base64.b64encode(raw_data).decode("utf-8")
348
+ if not file_data:
340
349
  req["url"] = url
350
+ else:
351
+ req["file_data"] = file_data
352
+ if filename:
353
+ req["file_name"] = filename
354
+
355
+ if raw_data and len(raw_data) > MAX_FILESIZE_ONCE:
356
+ # 走分片上传
357
+ return await self._send_file_chunked(raw_data, file_type, filename)
358
+
341
359
  if self.channel_id.startswith("private:") or (self.referrer and self.referrer.get("direct", False)):
342
360
  endpoint = f"v2/users/{self.channel_id.split(':',1)[-1]}/files"
343
361
  else:
@@ -349,11 +367,83 @@ class QQGroupMessageEncoder(QQBotMessageEncoder):
349
367
  logger.error(f"Failed to upload file to {self.channel_id}: {url}\nError: {e}")
350
368
  return None
351
369
 
370
+ async def _send_file_chunked(self, raw: bytes, file_type: int, filename: str | None = None):
371
+ req: dict = {
372
+ "file_type": file_type,
373
+ "file_name": filename or "file",
374
+ "file_size": len(raw),
375
+ "md5": md5(raw).hexdigest(),
376
+ "sha1": sha1(raw).hexdigest(),
377
+ }
378
+ buffer = memoryview(raw)
379
+ first_part = buffer[:MAX_FILESIZE_ONCE]
380
+ req["md5_10m"] = md5(first_part).hexdigest()
381
+ if self.channel_id.startswith("private:") or (self.referrer and self.referrer.get("direct", False)):
382
+ endpoint = f"v2/users/{self.channel_id.split(':', 1)[-1]}/files"
383
+ prepare_endpoint = f"v2/users/{self.channel_id.split(':', 1)[-1]}/upload_prepare"
384
+ finish_endpoint = f"v2/users/{self.channel_id.split(':', 1)[-1]}/upload_part_finish"
385
+ else:
386
+ endpoint = f"v2/groups/{self.channel_id}/files"
387
+ prepare_endpoint = f"v2/groups/{self.channel_id}/upload_prepare"
388
+ finish_endpoint = f"v2/groups/{self.channel_id}/upload_part_finish"
389
+ try:
390
+ prepare_resp = await self.net.call_api("post", prepare_endpoint, req)
391
+ except Exception as e:
392
+ logger.error(f"Failed to prepare file upload to {self.channel_id}: {filename}\nError: {e}")
393
+ return None
394
+
395
+ async def _put(url, data):
396
+ async with self.net.session.put(url, data=data) as rp:
397
+ rp.raise_for_status()
398
+ return
399
+
400
+ upload_id = prepare_resp["upload_id"]
401
+ base_block_size = int(prepare_resp["block_size"])
402
+ chunks = bytearray(raw)
403
+ parts = prepare_resp["parts"]
404
+ concurrency = prepare_resp["upload_config"]["concurrency"]
405
+ sent = 0
406
+ while parts:
407
+ batch = parts[:concurrency]
408
+ parts = parts[concurrency:]
409
+ tasks = []
410
+ finish = []
411
+ for part in batch:
412
+ index = part["index"]
413
+ block_size = int(part.get("block_size") or base_block_size)
414
+ chunk = chunks[sent : sent + block_size]
415
+ sent += block_size
416
+ tasks.append(_put(part["presigned_url"], chunk))
417
+ req = {
418
+ "upload_id": upload_id,
419
+ "part_index": index,
420
+ "block_size": block_size,
421
+ "md5": md5(chunk).hexdigest(),
422
+ }
423
+ finish.append(self.net.call_api("post", finish_endpoint, req))
424
+ if concurrency > 1:
425
+ await asyncio.gather(*tasks)
426
+ await asyncio.gather(*finish)
427
+ else:
428
+ await tasks[0]
429
+ await finish[0]
430
+ try:
431
+ resp = await self.net.call_api("post", endpoint, {"upload_id": upload_id})
432
+ return resp
433
+ except Exception as e:
434
+ logger.error(f"Failed to finalize file upload to {self.channel_id}: {filename}\nError: {e}")
435
+ return None
436
+
352
437
  async def visit(self, element: RawElement):
353
438
  type_, attrs, children = element.type, element.attrs, element.children
354
439
  match type_:
355
440
  case "text":
356
441
  self.content += attrs["text"]
442
+ case "at":
443
+ if attrs.get("id"):
444
+ self.content += f"<qqbot-at-user id=\"{attrs['id']}\" />"
445
+ else:
446
+ await self.render(children)
357
447
  case "img" | "image":
358
448
  if attrs.get("src") or attrs.get("url"):
359
449
  # await self.flush()
@@ -388,9 +478,13 @@ class QQGroupMessageEncoder(QQBotMessageEncoder):
388
478
  last = self.last_row()
389
479
  last.append(self.decode_button(attrs, "".join(map(str, children))))
390
480
  case "markdown":
481
+ if self.content:
482
+ self.content += "\n"
391
483
  self.use_markdown = True
392
484
  await self.render(children)
393
485
  case "qq:markdown":
486
+ if self.content:
487
+ self.content += "\n"
394
488
  self.use_markdown = True
395
489
  if attrs.get("template_id"):
396
490
  self.md_templates = {
@@ -398,6 +492,12 @@ class QQGroupMessageEncoder(QQBotMessageEncoder):
398
492
  "params": [{"key": k, "value": v} for k, v in attrs.items() if k != "template_id"],
399
493
  }
400
494
  await self.render(children)
495
+ case "qq:cmd-enter":
496
+ self.use_markdown = True
497
+ self.content += RawElement("qqbot-cmd-enter", attrs, []).dumps()
498
+ case "qq:cmd-input":
499
+ self.use_markdown = True
500
+ self.content += RawElement("qqbot-cmd-input", attrs, []).dumps()
401
501
  case "message":
402
502
  await self.flush()
403
503
  await self.render(children)
@@ -1,17 +1,23 @@
1
1
  from dataclasses import dataclass
2
2
  from datetime import datetime
3
3
  from enum import Enum
4
+ from pathlib import Path
4
5
  from typing import Literal, Protocol
6
+ from urllib.parse import urlparse
7
+ from urllib.request import url2pathname
5
8
 
6
- from aiohttp import ClientResponse
9
+ from aiohttp import ClientResponse, ClientSession
10
+
11
+ from satori.model import Channel, ChannelType, Guild, Member, Role, User
7
12
 
8
- from ... import Channel, ChannelType, Guild, Member, Role, User
9
13
  from .exception import ActionFailed, ApiNotAvailable, AuditException, RateLimitException, UnauthorizedException
10
14
 
11
15
  CallMethod = Literal["get", "post", "fetch", "update", "multipart", "put", "delete", "patch"]
12
16
 
13
17
 
14
18
  class QQBotNetwork(Protocol):
19
+ session: ClientSession
20
+
15
21
  async def call_api(self, method: CallMethod, action: str, params: dict | None = None) -> dict: ...
16
22
 
17
23
 
@@ -135,3 +141,17 @@ ROLE_MAPPING = {
135
141
  "2": Role("admin", "管理员"),
136
142
  "4": Role("owner", "创建者"),
137
143
  }
144
+
145
+
146
+ def parse_file_uri(uri):
147
+ """解析 file URI 为 Path 对象"""
148
+ parsed = urlparse(uri)
149
+ if parsed.scheme != "file":
150
+ raise ValueError(f"不是 file URI: {uri}")
151
+
152
+ path = url2pathname(parsed.path)
153
+
154
+ if parsed.netloc and parsed.netloc != "localhost":
155
+ path = f"//{parsed.netloc}{path}"
156
+
157
+ return Path(path)
@@ -112,7 +112,7 @@ class Intents:
112
112
  class QQBotWebsocketAdapter(BaseAdapter):
113
113
 
114
114
  connections: dict[tuple[int, int], aiohttp.ClientWebSocketResponse]
115
- session: aiohttp.ClientSession | None
115
+ session: aiohttp.ClientSession
116
116
  sequence: int | None
117
117
  session_id: str | None
118
118
  _access_token: str | None
@@ -139,7 +139,7 @@ class QQBotWebsocketAdapter(BaseAdapter):
139
139
  self.intent = intent
140
140
  self.api_base = URL(str(api_base if not is_sandbox else sandbox_api_base))
141
141
  self.auth_base = URL(str(auth_base))
142
- self.session = None
142
+ self.session = None # type: ignore
143
143
  self.logins: list[Login] = []
144
144
  self.bot_id_mapping: dict[str, str] = {} # login.id -> bot app_id
145
145
  self.close_signal = asyncio.Event()
@@ -330,7 +330,9 @@ class QQBotWebsocketAdapter(BaseAdapter):
330
330
  async def refresh_login(self, profile: dict):
331
331
  user = decode_user(profile)
332
332
  user.is_bot = True
333
- login = Login(0, LoginStatus.ONLINE, "qqbot", platform="qq", user=user, features=QQ_FEATURES.copy())
333
+ login = Login(
334
+ sn=0, status=LoginStatus.ONLINE, adapter="qqbot", platform="qq", user=user, features=QQ_FEATURES.copy()
335
+ )
334
336
  previous = next((lg for lg in self.logins if lg.id == login.id and lg.platform == "qq"), None)
335
337
  if previous:
336
338
  previous.user = login.user
@@ -343,7 +345,12 @@ class QQBotWebsocketAdapter(BaseAdapter):
343
345
  event_type = EventType.LOGIN_ADDED
344
346
  await self.server.post(Event(event_type, datetime.now(), login))
345
347
  guild_login = Login(
346
- 0, LoginStatus.ONLINE, "qqbot", platform="qqguild", user=user, features=QQ_GUILD_FEATURES.copy()
348
+ sn=0,
349
+ status=LoginStatus.ONLINE,
350
+ adapter="qqbot",
351
+ platform="qqguild",
352
+ user=user,
353
+ features=QQ_GUILD_FEATURES.copy(),
347
354
  )
348
355
  previous = next((lg for lg in self.logins if lg.id == guild_login.id and lg.platform == "qqguild"), None)
349
356
  if previous:
@@ -498,7 +505,7 @@ class QQBotWebsocketAdapter(BaseAdapter):
498
505
  async with self.stage("cleanup"):
499
506
  if self.session:
500
507
  await self.session.close()
501
- self.session = None
508
+ self.session = None # type: ignore
502
509
  for task in tasks:
503
510
  task.cancel()
504
511
  await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED)