ErisPulse-WechatMpAdapter 4.0.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,335 @@
1
+ import time
2
+ import xml.etree.ElementTree as ET
3
+ from typing import Dict, List, Optional
4
+
5
+
6
+ class WechatMpConverter:
7
+ """
8
+ 微信公众号事件转换器
9
+
10
+ 将微信公众号回调的 XML 数据转换为 OneBot12 标准事件。
11
+
12
+ 核心原则:
13
+ 1. 严格兼容:标准字段完全遵循 OneBot12 规范
14
+ 2. 明确扩展:平台特有功能添加 mp_ 前缀
15
+ 3. 数据完整:原始 XML 保留在 mp_raw 字段中
16
+ 4. 时间统一:CreateTime 转为 10 位 Unix 时间戳(秒级)
17
+ """
18
+
19
+ def __init__(self, self_id: str):
20
+ self.platform = "mp"
21
+ self.self_id = self_id or ""
22
+
23
+ def convert(self, raw_xml: str) -> Optional[Dict]:
24
+ """
25
+ 将微信公众号原始 XML 转换为 OneBot12 标准格式事件
26
+
27
+ :param raw_xml: 微信回调的原始 XML 字符串
28
+ :return: OneBot12 标准格式事件,解析失败返回 None
29
+ """
30
+ if not raw_xml:
31
+ return None
32
+
33
+ data = self._xml_to_dict(raw_xml)
34
+ if not data:
35
+ return None
36
+
37
+ msg_type = data.get("MsgType", "")
38
+ from_user = data.get("FromUserName", "")
39
+ to_user = data.get("ToUserName", "")
40
+ create_time = self._safe_int(data.get("CreateTime"), int(time.time()))
41
+ msg_id = data.get("MsgId", "")
42
+
43
+ if msg_type == "event":
44
+ return self._convert_event(data, from_user, to_user, create_time)
45
+ else:
46
+ return self._convert_message(
47
+ data, msg_type, from_user, to_user, create_time, msg_id
48
+ )
49
+
50
+ # ==================== 基础事件构建 ====================
51
+
52
+ def _create_base_event(
53
+ self,
54
+ event_type: str,
55
+ raw_xml: str,
56
+ raw_data: Dict,
57
+ from_user: str,
58
+ to_user: str,
59
+ create_time: int,
60
+ msg_id: str = "",
61
+ mp_event: str = "",
62
+ ) -> Dict:
63
+ """创建基础事件结构
64
+
65
+ 公众号场景下所有会话均为私聊(用户与公众号 1v1),
66
+ 因此 detail_type 统一为 private。
67
+ """
68
+ event_id = str(msg_id) if msg_id else f"{create_time}_{from_user}"
69
+ return {
70
+ "id": event_id,
71
+ "time": create_time,
72
+ "type": event_type,
73
+ "detail_type": "private",
74
+ "platform": self.platform,
75
+ "self": {
76
+ "platform": self.platform,
77
+ "user_id": self.self_id,
78
+ },
79
+ "user_id": from_user,
80
+ "mp_raw": raw_xml,
81
+ "mp_raw_type": raw_data.get("MsgType", ""),
82
+ "mp_msg_id": str(msg_id) if msg_id else "",
83
+ "mp_event": mp_event,
84
+ "mp_to_user": to_user,
85
+ "mp_from_user": from_user,
86
+ "mp_data": raw_data,
87
+ }
88
+
89
+ # ==================== 消息事件 ====================
90
+
91
+ def _convert_message(
92
+ self,
93
+ data: Dict,
94
+ msg_type: str,
95
+ from_user: str,
96
+ to_user: str,
97
+ create_time: int,
98
+ msg_id: str,
99
+ ) -> Dict:
100
+ """处理消息事件"""
101
+ event = self._create_base_event(
102
+ event_type="message",
103
+ raw_xml=data.get("_raw_xml", ""),
104
+ raw_data=data,
105
+ from_user=from_user,
106
+ to_user=to_user,
107
+ create_time=create_time,
108
+ msg_id=msg_id,
109
+ )
110
+
111
+ segments = self._parse_message_content(data, msg_type)
112
+ event["message"] = segments
113
+ event["alt_message"] = self._generate_alt_message(segments)
114
+
115
+ return event
116
+
117
+ def _parse_message_content(self, data: Dict, msg_type: str) -> List[Dict]:
118
+ """解析消息内容为 OneBot12 消息段列表"""
119
+ segments: List[Dict] = []
120
+
121
+ if msg_type == "text":
122
+ segments.append(
123
+ {
124
+ "type": "text",
125
+ "data": {"text": data.get("Content", "")},
126
+ }
127
+ )
128
+
129
+ elif msg_type == "image":
130
+ segments.append(
131
+ {
132
+ "type": "image",
133
+ "data": {
134
+ "file": data.get("PicUrl", ""),
135
+ "file_id": data.get("MediaId", ""),
136
+ },
137
+ }
138
+ )
139
+
140
+ elif msg_type == "voice":
141
+ seg_data = {
142
+ "file": data.get("MediaId", ""),
143
+ "file_id": data.get("MediaId", ""),
144
+ }
145
+ recognition = data.get("Recognition", "")
146
+ if recognition:
147
+ seg_data["text"] = recognition
148
+ segments.append({"type": "voice", "data": seg_data})
149
+
150
+ elif msg_type == "video":
151
+ segments.append(
152
+ {
153
+ "type": "video",
154
+ "data": {
155
+ "file": data.get("MediaId", ""),
156
+ "file_id": data.get("MediaId", ""),
157
+ "thumbnail": data.get("ThumbMediaId", ""),
158
+ },
159
+ }
160
+ )
161
+
162
+ elif msg_type == "shortvideo":
163
+ segments.append(
164
+ {
165
+ "type": "video",
166
+ "data": {
167
+ "file": data.get("MediaId", ""),
168
+ "file_id": data.get("MediaId", ""),
169
+ "thumbnail": data.get("ThumbMediaId", ""),
170
+ "mp_shortvideo": True,
171
+ },
172
+ }
173
+ )
174
+
175
+ elif msg_type == "location":
176
+ segments.append(
177
+ {
178
+ "type": "location",
179
+ "data": {
180
+ "latitude": self._safe_float(data.get("Location_X"), 0.0),
181
+ "longitude": self._safe_float(data.get("Location_Y"), 0.0),
182
+ "scale": self._safe_float(data.get("Scale"), 0.0),
183
+ "title": data.get("Label", ""),
184
+ },
185
+ }
186
+ )
187
+
188
+ elif msg_type == "link":
189
+ title = data.get("Title", "")
190
+ description = data.get("Description", "")
191
+ url = data.get("Url", "")
192
+ text = f"{title}\n{description}\n{url}" if title or description else url
193
+ segments.append(
194
+ {
195
+ "type": "text",
196
+ "data": {"text": text},
197
+ }
198
+ )
199
+
200
+ else:
201
+ segments.append(
202
+ {
203
+ "type": "text",
204
+ "data": {"text": f"[不支持的消息类型: {msg_type}]"},
205
+ }
206
+ )
207
+
208
+ return segments
209
+
210
+ # ==================== 事件通知 ====================
211
+
212
+ def _convert_event(
213
+ self,
214
+ data: Dict,
215
+ from_user: str,
216
+ to_user: str,
217
+ create_time: int,
218
+ ) -> Dict:
219
+ """处理事件(Event)通知
220
+
221
+ 公众号事件包括:subscribe/unsubscribe/SCAN/LOCATION/CLICK/VIEW/
222
+ TEMPLATESENDJOBFINISH/MASSSENDJOBFINISH 等。
223
+ """
224
+ event_key = data.get("Event", "")
225
+ event_key_lower = event_key.lower() if event_key else ""
226
+
227
+ event = self._create_base_event(
228
+ event_type="notice",
229
+ raw_xml=data.get("_raw_xml", ""),
230
+ raw_data=data,
231
+ from_user=from_user,
232
+ to_user=to_user,
233
+ create_time=create_time,
234
+ msg_id="",
235
+ mp_event=event_key,
236
+ )
237
+
238
+ if event_key_lower in ("subscribe", "unsubscribe", "scan"):
239
+ event["mp_event"] = event_key_lower
240
+ event["mp_event_key"] = data.get("EventKey", "")
241
+ ticket = data.get("Ticket", "")
242
+ if ticket:
243
+ event["mp_ticket"] = ticket
244
+
245
+ elif event_key_lower == "location":
246
+ # 上报地理位置事件
247
+ event["mp_event"] = "location_report"
248
+ event["latitude"] = self._safe_float(data.get("Latitude"), 0.0)
249
+ event["longitude"] = self._safe_float(data.get("Longitude"), 0.0)
250
+ event["precision"] = self._safe_float(data.get("Precision"), 0.0)
251
+
252
+ elif event_key_lower == "click":
253
+ # 自定义菜单点击事件
254
+ event["mp_event"] = "menu_click"
255
+ event["mp_event_key"] = data.get("EventKey", "")
256
+
257
+ elif event_key_lower == "view":
258
+ # 菜单跳转事件
259
+ event["mp_event"] = "menu_view"
260
+ event["mp_event_key"] = data.get("EventKey", "")
261
+
262
+ elif event_key_lower == "templatesendjobfinish":
263
+ event["mp_event"] = "template_send_finish"
264
+ event["mp_msg_id"] = data.get("MsgID", "")
265
+ event["mp_status"] = data.get("Status", "")
266
+
267
+ elif event_key_lower == "masssendjobfinish":
268
+ event["mp_event"] = "mass_send_finish"
269
+ event["mp_msg_id"] = data.get("MsgID", "")
270
+ event["mp_status"] = data.get("Status", "")
271
+ event["mp_total_count"] = self._safe_int(data.get("TotalCount"), 0)
272
+ event["mp_filter_count"] = self._safe_int(data.get("FilterCount"), 0)
273
+ event["mp_sent_count"] = self._safe_int(data.get("SentCount"), 0)
274
+ event["mp_error_count"] = self._safe_int(data.get("ErrorCount"), 0)
275
+
276
+ else:
277
+ event["mp_event"] = event_key_lower
278
+ event["mp_event_key"] = data.get("EventKey", "")
279
+
280
+ return event
281
+
282
+ # ==================== 工具方法 ====================
283
+
284
+ def _generate_alt_message(self, segments: List[Dict]) -> str:
285
+ """根据消息段生成纯文本备用内容"""
286
+ parts = []
287
+ for seg in segments:
288
+ seg_type = seg.get("type", "")
289
+ seg_data = seg.get("data", {})
290
+ if seg_type == "text":
291
+ parts.append(seg_data.get("text", ""))
292
+ elif seg_type == "image":
293
+ parts.append("[图片]")
294
+ elif seg_type == "voice":
295
+ text = seg_data.get("text", "")
296
+ parts.append(text if text else "[语音]")
297
+ elif seg_type == "video":
298
+ parts.append(
299
+ "[视频]" if not seg_data.get("mp_shortvideo") else "[小视频]"
300
+ )
301
+ elif seg_type == "location":
302
+ parts.append(f"[位置] {seg_data.get('title', '')}".strip())
303
+ else:
304
+ parts.append(f"[{seg_type}]")
305
+ return "".join(parts) if parts else ""
306
+
307
+ def _xml_to_dict(self, raw_xml: str) -> Optional[Dict]:
308
+ """解析 XML 为字典
309
+
310
+ 同时在返回的字典中附加 _raw_xml 保留原始字符串。
311
+ """
312
+ try:
313
+ root = ET.fromstring(raw_xml)
314
+ except ET.ParseError:
315
+ return None
316
+
317
+ result: Dict[str, str] = {}
318
+ for child in root:
319
+ result[child.tag] = child.text or ""
320
+ result["_raw_xml"] = raw_xml
321
+ return result
322
+
323
+ @staticmethod
324
+ def _safe_int(value, default: int = 0) -> int:
325
+ try:
326
+ return int(value)
327
+ except (TypeError, ValueError):
328
+ return default
329
+
330
+ @staticmethod
331
+ def _safe_float(value, default: float = 0.0) -> float:
332
+ try:
333
+ return float(value)
334
+ except (TypeError, ValueError):
335
+ return default