satori-python-adapter-milky 0.1.0__tar.gz → 0.2.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.
Files changed (17) hide show
  1. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.2.0}/.mina/adapter_milky.toml +2 -2
  2. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.2.0}/PKG-INFO +6 -4
  3. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.2.0}/README.md +4 -2
  4. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.2.0}/pyproject.toml +4 -3
  5. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.2.0}/src/satori/adapters/milky/api.py +6 -1
  6. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.2.0}/src/satori/adapters/milky/main.py +8 -8
  7. satori_python_adapter_milky-0.2.0/src/satori/adapters/milky/message.py +407 -0
  8. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.2.0}/src/satori/adapters/milky/utils.py +1 -1
  9. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.2.0}/src/satori/adapters/milky/webhook.py +8 -7
  10. satori_python_adapter_milky-0.1.0/src/satori/adapters/milky/message.py +0 -233
  11. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.2.0}/LICENSE +0 -0
  12. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.2.0}/src/satori/adapters/milky/__init__.py +0 -0
  13. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.2.0}/src/satori/adapters/milky/events/__init__.py +0 -0
  14. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.2.0}/src/satori/adapters/milky/events/base.py +0 -0
  15. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.2.0}/src/satori/adapters/milky/events/group.py +0 -0
  16. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.2.0}/src/satori/adapters/milky/events/message.py +0 -0
  17. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.2.0}/src/satori/adapters/milky/events/request.py +0 -0
@@ -1,9 +1,9 @@
1
1
  includes = ["src/satori/adapters/milky"]
2
- raw-dependencies = ["satori-python-server >= 0.17.2"]
2
+ raw-dependencies = ["satori-python-server >= 0.18.0"]
3
3
 
4
4
  [project]
5
5
  name = "satori-python-adapter-milky"
6
- version = "0.1.0"
6
+ version = "0.2.0"
7
7
  authors = [
8
8
  {name = "RF-Tar-Railt", email = "rf_tar_railt@qq.com"}
9
9
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: satori-python-adapter-milky
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Satori Protocol SDK for python, adapter for Milky
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-server>=0.17.2
20
+ Requires-Dist: satori-python-server>=0.18.0
21
21
  Description-Content-Type: text/markdown
22
22
 
23
23
  # satori-python
@@ -27,17 +27,18 @@ Description-Content-Type: text/markdown
27
27
  [![PyPI](https://img.shields.io/pypi/v/satori-python)](https://pypi.org/project/satori-python)
28
28
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/satori-python)](https://www.python.org/)
29
29
 
30
- 基于 [Satori](https://satori.js.org/zh-CN/) 协议的 Python 开发工具包
30
+ 基于 [Satori](https://satori.chat/zh-CN/) 协议的 Python 开发工具包
31
31
 
32
32
  ## 协议介绍
33
33
 
34
- [Satori Protocol](https://satori.js.org/zh-CN/)
34
+ [Satori Protocol](https://satori.chat/zh-CN/)
35
35
 
36
36
  ### 协议端
37
37
 
38
38
  目前提供了 `satori` 协议实现的有:
39
39
 
40
40
  - [Chronocat](https://chronocat.vercel.app)
41
+ - [LLBot](https://www.llonebot.com/guide/introduction)
41
42
  - [nekobox](https://github.com/wyapx/nekobox)
42
43
  - Koishi (搭配 `@koishijs/plugin-server`)
43
44
 
@@ -75,6 +76,7 @@ pip install satori-python-server
75
76
  | OneBot V11 | `pip install satori-python-adapter-onebot11` | satori.adapters.onebot11.forward, satori.adapters.onebot11.reverse |
76
77
  | Console | `pip install satori-python-adapter-console` | satori.adapters.console |
77
78
  | Milky | `pip install satori-python-adapter-milky` | satori.adapters.milky.main, satori.adapters.milky.webhook |
79
+ | QQ | `pip install satori-python-adapter-qq` | satori.adapters.milky.main, satori.adapters.milky.websocket |
78
80
 
79
81
  ### 社区适配器
80
82
 
@@ -5,17 +5,18 @@
5
5
  [![PyPI](https://img.shields.io/pypi/v/satori-python)](https://pypi.org/project/satori-python)
6
6
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/satori-python)](https://www.python.org/)
7
7
 
8
- 基于 [Satori](https://satori.js.org/zh-CN/) 协议的 Python 开发工具包
8
+ 基于 [Satori](https://satori.chat/zh-CN/) 协议的 Python 开发工具包
9
9
 
10
10
  ## 协议介绍
11
11
 
12
- [Satori Protocol](https://satori.js.org/zh-CN/)
12
+ [Satori Protocol](https://satori.chat/zh-CN/)
13
13
 
14
14
  ### 协议端
15
15
 
16
16
  目前提供了 `satori` 协议实现的有:
17
17
 
18
18
  - [Chronocat](https://chronocat.vercel.app)
19
+ - [LLBot](https://www.llonebot.com/guide/introduction)
19
20
  - [nekobox](https://github.com/wyapx/nekobox)
20
21
  - Koishi (搭配 `@koishijs/plugin-server`)
21
22
 
@@ -53,6 +54,7 @@ pip install satori-python-server
53
54
  | OneBot V11 | `pip install satori-python-adapter-onebot11` | satori.adapters.onebot11.forward, satori.adapters.onebot11.reverse |
54
55
  | Console | `pip install satori-python-adapter-console` | satori.adapters.console |
55
56
  | Milky | `pip install satori-python-adapter-milky` | satori.adapters.milky.main, satori.adapters.milky.webhook |
57
+ | QQ | `pip install satori-python-adapter-qq` | satori.adapters.milky.main, satori.adapters.milky.websocket |
56
58
 
57
59
  ### 社区适配器
58
60
 
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "satori-python-adapter-milky"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  authors = [
5
5
  { name = "RF-Tar-Railt", email = "rf_tar_railt@qq.com" },
6
6
  ]
7
7
  dependencies = [
8
- "satori-python-server >= 0.17.2",
8
+ "satori-python-server >= 0.18.0",
9
9
  ]
10
10
  description = "Satori Protocol SDK for python, adapter for Milky"
11
11
  readme = "README.md"
@@ -46,7 +46,7 @@ dev = [
46
46
  "mina-build<0.6,>=0.5.1",
47
47
  "pdm-mina>=0.3.2",
48
48
  "nonechat<0.7.0,>=0.6.0",
49
- "uvicorn[standard]>=0.35.0",
49
+ "uvicorn[standard]>=0.37.0",
50
50
  ]
51
51
 
52
52
  [tool.pdm.build]
@@ -89,6 +89,7 @@ exclude = [
89
89
  "exam_qps.py",
90
90
  "exam1.py",
91
91
  "exam2.py",
92
+ "src/satori/_vendor/*",
92
93
  ]
93
94
 
94
95
  [tool.ruff.lint]
@@ -214,7 +214,7 @@ def apply(
214
214
  {
215
215
  "group_id": int(request.params["guild_id"]),
216
216
  "user_id": int(request.params["user_id"]),
217
- "duration": int(request.params["duration"]),
217
+ "duration": int(request.params["duration"] / 1000),
218
218
  },
219
219
  )
220
220
  return
@@ -309,3 +309,8 @@ def apply(
309
309
  payload["reason"] = request.params.get("comment")
310
310
  await net.call_api("reject_friend_request", payload)
311
311
  return
312
+
313
+ @adapter.route("*")
314
+ async def internal_api(request: Request[dict]):
315
+ net = net_getter(request.self_id)
316
+ return await net.call_api(request.action.removeprefix("internal/"), request.params)
@@ -15,7 +15,7 @@ from satori.exception import ActionFailed
15
15
  from satori.model import Event, Login, LoginStatus
16
16
  from satori.server.adapter import Adapter as BaseAdapter
17
17
  from satori.server.model import Request
18
- from satori.utils import decode
18
+ from satori.utils import decode, encode
19
19
 
20
20
  from .api import apply
21
21
  from .events import event_handlers
@@ -116,17 +116,18 @@ class MilkyAdapter(BaseAdapter):
116
116
  content = await resp.read()
117
117
  return Response(content=content, media_type=resp.headers.get("Content-Type"))
118
118
 
119
- async def call_api(self, action: str, params: dict | None = None) -> dict | None:
119
+ async def call_api(self, action: str, params: dict | None = None) -> dict:
120
120
  if not self.session:
121
121
  raise RuntimeError("HTTP session not initialized")
122
122
  url = self.api_base.with_path(f"{self.api_base.path.rstrip('/')}/{action}")
123
123
  headers = self.headers.copy()
124
+ headers["Content-Type"] = "application/json"
124
125
  if self.token:
125
126
  headers.setdefault("Authorization", f"Bearer {self.token}")
126
- async with self.session.post(url, json=params or {}, headers=headers) as resp:
127
+ async with self.session.post(url, data=encode(params or {}), headers=headers) as resp:
127
128
  resp.raise_for_status()
128
- data = await resp.json()
129
- if data.get("status") == "failed":
129
+ data = decode(await resp.text())
130
+ if data.get("status") == "failed" or data.get("retcode", 0) != 0:
130
131
  raise ActionFailed(f"{data.get('retcode')}: {data.get('message')}", data)
131
132
  return data.get("data")
132
133
 
@@ -195,15 +196,14 @@ class MilkyAdapter(BaseAdapter):
195
196
  if handler:
196
197
  event = await handler(login, network, payload)
197
198
  else:
198
- body = payload.get("data", {})
199
199
  event = Event(
200
200
  EventType.INTERNAL,
201
201
  datetime.fromtimestamp(payload.get("time", datetime.now().timestamp())),
202
202
  login,
203
- _type=event_type,
204
- _data=body,
205
203
  )
206
204
  if event:
205
+ event._type = event_type
206
+ event._data = payload.get("data", {})
207
207
  await self.server.post(event)
208
208
 
209
209
  async def refresh_login(self):
@@ -0,0 +1,407 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Sequence
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from typing import Any, Literal
8
+
9
+ from satori.element import Custom, E, Element
10
+ from satori.model import Channel, ChannelType, Login, MessageObject, User
11
+ from satori.parser import Element as RawElement
12
+ from satori.parser import parse
13
+
14
+ from .utils import MilkyNetwork, decode_guild, decode_guild_channel_id, decode_member, get_scene_and_peer, user_avatar
15
+
16
+ _BASE64_RE = re.compile(r"^data:([\w/.+-]+);base64,")
17
+
18
+
19
+ @dataclass
20
+ class State:
21
+ type: Literal["message", "reply", "forward"]
22
+ children: list[dict[str, Any]] = field(default_factory=list)
23
+ author: dict[str, Any] = field(default_factory=dict)
24
+
25
+
26
+ class MilkyMessageEncoder:
27
+ def __init__(self, login: Login, net: MilkyNetwork, channel_id: str):
28
+ self.login = login
29
+ self.net = net
30
+ self.channel_id = channel_id
31
+ self.segments: list[dict[str, Any]] = []
32
+ self.stack = [State("message")]
33
+ self.results: list[MessageObject] = []
34
+
35
+ async def send_forward(self):
36
+ if not self.stack[0].children:
37
+ return
38
+ scene, peer_id = get_scene_and_peer(self.channel_id)
39
+ seg = {"type": "forward", "data": {"messages": self.stack[0].children}}
40
+ if scene == "group":
41
+ resp = await self.net.call_api("send_group_message", {"group_id": peer_id, "message": [seg]})
42
+ else:
43
+ resp = await self.net.call_api("send_private_message", {"user_id": peer_id, "message": [seg]})
44
+ if resp:
45
+ channel_type = ChannelType.TEXT if scene == "group" else ChannelType.DIRECT
46
+ channel_id = str(peer_id) if scene == "group" else self.channel_id
47
+ channel = Channel(channel_id, channel_type)
48
+ created_at = datetime.fromtimestamp(resp.get("time", datetime.now().timestamp()))
49
+ message = MessageObject(str(resp.get("message_seq", "")), "", channel=channel, created_at=created_at)
50
+ self.results.append(message)
51
+
52
+ async def _send_file(self, attrs: dict[str, Any]):
53
+ uri = attrs.get("src") or attrs.get("url")
54
+ if not uri:
55
+ return
56
+ name = attrs.get("title") or uri.split("/")[-1]
57
+ if match := _BASE64_RE.match(uri):
58
+ uri = f"base64://{uri[len(match.group(0)) :]}"
59
+ scene, peer_id = get_scene_and_peer(self.channel_id)
60
+ if scene == "group":
61
+ await self.net.call_api(
62
+ "upload_group_file",
63
+ {
64
+ "group_id": peer_id,
65
+ "file_uri": uri,
66
+ "file_name": name,
67
+ },
68
+ )
69
+ else:
70
+ await self.net.call_api(
71
+ "upload_private_file",
72
+ {
73
+ "user_id": peer_id,
74
+ "file_uri": uri,
75
+ "file_name": name,
76
+ },
77
+ )
78
+ self.results.append(MessageObject("", ""))
79
+
80
+ async def send(self, content: str) -> list[MessageObject]:
81
+ raw_elements = parse(content)
82
+ await self.render(raw_elements)
83
+ await self.flush()
84
+ return self.results
85
+
86
+ async def render(self, elements: Sequence[RawElement]):
87
+ for element in elements:
88
+ await self.visit(element)
89
+
90
+ async def visit(self, element: RawElement):
91
+ type_ = element.type
92
+ attrs = element.attrs
93
+ children = element.children
94
+ match type_:
95
+ case "text":
96
+ text = attrs.get("text", "")
97
+ if not self.segments or self.segments[-1]["type"] != "text":
98
+ self.segments.append({"type": "text", "data": {"text": text}})
99
+ else:
100
+ self.segments[-1]["data"]["text"] += text
101
+ case "br":
102
+ if not self.segments or self.segments[-1]["type"] != "text":
103
+ self.segments.append({"type": "text", "data": {"text": "\n"}})
104
+ else:
105
+ self.segments[-1]["data"]["text"] += "\n"
106
+ case "p":
107
+ prev = self.segments[-1] if self.segments else None
108
+ if prev and prev["type"] == "text":
109
+ if not prev["data"]["text"].endswith("\n"):
110
+ prev["data"]["text"] += "\n"
111
+ else:
112
+ self.segments.append({"type": "text", "data": {"text": "\n"}})
113
+ await self.render(children)
114
+ if self.segments and self.segments[-1]["type"] == "text":
115
+ if not self.segments[-1]["data"]["text"].endswith("\n"):
116
+ self.segments[-1]["data"]["text"] += "\n"
117
+ else:
118
+ self.segments.append({"type": "text", "data": {"text": "\n"}})
119
+ case "at":
120
+ if attrs.get("type") == "all":
121
+ self.segments.append({"type": "mention_all", "data": {}})
122
+ elif "id" in attrs:
123
+ target = attrs["id"]
124
+ self.segments.append({"type": "mention", "data": {"user_id": int(target)}})
125
+ case "sharp":
126
+ self.segments.append({"type": "text", "data": {"text": attrs["id"]}})
127
+ case "a":
128
+ await self.render(children)
129
+ if "href" in attrs:
130
+ if not self.segments or self.segments[-1]["type"] != "text":
131
+ self.segments.append({"type": "text", "data": {"text": f" ({attrs['href']})"}})
132
+ else:
133
+ self.segments[-1]["data"]["text"] += f" ({attrs['href']})"
134
+ case "img" | "image":
135
+ uri = attrs.get("src") or attrs.get("url")
136
+ if not uri:
137
+ return
138
+ if match := _BASE64_RE.match(uri):
139
+ uri = f"base64://{uri[len(match.group(0)) :]}"
140
+ self.segments.append(
141
+ {"type": "image", "data": {"uri": uri, "sub_type": attrs.get("sub_type", "normal")}}
142
+ )
143
+ case "audio":
144
+ uri = attrs.get("src") or attrs.get("url")
145
+ if not uri:
146
+ return
147
+ if match := _BASE64_RE.match(uri):
148
+ uri = f"base64://{uri[len(match.group(0)) :]}"
149
+ self.segments.append({"type": "record", "data": {"uri": uri}})
150
+ case "video":
151
+ uri = attrs.get("src") or attrs.get("url")
152
+ if not uri:
153
+ return
154
+ if match := _BASE64_RE.match(uri):
155
+ uri = f"base64://{uri[len(match.group(0)) :]}"
156
+ payload = {"uri": uri}
157
+ if poster := attrs.get("poster"):
158
+ payload["thumb_uri"] = poster
159
+ self.segments.append({"type": "video", "data": payload})
160
+ case "milky:face":
161
+ self.segments.append({"type": "face", "data": {"face_id": attrs["id"]}})
162
+ case "file":
163
+ await self.flush()
164
+ await self._send_file(attrs)
165
+ case "author":
166
+ self.stack[0].author.update(attrs)
167
+ case "quote":
168
+ await self.flush()
169
+ self.segments.append({"type": "reply", "data": {"message_seq": int(attrs["id"])}})
170
+ case "message":
171
+ await self.flush()
172
+ if "forward" in attrs:
173
+ self.stack.insert(0, State("forward"))
174
+ await self.render(children)
175
+ await self.flush()
176
+ self.stack.pop(0)
177
+ await self.send_forward()
178
+ elif "id" in attrs:
179
+ self.stack[0].author["seq"] = int(attrs["id"])
180
+ else:
181
+ payload = {}
182
+ if "name" in attrs:
183
+ payload["name"] = attrs["name"]
184
+ if "nickname" in attrs:
185
+ payload["name"] = attrs["nickname"]
186
+ if "username" in attrs:
187
+ payload["name"] = attrs["username"]
188
+ if "id" in attrs:
189
+ payload["id"] = int(attrs["id"])
190
+ if "user_id" in attrs:
191
+ payload["id"] = int(attrs["user_id"])
192
+ if "time" in attrs:
193
+ payload["time"] = int(attrs["time"])
194
+ self.stack[0].author.update(payload)
195
+ await self.render(children)
196
+ await self.flush()
197
+ case _:
198
+ await self.render(children)
199
+
200
+ async def flush(self):
201
+ if not self.segments:
202
+ return
203
+
204
+ while True:
205
+ first = self.segments[0]
206
+ if first["type"] != "text":
207
+ break
208
+ first["data"]["text"] = first["data"]["text"].lstrip()
209
+ if first["data"]["text"]:
210
+ break
211
+ self.segments.pop(0)
212
+
213
+ while True:
214
+ last = self.segments[-1]
215
+ if last["type"] != "text":
216
+ break
217
+ last["data"]["text"] = last["data"]["text"].rstrip()
218
+ if last["data"]["text"]:
219
+ break
220
+ self.segments.pop()
221
+
222
+ scene, peer_id = get_scene_and_peer(self.channel_id)
223
+ slot = self.stack[0]
224
+ type_, author = slot.type, slot.author
225
+ if not self.segments and "seq" not in author:
226
+ return
227
+ if type_ == "forward":
228
+ if "seq" in author:
229
+ origin = await self.net.call_api(
230
+ "get_message", {"message_scene": scene, "peer_id": peer_id, "message_seq": author["seq"]}
231
+ )
232
+ segments = origin["message"]["segments"]
233
+ nickname = (
234
+ origin["message"]["friend"]["nickname"]
235
+ if scene == "friend"
236
+ else origin["message"]["group_member"]["nickname"]
237
+ )
238
+ self.stack[1].children.append(
239
+ {
240
+ "user_id": origin["message"]["sender_id"],
241
+ "sender_name": nickname,
242
+ "segments": await self.sendable(segments),
243
+ }
244
+ )
245
+ else:
246
+ self.stack[1].children.append(
247
+ {
248
+ "user_id": int(author.get("id", self.login.id)),
249
+ "sender_name": author.get("name", self.login.user.name or self.login.id),
250
+ "segments": self.segments,
251
+ }
252
+ )
253
+ self.segments = []
254
+ return
255
+
256
+ if scene == "group":
257
+ resp = await self.net.call_api("send_group_message", {"group_id": peer_id, "message": self.segments})
258
+ else:
259
+ resp = await self.net.call_api("send_private_message", {"user_id": peer_id, "message": self.segments})
260
+ if resp:
261
+ channel_type = ChannelType.TEXT if scene == "group" else ChannelType.DIRECT
262
+ channel_id = str(peer_id) if scene == "group" else self.channel_id
263
+ channel = Channel(channel_id, channel_type)
264
+ created_at = datetime.fromtimestamp(resp.get("time", datetime.now().timestamp()))
265
+ message = MessageObject(str(resp.get("message_seq", "")), "", channel=channel, created_at=created_at)
266
+ self.results.append(message)
267
+ self.segments = []
268
+
269
+ async def sendable(self, segments: list[dict[str, Any]]) -> list[dict[str, Any]]:
270
+ new = []
271
+ for seg in segments:
272
+ if (
273
+ seg["type"] in ("image", "record", "video")
274
+ and "resource_id" in seg["data"]
275
+ and "uri" not in seg["data"]
276
+ ):
277
+ data = seg["data"]
278
+ if "temp_url" not in data:
279
+ data["uri"] = (
280
+ await self.net.call_api("get_resource_temp_url", {"resource_id": data["resource_id"]})
281
+ )["url"]
282
+ else:
283
+ data["uri"] = data["temp_url"]
284
+ new.append({"type": seg["type"], "data": data})
285
+ elif seg["type"] == "forward" and "forward_id" in seg["data"]:
286
+ forward_id = seg["data"]["forward_id"]
287
+ messages = (await self.net.call_api("get_forwarded_messages", {"forward_id": forward_id}))["messages"]
288
+ new.append(
289
+ {
290
+ "type": "forward",
291
+ "data": {
292
+ "messages": [
293
+ {
294
+ "user_id": int(self.login.id),
295
+ "sender_name": msg["sender_name"],
296
+ "segments": await self.sendable(msg["segments"]),
297
+ }
298
+ for msg in messages
299
+ ]
300
+ },
301
+ }
302
+ )
303
+ elif seg["type"] in ("market_face", "light_app", "xml"):
304
+ continue
305
+ return new
306
+
307
+
308
+ async def decode_message(net: MilkyNetwork, payload: dict) -> MessageObject:
309
+ elements = await _decode_segments(net, payload, payload.get("segments") or [])
310
+ guild_id, channel_id = decode_guild_channel_id(payload)
311
+ channel_type = ChannelType.TEXT if guild_id else ChannelType.DIRECT
312
+ channel_name = None
313
+ guild = None
314
+ member = None
315
+ if payload["message_scene"] == "group":
316
+ group_info = payload.get("group")
317
+ if group_info:
318
+ guild = decode_guild(group_info)
319
+ channel_name = group_info.get("group_name")
320
+ member_info = payload.get("group_member")
321
+ if member_info:
322
+ member = decode_member(member_info)
323
+ elif payload["message_scene"] == "friend":
324
+ friend_info = payload.get("friend")
325
+ if friend_info:
326
+ channel_name = friend_info.get("nickname")
327
+ channel = Channel(channel_id, channel_type, channel_name)
328
+
329
+ user_name = None
330
+ if payload["message_scene"] == "group":
331
+ member_info = payload.get("group_member")
332
+ if member_info:
333
+ user_name = member_info.get("nickname")
334
+ elif payload["message_scene"] == "friend":
335
+ friend_info = payload.get("friend")
336
+ if friend_info:
337
+ user_name = friend_info.get("nickname")
338
+ user = User(str(payload["sender_id"]), user_name, avatar=user_avatar(payload["sender_id"]))
339
+
340
+ message = MessageObject(
341
+ str(payload["message_seq"]),
342
+ "".join(str(elem) for elem in elements),
343
+ channel=channel,
344
+ guild=guild,
345
+ member=member,
346
+ user=user,
347
+ created_at=datetime.fromtimestamp(payload["time"]),
348
+ )
349
+ message.message = elements
350
+ return message
351
+
352
+
353
+ async def _decode_segments(net: MilkyNetwork, payload: dict, segments: Sequence[dict]) -> list[Element]:
354
+ result: list[Element] = []
355
+ for segment in segments:
356
+ seg_type = segment.get("type")
357
+ data = segment.get("data", {})
358
+ match seg_type:
359
+ case "text":
360
+ result.append(E.text(data.get("text", "")))
361
+ case "mention":
362
+ result.append(E.at(str(data.get("user_id"))))
363
+ case "mention_all":
364
+ result.append(E.at_all())
365
+ case "image":
366
+ result.append(E.image(_resource_url(data)))
367
+ case "record":
368
+ result.append(E.audio(_resource_url(data)))
369
+ case "video":
370
+ result.append(E.video(_resource_url(data)))
371
+ case "file":
372
+ result.append(E.file(_resource_url(data)))
373
+ case "reply":
374
+ seq = data.get("message_seq")
375
+ if seq is not None:
376
+ quote = await _decode_reply(net, payload, int(seq))
377
+ if quote:
378
+ result.append(quote)
379
+ case _:
380
+ result.append(Custom(f"milky:{seg_type}", data))
381
+ return result
382
+
383
+
384
+ async def _decode_reply(net: MilkyNetwork, payload: dict, message_seq: int) -> Element | None:
385
+ try:
386
+ response = await net.call_api(
387
+ "get_message",
388
+ {
389
+ "message_scene": payload["message_scene"],
390
+ "peer_id": payload["peer_id"],
391
+ "message_seq": message_seq,
392
+ },
393
+ )
394
+ except Exception:
395
+ return None
396
+ if not response or "message" not in response:
397
+ return None
398
+ quoted = await decode_message(net, response["message"])
399
+ content = []
400
+ if quoted.user:
401
+ content.append(E.author(quoted.user.id, quoted.user.name, quoted.user.avatar))
402
+ content.extend(quoted.message)
403
+ return E.quote(str(message_seq), content=content)
404
+
405
+
406
+ def _resource_url(data: dict) -> str:
407
+ return data.get("temp_url") or data.get("url") or data.get("uri") or ""
@@ -10,7 +10,7 @@ GROUP_AVATAR_URL = "https://p.qlogo.cn/gh/{group}/{group}/640"
10
10
 
11
11
 
12
12
  class MilkyNetwork(Protocol):
13
- async def call_api(self, action: str, params: dict | None = None) -> dict | None: ...
13
+ async def call_api(self, action: str, params: dict | None = None) -> dict: ...
14
14
 
15
15
 
16
16
  def user_avatar(uin: int | str) -> str:
@@ -16,6 +16,7 @@ from satori.exception import ActionFailed
16
16
  from satori.model import Event, Login, LoginStatus
17
17
  from satori.server.adapter import Adapter as BaseAdapter
18
18
  from satori.server.model import Request
19
+ from satori.utils import decode, encode
19
20
 
20
21
  from .api import apply
21
22
  from .events import event_handlers
@@ -109,17 +110,18 @@ class MilkyWebhookAdapter(BaseAdapter):
109
110
  content = await resp.read()
110
111
  return Response(content=content, media_type=resp.headers.get("Content-Type"))
111
112
 
112
- async def call_api(self, action: str, params: dict | None = None) -> dict | None:
113
+ async def call_api(self, action: str, params: dict | None = None) -> dict:
113
114
  if not self.session:
114
115
  raise RuntimeError("HTTP session not initialized")
115
116
  url = self.api_base.with_path(f"{self.api_base.path.rstrip('/')}/{action}")
116
117
  headers = self.headers.copy()
118
+ headers["Content-Type"] = "application/json"
117
119
  if self.token:
118
120
  headers.setdefault("Authorization", f"Bearer {self.token}")
119
- async with self.session.post(url, json=params or {}, headers=headers) as resp:
121
+ async with self.session.post(url, data=encode(params or {}), headers=headers) as resp:
120
122
  resp.raise_for_status()
121
- data = await resp.json()
122
- if data.get("status") == "failed":
123
+ data = decode(await resp.text())
124
+ if data.get("status") == "failed" or data.get("retcode", 0) != 0:
123
125
  raise ActionFailed(f"{data.get('retcode')}: {data.get('message')}", data)
124
126
  return data.get("data")
125
127
 
@@ -145,15 +147,14 @@ class MilkyWebhookAdapter(BaseAdapter):
145
147
  if handler:
146
148
  event = await handler(login, network, payload)
147
149
  else:
148
- body = payload.get("data", {})
149
150
  event = Event(
150
151
  EventType.INTERNAL,
151
152
  datetime.fromtimestamp(payload.get("time", datetime.now().timestamp())),
152
153
  login,
153
- _type=event_type,
154
- _data=body,
155
154
  )
156
155
  if event:
156
+ event._type = event_type
157
+ event._data = payload.get("data", {})
157
158
  await self.server.post(event)
158
159
 
159
160
  async def refresh_login(self):
@@ -1,233 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import re
4
- from collections.abc import Sequence
5
- from datetime import datetime
6
- from typing import Any
7
-
8
- from satori.element import Custom, E, Element
9
- from satori.model import Channel, ChannelType, Login, MessageObject, User
10
- from satori.parser import Element as RawElement
11
- from satori.parser import parse
12
-
13
- from .utils import MilkyNetwork, decode_guild, decode_guild_channel_id, decode_member, get_scene_and_peer, user_avatar
14
-
15
- _BASE64_RE = re.compile(r"^data:([\w/.+-]+);base64,")
16
-
17
-
18
- class MilkyMessageEncoder:
19
- def __init__(self, login: Login, net: MilkyNetwork, channel_id: str):
20
- self.login = login
21
- self.net = net
22
- self.channel_id = channel_id
23
- self.segments: list[dict[str, Any]] = []
24
- self.elements: list[Element] = []
25
- self.results: list[MessageObject] = []
26
-
27
- async def send(self, content: str) -> list[MessageObject]:
28
- raw_elements = parse(content)
29
- await self.render(raw_elements)
30
- await self.flush()
31
- return self.results
32
-
33
- async def render(self, elements: Sequence[RawElement]):
34
- for element in elements:
35
- await self.visit(element)
36
-
37
- async def visit(self, element: RawElement):
38
- type_ = element.type
39
- attrs = element.attrs
40
- children = element.children
41
- if type_ == "text":
42
- text = attrs.get("text", "")
43
- if not self.segments or self.segments[-1]["type"] != "text":
44
- self.segments.append({"type": "text", "data": {"text": text}})
45
- else:
46
- self.segments[-1]["data"]["text"] += text
47
- self.elements.append(E.text(text))
48
- elif type_ == "br":
49
- if not self.segments or self.segments[-1]["type"] != "text":
50
- self.segments.append({"type": "text", "data": {"text": "\n"}})
51
- else:
52
- self.segments[-1]["data"]["text"] += "\n"
53
- self.elements.append(E.text("\n"))
54
- elif type_ == "p":
55
- await self.render(children)
56
- if self.segments:
57
- if self.segments[-1]["type"] == "text":
58
- self.segments[-1]["data"]["text"] += "\n"
59
- else:
60
- self.segments.append({"type": "text", "data": {"text": "\n"}})
61
- self.elements.append(E.text("\n"))
62
- elif type_ == "at":
63
- if attrs.get("type") == "all":
64
- self.segments.append({"type": "mention_all", "data": {}})
65
- self.elements.append(E.at_all())
66
- elif "id" in attrs:
67
- target = attrs["id"]
68
- self.segments.append({"type": "mention", "data": {"user_id": int(target)}})
69
- self.elements.append(E.at(str(target)))
70
- elif type_ == "quote":
71
- quote_id = attrs.get("id")
72
- if quote_id is not None:
73
- try:
74
- seq = int(quote_id)
75
- except ValueError:
76
- seq = None
77
- if seq is not None:
78
- self.segments.append({"type": "reply", "data": {"message_seq": seq}})
79
- self.elements.append(E.quote(str(seq), content=[E.text("")]))
80
- await self.render(children)
81
- elif type_ in {"img", "image"}:
82
- uri = attrs.get("src") or attrs.get("url")
83
- if not uri:
84
- return
85
- if match := _BASE64_RE.match(uri):
86
- uri = f"base64://{uri[len(match.group(0)) :]}"
87
- self.segments.append({"type": "image", "data": {"uri": uri, "sub_type": attrs.get("sub_type", "normal")}})
88
- self.elements.append(E.image(uri))
89
- elif type_ == "audio":
90
- uri = attrs.get("src") or attrs.get("url")
91
- if not uri:
92
- return
93
- if match := _BASE64_RE.match(uri):
94
- uri = f"base64://{uri[len(match.group(0)) :]}"
95
- self.segments.append({"type": "record", "data": {"uri": uri}})
96
- self.elements.append(E.audio(uri))
97
- elif type_ == "video":
98
- uri = attrs.get("src") or attrs.get("url")
99
- if not uri:
100
- return
101
- if match := _BASE64_RE.match(uri):
102
- uri = f"base64://{uri[len(match.group(0)) :]}"
103
- payload = {"uri": uri}
104
- if poster := attrs.get("poster"):
105
- payload["thumb_uri"] = poster
106
- self.segments.append({"type": "video", "data": payload})
107
- self.elements.append(E.video(uri))
108
- else:
109
- await self.render(children)
110
-
111
- async def flush(self):
112
- if not self.segments:
113
- return
114
- scene, peer_id = get_scene_and_peer(self.channel_id)
115
- payload: dict = {"message": self.segments}
116
- if scene == "group":
117
- payload["group_id"] = peer_id
118
- resp = await self.net.call_api("send_group_message", payload)
119
- else:
120
- payload["user_id"] = peer_id
121
- resp = await self.net.call_api("send_private_message", payload)
122
- if resp:
123
- channel_type = ChannelType.TEXT if scene == "group" else ChannelType.DIRECT
124
- channel_id = str(peer_id) if scene == "group" else self.channel_id
125
- channel = Channel(channel_id, channel_type)
126
- created_at = datetime.fromtimestamp(resp.get("time", datetime.now().timestamp()))
127
- content = "".join(str(elem) for elem in self.elements)
128
- message = MessageObject(str(resp.get("message_seq", "")), content, channel=channel, created_at=created_at)
129
- message.message = list(self.elements)
130
- self.results.append(message)
131
- self.segments = []
132
- self.elements = []
133
-
134
-
135
- async def decode_message(net: MilkyNetwork, payload: dict) -> MessageObject:
136
- elements = await _decode_segments(net, payload, payload.get("segments") or [])
137
- guild_id, channel_id = decode_guild_channel_id(payload)
138
- channel_type = ChannelType.TEXT if guild_id else ChannelType.DIRECT
139
- channel_name = None
140
- guild = None
141
- member = None
142
- if payload["message_scene"] == "group":
143
- group_info = payload.get("group")
144
- if group_info:
145
- guild = decode_guild(group_info)
146
- channel_name = group_info.get("group_name")
147
- member_info = payload.get("group_member")
148
- if member_info:
149
- member = decode_member(member_info)
150
- elif payload["message_scene"] == "friend":
151
- friend_info = payload.get("friend")
152
- if friend_info:
153
- channel_name = friend_info.get("nickname")
154
- channel = Channel(channel_id, channel_type, channel_name)
155
-
156
- user_name = None
157
- if payload["message_scene"] == "group":
158
- member_info = payload.get("group_member")
159
- if member_info:
160
- user_name = member_info.get("nickname")
161
- elif payload["message_scene"] == "friend":
162
- friend_info = payload.get("friend")
163
- if friend_info:
164
- user_name = friend_info.get("nickname")
165
- user = User(str(payload["sender_id"]), user_name, avatar=user_avatar(payload["sender_id"]))
166
-
167
- message = MessageObject(
168
- str(payload["message_seq"]),
169
- "".join(str(elem) for elem in elements),
170
- channel=channel,
171
- guild=guild,
172
- member=member,
173
- user=user,
174
- created_at=datetime.fromtimestamp(payload["time"]),
175
- )
176
- message.message = elements
177
- return message
178
-
179
-
180
- async def _decode_segments(net: MilkyNetwork, payload: dict, segments: Sequence[dict]) -> list[Element]:
181
- result: list[Element] = []
182
- for segment in segments:
183
- seg_type = segment.get("type")
184
- data = segment.get("data", {})
185
- if seg_type == "text":
186
- result.append(E.text(data.get("text", "")))
187
- elif seg_type == "mention":
188
- result.append(E.at(str(data.get("user_id"))))
189
- elif seg_type == "mention_all":
190
- result.append(E.at_all())
191
- elif seg_type == "image":
192
- result.append(E.image(_resource_url(data)))
193
- elif seg_type == "record":
194
- result.append(E.audio(_resource_url(data)))
195
- elif seg_type == "video":
196
- result.append(E.video(_resource_url(data)))
197
- elif seg_type == "file":
198
- result.append(E.file(_resource_url(data)))
199
- elif seg_type == "reply":
200
- seq = data.get("message_seq")
201
- if seq is not None:
202
- quote = await _decode_reply(net, payload, int(seq))
203
- if quote:
204
- result.append(quote)
205
- else:
206
- result.append(Custom(f"milky:{seg_type}", data))
207
- return result
208
-
209
-
210
- async def _decode_reply(net: MilkyNetwork, payload: dict, message_seq: int) -> Element | None:
211
- try:
212
- response = await net.call_api(
213
- "get_message",
214
- {
215
- "message_scene": payload["message_scene"],
216
- "peer_id": payload["peer_id"],
217
- "message_seq": message_seq,
218
- },
219
- )
220
- except Exception:
221
- return None
222
- if not response or "message" not in response:
223
- return None
224
- quoted = await decode_message(net, response["message"])
225
- content = []
226
- if quoted.user:
227
- content.append(E.author(quoted.user.id, quoted.user.name, quoted.user.avatar))
228
- content.extend(quoted.message)
229
- return E.quote(str(message_seq), content=content)
230
-
231
-
232
- def _resource_url(data: dict) -> str:
233
- return data.get("temp_url") or data.get("url") or data.get("uri") or ""