satori-python-adapter-milky 0.1.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {satori_python_adapter_milky-0.1.2 → satori_python_adapter_milky-0.3.0}/.mina/adapter_milky.toml +2 -2
  2. {satori_python_adapter_milky-0.1.2 → satori_python_adapter_milky-0.3.0}/PKG-INFO +12 -10
  3. {satori_python_adapter_milky-0.1.2 → satori_python_adapter_milky-0.3.0}/README.md +10 -8
  4. {satori_python_adapter_milky-0.1.2 → satori_python_adapter_milky-0.3.0}/pyproject.toml +2 -2
  5. satori_python_adapter_milky-0.3.0/src/satori/adapters/milky/__init__.py +5 -0
  6. {satori_python_adapter_milky-0.1.2 → satori_python_adapter_milky-0.3.0}/src/satori/adapters/milky/api.py +45 -6
  7. satori_python_adapter_milky-0.1.2/src/satori/adapters/milky/webhook.py → satori_python_adapter_milky-0.3.0/src/satori/adapters/milky/base.py +10 -90
  8. {satori_python_adapter_milky-0.1.2 → satori_python_adapter_milky-0.3.0}/src/satori/adapters/milky/events/message.py +4 -3
  9. satori_python_adapter_milky-0.3.0/src/satori/adapters/milky/main.py +105 -0
  10. {satori_python_adapter_milky-0.1.2 → satori_python_adapter_milky-0.3.0}/src/satori/adapters/milky/message.py +123 -119
  11. satori_python_adapter_milky-0.3.0/src/satori/adapters/milky/sse.py +142 -0
  12. {satori_python_adapter_milky-0.1.2 → satori_python_adapter_milky-0.3.0}/src/satori/adapters/milky/utils.py +11 -3
  13. satori_python_adapter_milky-0.3.0/src/satori/adapters/milky/webhook.py +82 -0
  14. satori_python_adapter_milky-0.1.2/src/satori/adapters/milky/__init__.py +0 -4
  15. satori_python_adapter_milky-0.1.2/src/satori/adapters/milky/main.py +0 -248
  16. {satori_python_adapter_milky-0.1.2 → satori_python_adapter_milky-0.3.0}/LICENSE +0 -0
  17. {satori_python_adapter_milky-0.1.2 → satori_python_adapter_milky-0.3.0}/src/satori/adapters/milky/events/__init__.py +0 -0
  18. {satori_python_adapter_milky-0.1.2 → satori_python_adapter_milky-0.3.0}/src/satori/adapters/milky/events/base.py +0 -0
  19. {satori_python_adapter_milky-0.1.2 → satori_python_adapter_milky-0.3.0}/src/satori/adapters/milky/events/group.py +0 -0
  20. {satori_python_adapter_milky-0.1.2 → satori_python_adapter_milky-0.3.0}/src/satori/adapters/milky/events/request.py +0 -0
@@ -1,9 +1,9 @@
1
1
  includes = ["src/satori/adapters/milky"]
2
- raw-dependencies = ["satori-python-server >= 0.17.6"]
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.1.2"
6
+ version = "0.3.0"
7
7
  authors = [
8
8
  {name = "RF-Tar-Railt", email = "rf_tar_railt@qq.com"}
9
9
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: satori-python-adapter-milky
3
- Version: 0.1.2
3
+ Version: 0.3.0
4
4
  Summary: Satori Protocol SDK for python, adapter for Milky
5
5
  Home-page: https://github.com/RF-Tar-Railt/satori-python
6
6
  Author-Email: RF-Tar-Railt <rf_tar_railt@qq.com>
@@ -17,7 +17,7 @@ Classifier: Operating System :: OS Independent
17
17
  Project-URL: Homepage, https://github.com/RF-Tar-Railt/satori-python
18
18
  Project-URL: Repository, https://github.com/RF-Tar-Railt/satori-python
19
19
  Requires-Python: <4.0,>=3.10
20
- Requires-Dist: satori-python-server>=0.17.6
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
@@ -27,17 +27,18 @@ Description-Content-Type: text/markdown
27
27
  [![PyPI](https://img.shields.io/pypi/v/satori-python)](https://pypi.org/project/satori-python)
28
28
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/satori-python)](https://www.python.org/)
29
29
 
30
- 基于 [Satori](https://satori.js.org/zh-CN/) 协议的 Python 开发工具包
30
+ 基于 [Satori](https://satori.chat/zh-CN/) 协议的 Python 开发工具包
31
31
 
32
32
  ## 协议介绍
33
33
 
34
- [Satori Protocol](https://satori.js.org/zh-CN/)
34
+ [Satori Protocol](https://satori.chat/zh-CN/)
35
35
 
36
36
  ### 协议端
37
37
 
38
38
  目前提供了 `satori` 协议实现的有:
39
39
 
40
40
  - [Chronocat](https://chronocat.vercel.app)
41
+ - [LLBot](https://www.llonebot.com/guide/introduction)
41
42
  - [nekobox](https://github.com/wyapx/nekobox)
42
43
  - Koishi (搭配 `@koishijs/plugin-server`)
43
44
 
@@ -69,12 +70,13 @@ pip install satori-python-server
69
70
 
70
71
  ### 官方适配器
71
72
 
72
- | 适配器 | 安装 | 路径 |
73
- |------------|----------------------------------------------|--------------------------------------------------------------------|
74
- | Satori | `pip install satori-python-adapter-satori` | satori.adapters.satori |
75
- | OneBot V11 | `pip install satori-python-adapter-onebot11` | satori.adapters.onebot11.forward, satori.adapters.onebot11.reverse |
76
- | Console | `pip install satori-python-adapter-console` | satori.adapters.console |
77
- | Milky | `pip install satori-python-adapter-milky` | satori.adapters.milky.main, satori.adapters.milky.webhook |
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 |
78
80
 
79
81
  ### 社区适配器
80
82
 
@@ -5,17 +5,18 @@
5
5
  [![PyPI](https://img.shields.io/pypi/v/satori-python)](https://pypi.org/project/satori-python)
6
6
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/satori-python)](https://www.python.org/)
7
7
 
8
- 基于 [Satori](https://satori.js.org/zh-CN/) 协议的 Python 开发工具包
8
+ 基于 [Satori](https://satori.chat/zh-CN/) 协议的 Python 开发工具包
9
9
 
10
10
  ## 协议介绍
11
11
 
12
- [Satori Protocol](https://satori.js.org/zh-CN/)
12
+ [Satori Protocol](https://satori.chat/zh-CN/)
13
13
 
14
14
  ### 协议端
15
15
 
16
16
  目前提供了 `satori` 协议实现的有:
17
17
 
18
18
  - [Chronocat](https://chronocat.vercel.app)
19
+ - [LLBot](https://www.llonebot.com/guide/introduction)
19
20
  - [nekobox](https://github.com/wyapx/nekobox)
20
21
  - Koishi (搭配 `@koishijs/plugin-server`)
21
22
 
@@ -47,12 +48,13 @@ pip install satori-python-server
47
48
 
48
49
  ### 官方适配器
49
50
 
50
- | 适配器 | 安装 | 路径 |
51
- |------------|----------------------------------------------|--------------------------------------------------------------------|
52
- | Satori | `pip install satori-python-adapter-satori` | satori.adapters.satori |
53
- | OneBot V11 | `pip install satori-python-adapter-onebot11` | satori.adapters.onebot11.forward, satori.adapters.onebot11.reverse |
54
- | Console | `pip install satori-python-adapter-console` | satori.adapters.console |
55
- | Milky | `pip install satori-python-adapter-milky` | satori.adapters.milky.main, satori.adapters.milky.webhook |
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 |
56
58
 
57
59
  ### 社区适配器
58
60
 
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "satori-python-adapter-milky"
3
- version = "0.1.2"
3
+ version = "0.3.0"
4
4
  authors = [
5
5
  { name = "RF-Tar-Railt", email = "rf_tar_railt@qq.com" },
6
6
  ]
7
7
  dependencies = [
8
- "satori-python-server >= 0.17.6",
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,22 +110,17 @@ 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
- body = payload.get("data", {})
151
116
  event = Event(
152
117
  EventType.INTERNAL,
153
118
  datetime.fromtimestamp(payload.get("time", datetime.now().timestamp())),
154
119
  login,
155
- _type=event_type,
156
- _data=body,
157
120
  )
158
121
  if event:
122
+ event._type = event_type
123
+ event._data = payload.get("data", {})
159
124
  await self.server.post(event)
160
125
 
161
126
  async def refresh_login(self):
@@ -171,7 +136,6 @@ class MilkyWebhookAdapter(BaseAdapter):
171
136
  self_id = login.id
172
137
  previous = self.logins.get(self_id)
173
138
  self.logins[self_id] = login
174
- self.networks[self_id] = _MilkyNetwork(self)
175
139
  event_type = EventType.LOGIN_ADDED if previous is None else EventType.LOGIN_UPDATED
176
140
  await self.server.post(Event(event_type, datetime.now(), login))
177
141
 
@@ -180,53 +144,9 @@ class MilkyWebhookAdapter(BaseAdapter):
180
144
  login.status = LoginStatus.OFFLINE
181
145
  await self.server.post(Event(EventType.LOGIN_REMOVED, datetime.now(), login))
182
146
  self.logins.pop(self_id, None)
183
- self.networks.pop(self_id, None)
184
-
185
- def _normalize_webhook_paths(self, webhook_path: str) -> tuple[str, ...]:
186
- path = webhook_path or "/"
187
- normalized = path if path.startswith("/") else f"/{path}"
188
- paths: set[str] = {normalized}
189
- stripped = normalized.rstrip("/")
190
- if stripped and stripped != normalized:
191
- paths.add(stripped)
192
- return tuple(sorted(paths))
193
-
194
- def _get_network(self, self_id: str) -> MilkyNetwork:
195
- network = self.networks.get(self_id)
196
- if not network:
197
- network = _MilkyNetwork(self)
198
- self.networks[self_id] = network
199
- return network
200
147
 
201
148
  def _get_login(self, self_id: str) -> Login:
202
149
  return self.logins[self_id]
203
150
 
204
- async def webhook_endpoint(self, request: StarletteRequest) -> Response:
205
- if self.webhook_token:
206
- auth_header = request.headers.get("Authorization")
207
- provided = None
208
- if auth_header:
209
- if auth_header.lower().startswith("bearer "):
210
- provided = auth_header[7:]
211
- else:
212
- provided = auth_header
213
- if provided is None:
214
- provided = request.query_params.get("access_token")
215
- if provided != self.webhook_token:
216
- return JSONResponse({"error": "unauthorized"}, status_code=401)
217
- try:
218
- payload = await request.json()
219
- except Exception as e: # pragma: no cover - defensive
220
- logger.error(f"Failed to parse milky webhook payload: {e}")
221
- return JSONResponse({"error": "invalid json"}, status_code=400)
222
- if not isinstance(payload, dict):
223
- return JSONResponse({"error": "invalid payload"}, status_code=400)
224
- try:
225
- await self.handle_event(payload)
226
- except Exception as e: # pragma: no cover - defensive
227
- logger.exception("Error while processing milky webhook event", exc_info=e)
228
- return JSONResponse({"error": "internal error"}, status_code=500)
229
- return Response(status_code=204)
230
-
231
151
 
232
- __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"]