ErisPulse-SnowLumaAdapter 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,439 @@
1
+ """
2
+ SnowLuma 事件转换器
3
+
4
+ 将 OneBot11 协议事件(SnowLuma 后端)转换为 ErisPulse 统一的事件格式(兼容 OneBot12 结构)。
5
+
6
+ SnowLuma 使用 OneBot v11 协议,因此本转换器复用 OB11 的事件/消息段映射逻辑,
7
+ 仅将平台标识从 "onebot11" 改为 "snowluma"。
8
+
9
+ {!--< tips >!--}
10
+ 1. 转换后的事件保留原始数据在 snowluma_raw 字段中
11
+ 2. CQ 码和数组格式消息均支持解析
12
+ 3. 未识别的 CQ 码类型会以 onebot11_ 前缀透传(协议级前缀)
13
+ 4. 平台特有事件(poke/honor 等)使用 snowluma_ 前缀
14
+ {!--< /tips >!--}
15
+ """
16
+
17
+ import time
18
+ import uuid
19
+ from typing import Dict, Optional, List, Any
20
+
21
+ from ErisPulse.Core.Bases import BaseConverter
22
+
23
+
24
+ class SnowLumaConverter(BaseConverter):
25
+ """
26
+ SnowLuma (OB11 协议) → ErisPulse 事件转换器
27
+
28
+ 继承 BaseConverter,公共字段(id/time/platform/self/{platform}_raw)由
29
+ build_base_event 构建后按 OB11 字段名(echo/time/self_id)覆盖。
30
+
31
+ {!--< tips >!--}
32
+ 1. 消息事件会解析 CQ 码并转换为 OB12 消息段格式
33
+ 2. 通知事件会映射到统一的 detail_type 命名
34
+ 3. 复用 OB11 协议映射,平台标识为 snowluma
35
+ {!--< /tips >!--}
36
+ """
37
+
38
+ def __init__(self, platform: str = "snowluma"):
39
+ super().__init__(platform=platform)
40
+ self._setup_event_mapping()
41
+
42
+ def _setup_event_mapping(self):
43
+ self.event_map = {
44
+ "message": "message",
45
+ "notice": "notice",
46
+ "request": "request",
47
+ "meta_event": "meta_event",
48
+ }
49
+
50
+ self.notice_subtypes = {
51
+ "group_upload": "group_file_upload",
52
+ "group_admin": "group_admin_change",
53
+ "group_decrease": "group_member_decrease",
54
+ "group_increase": "group_member_increase",
55
+ "group_ban": "group_ban",
56
+ "friend_add": "friend_increase",
57
+ "friend_delete": "friend_decrease",
58
+ "group_recall": "message_recall",
59
+ "friend_recall": "message_recall",
60
+ "notify": "notify",
61
+ }
62
+
63
+ def convert(self, raw_event: Dict) -> Optional[Dict]:
64
+ if not isinstance(raw_event, dict):
65
+ raise ValueError("事件数据必须是字典类型")
66
+
67
+ post_type = raw_event.get("post_type")
68
+ if post_type not in self.event_map:
69
+ return None
70
+
71
+ # 基础事件结构(BaseConverter 提供骨架,按 OB11 字段名覆盖)
72
+ onebot_event = self.build_base_event(raw_event, post_type)
73
+ onebot_event["id"] = str(raw_event.get("echo", str(uuid.uuid4())))
74
+ onebot_event["time"] = self._convert_timestamp(raw_event.get("time", int(time.time())))
75
+ onebot_event["self"]["user_id"] = str(raw_event.get("self_id", ""))
76
+
77
+ handler = getattr(self, f"_handle_{post_type}", None)
78
+ if handler:
79
+ return handler(raw_event, onebot_event)
80
+
81
+ return None
82
+
83
+ def _convert_timestamp(self, ts) -> int:
84
+ if isinstance(ts, str):
85
+ if len(ts) == 13:
86
+ return int(ts) // 1000
87
+ return int(ts)
88
+ elif isinstance(ts, (int, float)):
89
+ if ts > 9999999999:
90
+ return int(ts // 1000)
91
+ return int(ts)
92
+ return int(time.time())
93
+
94
+ def _handle_message(self, raw_event: Dict, base_event: Dict) -> Dict:
95
+ message_type = raw_event["message_type"]
96
+ detail_type = "private" if message_type == "private" else "group"
97
+
98
+ message_segments = self._parse_cq_code(raw_event["message"])
99
+ alt_message = self._generate_alt_message(message_segments)
100
+
101
+ # message_id 为非零有符号 32 位整数,负值有效:
102
+ # 引用、查询与撤回时应原样保留,任何环节不得取绝对值
103
+ base_event.update(
104
+ {
105
+ "type": "message",
106
+ "detail_type": detail_type,
107
+ "message_id": str(raw_event.get("message_id", "")),
108
+ "message": message_segments,
109
+ "alt_message": alt_message,
110
+ "user_id": str(
111
+ raw_event.get("sender", {}).get(
112
+ "user_id", raw_event.get("user_id", "")
113
+ )
114
+ ),
115
+ }
116
+ )
117
+
118
+ sender = raw_event.get("sender", {})
119
+ if "nickname" in sender:
120
+ base_event["user_nickname"] = sender["nickname"]
121
+ if "card" in sender and sender["card"]:
122
+ base_event["user_nickname"] = sender["card"]
123
+
124
+ if detail_type == "group":
125
+ base_event["group_id"] = str(raw_event.get("group_id", ""))
126
+ if "group_name" in raw_event:
127
+ base_event["group_name"] = raw_event["group_name"]
128
+
129
+ if raw_event.get("sub_type"):
130
+ base_event["sub_type"] = raw_event["sub_type"]
131
+
132
+ return base_event
133
+
134
+ @staticmethod
135
+ def _unescape_cq(value: str) -> str:
136
+ """
137
+ 还原 CQ 码转义字符(对齐官方 SDK fromCQString 语义)
138
+
139
+ ``&#91;`` → ``[``、``&#93;`` → ``]``、``&#44;`` → ``,``、``&amp;`` → ``&``;
140
+ ``&amp;`` 必须最后处理,避免把其他实体的 ``&`` 前缀二次还原。
141
+
142
+ {!--< internal-use >!--}
143
+ """
144
+ value = value.replace("&#91;", "[").replace("&#93;", "]").replace("&#44;", ",")
145
+ return value.replace("&amp;", "&")
146
+
147
+ def _parse_cq_code(self, message: Any) -> List[Dict]:
148
+ if isinstance(message, str):
149
+ if "[CQ:" not in message:
150
+ return [{"type": "text", "data": {"text": self._unescape_cq(message)}}]
151
+
152
+ segments = []
153
+ last_pos = 0
154
+
155
+ while True:
156
+ cq_start = message.find("[CQ:", last_pos)
157
+ if cq_start == -1:
158
+ if last_pos < len(message):
159
+ text = message[last_pos:]
160
+ if text:
161
+ segments.append({"type": "text", "data": {"text": self._unescape_cq(text)}})
162
+ break
163
+
164
+ if cq_start > last_pos:
165
+ text = message[last_pos:cq_start]
166
+ if text:
167
+ segments.append({"type": "text", "data": {"text": self._unescape_cq(text)}})
168
+
169
+ cq_end = message.find("]", cq_start)
170
+ if cq_end == -1:
171
+ segments.append(
172
+ {"type": "text", "data": {"text": self._unescape_cq(message[cq_start:])}}
173
+ )
174
+ break
175
+
176
+ cq_content = message[cq_start + 4 : cq_end]
177
+ parts = cq_content.split(",", 1)
178
+ cq_type = parts[0]
179
+ cq_data = {}
180
+
181
+ if len(parts) > 1:
182
+ data_str = parts[1]
183
+ data_parts = data_str.split(",")
184
+ for part in data_parts:
185
+ if "=" in part:
186
+ key, value = part.split("=", 1)
187
+ cq_data[key] = self._unescape_cq(value)
188
+
189
+ segment = self._convert_cq_segment(cq_type, cq_data)
190
+ segments.append(segment)
191
+ last_pos = cq_end + 1
192
+
193
+ return segments
194
+
195
+ elif isinstance(message, list):
196
+ segments = []
197
+ for item in message:
198
+ if isinstance(item, str):
199
+ segments.append({"type": "text", "data": {"text": item}})
200
+ elif isinstance(item, dict):
201
+ cq_type = item.get("type", "")
202
+ cq_data = item.get("data", {})
203
+ segment = self._convert_cq_segment(cq_type, cq_data)
204
+ segments.append(segment)
205
+ return segments
206
+
207
+ return [{"type": "text", "data": {"text": str(message)}}]
208
+
209
+ def _convert_cq_segment(self, cq_type: str, cq_data: Dict) -> Dict:
210
+ """将单个 CQ 码段转换为 OB12 消息段"""
211
+ if cq_type == "text":
212
+ return {"type": "text", "data": {"text": cq_data.get("text", "")}}
213
+ elif cq_type == "image":
214
+ return {
215
+ "type": "image",
216
+ "data": {
217
+ "file": cq_data.get("file"),
218
+ "url": cq_data.get("url"),
219
+ "cache": cq_data.get("cache", "1"),
220
+ },
221
+ }
222
+ elif cq_type == "record":
223
+ return {
224
+ "type": "audio",
225
+ "data": {
226
+ "file": cq_data.get("file"),
227
+ "url": cq_data.get("url"),
228
+ "magic": cq_data.get("magic", "0"),
229
+ },
230
+ }
231
+ elif cq_type == "at":
232
+ return {
233
+ "type": "mention",
234
+ "data": {
235
+ "user_id": cq_data.get("qq"),
236
+ "user_name": cq_data.get("name", ""),
237
+ },
238
+ }
239
+ elif cq_type == "face":
240
+ return {"type": "face", "data": {"id": cq_data.get("id")}}
241
+ elif cq_type == "reply":
242
+ return {
243
+ "type": "reply",
244
+ "data": {
245
+ "message_id": cq_data.get("id"),
246
+ "user_id": cq_data.get("qq", ""),
247
+ },
248
+ }
249
+ else:
250
+ return {"type": f"onebot11_{cq_type}", "data": cq_data}
251
+
252
+ def _generate_alt_message(self, segments: List[Dict]) -> str:
253
+ parts = []
254
+ for seg in segments:
255
+ if seg["type"] == "text":
256
+ parts.append(seg["data"]["text"])
257
+ elif seg["type"] == "image":
258
+ parts.append("[图片]")
259
+ elif seg["type"] == "audio":
260
+ parts.append("[语音]")
261
+ elif seg["type"] == "mention":
262
+ data = seg.get("data") or {}
263
+ user_id = str(data.get("user_id") or "").strip()
264
+ user_name = str(data.get("user_name") or "").strip()
265
+ # 昵称缺失回退 user_id;@全体成员固定文案;两者皆缺省略该段,
266
+ # 不输出空 "@"(mention 段构造时 user_name 默认存 "",不能用
267
+ # get('user_name', fallback)——键存在时 fallback 永不生效)
268
+ if user_id == "all":
269
+ parts.append("@全体成员")
270
+ elif user_name:
271
+ parts.append(f"@{user_name}")
272
+ elif user_id:
273
+ parts.append(f"@{user_id}")
274
+ elif seg["type"] == "face":
275
+ parts.append(f"[表情:{seg['data'].get('id', '')}]")
276
+ elif seg["type"] == "reply":
277
+ parts.append("[回复]")
278
+ elif seg["type"].startswith("onebot11_"):
279
+ parts.append(f"[{seg['type'][9:]}]")
280
+ return "".join(parts)
281
+
282
+ def _handle_notice(self, raw_event: Dict, base_event: Dict) -> Dict:
283
+ notice_type = raw_event["notice_type"]
284
+ sub_type = raw_event.get("sub_type", "")
285
+
286
+ if notice_type == "bot_status":
287
+ # SnowLuma 账号上下线通知(sub_type: online/offline/bot_offline)转换为
288
+ # OB12 标准元事件 connect/disconnect——这是协议标准形态,不是平台拓展
289
+ base_event.update(
290
+ {
291
+ "type": "meta_event",
292
+ "detail_type": "connect" if sub_type == "online" else "disconnect",
293
+ "sub_type": sub_type,
294
+ "user_id": str(raw_event.get("user_id", "")),
295
+ f"{self.platform}_meta": raw_event,
296
+ }
297
+ )
298
+ return base_event
299
+
300
+ detail_type = self.notice_subtypes.get(notice_type, notice_type)
301
+
302
+ base_event.update(
303
+ {"type": "notice", "detail_type": detail_type, f"{self.platform}_notice": raw_event}
304
+ )
305
+
306
+ if notice_type == "group_upload":
307
+ base_event.update(
308
+ {
309
+ "group_id": str(raw_event.get("group_id")),
310
+ "user_id": str(raw_event.get("user_id")),
311
+ f"{self.platform}_file": raw_event.get("file"),
312
+ }
313
+ )
314
+ elif notice_type == "group_admin":
315
+ base_event.update(
316
+ {
317
+ "group_id": str(raw_event.get("group_id")),
318
+ "user_id": str(raw_event.get("user_id")),
319
+ "sub_type": "set" if sub_type == "set" else "unset",
320
+ }
321
+ )
322
+ elif notice_type in ["group_increase", "group_decrease"]:
323
+ base_event.update(
324
+ {
325
+ "group_id": str(raw_event.get("group_id")),
326
+ "user_id": str(raw_event.get("user_id")),
327
+ "operator_id": str(raw_event.get("operator_id", "")),
328
+ "sub_type": sub_type,
329
+ }
330
+ )
331
+ elif notice_type == "group_ban":
332
+ base_event.update(
333
+ {
334
+ "group_id": str(raw_event.get("group_id")),
335
+ "operator_id": str(raw_event.get("operator_id")),
336
+ "user_id": str(raw_event.get("user_id")),
337
+ "duration": raw_event.get("duration", 0),
338
+ }
339
+ )
340
+ elif notice_type in ["friend_add", "friend_delete"]:
341
+ base_event.update({"user_id": str(raw_event.get("user_id"))})
342
+ elif notice_type in ["group_recall", "friend_recall"]:
343
+ base_event.update(
344
+ {
345
+ "message_id": str(raw_event.get("message_id")),
346
+ "user_id": str(raw_event.get("user_id")),
347
+ "group_id": str(raw_event.get("group_id", ""))
348
+ if notice_type == "group_recall"
349
+ else None,
350
+ }
351
+ )
352
+ elif notice_type == "notify":
353
+ if sub_type == "honor":
354
+ base_event["detail_type"] = f"{self.platform}_honor"
355
+ base_event.update(
356
+ {
357
+ "group_id": str(raw_event.get("group_id")),
358
+ "user_id": str(raw_event.get("user_id")),
359
+ "honor_type": raw_event.get("honor_type"),
360
+ }
361
+ )
362
+ elif sub_type == "poke":
363
+ base_event["detail_type"] = f"{self.platform}_poke"
364
+ base_event.update(
365
+ {
366
+ "group_id": str(raw_event.get("group_id", "")),
367
+ "user_id": str(raw_event.get("user_id")),
368
+ "target_id": str(raw_event.get("target_id")),
369
+ }
370
+ )
371
+ elif sub_type == "lucky_king":
372
+ base_event["detail_type"] = f"{self.platform}_lucky_king"
373
+ base_event.update(
374
+ {
375
+ "group_id": str(raw_event.get("group_id")),
376
+ "user_id": str(raw_event.get("user_id")),
377
+ "target_id": str(raw_event.get("target_id")),
378
+ }
379
+ )
380
+
381
+ return base_event
382
+
383
+ def _handle_request(self, raw_event: Dict, base_event: Dict) -> Dict:
384
+ request_type = raw_event["request_type"]
385
+
386
+ base_event.update(
387
+ {
388
+ "type": "request",
389
+ "detail_type": request_type,
390
+ f"{self.platform}_request": raw_event,
391
+ }
392
+ )
393
+
394
+ if request_type == "friend":
395
+ base_event.update(
396
+ {
397
+ "user_id": str(raw_event.get("user_id")),
398
+ "comment": raw_event.get("comment"),
399
+ "flag": raw_event.get("flag"),
400
+ "request_id": raw_event.get("flag"),
401
+ }
402
+ )
403
+ elif request_type == "group":
404
+ base_event.update(
405
+ {
406
+ "group_id": str(raw_event.get("group_id")),
407
+ "user_id": str(raw_event.get("user_id")),
408
+ "comment": raw_event.get("comment"),
409
+ "sub_type": raw_event.get("sub_type"),
410
+ "flag": raw_event.get("flag"),
411
+ "request_id": raw_event.get("flag"),
412
+ }
413
+ )
414
+
415
+ return base_event
416
+
417
+ def _handle_meta_event(self, raw_event: Dict, base_event: Dict) -> Dict:
418
+ meta_type = raw_event["meta_event_type"]
419
+
420
+ if meta_type == "heartbeat":
421
+ detail_type = "heartbeat"
422
+ else:
423
+ detail_type = f"{self.platform}_{meta_type}"
424
+
425
+ base_event.update(
426
+ {
427
+ "type": "meta_event",
428
+ "detail_type": detail_type,
429
+ f"{self.platform}_meta": raw_event,
430
+ }
431
+ )
432
+
433
+ if meta_type == "lifecycle":
434
+ base_event["sub_type"] = raw_event.get("sub_type", "")
435
+ elif meta_type == "heartbeat":
436
+ base_event["interval"] = raw_event.get("interval", 0)
437
+ base_event["status"] = raw_event.get("status", {})
438
+
439
+ return base_event