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.
- .mina/adapter_milky.toml +29 -0
- satori/adapters/milky/__init__.py +4 -0
- satori/adapters/milky/api.py +311 -0
- satori/adapters/milky/events/__init__.py +7 -0
- satori/adapters/milky/events/base.py +19 -0
- satori/adapters/milky/events/group.py +74 -0
- satori/adapters/milky/events/message.py +80 -0
- satori/adapters/milky/events/request.py +92 -0
- satori/adapters/milky/main.py +247 -0
- satori/adapters/milky/message.py +233 -0
- satori/adapters/milky/utils.py +77 -0
- satori/adapters/milky/webhook.py +230 -0
- satori_python_adapter_milky-0.1.0.dist-info/METADATA +151 -0
- satori_python_adapter_milky-0.1.0.dist-info/RECORD +16 -0
- satori_python_adapter_milky-0.1.0.dist-info/WHEEL +4 -0
- satori_python_adapter_milky-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|