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.
Files changed (20) hide show
  1. {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/.mina/adapter_milky.toml +2 -2
  2. {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/PKG-INFO +9 -9
  3. {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/README.md +7 -7
  4. {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/pyproject.toml +2 -2
  5. satori_python_adapter_milky-0.3.1/src/satori/adapters/milky/__init__.py +5 -0
  6. {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/src/satori/adapters/milky/api.py +45 -6
  7. 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
  8. {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/src/satori/adapters/milky/events/message.py +4 -3
  9. satori_python_adapter_milky-0.3.1/src/satori/adapters/milky/main.py +105 -0
  10. {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/src/satori/adapters/milky/message.py +9 -2
  11. satori_python_adapter_milky-0.3.1/src/satori/adapters/milky/sse.py +142 -0
  12. {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/src/satori/adapters/milky/utils.py +11 -3
  13. satori_python_adapter_milky-0.3.1/src/satori/adapters/milky/webhook.py +82 -0
  14. satori_python_adapter_milky-0.2.0/src/satori/adapters/milky/__init__.py +0 -4
  15. satori_python_adapter_milky-0.2.0/src/satori/adapters/milky/main.py +0 -247
  16. {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/LICENSE +0 -0
  17. {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/src/satori/adapters/milky/events/__init__.py +0 -0
  18. {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/src/satori/adapters/milky/events/base.py +0 -0
  19. {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/src/satori/adapters/milky/events/group.py +0 -0
  20. {satori_python_adapter_milky-0.2.0 → satori_python_adapter_milky-0.3.1}/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.18.0"]
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.2.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.2.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>=0.18.0
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.milky.main, satori.adapters.milky.websocket |
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.milky.main, satori.adapters.milky.websocket |
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.2.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 >= 0.18.0",
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"
@@ -0,0 +1,5 @@
1
+ from .main import MilkyWebsocketAdapter as MilkyWebsocketAdapter
2
+ from .sse import MilkySSEAdapter as MilkySSEAdapter
3
+ from .webhook import MilkyWebhookAdapter as MilkyWebhookAdapter
4
+
5
+ __all__ = ["MilkyWebsocketAdapter", "MilkySSEAdapter", "MilkyWebhookAdapter"]
@@ -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
- UserGetParam,
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["emoji"],
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["emoji"],
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[UserGetParam]):
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 MilkyNetwork, decode_login_user
21
+ from .utils import decode_login_user
24
22
 
25
23
  DEFAULT_FEATURES = ["guild.plain", "reaction"]
26
24
 
27
25
 
28
- class _MilkyNetwork:
29
- def __init__(self, adapter: MilkyWebhookAdapter): # type: ignore[name-defined]
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.networks: dict[str, MilkyNetwork] = {}
58
- self.features = list(DEFAULT_FEATURES)
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, network, payload)
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__ = ["MilkyWebhookAdapter"]
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
- face_id = data["face_id"]
64
+ emoji_id = data["face_id"]
65
65
  message = MessageObject(
66
- str(data["message_seq"]), f"<milky:face id='{face_id}'>", channel=channel, guild=guild, user=user
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
- self.segments.append({"type": "face", "data": {"face_id": attrs["id"]}})
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) -> User:
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,4 +0,0 @@
1
- from .main import MilkyAdapter as MilkyAdapter
2
- from .webhook import MilkyWebhookAdapter as MilkyWebhookAdapter
3
-
4
- __all__ = ["MilkyAdapter", "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"]