satori-python-adapter-milky 0.1.0__tar.gz → 0.1.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 (17) hide show
  1. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.1.2}/.mina/adapter_milky.toml +2 -2
  2. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.1.2}/PKG-INFO +2 -2
  3. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.1.2}/pyproject.toml +4 -3
  4. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.1.2}/src/satori/adapters/milky/api.py +6 -1
  5. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.1.2}/src/satori/adapters/milky/main.py +6 -5
  6. satori_python_adapter_milky-0.1.2/src/satori/adapters/milky/message.py +403 -0
  7. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.1.2}/src/satori/adapters/milky/utils.py +1 -1
  8. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.1.2}/src/satori/adapters/milky/webhook.py +6 -4
  9. satori_python_adapter_milky-0.1.0/src/satori/adapters/milky/message.py +0 -233
  10. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.1.2}/LICENSE +0 -0
  11. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.1.2}/README.md +0 -0
  12. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.1.2}/src/satori/adapters/milky/__init__.py +0 -0
  13. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.1.2}/src/satori/adapters/milky/events/__init__.py +0 -0
  14. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.1.2}/src/satori/adapters/milky/events/base.py +0 -0
  15. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.1.2}/src/satori/adapters/milky/events/group.py +0 -0
  16. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.1.2}/src/satori/adapters/milky/events/message.py +0 -0
  17. {satori_python_adapter_milky-0.1.0 → satori_python_adapter_milky-0.1.2}/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.17.6"]
3
3
 
4
4
  [project]
5
5
  name = "satori-python-adapter-milky"
6
- version = "0.1.0"
6
+ version = "0.1.2"
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.1.2
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.17.6
21
21
  Description-Content-Type: text/markdown
22
22
 
23
23
  # satori-python
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "satori-python-adapter-milky"
3
- version = "0.1.0"
3
+ version = "0.1.2"
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.17.6",
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
 
@@ -0,0 +1,403 @@
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
+ if type_ == "text":
95
+ text = attrs.get("text", "")
96
+ if not self.segments or self.segments[-1]["type"] != "text":
97
+ self.segments.append({"type": "text", "data": {"text": text}})
98
+ else:
99
+ self.segments[-1]["data"]["text"] += text
100
+ elif type_ == "br":
101
+ if not self.segments or self.segments[-1]["type"] != "text":
102
+ self.segments.append({"type": "text", "data": {"text": "\n"}})
103
+ else:
104
+ self.segments[-1]["data"]["text"] += "\n"
105
+ elif type_ == "p":
106
+ prev = self.segments[-1] if self.segments else None
107
+ if prev and prev["type"] == "text":
108
+ if not prev["data"]["text"].endswith("\n"):
109
+ prev["data"]["text"] += "\n"
110
+ else:
111
+ self.segments.append({"type": "text", "data": {"text": "\n"}})
112
+ await self.render(children)
113
+ if self.segments and self.segments[-1]["type"] == "text":
114
+ if not self.segments[-1]["data"]["text"].endswith("\n"):
115
+ self.segments[-1]["data"]["text"] += "\n"
116
+ else:
117
+ self.segments.append({"type": "text", "data": {"text": "\n"}})
118
+ elif type_ == "at":
119
+ if attrs.get("type") == "all":
120
+ self.segments.append({"type": "mention_all", "data": {}})
121
+ elif "id" in attrs:
122
+ target = attrs["id"]
123
+ self.segments.append({"type": "mention", "data": {"user_id": int(target)}})
124
+ elif type_ == "sharp":
125
+ self.segments.append({"type": "text", "data": {"text": attrs["id"]}})
126
+ elif type_ == "a":
127
+ await self.render(children)
128
+ if "href" in attrs:
129
+ if not self.segments or self.segments[-1]["type"] != "text":
130
+ self.segments.append({"type": "text", "data": {"text": f" ({attrs['href']})"}})
131
+ else:
132
+ self.segments[-1]["data"]["text"] += f" ({attrs['href']})"
133
+ elif type_ in {"img", "image"}:
134
+ uri = attrs.get("src") or attrs.get("url")
135
+ if not uri:
136
+ return
137
+ if match := _BASE64_RE.match(uri):
138
+ uri = f"base64://{uri[len(match.group(0)) :]}"
139
+ self.segments.append({"type": "image", "data": {"uri": uri, "sub_type": attrs.get("sub_type", "normal")}})
140
+ elif type_ == "audio":
141
+ uri = attrs.get("src") or attrs.get("url")
142
+ if not uri:
143
+ return
144
+ if match := _BASE64_RE.match(uri):
145
+ uri = f"base64://{uri[len(match.group(0)) :]}"
146
+ self.segments.append({"type": "record", "data": {"uri": uri}})
147
+ elif type_ == "video":
148
+ uri = attrs.get("src") or attrs.get("url")
149
+ if not uri:
150
+ return
151
+ if match := _BASE64_RE.match(uri):
152
+ uri = f"base64://{uri[len(match.group(0)) :]}"
153
+ payload = {"uri": uri}
154
+ if poster := attrs.get("poster"):
155
+ payload["thumb_uri"] = poster
156
+ self.segments.append({"type": "video", "data": payload})
157
+ elif type_ == "milky:face":
158
+ self.segments.append({"type": "face", "data": {"face_id": attrs["id"]}})
159
+ elif type_ == "file":
160
+ await self.flush()
161
+ await self._send_file(attrs)
162
+ elif type_ == "author":
163
+ self.stack[0].author.update(attrs)
164
+ elif type_ == "quote":
165
+ await self.flush()
166
+ self.segments.append({"type": "reply", "data": {"message_seq": int(attrs["id"])}})
167
+ elif type_ == "message":
168
+ await self.flush()
169
+ if "forward" in attrs:
170
+ self.stack.insert(0, State("forward"))
171
+ await self.render(children)
172
+ await self.flush()
173
+ self.stack.pop(0)
174
+ await self.send_forward()
175
+ elif "id" in attrs:
176
+ self.stack[0].author["seq"] = int(attrs["id"])
177
+ else:
178
+ payload = {}
179
+ if "name" in attrs:
180
+ payload["name"] = attrs["name"]
181
+ if "nickname" in attrs:
182
+ payload["name"] = attrs["nickname"]
183
+ if "username" in attrs:
184
+ payload["name"] = attrs["username"]
185
+ if "id" in attrs:
186
+ payload["id"] = int(attrs["id"])
187
+ if "user_id" in attrs:
188
+ payload["id"] = int(attrs["user_id"])
189
+ if "time" in attrs:
190
+ payload["time"] = int(attrs["time"])
191
+ self.stack[0].author.update(payload)
192
+ await self.render(children)
193
+ await self.flush()
194
+ else:
195
+ await self.render(children)
196
+
197
+ async def flush(self):
198
+ if not self.segments:
199
+ return
200
+
201
+ while True:
202
+ first = self.segments[0]
203
+ if first["type"] != "text":
204
+ break
205
+ first["data"]["text"] = first["data"]["text"].lstrip()
206
+ if first["data"]["text"]:
207
+ break
208
+ self.segments.pop(0)
209
+
210
+ while True:
211
+ last = self.segments[-1]
212
+ if last["type"] != "text":
213
+ break
214
+ last["data"]["text"] = last["data"]["text"].rstrip()
215
+ if last["data"]["text"]:
216
+ break
217
+ self.segments.pop()
218
+
219
+ scene, peer_id = get_scene_and_peer(self.channel_id)
220
+ slot = self.stack[0]
221
+ type_, author = slot.type, slot.author
222
+ if not self.segments and "seq" not in author:
223
+ return
224
+ if type_ == "forward":
225
+ if "seq" in author:
226
+ origin = await self.net.call_api(
227
+ "get_message", {"message_scene": scene, "peer_id": peer_id, "message_seq": author["seq"]}
228
+ )
229
+ segments = origin["message"]["segments"]
230
+ nickname = (
231
+ origin["message"]["friend"]["nickname"]
232
+ if scene == "friend"
233
+ else origin["message"]["group_member"]["nickname"]
234
+ )
235
+ self.stack[1].children.append(
236
+ {
237
+ "user_id": origin["message"]["sender_id"],
238
+ "sender_name": nickname,
239
+ "segments": await self.sendable(segments),
240
+ }
241
+ )
242
+ else:
243
+ self.stack[1].children.append(
244
+ {
245
+ "user_id": int(author.get("id", self.login.id)),
246
+ "sender_name": author.get("name", self.login.user.name or self.login.id),
247
+ "segments": self.segments,
248
+ }
249
+ )
250
+ self.segments = []
251
+ return
252
+
253
+ if scene == "group":
254
+ resp = await self.net.call_api("send_group_message", {"group_id": peer_id, "message": self.segments})
255
+ else:
256
+ resp = await self.net.call_api("send_private_message", {"user_id": peer_id, "message": self.segments})
257
+ if resp:
258
+ channel_type = ChannelType.TEXT if scene == "group" else ChannelType.DIRECT
259
+ channel_id = str(peer_id) if scene == "group" else self.channel_id
260
+ channel = Channel(channel_id, channel_type)
261
+ created_at = datetime.fromtimestamp(resp.get("time", datetime.now().timestamp()))
262
+ message = MessageObject(str(resp.get("message_seq", "")), "", channel=channel, created_at=created_at)
263
+ self.results.append(message)
264
+ self.segments = []
265
+
266
+ async def sendable(self, segments: list[dict[str, Any]]) -> list[dict[str, Any]]:
267
+ new = []
268
+ for seg in segments:
269
+ if (
270
+ seg["type"] in ("image", "record", "video")
271
+ and "resource_id" in seg["data"]
272
+ and "uri" not in seg["data"]
273
+ ):
274
+ data = seg["data"]
275
+ if "temp_url" not in data:
276
+ data["uri"] = (
277
+ await self.net.call_api("get_resource_temp_url", {"resource_id": data["resource_id"]})
278
+ )["url"]
279
+ else:
280
+ data["uri"] = data["temp_url"]
281
+ new.append({"type": seg["type"], "data": data})
282
+ elif seg["type"] == "forward" and "forward_id" in seg["data"]:
283
+ forward_id = seg["data"]["forward_id"]
284
+ messages = (await self.net.call_api("get_forwarded_messages", {"forward_id": forward_id}))["messages"]
285
+ new.append(
286
+ {
287
+ "type": "forward",
288
+ "data": {
289
+ "messages": [
290
+ {
291
+ "user_id": int(self.login.id),
292
+ "sender_name": msg["sender_name"],
293
+ "segments": await self.sendable(msg["segments"]),
294
+ }
295
+ for msg in messages
296
+ ]
297
+ },
298
+ }
299
+ )
300
+ elif seg["type"] in ("market_face", "light_app", "xml"):
301
+ continue
302
+ return new
303
+
304
+
305
+ async def decode_message(net: MilkyNetwork, payload: dict) -> MessageObject:
306
+ elements = await _decode_segments(net, payload, payload.get("segments") or [])
307
+ guild_id, channel_id = decode_guild_channel_id(payload)
308
+ channel_type = ChannelType.TEXT if guild_id else ChannelType.DIRECT
309
+ channel_name = None
310
+ guild = None
311
+ member = None
312
+ if payload["message_scene"] == "group":
313
+ group_info = payload.get("group")
314
+ if group_info:
315
+ guild = decode_guild(group_info)
316
+ channel_name = group_info.get("group_name")
317
+ member_info = payload.get("group_member")
318
+ if member_info:
319
+ member = decode_member(member_info)
320
+ elif payload["message_scene"] == "friend":
321
+ friend_info = payload.get("friend")
322
+ if friend_info:
323
+ channel_name = friend_info.get("nickname")
324
+ channel = Channel(channel_id, channel_type, channel_name)
325
+
326
+ user_name = None
327
+ if payload["message_scene"] == "group":
328
+ member_info = payload.get("group_member")
329
+ if member_info:
330
+ user_name = member_info.get("nickname")
331
+ elif payload["message_scene"] == "friend":
332
+ friend_info = payload.get("friend")
333
+ if friend_info:
334
+ user_name = friend_info.get("nickname")
335
+ user = User(str(payload["sender_id"]), user_name, avatar=user_avatar(payload["sender_id"]))
336
+
337
+ message = MessageObject(
338
+ str(payload["message_seq"]),
339
+ "".join(str(elem) for elem in elements),
340
+ channel=channel,
341
+ guild=guild,
342
+ member=member,
343
+ user=user,
344
+ created_at=datetime.fromtimestamp(payload["time"]),
345
+ )
346
+ message.message = elements
347
+ return message
348
+
349
+
350
+ async def _decode_segments(net: MilkyNetwork, payload: dict, segments: Sequence[dict]) -> list[Element]:
351
+ result: list[Element] = []
352
+ for segment in segments:
353
+ seg_type = segment.get("type")
354
+ data = segment.get("data", {})
355
+ if seg_type == "text":
356
+ result.append(E.text(data.get("text", "")))
357
+ elif seg_type == "mention":
358
+ result.append(E.at(str(data.get("user_id"))))
359
+ elif seg_type == "mention_all":
360
+ result.append(E.at_all())
361
+ elif seg_type == "image":
362
+ result.append(E.image(_resource_url(data)))
363
+ elif seg_type == "record":
364
+ result.append(E.audio(_resource_url(data)))
365
+ elif seg_type == "video":
366
+ result.append(E.video(_resource_url(data)))
367
+ elif seg_type == "file":
368
+ result.append(E.file(_resource_url(data)))
369
+ elif seg_type == "reply":
370
+ seq = data.get("message_seq")
371
+ if seq is not None:
372
+ quote = await _decode_reply(net, payload, int(seq))
373
+ if quote:
374
+ result.append(quote)
375
+ else:
376
+ result.append(Custom(f"milky:{seg_type}", data))
377
+ return result
378
+
379
+
380
+ async def _decode_reply(net: MilkyNetwork, payload: dict, message_seq: int) -> Element | None:
381
+ try:
382
+ response = await net.call_api(
383
+ "get_message",
384
+ {
385
+ "message_scene": payload["message_scene"],
386
+ "peer_id": payload["peer_id"],
387
+ "message_seq": message_seq,
388
+ },
389
+ )
390
+ except Exception:
391
+ return None
392
+ if not response or "message" not in response:
393
+ return None
394
+ quoted = await decode_message(net, response["message"])
395
+ content = []
396
+ if quoted.user:
397
+ content.append(E.author(quoted.user.id, quoted.user.name, quoted.user.avatar))
398
+ content.extend(quoted.message)
399
+ return E.quote(str(message_seq), content=content)
400
+
401
+
402
+ def _resource_url(data: dict) -> str:
403
+ 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
 
@@ -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 ""