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.
- WechatMpAdapter/Converter.py +335 -0
- WechatMpAdapter/Core.py +1093 -0
- WechatMpAdapter/__init__.py +1 -0
- erispulse_wechatmpadapter-4.0.0.dist-info/METADATA +222 -0
- erispulse_wechatmpadapter-4.0.0.dist-info/RECORD +9 -0
- erispulse_wechatmpadapter-4.0.0.dist-info/WHEEL +5 -0
- erispulse_wechatmpadapter-4.0.0.dist-info/entry_points.txt +3 -0
- erispulse_wechatmpadapter-4.0.0.dist-info/licenses/LICENSE +21 -0
- erispulse_wechatmpadapter-4.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|