satori-python-adapter-milky 0.2.0__tar.gz → 0.3.1__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.
- {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/.mina/adapter_milky.toml +2 -2
- {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/PKG-INFO +9 -9
- {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/README.md +7 -7
- {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/pyproject.toml +2 -2
- satori_python_adapter_milky-0.3.1/src/satori/adapters/milky/__init__.py +5 -0
- {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/src/satori/adapters/milky/api.py +45 -6
- satori_python_adapter_milky-0.2.0/src/satori/adapters/milky/webhook.py → satori_python_adapter_milky-0.3.1/src/satori/adapters/milky/base.py +8 -87
- {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/src/satori/adapters/milky/events/message.py +4 -3
- satori_python_adapter_milky-0.3.1/src/satori/adapters/milky/main.py +105 -0
- {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/src/satori/adapters/milky/message.py +9 -2
- satori_python_adapter_milky-0.3.1/src/satori/adapters/milky/sse.py +142 -0
- {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/src/satori/adapters/milky/utils.py +11 -3
- satori_python_adapter_milky-0.3.1/src/satori/adapters/milky/webhook.py +82 -0
- satori_python_adapter_milky-0.2.0/src/satori/adapters/milky/__init__.py +0 -4
- satori_python_adapter_milky-0.2.0/src/satori/adapters/milky/main.py +0 -247
- {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/LICENSE +0 -0
- {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/src/satori/adapters/milky/events/__init__.py +0 -0
- {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/src/satori/adapters/milky/events/base.py +0 -0
- {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/src/satori/adapters/milky/events/group.py +0 -0
- {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/src/satori/adapters/milky/events/request.py +0 -0
{satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/.mina/adapter_milky.toml
RENAMED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
includes = ["src/satori/adapters/milky"]
|
|
2
|
-
raw-dependencies = ["satori-python-server
|
|
2
|
+
raw-dependencies = ["satori-python-server<1.4.0,>= 1.3.0"]
|
|
3
3
|
|
|
4
4
|
[project]
|
|
5
5
|
name = "satori-python-adapter-milky"
|
|
6
|
-
version = "0.
|
|
6
|
+
version = "0.3.1"
|
|
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.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
20
|
+
Requires-Dist: satori-python-server<1.4.0,>=1.3.0
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
|
|
23
23
|
# satori-python
|
|
@@ -70,13 +70,13 @@ pip install satori-python-server
|
|
|
70
70
|
|
|
71
71
|
### 官方适配器
|
|
72
72
|
|
|
73
|
-
| 适配器 | 安装 | 路径
|
|
74
|
-
|
|
75
|
-
| Satori | `pip install satori-python-adapter-satori` | satori.adapters.satori
|
|
76
|
-
| OneBot V11 | `pip install satori-python-adapter-onebot11` | satori.adapters.onebot11.forward, satori.adapters.onebot11.reverse
|
|
77
|
-
| Console | `pip install satori-python-adapter-console` | satori.adapters.console
|
|
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.
|
|
73
|
+
| 适配器 | 安装 | 路径 |
|
|
74
|
+
|------------|----------------------------------------------|--------------------------------------------------------------------------------------|
|
|
75
|
+
| Satori | `pip install satori-python-adapter-satori` | satori.adapters.satori |
|
|
76
|
+
| OneBot V11 | `pip install satori-python-adapter-onebot11` | satori.adapters.onebot11.forward, satori.adapters.onebot11.reverse |
|
|
77
|
+
| Console | `pip install satori-python-adapter-console` | satori.adapters.console |
|
|
78
|
+
| Milky | `pip install satori-python-adapter-milky` | satori.adapters.milky.main, satori.adapters.milky.webhook, satori.adapters.milky.sse |
|
|
79
|
+
| QQ | `pip install satori-python-adapter-qq` | satori.adapters.qq.main, satori.adapters.qq.websocket |
|
|
80
80
|
|
|
81
81
|
### 社区适配器
|
|
82
82
|
|
|
@@ -48,13 +48,13 @@ pip install satori-python-server
|
|
|
48
48
|
|
|
49
49
|
### 官方适配器
|
|
50
50
|
|
|
51
|
-
| 适配器 | 安装 | 路径
|
|
52
|
-
|
|
53
|
-
| Satori | `pip install satori-python-adapter-satori` | satori.adapters.satori
|
|
54
|
-
| OneBot V11 | `pip install satori-python-adapter-onebot11` | satori.adapters.onebot11.forward, satori.adapters.onebot11.reverse
|
|
55
|
-
| Console | `pip install satori-python-adapter-console` | satori.adapters.console
|
|
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.
|
|
51
|
+
| 适配器 | 安装 | 路径 |
|
|
52
|
+
|------------|----------------------------------------------|--------------------------------------------------------------------------------------|
|
|
53
|
+
| Satori | `pip install satori-python-adapter-satori` | satori.adapters.satori |
|
|
54
|
+
| OneBot V11 | `pip install satori-python-adapter-onebot11` | satori.adapters.onebot11.forward, satori.adapters.onebot11.reverse |
|
|
55
|
+
| Console | `pip install satori-python-adapter-console` | satori.adapters.console |
|
|
56
|
+
| Milky | `pip install satori-python-adapter-milky` | satori.adapters.milky.main, satori.adapters.milky.webhook, satori.adapters.milky.sse |
|
|
57
|
+
| QQ | `pip install satori-python-adapter-qq` | satori.adapters.qq.main, satori.adapters.qq.websocket |
|
|
58
58
|
|
|
59
59
|
### 社区适配器
|
|
60
60
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "satori-python-adapter-milky"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
authors = [
|
|
5
5
|
{ name = "RF-Tar-Railt", email = "rf_tar_railt@qq.com" },
|
|
6
6
|
]
|
|
7
7
|
dependencies = [
|
|
8
|
-
"satori-python-server
|
|
8
|
+
"satori-python-server<1.4.0,>= 1.3.0",
|
|
9
9
|
]
|
|
10
10
|
description = "Satori Protocol SDK for python, adapter for Milky"
|
|
11
11
|
readme = "README.md"
|
|
@@ -17,6 +17,7 @@ from satori.server.route import (
|
|
|
17
17
|
GuildMemberGetParam,
|
|
18
18
|
GuildMemberKickParam,
|
|
19
19
|
GuildMemberMuteParam,
|
|
20
|
+
GuildMemberRoleParam,
|
|
20
21
|
GuildXXXListParam,
|
|
21
22
|
MessageListParam,
|
|
22
23
|
MessageOpParam,
|
|
@@ -24,11 +25,12 @@ from satori.server.route import (
|
|
|
24
25
|
ReactionCreateParam,
|
|
25
26
|
ReactionDeleteParam,
|
|
26
27
|
UserChannelCreateParam,
|
|
27
|
-
|
|
28
|
+
UserOpParam,
|
|
28
29
|
)
|
|
29
30
|
|
|
30
31
|
from .message import MilkyMessageEncoder, decode_message
|
|
31
32
|
from .utils import (
|
|
33
|
+
ROLE_MAPPING,
|
|
32
34
|
MilkyNetwork,
|
|
33
35
|
decode_friend,
|
|
34
36
|
decode_group_channel,
|
|
@@ -219,12 +221,42 @@ def apply(
|
|
|
219
221
|
)
|
|
220
222
|
return
|
|
221
223
|
|
|
224
|
+
@adapter.route(Api.GUILD_MEMBER_ROLE_SET)
|
|
225
|
+
async def guild_member_role_set(request: Request[GuildMemberRoleParam]):
|
|
226
|
+
net = net_getter(request.self_id)
|
|
227
|
+
await net.call_api(
|
|
228
|
+
"set_group_member_admin ",
|
|
229
|
+
{
|
|
230
|
+
"group_id": int(request.params["guild_id"]),
|
|
231
|
+
"user_id": int(request.params["user_id"]),
|
|
232
|
+
"is_set": request.params["role_id"] == "admin",
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
@adapter.route(Api.GUILD_MEMBER_ROLE_UNSET)
|
|
238
|
+
async def guild_member_role_unset(request: Request[GuildMemberRoleParam]):
|
|
239
|
+
net = net_getter(request.self_id)
|
|
240
|
+
await net.call_api(
|
|
241
|
+
"set_group_member_admin ",
|
|
242
|
+
{
|
|
243
|
+
"group_id": int(request.params["guild_id"]),
|
|
244
|
+
"user_id": int(request.params["user_id"]),
|
|
245
|
+
"is_set": request.params["role_id"] != "admin",
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
@adapter.route(Api.GUILD_ROLE_LIST)
|
|
251
|
+
async def guild_role_list(request: Request[GuildXXXListParam]):
|
|
252
|
+
return PageResult(list(ROLE_MAPPING.values()))
|
|
253
|
+
|
|
222
254
|
@adapter.route(Api.GUILD_MEMBER_APPROVE)
|
|
223
255
|
async def guild_member_approve(request: Request[ApproveParam]):
|
|
224
256
|
net = net_getter(request.self_id)
|
|
225
257
|
message_id = request.params["message_id"]
|
|
226
258
|
notification_seq, notification_type, group_id, is_filtered = message_id.split("|")
|
|
227
|
-
params = {
|
|
259
|
+
params: dict = {
|
|
228
260
|
"notification_seq": int(notification_seq),
|
|
229
261
|
"notification_type": notification_type,
|
|
230
262
|
"group_id": int(group_id),
|
|
@@ -259,7 +291,7 @@ def apply(
|
|
|
259
291
|
{
|
|
260
292
|
"group_id": peer_id,
|
|
261
293
|
"message_seq": int(request.params["message_id"]),
|
|
262
|
-
"reaction": request.params["
|
|
294
|
+
"reaction": request.params["emoji_id"],
|
|
263
295
|
"is_add": True,
|
|
264
296
|
},
|
|
265
297
|
)
|
|
@@ -276,14 +308,14 @@ def apply(
|
|
|
276
308
|
{
|
|
277
309
|
"group_id": peer_id,
|
|
278
310
|
"message_seq": int(request.params["message_id"]),
|
|
279
|
-
"reaction": request.params["
|
|
311
|
+
"reaction": request.params["emoji_id"],
|
|
280
312
|
"is_add": False,
|
|
281
313
|
},
|
|
282
314
|
)
|
|
283
315
|
return
|
|
284
316
|
|
|
285
317
|
@adapter.route(Api.USER_GET)
|
|
286
|
-
async def user_get(request: Request[
|
|
318
|
+
async def user_get(request: Request[UserOpParam]):
|
|
287
319
|
net = net_getter(request.self_id)
|
|
288
320
|
user_id = request.params["user_id"]
|
|
289
321
|
profile = await net.call_api("get_user_profile", {"user_id": int(user_id)})
|
|
@@ -291,6 +323,13 @@ def apply(
|
|
|
291
323
|
raise RuntimeError("Failed to get user profile")
|
|
292
324
|
return decode_user_profile(profile, user_id)
|
|
293
325
|
|
|
326
|
+
@adapter.route(Api.FRIEND_DELETE)
|
|
327
|
+
async def friend_delete(request: Request[UserOpParam]):
|
|
328
|
+
net = net_getter(request.self_id)
|
|
329
|
+
user_id = request.params["user_id"]
|
|
330
|
+
await net.call_api("delete_friend", {"user_id": int(user_id)})
|
|
331
|
+
return
|
|
332
|
+
|
|
294
333
|
@adapter.route(Api.FRIEND_LIST)
|
|
295
334
|
async def friend_list(request: Request[FriendListParam]):
|
|
296
335
|
net = net_getter(request.self_id)
|
|
@@ -302,7 +341,7 @@ def apply(
|
|
|
302
341
|
async def friend_approve(request: Request[ApproveParam]):
|
|
303
342
|
net = net_getter(request.self_id)
|
|
304
343
|
initiator_uid, is_filtered = request.params["message_id"].split("|")
|
|
305
|
-
payload = {"initiator_uid": initiator_uid, "is_filtered": bool(int(is_filtered))}
|
|
344
|
+
payload: dict = {"initiator_uid": initiator_uid, "is_filtered": bool(int(is_filtered))}
|
|
306
345
|
if request.params["approve"]:
|
|
307
346
|
await net.call_api("accept_friend_request", payload)
|
|
308
347
|
else:
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from abc import ABC
|
|
3
4
|
from datetime import datetime
|
|
4
5
|
|
|
5
6
|
import aiohttp
|
|
6
|
-
from launart import Launart
|
|
7
7
|
from launart.status import Phase
|
|
8
8
|
from loguru import logger
|
|
9
|
-
from starlette.requests import Request as StarletteRequest
|
|
10
9
|
from starlette.responses import JSONResponse, Response
|
|
11
|
-
from starlette.routing import Route
|
|
12
10
|
from yarl import URL
|
|
13
11
|
|
|
14
12
|
from satori import EventType
|
|
@@ -20,20 +18,13 @@ from satori.utils import decode, encode
|
|
|
20
18
|
|
|
21
19
|
from .api import apply
|
|
22
20
|
from .events import event_handlers
|
|
23
|
-
from .utils import
|
|
21
|
+
from .utils import decode_login_user
|
|
24
22
|
|
|
25
23
|
DEFAULT_FEATURES = ["guild.plain", "reaction"]
|
|
26
24
|
|
|
27
25
|
|
|
28
|
-
class
|
|
29
|
-
|
|
30
|
-
self.adapter = adapter
|
|
31
|
-
|
|
32
|
-
async def call_api(self, action: str, params: dict | None = None):
|
|
33
|
-
return await self.adapter.call_api(action, params or {})
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class MilkyWebhookAdapter(BaseAdapter):
|
|
26
|
+
class MilkyBaseAdapter(BaseAdapter, ABC):
|
|
27
|
+
"""Base adapter for Milky protocol with common functionality."""
|
|
37
28
|
|
|
38
29
|
session: aiohttp.ClientSession | None
|
|
39
30
|
|
|
@@ -43,8 +34,6 @@ class MilkyWebhookAdapter(BaseAdapter):
|
|
|
43
34
|
*,
|
|
44
35
|
token: str | None = None,
|
|
45
36
|
headers: dict[str, str] | None = None,
|
|
46
|
-
path: str = "/milky",
|
|
47
|
-
self_token: str | None = None,
|
|
48
37
|
):
|
|
49
38
|
super().__init__()
|
|
50
39
|
self.base_url = URL(str(endpoint))
|
|
@@ -54,11 +43,8 @@ class MilkyWebhookAdapter(BaseAdapter):
|
|
|
54
43
|
self.headers = headers.copy() if headers else {}
|
|
55
44
|
self.session = None
|
|
56
45
|
self.logins: dict[str, Login] = {}
|
|
57
|
-
self.
|
|
58
|
-
self
|
|
59
|
-
self.webhook_token = self_token if self_token is not None else token
|
|
60
|
-
self.webhook_paths = self._normalize_webhook_paths(path)
|
|
61
|
-
apply(self, self._get_network, self._get_login)
|
|
46
|
+
self.features: list[str] = list(DEFAULT_FEATURES)
|
|
47
|
+
apply(self, lambda _: self, self._get_login)
|
|
62
48
|
|
|
63
49
|
def get_platform(self) -> str:
|
|
64
50
|
return "milky"
|
|
@@ -80,19 +66,6 @@ class MilkyWebhookAdapter(BaseAdapter):
|
|
|
80
66
|
def stages(self) -> set[Phase]:
|
|
81
67
|
return {"preparing", "blocking", "cleanup"}
|
|
82
68
|
|
|
83
|
-
async def launch(self, manager: Launart):
|
|
84
|
-
async with self.stage("preparing"):
|
|
85
|
-
self.session = aiohttp.ClientSession()
|
|
86
|
-
|
|
87
|
-
async with self.stage("blocking"):
|
|
88
|
-
await manager.status.wait_for_sigexit()
|
|
89
|
-
|
|
90
|
-
async with self.stage("cleanup"):
|
|
91
|
-
if self.session:
|
|
92
|
-
await self.session.close()
|
|
93
|
-
self.session = None
|
|
94
|
-
await self._handle_disconnect()
|
|
95
|
-
|
|
96
69
|
def proxy_urls(self) -> list[str]:
|
|
97
70
|
return []
|
|
98
71
|
|
|
@@ -125,9 +98,6 @@ class MilkyWebhookAdapter(BaseAdapter):
|
|
|
125
98
|
raise ActionFailed(f"{data.get('retcode')}: {data.get('message')}", data)
|
|
126
99
|
return data.get("data")
|
|
127
100
|
|
|
128
|
-
def get_routes(self) -> list[Route]:
|
|
129
|
-
return [Route(path, self.webhook_endpoint, methods=["POST"]) for path in self.webhook_paths]
|
|
130
|
-
|
|
131
101
|
async def handle_event(self, payload: dict):
|
|
132
102
|
event_type = payload.get("event_type")
|
|
133
103
|
self_id = str(payload.get("self_id"))
|
|
@@ -140,12 +110,8 @@ class MilkyWebhookAdapter(BaseAdapter):
|
|
|
140
110
|
return
|
|
141
111
|
login = self.logins[self_id]
|
|
142
112
|
handler = event_handlers.get(event_type)
|
|
143
|
-
network = self.networks.get(self_id)
|
|
144
|
-
if not network:
|
|
145
|
-
network = _MilkyNetwork(self)
|
|
146
|
-
self.networks[self_id] = network
|
|
147
113
|
if handler:
|
|
148
|
-
event = await handler(login,
|
|
114
|
+
event = await handler(login, self, payload)
|
|
149
115
|
else:
|
|
150
116
|
event = Event(
|
|
151
117
|
EventType.INTERNAL,
|
|
@@ -170,7 +136,6 @@ class MilkyWebhookAdapter(BaseAdapter):
|
|
|
170
136
|
self_id = login.id
|
|
171
137
|
previous = self.logins.get(self_id)
|
|
172
138
|
self.logins[self_id] = login
|
|
173
|
-
self.networks[self_id] = _MilkyNetwork(self)
|
|
174
139
|
event_type = EventType.LOGIN_ADDED if previous is None else EventType.LOGIN_UPDATED
|
|
175
140
|
await self.server.post(Event(event_type, datetime.now(), login))
|
|
176
141
|
|
|
@@ -179,53 +144,9 @@ class MilkyWebhookAdapter(BaseAdapter):
|
|
|
179
144
|
login.status = LoginStatus.OFFLINE
|
|
180
145
|
await self.server.post(Event(EventType.LOGIN_REMOVED, datetime.now(), login))
|
|
181
146
|
self.logins.pop(self_id, None)
|
|
182
|
-
self.networks.pop(self_id, None)
|
|
183
|
-
|
|
184
|
-
def _normalize_webhook_paths(self, webhook_path: str) -> tuple[str, ...]:
|
|
185
|
-
path = webhook_path or "/"
|
|
186
|
-
normalized = path if path.startswith("/") else f"/{path}"
|
|
187
|
-
paths: set[str] = {normalized}
|
|
188
|
-
stripped = normalized.rstrip("/")
|
|
189
|
-
if stripped and stripped != normalized:
|
|
190
|
-
paths.add(stripped)
|
|
191
|
-
return tuple(sorted(paths))
|
|
192
|
-
|
|
193
|
-
def _get_network(self, self_id: str) -> MilkyNetwork:
|
|
194
|
-
network = self.networks.get(self_id)
|
|
195
|
-
if not network:
|
|
196
|
-
network = _MilkyNetwork(self)
|
|
197
|
-
self.networks[self_id] = network
|
|
198
|
-
return network
|
|
199
147
|
|
|
200
148
|
def _get_login(self, self_id: str) -> Login:
|
|
201
149
|
return self.logins[self_id]
|
|
202
150
|
|
|
203
|
-
async def webhook_endpoint(self, request: StarletteRequest) -> Response:
|
|
204
|
-
if self.webhook_token:
|
|
205
|
-
auth_header = request.headers.get("Authorization")
|
|
206
|
-
provided = None
|
|
207
|
-
if auth_header:
|
|
208
|
-
if auth_header.lower().startswith("bearer "):
|
|
209
|
-
provided = auth_header[7:]
|
|
210
|
-
else:
|
|
211
|
-
provided = auth_header
|
|
212
|
-
if provided is None:
|
|
213
|
-
provided = request.query_params.get("access_token")
|
|
214
|
-
if provided != self.webhook_token:
|
|
215
|
-
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
216
|
-
try:
|
|
217
|
-
payload = await request.json()
|
|
218
|
-
except Exception as e: # pragma: no cover - defensive
|
|
219
|
-
logger.error(f"Failed to parse milky webhook payload: {e}")
|
|
220
|
-
return JSONResponse({"error": "invalid json"}, status_code=400)
|
|
221
|
-
if not isinstance(payload, dict):
|
|
222
|
-
return JSONResponse({"error": "invalid payload"}, status_code=400)
|
|
223
|
-
try:
|
|
224
|
-
await self.handle_event(payload)
|
|
225
|
-
except Exception as e: # pragma: no cover - defensive
|
|
226
|
-
logger.exception("Error while processing milky webhook event", exc_info=e)
|
|
227
|
-
return JSONResponse({"error": "internal error"}, status_code=500)
|
|
228
|
-
return Response(status_code=204)
|
|
229
|
-
|
|
230
151
|
|
|
231
|
-
__all__ = ["
|
|
152
|
+
__all__ = ["MilkyBaseAdapter", "DEFAULT_FEATURES"]
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
|
|
5
5
|
from satori import EventType
|
|
6
|
-
from satori.model import Channel, ChannelType, Event, Guild, MessageObject, User
|
|
6
|
+
from satori.model import Channel, ChannelType, EmojiObject, Event, Guild, MessageObject, User
|
|
7
7
|
|
|
8
8
|
from ..message import decode_message
|
|
9
9
|
from ..utils import group_avatar, user_avatar
|
|
@@ -61,9 +61,9 @@ async def group_message_reaction(login, net, raw):
|
|
|
61
61
|
guild = Guild(guild_id, avatar=group_avatar(guild_id))
|
|
62
62
|
channel = Channel(guild_id, ChannelType.TEXT)
|
|
63
63
|
user = User(str(data["user_id"]), avatar=user_avatar(data["user_id"]))
|
|
64
|
-
|
|
64
|
+
emoji_id = data["face_id"]
|
|
65
65
|
message = MessageObject(
|
|
66
|
-
str(data["message_seq"]), f"<
|
|
66
|
+
str(data["message_seq"]), f"<emoji id='{emoji_id}'>", channel=channel, guild=guild, user=user
|
|
67
67
|
)
|
|
68
68
|
if data["is_add"]:
|
|
69
69
|
event_type = EventType.REACTION_ADDED
|
|
@@ -77,4 +77,5 @@ async def group_message_reaction(login, net, raw):
|
|
|
77
77
|
guild=guild,
|
|
78
78
|
user=user,
|
|
79
79
|
message=message,
|
|
80
|
+
emoji=EmojiObject(emoji_id),
|
|
80
81
|
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
from launart import Launart, any_completed
|
|
7
|
+
from loguru import logger
|
|
8
|
+
from yarl import URL
|
|
9
|
+
|
|
10
|
+
from satori.utils import decode
|
|
11
|
+
|
|
12
|
+
from .base import MilkyBaseAdapter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MilkyWebsocketAdapter(MilkyBaseAdapter):
|
|
16
|
+
|
|
17
|
+
connection: aiohttp.ClientWebSocketResponse | None
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
endpoint: str | URL,
|
|
22
|
+
*,
|
|
23
|
+
token: str | None = None,
|
|
24
|
+
token_in_query: bool = False,
|
|
25
|
+
headers: dict[str, str] | None = None,
|
|
26
|
+
):
|
|
27
|
+
super().__init__(endpoint, token=token, headers=headers)
|
|
28
|
+
base_path = self.base_url.path.rstrip("/")
|
|
29
|
+
ws_scheme = "wss" if self.base_url.scheme == "https" else "ws"
|
|
30
|
+
self.event_url = self.base_url.with_scheme(ws_scheme).with_path(f"{base_path}/event")
|
|
31
|
+
if token_in_query and token:
|
|
32
|
+
self.event_url = self.event_url.update_query(access_token=token)
|
|
33
|
+
self.connection = None
|
|
34
|
+
self.close_signal = asyncio.Event()
|
|
35
|
+
|
|
36
|
+
async def launch(self, manager: Launart):
|
|
37
|
+
async with self.stage("preparing"):
|
|
38
|
+
self.session = aiohttp.ClientSession()
|
|
39
|
+
|
|
40
|
+
async with self.stage("blocking"):
|
|
41
|
+
await self.connection_daemon(manager, self.session)
|
|
42
|
+
|
|
43
|
+
async with self.stage("cleanup"):
|
|
44
|
+
if self.connection and not self.connection.closed:
|
|
45
|
+
await self.connection.close()
|
|
46
|
+
if self.session:
|
|
47
|
+
await self.session.close()
|
|
48
|
+
self.connection = None
|
|
49
|
+
self.session = None
|
|
50
|
+
await self._handle_disconnect()
|
|
51
|
+
|
|
52
|
+
async def connection_daemon(self, manager: Launart, session: aiohttp.ClientSession):
|
|
53
|
+
while not manager.status.exiting:
|
|
54
|
+
headers = self.headers.copy()
|
|
55
|
+
if self.token:
|
|
56
|
+
headers.setdefault("Authorization", f"Bearer {self.token}")
|
|
57
|
+
try:
|
|
58
|
+
self.connection = await session.ws_connect(self.event_url, headers=headers)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logger.error(f"Milky adapter websocket connect failed: {e}")
|
|
61
|
+
await asyncio.sleep(5)
|
|
62
|
+
continue
|
|
63
|
+
logger.info("Milky adapter websocket connected")
|
|
64
|
+
self.close_signal.clear()
|
|
65
|
+
await self.refresh_login()
|
|
66
|
+
receiver_task = asyncio.create_task(self.message_handle())
|
|
67
|
+
close_task = asyncio.create_task(self.close_signal.wait())
|
|
68
|
+
sigexit_task = asyncio.create_task(manager.status.wait_for_sigexit())
|
|
69
|
+
|
|
70
|
+
done, pending = await any_completed(receiver_task, close_task, sigexit_task)
|
|
71
|
+
for task in pending:
|
|
72
|
+
task.cancel()
|
|
73
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
74
|
+
if sigexit_task in done:
|
|
75
|
+
break
|
|
76
|
+
logger.warning("Milky adapter websocket closed, retrying in 5 seconds")
|
|
77
|
+
await self._handle_disconnect()
|
|
78
|
+
await asyncio.sleep(5)
|
|
79
|
+
await self._handle_disconnect()
|
|
80
|
+
|
|
81
|
+
async def message_handle(self):
|
|
82
|
+
assert self.connection is not None
|
|
83
|
+
async for msg in self.connection:
|
|
84
|
+
if msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED):
|
|
85
|
+
self.close_signal.set()
|
|
86
|
+
break
|
|
87
|
+
if msg.type != aiohttp.WSMsgType.TEXT:
|
|
88
|
+
continue
|
|
89
|
+
try:
|
|
90
|
+
data = decode(msg.data)
|
|
91
|
+
except Exception as e: # pragma: no cover - defensive
|
|
92
|
+
logger.error(f"Failed to decode milky event: {e}")
|
|
93
|
+
continue
|
|
94
|
+
if not isinstance(data, dict):
|
|
95
|
+
continue
|
|
96
|
+
await self.handle_event(data)
|
|
97
|
+
|
|
98
|
+
async def _handle_disconnect(self):
|
|
99
|
+
await super()._handle_disconnect()
|
|
100
|
+
if self.connection and not self.connection.closed:
|
|
101
|
+
await self.connection.close()
|
|
102
|
+
self.close_signal.set()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = ["MilkyWebsocketAdapter"]
|
|
@@ -157,8 +157,13 @@ class MilkyMessageEncoder:
|
|
|
157
157
|
if poster := attrs.get("poster"):
|
|
158
158
|
payload["thumb_uri"] = poster
|
|
159
159
|
self.segments.append({"type": "video", "data": payload})
|
|
160
|
-
case "milky:face":
|
|
161
|
-
|
|
160
|
+
case "milky:face" | "emoji":
|
|
161
|
+
if ":" in attrs["id"]:
|
|
162
|
+
_, emj_id_str = attrs["id"].split(":", 1)
|
|
163
|
+
emj_id = emj_id_str
|
|
164
|
+
else:
|
|
165
|
+
emj_id = attrs["id"]
|
|
166
|
+
self.segments.append({"type": "face", "data": {"face_id": emj_id}})
|
|
162
167
|
case "file":
|
|
163
168
|
await self.flush()
|
|
164
169
|
await self._send_file(attrs)
|
|
@@ -362,6 +367,8 @@ async def _decode_segments(net: MilkyNetwork, payload: dict, segments: Sequence[
|
|
|
362
367
|
result.append(E.at(str(data.get("user_id"))))
|
|
363
368
|
case "mention_all":
|
|
364
369
|
result.append(E.at_all())
|
|
370
|
+
case "face":
|
|
371
|
+
result.append(E.emoji(str(data.get("face_id"))))
|
|
365
372
|
case "image":
|
|
366
373
|
result.append(E.image(_resource_url(data)))
|
|
367
374
|
case "record":
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
from launart import Launart, any_completed
|
|
7
|
+
from loguru import logger
|
|
8
|
+
from yarl import URL
|
|
9
|
+
|
|
10
|
+
from satori.utils import decode
|
|
11
|
+
|
|
12
|
+
from .base import MilkyBaseAdapter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MilkySSEAdapter(MilkyBaseAdapter):
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
endpoint: str | URL,
|
|
20
|
+
*,
|
|
21
|
+
token: str | None = None,
|
|
22
|
+
token_in_query: bool = False,
|
|
23
|
+
headers: dict[str, str] | None = None,
|
|
24
|
+
):
|
|
25
|
+
super().__init__(endpoint, token=token, headers=headers)
|
|
26
|
+
base_path = self.base_url.path.rstrip("/")
|
|
27
|
+
self.event_url = self.base_url.with_path(f"{base_path}/event")
|
|
28
|
+
if token_in_query and token:
|
|
29
|
+
self.event_url = self.event_url.update_query(access_token=token)
|
|
30
|
+
self.close_signal = asyncio.Event()
|
|
31
|
+
|
|
32
|
+
async def launch(self, manager: Launart):
|
|
33
|
+
async with self.stage("preparing"):
|
|
34
|
+
self.session = aiohttp.ClientSession()
|
|
35
|
+
|
|
36
|
+
async with self.stage("blocking"):
|
|
37
|
+
await self.connection_daemon(manager, self.session)
|
|
38
|
+
|
|
39
|
+
async with self.stage("cleanup"):
|
|
40
|
+
if self.session:
|
|
41
|
+
await self.session.close()
|
|
42
|
+
self.session = None
|
|
43
|
+
await self._handle_disconnect()
|
|
44
|
+
|
|
45
|
+
async def connection_daemon(self, manager: Launart, session: aiohttp.ClientSession):
|
|
46
|
+
while not manager.status.exiting:
|
|
47
|
+
headers = self.headers.copy()
|
|
48
|
+
headers["Accept"] = "text/event-stream"
|
|
49
|
+
headers["Cache-Control"] = "no-cache"
|
|
50
|
+
headers["Upgrade"] = "none"
|
|
51
|
+
if self.token:
|
|
52
|
+
headers.setdefault("Authorization", f"Bearer {self.token}")
|
|
53
|
+
try:
|
|
54
|
+
async with session.get(self.event_url, headers=headers) as response:
|
|
55
|
+
if response.status != 200:
|
|
56
|
+
logger.error(f"Milky SSE adapter connect failed with status {response.status}")
|
|
57
|
+
await asyncio.sleep(5)
|
|
58
|
+
continue
|
|
59
|
+
logger.info("Milky SSE adapter connected")
|
|
60
|
+
self.close_signal.clear()
|
|
61
|
+
await self.refresh_login()
|
|
62
|
+
|
|
63
|
+
receiver_task = asyncio.create_task(self._read_sse_stream(response))
|
|
64
|
+
close_task = asyncio.create_task(self.close_signal.wait())
|
|
65
|
+
sigexit_task = asyncio.create_task(manager.status.wait_for_sigexit())
|
|
66
|
+
|
|
67
|
+
done, pending = await any_completed(receiver_task, close_task, sigexit_task)
|
|
68
|
+
for task in pending:
|
|
69
|
+
task.cancel()
|
|
70
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
71
|
+
if sigexit_task in done:
|
|
72
|
+
break
|
|
73
|
+
except aiohttp.ClientError as e:
|
|
74
|
+
logger.error(f"Milky SSE adapter connection error: {e}")
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.error(f"Milky SSE adapter unexpected error: {e}")
|
|
77
|
+
|
|
78
|
+
logger.warning("Milky SSE adapter connection closed, retrying in 5 seconds")
|
|
79
|
+
await self._handle_disconnect()
|
|
80
|
+
await asyncio.sleep(5)
|
|
81
|
+
await self._handle_disconnect()
|
|
82
|
+
|
|
83
|
+
async def _read_sse_stream(self, response: aiohttp.ClientResponse):
|
|
84
|
+
"""Read and parse SSE stream without external dependencies."""
|
|
85
|
+
event_type: str | None = None
|
|
86
|
+
data_buffer: list[str] = []
|
|
87
|
+
|
|
88
|
+
async for line_bytes in response.content:
|
|
89
|
+
line = line_bytes.decode("utf-8").rstrip("\r\n")
|
|
90
|
+
|
|
91
|
+
if not line:
|
|
92
|
+
# Empty line indicates end of event
|
|
93
|
+
if data_buffer:
|
|
94
|
+
data_str = "\n".join(data_buffer)
|
|
95
|
+
await self._dispatch_sse_event(event_type, data_str)
|
|
96
|
+
event_type = None
|
|
97
|
+
data_buffer = []
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if line.startswith(":"):
|
|
101
|
+
# Comment line, ignore
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
if ":" in line:
|
|
105
|
+
field, _, value = line.partition(":")
|
|
106
|
+
# Remove leading space from value if present
|
|
107
|
+
if value.startswith(" "):
|
|
108
|
+
value = value[1:]
|
|
109
|
+
else:
|
|
110
|
+
field = line
|
|
111
|
+
value = ""
|
|
112
|
+
|
|
113
|
+
if field == "event":
|
|
114
|
+
event_type = value
|
|
115
|
+
elif field == "data":
|
|
116
|
+
data_buffer.append(value)
|
|
117
|
+
elif field == "id":
|
|
118
|
+
# Event ID, could be used for reconnection
|
|
119
|
+
pass
|
|
120
|
+
elif field == "retry":
|
|
121
|
+
# Retry interval, could be implemented if needed
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
async def _dispatch_sse_event(self, event_type: str | None, data: str):
|
|
125
|
+
"""Dispatch a parsed SSE event."""
|
|
126
|
+
if not data or event_type != "milky_event":
|
|
127
|
+
return
|
|
128
|
+
try:
|
|
129
|
+
payload = decode(data)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.error(f"Failed to decode SSE event data: {e}")
|
|
132
|
+
return
|
|
133
|
+
if not isinstance(payload, dict):
|
|
134
|
+
return
|
|
135
|
+
await self.handle_event(payload)
|
|
136
|
+
|
|
137
|
+
async def _handle_disconnect(self):
|
|
138
|
+
await super()._handle_disconnect()
|
|
139
|
+
self.close_signal.set()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
__all__ = ["MilkySSEAdapter"]
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
from typing import Literal, Protocol
|
|
5
5
|
|
|
6
|
-
from satori.model import Channel, ChannelType, Guild, Member, User
|
|
6
|
+
from satori.model import Channel, ChannelType, Friend, Guild, Member, Role, User
|
|
7
7
|
|
|
8
8
|
AVATAR_URL = "https://q.qlogo.cn/headimg_dl?dst_uin={uin}&spec=640"
|
|
9
9
|
GROUP_AVATAR_URL = "https://p.qlogo.cn/gh/{group}/{group}/640"
|
|
@@ -33,6 +33,13 @@ def decode_guild(group: dict) -> Guild:
|
|
|
33
33
|
return Guild(str(group["group_id"]), group.get("group_name"), group_avatar(group["group_id"]))
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
ROLE_MAPPING = {
|
|
37
|
+
"member": Role("member", "群成员"),
|
|
38
|
+
"admin": Role("admin", "管理员"),
|
|
39
|
+
"owner": Role("owner", "群主"),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
36
43
|
def decode_member(member: dict) -> Member:
|
|
37
44
|
user_id = str(member["user_id"])
|
|
38
45
|
user = User(user_id, member.get("nickname"), avatar=user_avatar(user_id))
|
|
@@ -42,12 +49,13 @@ def decode_member(member: dict) -> Member:
|
|
|
42
49
|
nick=member.get("card") or member.get("nickname"),
|
|
43
50
|
avatar=user_avatar(user_id),
|
|
44
51
|
joined_at=datetime.fromtimestamp(joined_at) if joined_at else None,
|
|
52
|
+
roles=[ROLE_MAPPING[member.get("role", "member")]],
|
|
45
53
|
)
|
|
46
54
|
|
|
47
55
|
|
|
48
|
-
def decode_friend(friend: dict) ->
|
|
56
|
+
def decode_friend(friend: dict) -> Friend:
|
|
49
57
|
user_id = str(friend["user_id"])
|
|
50
|
-
return User(user_id, friend.get("nickname"), avatar=user_avatar(user_id))
|
|
58
|
+
return Friend(User(user_id, friend.get("nickname"), avatar=user_avatar(user_id)), friend.get("remark"))
|
|
51
59
|
|
|
52
60
|
|
|
53
61
|
def decode_login_user(login: dict) -> User:
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import aiohttp
|
|
4
|
+
from launart import Launart
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from starlette.requests import Request as StarletteRequest
|
|
7
|
+
from starlette.responses import JSONResponse, Response
|
|
8
|
+
from starlette.routing import Route
|
|
9
|
+
from yarl import URL
|
|
10
|
+
|
|
11
|
+
from .base import MilkyBaseAdapter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MilkyWebhookAdapter(MilkyBaseAdapter):
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
endpoint: str | URL,
|
|
19
|
+
*,
|
|
20
|
+
token: str | None = None,
|
|
21
|
+
headers: dict[str, str] | None = None,
|
|
22
|
+
path: str = "/milky",
|
|
23
|
+
self_token: str | None = None,
|
|
24
|
+
):
|
|
25
|
+
super().__init__(endpoint, token=token, headers=headers)
|
|
26
|
+
self.webhook_token = self_token if self_token is not None else token
|
|
27
|
+
self.webhook_paths = self._normalize_webhook_paths(path)
|
|
28
|
+
|
|
29
|
+
async def launch(self, manager: Launart):
|
|
30
|
+
async with self.stage("preparing"):
|
|
31
|
+
self.session = aiohttp.ClientSession()
|
|
32
|
+
|
|
33
|
+
async with self.stage("blocking"):
|
|
34
|
+
await manager.status.wait_for_sigexit()
|
|
35
|
+
|
|
36
|
+
async with self.stage("cleanup"):
|
|
37
|
+
if self.session:
|
|
38
|
+
await self.session.close()
|
|
39
|
+
self.session = None
|
|
40
|
+
await self._handle_disconnect()
|
|
41
|
+
|
|
42
|
+
def get_routes(self) -> list[Route]:
|
|
43
|
+
return [Route(path, self.webhook_endpoint, methods=["POST"]) for path in self.webhook_paths]
|
|
44
|
+
|
|
45
|
+
def _normalize_webhook_paths(self, webhook_path: str) -> tuple[str, ...]:
|
|
46
|
+
path = webhook_path or "/"
|
|
47
|
+
normalized = path if path.startswith("/") else f"/{path}"
|
|
48
|
+
paths: set[str] = {normalized}
|
|
49
|
+
stripped = normalized.rstrip("/")
|
|
50
|
+
if stripped and stripped != normalized:
|
|
51
|
+
paths.add(stripped)
|
|
52
|
+
return tuple(sorted(paths))
|
|
53
|
+
|
|
54
|
+
async def webhook_endpoint(self, request: StarletteRequest) -> Response:
|
|
55
|
+
if self.webhook_token:
|
|
56
|
+
auth_header = request.headers.get("Authorization")
|
|
57
|
+
provided = None
|
|
58
|
+
if auth_header:
|
|
59
|
+
if auth_header.lower().startswith("bearer "):
|
|
60
|
+
provided = auth_header[7:]
|
|
61
|
+
else:
|
|
62
|
+
provided = auth_header
|
|
63
|
+
if provided is None:
|
|
64
|
+
provided = request.query_params.get("access_token")
|
|
65
|
+
if provided != self.webhook_token:
|
|
66
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
67
|
+
try:
|
|
68
|
+
payload = await request.json()
|
|
69
|
+
except Exception as e: # pragma: no cover - defensive
|
|
70
|
+
logger.error(f"Failed to parse milky webhook payload: {e}")
|
|
71
|
+
return JSONResponse({"error": "invalid json"}, status_code=400)
|
|
72
|
+
if not isinstance(payload, dict):
|
|
73
|
+
return JSONResponse({"error": "invalid payload"}, status_code=400)
|
|
74
|
+
try:
|
|
75
|
+
await self.handle_event(payload)
|
|
76
|
+
except Exception as e: # pragma: no cover - defensive
|
|
77
|
+
logger.exception("Error while processing milky webhook event", exc_info=e)
|
|
78
|
+
return JSONResponse({"error": "internal error"}, status_code=500)
|
|
79
|
+
return Response(status_code=204)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = ["MilkyWebhookAdapter"]
|
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
from datetime import datetime
|
|
5
|
-
|
|
6
|
-
import aiohttp
|
|
7
|
-
from launart import Launart, any_completed
|
|
8
|
-
from launart.status import Phase
|
|
9
|
-
from loguru import logger
|
|
10
|
-
from starlette.responses import JSONResponse, Response
|
|
11
|
-
from yarl import URL
|
|
12
|
-
|
|
13
|
-
from satori import EventType
|
|
14
|
-
from satori.exception import ActionFailed
|
|
15
|
-
from satori.model import Event, Login, LoginStatus
|
|
16
|
-
from satori.server.adapter import Adapter as BaseAdapter
|
|
17
|
-
from satori.server.model import Request
|
|
18
|
-
from satori.utils import decode, encode
|
|
19
|
-
|
|
20
|
-
from .api import apply
|
|
21
|
-
from .events import event_handlers
|
|
22
|
-
from .utils import MilkyNetwork, decode_login_user
|
|
23
|
-
|
|
24
|
-
DEFAULT_FEATURES = ["guild.plain", "reaction"]
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class _MilkyNetwork:
|
|
28
|
-
def __init__(self, adapter: MilkyAdapter): # type: ignore[name-defined]
|
|
29
|
-
self.adapter = adapter
|
|
30
|
-
|
|
31
|
-
async def call_api(self, action: str, params: dict | None = None):
|
|
32
|
-
return await self.adapter.call_api(action, params or {})
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class MilkyAdapter(BaseAdapter):
|
|
36
|
-
|
|
37
|
-
session: aiohttp.ClientSession | None
|
|
38
|
-
connection: aiohttp.ClientWebSocketResponse | None
|
|
39
|
-
|
|
40
|
-
def __init__(
|
|
41
|
-
self,
|
|
42
|
-
endpoint: str | URL,
|
|
43
|
-
*,
|
|
44
|
-
token: str | None = None,
|
|
45
|
-
token_in_query: bool = False,
|
|
46
|
-
headers: dict[str, str] | None = None,
|
|
47
|
-
):
|
|
48
|
-
super().__init__()
|
|
49
|
-
self.base_url = URL(str(endpoint))
|
|
50
|
-
base_path = self.base_url.path.rstrip("/")
|
|
51
|
-
self.api_base = self.base_url.with_path(f"{base_path}/api")
|
|
52
|
-
ws_scheme = "wss" if self.base_url.scheme == "https" else "ws"
|
|
53
|
-
self.event_url = self.base_url.with_scheme(ws_scheme).with_path(f"{base_path}/event")
|
|
54
|
-
if token_in_query and token:
|
|
55
|
-
self.event_url = self.event_url.update_query(access_token=token)
|
|
56
|
-
self.token = token
|
|
57
|
-
self.headers = headers.copy() if headers else {}
|
|
58
|
-
self.session = None
|
|
59
|
-
self.connection = None
|
|
60
|
-
self.close_signal = asyncio.Event()
|
|
61
|
-
self.logins: dict[str, Login] = {}
|
|
62
|
-
self.networks: dict[str, MilkyNetwork] = {}
|
|
63
|
-
self.features = list(DEFAULT_FEATURES)
|
|
64
|
-
apply(self, self._get_network, self._get_login)
|
|
65
|
-
|
|
66
|
-
def get_platform(self) -> str:
|
|
67
|
-
return "milky"
|
|
68
|
-
|
|
69
|
-
def ensure(self, platform: str, self_id: str) -> bool:
|
|
70
|
-
return platform == "milky" and self_id in self.logins
|
|
71
|
-
|
|
72
|
-
async def get_logins(self) -> list[Login]:
|
|
73
|
-
logins = list(self.logins.values())
|
|
74
|
-
for index, login in enumerate(logins):
|
|
75
|
-
login.sn = index
|
|
76
|
-
return logins
|
|
77
|
-
|
|
78
|
-
@property
|
|
79
|
-
def required(self) -> set[str]:
|
|
80
|
-
return {"satori-python.server"}
|
|
81
|
-
|
|
82
|
-
@property
|
|
83
|
-
def stages(self) -> set[Phase]:
|
|
84
|
-
return {"preparing", "blocking", "cleanup"}
|
|
85
|
-
|
|
86
|
-
async def launch(self, manager: Launart):
|
|
87
|
-
async with self.stage("preparing"):
|
|
88
|
-
self.session = aiohttp.ClientSession()
|
|
89
|
-
|
|
90
|
-
async with self.stage("blocking"):
|
|
91
|
-
await self.connection_daemon(manager, self.session)
|
|
92
|
-
|
|
93
|
-
async with self.stage("cleanup"):
|
|
94
|
-
if self.connection and not self.connection.closed:
|
|
95
|
-
await self.connection.close()
|
|
96
|
-
if self.session:
|
|
97
|
-
await self.session.close()
|
|
98
|
-
self.connection = None
|
|
99
|
-
self.session = None
|
|
100
|
-
await self._handle_disconnect()
|
|
101
|
-
|
|
102
|
-
def proxy_urls(self) -> list[str]:
|
|
103
|
-
return []
|
|
104
|
-
|
|
105
|
-
async def handle_internal(self, request: Request, path: str) -> Response:
|
|
106
|
-
if path.startswith("_api"):
|
|
107
|
-
data = await request.origin.json()
|
|
108
|
-
return JSONResponse(await self.call_api(path[5:], data))
|
|
109
|
-
if not self.session:
|
|
110
|
-
raise RuntimeError("HTTP session not initialized")
|
|
111
|
-
url = self.base_url.with_path(path)
|
|
112
|
-
headers = self.headers.copy()
|
|
113
|
-
if self.token:
|
|
114
|
-
headers.setdefault("Authorization", f"Bearer {self.token}")
|
|
115
|
-
async with self.session.get(url, headers=headers) as resp:
|
|
116
|
-
content = await resp.read()
|
|
117
|
-
return Response(content=content, media_type=resp.headers.get("Content-Type"))
|
|
118
|
-
|
|
119
|
-
async def call_api(self, action: str, params: dict | None = None) -> dict:
|
|
120
|
-
if not self.session:
|
|
121
|
-
raise RuntimeError("HTTP session not initialized")
|
|
122
|
-
url = self.api_base.with_path(f"{self.api_base.path.rstrip('/')}/{action}")
|
|
123
|
-
headers = self.headers.copy()
|
|
124
|
-
headers["Content-Type"] = "application/json"
|
|
125
|
-
if self.token:
|
|
126
|
-
headers.setdefault("Authorization", f"Bearer {self.token}")
|
|
127
|
-
async with self.session.post(url, data=encode(params or {}), headers=headers) as resp:
|
|
128
|
-
resp.raise_for_status()
|
|
129
|
-
data = decode(await resp.text())
|
|
130
|
-
if data.get("status") == "failed" or data.get("retcode", 0) != 0:
|
|
131
|
-
raise ActionFailed(f"{data.get('retcode')}: {data.get('message')}", data)
|
|
132
|
-
return data.get("data")
|
|
133
|
-
|
|
134
|
-
async def connection_daemon(self, manager: Launart, session: aiohttp.ClientSession):
|
|
135
|
-
while not manager.status.exiting:
|
|
136
|
-
headers = self.headers.copy()
|
|
137
|
-
if self.token:
|
|
138
|
-
headers.setdefault("Authorization", f"Bearer {self.token}")
|
|
139
|
-
try:
|
|
140
|
-
self.connection = await session.ws_connect(self.event_url, headers=headers)
|
|
141
|
-
except Exception as e:
|
|
142
|
-
logger.error(f"Milky adapter websocket connect failed: {e}")
|
|
143
|
-
await asyncio.sleep(5)
|
|
144
|
-
continue
|
|
145
|
-
logger.info("Milky adapter websocket connected")
|
|
146
|
-
self.close_signal.clear()
|
|
147
|
-
await self.refresh_login()
|
|
148
|
-
receiver_task = asyncio.create_task(self.message_handle())
|
|
149
|
-
close_task = asyncio.create_task(self.close_signal.wait())
|
|
150
|
-
sigexit_task = asyncio.create_task(manager.status.wait_for_sigexit())
|
|
151
|
-
|
|
152
|
-
done, pending = await any_completed(receiver_task, close_task, sigexit_task)
|
|
153
|
-
for task in pending:
|
|
154
|
-
task.cancel()
|
|
155
|
-
await asyncio.gather(*pending, return_exceptions=True)
|
|
156
|
-
if sigexit_task in done:
|
|
157
|
-
break
|
|
158
|
-
logger.warning("Milky adapter websocket closed, retrying in 5 seconds")
|
|
159
|
-
await self._handle_disconnect()
|
|
160
|
-
await asyncio.sleep(5)
|
|
161
|
-
await self._handle_disconnect()
|
|
162
|
-
|
|
163
|
-
async def message_handle(self):
|
|
164
|
-
assert self.connection is not None
|
|
165
|
-
async for msg in self.connection:
|
|
166
|
-
if msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED):
|
|
167
|
-
self.close_signal.set()
|
|
168
|
-
break
|
|
169
|
-
if msg.type != aiohttp.WSMsgType.TEXT:
|
|
170
|
-
continue
|
|
171
|
-
try:
|
|
172
|
-
data = decode(msg.data)
|
|
173
|
-
except Exception as e: # pragma: no cover - defensive
|
|
174
|
-
logger.error(f"Failed to decode milky event: {e}")
|
|
175
|
-
continue
|
|
176
|
-
if not isinstance(data, dict):
|
|
177
|
-
continue
|
|
178
|
-
await self.handle_event(data)
|
|
179
|
-
|
|
180
|
-
async def handle_event(self, payload: dict):
|
|
181
|
-
event_type = payload.get("event_type")
|
|
182
|
-
self_id = str(payload.get("self_id"))
|
|
183
|
-
if not event_type or not self_id:
|
|
184
|
-
return
|
|
185
|
-
if self_id not in self.logins:
|
|
186
|
-
await self.refresh_login()
|
|
187
|
-
if self_id not in self.logins:
|
|
188
|
-
logger.warning(f"Ignoring event for unknown self_id {self_id}")
|
|
189
|
-
return
|
|
190
|
-
login = self.logins[self_id]
|
|
191
|
-
handler = event_handlers.get(event_type)
|
|
192
|
-
network = self.networks.get(self_id)
|
|
193
|
-
if not network:
|
|
194
|
-
network = _MilkyNetwork(self)
|
|
195
|
-
self.networks[self_id] = network
|
|
196
|
-
if handler:
|
|
197
|
-
event = await handler(login, network, payload)
|
|
198
|
-
else:
|
|
199
|
-
event = Event(
|
|
200
|
-
EventType.INTERNAL,
|
|
201
|
-
datetime.fromtimestamp(payload.get("time", datetime.now().timestamp())),
|
|
202
|
-
login,
|
|
203
|
-
)
|
|
204
|
-
if event:
|
|
205
|
-
event._type = event_type
|
|
206
|
-
event._data = payload.get("data", {})
|
|
207
|
-
await self.server.post(event)
|
|
208
|
-
|
|
209
|
-
async def refresh_login(self):
|
|
210
|
-
try:
|
|
211
|
-
data = await self.call_api("get_login_info", {})
|
|
212
|
-
except Exception as e:
|
|
213
|
-
logger.error(f"Failed to fetch milky login info: {e}")
|
|
214
|
-
return
|
|
215
|
-
if not data:
|
|
216
|
-
return
|
|
217
|
-
user = decode_login_user(data)
|
|
218
|
-
login = Login(0, LoginStatus.ONLINE, "milky", platform="milky", user=user, features=self.features.copy())
|
|
219
|
-
self_id = login.id
|
|
220
|
-
previous = self.logins.get(self_id)
|
|
221
|
-
self.logins[self_id] = login
|
|
222
|
-
self.networks[self_id] = _MilkyNetwork(self)
|
|
223
|
-
event_type = EventType.LOGIN_ADDED if previous is None else EventType.LOGIN_UPDATED
|
|
224
|
-
await self.server.post(Event(event_type, datetime.now(), login))
|
|
225
|
-
|
|
226
|
-
async def _handle_disconnect(self):
|
|
227
|
-
for self_id, login in list(self.logins.items()):
|
|
228
|
-
login.status = LoginStatus.OFFLINE
|
|
229
|
-
await self.server.post(Event(EventType.LOGIN_REMOVED, datetime.now(), login))
|
|
230
|
-
self.logins.pop(self_id, None)
|
|
231
|
-
self.networks.pop(self_id, None)
|
|
232
|
-
if self.connection and not self.connection.closed:
|
|
233
|
-
await self.connection.close()
|
|
234
|
-
self.close_signal.set()
|
|
235
|
-
|
|
236
|
-
def _get_network(self, self_id: str) -> MilkyNetwork:
|
|
237
|
-
network = self.networks.get(self_id)
|
|
238
|
-
if not network:
|
|
239
|
-
network = _MilkyNetwork(self)
|
|
240
|
-
self.networks[self_id] = network
|
|
241
|
-
return network
|
|
242
|
-
|
|
243
|
-
def _get_login(self, self_id: str) -> Login:
|
|
244
|
-
return self.logins[self_id]
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
__all__ = ["MilkyAdapter"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|