satori-python-adapter-milky 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,247 @@
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
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 | None:
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
+ if self.token:
125
+ headers.setdefault("Authorization", f"Bearer {self.token}")
126
+ async with self.session.post(url, json=params or {}, headers=headers) as resp:
127
+ resp.raise_for_status()
128
+ data = await resp.json()
129
+ if data.get("status") == "failed":
130
+ raise ActionFailed(f"{data.get('retcode')}: {data.get('message')}", data)
131
+ return data.get("data")
132
+
133
+ async def connection_daemon(self, manager: Launart, session: aiohttp.ClientSession):
134
+ while not manager.status.exiting:
135
+ headers = self.headers.copy()
136
+ if self.token:
137
+ headers.setdefault("Authorization", f"Bearer {self.token}")
138
+ try:
139
+ self.connection = await session.ws_connect(self.event_url, headers=headers)
140
+ except Exception as e:
141
+ logger.error(f"Milky adapter websocket connect failed: {e}")
142
+ await asyncio.sleep(5)
143
+ continue
144
+ logger.info("Milky adapter websocket connected")
145
+ self.close_signal.clear()
146
+ await self.refresh_login()
147
+ receiver_task = asyncio.create_task(self.message_handle())
148
+ close_task = asyncio.create_task(self.close_signal.wait())
149
+ sigexit_task = asyncio.create_task(manager.status.wait_for_sigexit())
150
+
151
+ done, pending = await any_completed(receiver_task, close_task, sigexit_task)
152
+ for task in pending:
153
+ task.cancel()
154
+ await asyncio.gather(*pending, return_exceptions=True)
155
+ if sigexit_task in done:
156
+ break
157
+ logger.warning("Milky adapter websocket closed, retrying in 5 seconds")
158
+ await self._handle_disconnect()
159
+ await asyncio.sleep(5)
160
+ await self._handle_disconnect()
161
+
162
+ async def message_handle(self):
163
+ assert self.connection is not None
164
+ async for msg in self.connection:
165
+ if msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED):
166
+ self.close_signal.set()
167
+ break
168
+ if msg.type != aiohttp.WSMsgType.TEXT:
169
+ continue
170
+ try:
171
+ data = decode(msg.data)
172
+ except Exception as e: # pragma: no cover - defensive
173
+ logger.error(f"Failed to decode milky event: {e}")
174
+ continue
175
+ if not isinstance(data, dict):
176
+ continue
177
+ await self.handle_event(data)
178
+
179
+ async def handle_event(self, payload: dict):
180
+ event_type = payload.get("event_type")
181
+ self_id = str(payload.get("self_id"))
182
+ if not event_type or not self_id:
183
+ return
184
+ if self_id not in self.logins:
185
+ await self.refresh_login()
186
+ if self_id not in self.logins:
187
+ logger.warning(f"Ignoring event for unknown self_id {self_id}")
188
+ return
189
+ login = self.logins[self_id]
190
+ handler = event_handlers.get(event_type)
191
+ network = self.networks.get(self_id)
192
+ if not network:
193
+ network = _MilkyNetwork(self)
194
+ self.networks[self_id] = network
195
+ if handler:
196
+ event = await handler(login, network, payload)
197
+ else:
198
+ body = payload.get("data", {})
199
+ event = Event(
200
+ EventType.INTERNAL,
201
+ datetime.fromtimestamp(payload.get("time", datetime.now().timestamp())),
202
+ login,
203
+ _type=event_type,
204
+ _data=body,
205
+ )
206
+ if event:
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"]
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Sequence
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from satori.element import Custom, E, Element
9
+ from satori.model import Channel, ChannelType, Login, MessageObject, User
10
+ from satori.parser import Element as RawElement
11
+ from satori.parser import parse
12
+
13
+ from .utils import MilkyNetwork, decode_guild, decode_guild_channel_id, decode_member, get_scene_and_peer, user_avatar
14
+
15
+ _BASE64_RE = re.compile(r"^data:([\w/.+-]+);base64,")
16
+
17
+
18
+ class MilkyMessageEncoder:
19
+ def __init__(self, login: Login, net: MilkyNetwork, channel_id: str):
20
+ self.login = login
21
+ self.net = net
22
+ self.channel_id = channel_id
23
+ self.segments: list[dict[str, Any]] = []
24
+ self.elements: list[Element] = []
25
+ self.results: list[MessageObject] = []
26
+
27
+ async def send(self, content: str) -> list[MessageObject]:
28
+ raw_elements = parse(content)
29
+ await self.render(raw_elements)
30
+ await self.flush()
31
+ return self.results
32
+
33
+ async def render(self, elements: Sequence[RawElement]):
34
+ for element in elements:
35
+ await self.visit(element)
36
+
37
+ async def visit(self, element: RawElement):
38
+ type_ = element.type
39
+ attrs = element.attrs
40
+ children = element.children
41
+ if type_ == "text":
42
+ text = attrs.get("text", "")
43
+ if not self.segments or self.segments[-1]["type"] != "text":
44
+ self.segments.append({"type": "text", "data": {"text": text}})
45
+ else:
46
+ self.segments[-1]["data"]["text"] += text
47
+ self.elements.append(E.text(text))
48
+ elif type_ == "br":
49
+ if not self.segments or self.segments[-1]["type"] != "text":
50
+ self.segments.append({"type": "text", "data": {"text": "\n"}})
51
+ else:
52
+ self.segments[-1]["data"]["text"] += "\n"
53
+ self.elements.append(E.text("\n"))
54
+ elif type_ == "p":
55
+ await self.render(children)
56
+ if self.segments:
57
+ if self.segments[-1]["type"] == "text":
58
+ self.segments[-1]["data"]["text"] += "\n"
59
+ else:
60
+ self.segments.append({"type": "text", "data": {"text": "\n"}})
61
+ self.elements.append(E.text("\n"))
62
+ elif type_ == "at":
63
+ if attrs.get("type") == "all":
64
+ self.segments.append({"type": "mention_all", "data": {}})
65
+ self.elements.append(E.at_all())
66
+ elif "id" in attrs:
67
+ target = attrs["id"]
68
+ self.segments.append({"type": "mention", "data": {"user_id": int(target)}})
69
+ self.elements.append(E.at(str(target)))
70
+ elif type_ == "quote":
71
+ quote_id = attrs.get("id")
72
+ if quote_id is not None:
73
+ try:
74
+ seq = int(quote_id)
75
+ except ValueError:
76
+ seq = None
77
+ if seq is not None:
78
+ self.segments.append({"type": "reply", "data": {"message_seq": seq}})
79
+ self.elements.append(E.quote(str(seq), content=[E.text("")]))
80
+ await self.render(children)
81
+ elif type_ in {"img", "image"}:
82
+ uri = attrs.get("src") or attrs.get("url")
83
+ if not uri:
84
+ return
85
+ if match := _BASE64_RE.match(uri):
86
+ uri = f"base64://{uri[len(match.group(0)) :]}"
87
+ self.segments.append({"type": "image", "data": {"uri": uri, "sub_type": attrs.get("sub_type", "normal")}})
88
+ self.elements.append(E.image(uri))
89
+ elif type_ == "audio":
90
+ uri = attrs.get("src") or attrs.get("url")
91
+ if not uri:
92
+ return
93
+ if match := _BASE64_RE.match(uri):
94
+ uri = f"base64://{uri[len(match.group(0)) :]}"
95
+ self.segments.append({"type": "record", "data": {"uri": uri}})
96
+ self.elements.append(E.audio(uri))
97
+ elif type_ == "video":
98
+ uri = attrs.get("src") or attrs.get("url")
99
+ if not uri:
100
+ return
101
+ if match := _BASE64_RE.match(uri):
102
+ uri = f"base64://{uri[len(match.group(0)) :]}"
103
+ payload = {"uri": uri}
104
+ if poster := attrs.get("poster"):
105
+ payload["thumb_uri"] = poster
106
+ self.segments.append({"type": "video", "data": payload})
107
+ self.elements.append(E.video(uri))
108
+ else:
109
+ await self.render(children)
110
+
111
+ async def flush(self):
112
+ if not self.segments:
113
+ return
114
+ scene, peer_id = get_scene_and_peer(self.channel_id)
115
+ payload: dict = {"message": self.segments}
116
+ if scene == "group":
117
+ payload["group_id"] = peer_id
118
+ resp = await self.net.call_api("send_group_message", payload)
119
+ else:
120
+ payload["user_id"] = peer_id
121
+ resp = await self.net.call_api("send_private_message", payload)
122
+ if resp:
123
+ channel_type = ChannelType.TEXT if scene == "group" else ChannelType.DIRECT
124
+ channel_id = str(peer_id) if scene == "group" else self.channel_id
125
+ channel = Channel(channel_id, channel_type)
126
+ created_at = datetime.fromtimestamp(resp.get("time", datetime.now().timestamp()))
127
+ content = "".join(str(elem) for elem in self.elements)
128
+ message = MessageObject(str(resp.get("message_seq", "")), content, channel=channel, created_at=created_at)
129
+ message.message = list(self.elements)
130
+ self.results.append(message)
131
+ self.segments = []
132
+ self.elements = []
133
+
134
+
135
+ async def decode_message(net: MilkyNetwork, payload: dict) -> MessageObject:
136
+ elements = await _decode_segments(net, payload, payload.get("segments") or [])
137
+ guild_id, channel_id = decode_guild_channel_id(payload)
138
+ channel_type = ChannelType.TEXT if guild_id else ChannelType.DIRECT
139
+ channel_name = None
140
+ guild = None
141
+ member = None
142
+ if payload["message_scene"] == "group":
143
+ group_info = payload.get("group")
144
+ if group_info:
145
+ guild = decode_guild(group_info)
146
+ channel_name = group_info.get("group_name")
147
+ member_info = payload.get("group_member")
148
+ if member_info:
149
+ member = decode_member(member_info)
150
+ elif payload["message_scene"] == "friend":
151
+ friend_info = payload.get("friend")
152
+ if friend_info:
153
+ channel_name = friend_info.get("nickname")
154
+ channel = Channel(channel_id, channel_type, channel_name)
155
+
156
+ user_name = None
157
+ if payload["message_scene"] == "group":
158
+ member_info = payload.get("group_member")
159
+ if member_info:
160
+ user_name = member_info.get("nickname")
161
+ elif payload["message_scene"] == "friend":
162
+ friend_info = payload.get("friend")
163
+ if friend_info:
164
+ user_name = friend_info.get("nickname")
165
+ user = User(str(payload["sender_id"]), user_name, avatar=user_avatar(payload["sender_id"]))
166
+
167
+ message = MessageObject(
168
+ str(payload["message_seq"]),
169
+ "".join(str(elem) for elem in elements),
170
+ channel=channel,
171
+ guild=guild,
172
+ member=member,
173
+ user=user,
174
+ created_at=datetime.fromtimestamp(payload["time"]),
175
+ )
176
+ message.message = elements
177
+ return message
178
+
179
+
180
+ async def _decode_segments(net: MilkyNetwork, payload: dict, segments: Sequence[dict]) -> list[Element]:
181
+ result: list[Element] = []
182
+ for segment in segments:
183
+ seg_type = segment.get("type")
184
+ data = segment.get("data", {})
185
+ if seg_type == "text":
186
+ result.append(E.text(data.get("text", "")))
187
+ elif seg_type == "mention":
188
+ result.append(E.at(str(data.get("user_id"))))
189
+ elif seg_type == "mention_all":
190
+ result.append(E.at_all())
191
+ elif seg_type == "image":
192
+ result.append(E.image(_resource_url(data)))
193
+ elif seg_type == "record":
194
+ result.append(E.audio(_resource_url(data)))
195
+ elif seg_type == "video":
196
+ result.append(E.video(_resource_url(data)))
197
+ elif seg_type == "file":
198
+ result.append(E.file(_resource_url(data)))
199
+ elif seg_type == "reply":
200
+ seq = data.get("message_seq")
201
+ if seq is not None:
202
+ quote = await _decode_reply(net, payload, int(seq))
203
+ if quote:
204
+ result.append(quote)
205
+ else:
206
+ result.append(Custom(f"milky:{seg_type}", data))
207
+ return result
208
+
209
+
210
+ async def _decode_reply(net: MilkyNetwork, payload: dict, message_seq: int) -> Element | None:
211
+ try:
212
+ response = await net.call_api(
213
+ "get_message",
214
+ {
215
+ "message_scene": payload["message_scene"],
216
+ "peer_id": payload["peer_id"],
217
+ "message_seq": message_seq,
218
+ },
219
+ )
220
+ except Exception:
221
+ return None
222
+ if not response or "message" not in response:
223
+ return None
224
+ quoted = await decode_message(net, response["message"])
225
+ content = []
226
+ if quoted.user:
227
+ content.append(E.author(quoted.user.id, quoted.user.name, quoted.user.avatar))
228
+ content.extend(quoted.message)
229
+ return E.quote(str(message_seq), content=content)
230
+
231
+
232
+ def _resource_url(data: dict) -> str:
233
+ return data.get("temp_url") or data.get("url") or data.get("uri") or ""
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Literal, Protocol
5
+
6
+ from satori.model import Channel, ChannelType, Guild, Member, User
7
+
8
+ AVATAR_URL = "https://q.qlogo.cn/headimg_dl?dst_uin={uin}&spec=640"
9
+ GROUP_AVATAR_URL = "https://p.qlogo.cn/gh/{group}/{group}/640"
10
+
11
+
12
+ class MilkyNetwork(Protocol):
13
+ async def call_api(self, action: str, params: dict | None = None) -> dict | None: ...
14
+
15
+
16
+ def user_avatar(uin: int | str) -> str:
17
+ return AVATAR_URL.format(uin=uin)
18
+
19
+
20
+ def group_avatar(group_id: int | str) -> str:
21
+ return GROUP_AVATAR_URL.format(group=group_id)
22
+
23
+
24
+ def decode_group_channel(group: dict) -> Channel:
25
+ return Channel(str(group["group_id"]), ChannelType.TEXT, group.get("group_name"))
26
+
27
+
28
+ def decode_private_channel(profile: dict, channel_id: str) -> Channel:
29
+ return Channel(channel_id, ChannelType.DIRECT, profile.get("nickname"))
30
+
31
+
32
+ def decode_guild(group: dict) -> Guild:
33
+ return Guild(str(group["group_id"]), group.get("group_name"), group_avatar(group["group_id"]))
34
+
35
+
36
+ def decode_member(member: dict) -> Member:
37
+ user_id = str(member["user_id"])
38
+ user = User(user_id, member.get("nickname"), avatar=user_avatar(user_id))
39
+ joined_at = member.get("join_time")
40
+ return Member(
41
+ user=user,
42
+ nick=member.get("card") or member.get("nickname"),
43
+ avatar=user_avatar(user_id),
44
+ joined_at=datetime.fromtimestamp(joined_at) if joined_at else None,
45
+ )
46
+
47
+
48
+ def decode_friend(friend: dict) -> User:
49
+ user_id = str(friend["user_id"])
50
+ return User(user_id, friend.get("nickname"), avatar=user_avatar(user_id))
51
+
52
+
53
+ def decode_login_user(login: dict) -> User:
54
+ user_id = str(login["uin"])
55
+ return User(user_id, login.get("nickname"), avatar=user_avatar(user_id))
56
+
57
+
58
+ def decode_user_profile(profile: dict, user_id: str) -> User:
59
+ return User(user_id, profile.get("nickname"), avatar=user_avatar(user_id), nick=profile.get("remark"))
60
+
61
+
62
+ def decode_guild_channel_id(data: dict) -> tuple[str | None, str]:
63
+ scene = data.get("message_scene")
64
+ peer_id = str(data.get("peer_id"))
65
+ if scene == "group":
66
+ return peer_id, peer_id
67
+ if scene == "temp":
68
+ return None, f"private:temp_{peer_id}"
69
+ return None, f"private:{peer_id}"
70
+
71
+
72
+ def get_scene_and_peer(channel_id: str) -> tuple[Literal["friend", "group", "temp"], int]:
73
+ if channel_id.startswith("private:temp_"):
74
+ return "temp", int(channel_id.removeprefix("private:temp_"))
75
+ if channel_id.startswith("private:"):
76
+ return "friend", int(channel_id.removeprefix("private:"))
77
+ return "group", int(channel_id)