wechat-cli 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.
wechat_cli/media.py ADDED
@@ -0,0 +1,341 @@
1
+ """Message-scoped media access, bounded decoding, OCR and voice conversion."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import io
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import urllib.error
11
+ import urllib.request
12
+ import wave
13
+ from pathlib import Path
14
+ from urllib.parse import urljoin, urlsplit, urlunsplit
15
+
16
+ from cryptography.hazmat.primitives import padding
17
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
18
+ from PIL import Image
19
+
20
+ from .errors import AppError
21
+ from .models import media_descriptor
22
+ from .network import ssl_context
23
+
24
+ MAX_MEDIA = 32 * 1024**2
25
+ CDN_HOSTS = {
26
+ "wxapp.tc.qq.com",
27
+ "emoji.qpic.cn",
28
+ "mmbiz.qpic.cn",
29
+ "emoji.qpic.tc.qq.com",
30
+ "socwxsns.video.qq.com",
31
+ }
32
+
33
+
34
+ def decode_upload(encoded, maximum=8 * 1024**2):
35
+ try:
36
+ if not isinstance(encoded, str) or not encoded or len(encoded) > (maximum + 2) // 3 * 4:
37
+ raise ValueError()
38
+ data = base64.b64decode(encoded, validate=True)
39
+ if not data or len(data) > maximum or base64.b64encode(data).decode() != encoded:
40
+ raise ValueError()
41
+ return data
42
+ except (ValueError, TypeError):
43
+ raise AppError("invalid_input", "Supply canonical base64 within the upload limit") from None
44
+
45
+
46
+ def mime_type(data):
47
+ if data.startswith(b"%PDF-"):
48
+ return "application/pdf"
49
+ for header, mime in [
50
+ (b"\xff\xd8\xff", "image/jpeg"),
51
+ (b"\x89PNG\r\n\x1a\n", "image/png"),
52
+ (b"GIF87a", "image/gif"),
53
+ (b"GIF89a", "image/gif"),
54
+ (b"#!AMR\n", "audio/amr"),
55
+ (b"OggS", "audio/ogg"),
56
+ (b"#!SILK_V3", "audio/silk"),
57
+ (b"\x02#!SILK_V3", "audio/silk"),
58
+ ]:
59
+ if data.startswith(header):
60
+ return mime
61
+ if data[:4] == b"RIFF":
62
+ return {b"WEBP": "image/webp", b"WAVE": "audio/wav"}.get(data[8:12])
63
+ if data[4:8] == b"ftyp":
64
+ return "video/mp4"
65
+ return None
66
+
67
+
68
+ def decode_media(data, key=None):
69
+ if data.startswith(b"#!AMR\n") and mime_type(data[6:]) == "audio/silk":
70
+ return data[6:]
71
+ if mime_type(data):
72
+ return data
73
+ if not data:
74
+ return None
75
+ for header in [255, 137, 71, 82]:
76
+ xor = data[0] ^ header
77
+ probe = bytes(x ^ xor for x in data[:16])
78
+ if (mime_type(probe) or "").startswith("image/"):
79
+ return bytes(x ^ xor for x in data)
80
+ if isinstance(key, str) and re.fullmatch(r"[a-fA-F0-9]{32}", key) and len(data) % 16 == 0:
81
+ secret = bytes.fromhex(key)
82
+ for mode in [modes.CBC(bytes(16)), modes.CBC(secret), modes.ECB()]:
83
+ try:
84
+ decryptor = Cipher(algorithms.AES(secret), mode).decryptor()
85
+ plain = decryptor.update(data) + decryptor.finalize()
86
+ unpad = padding.PKCS7(128).unpadder()
87
+ plain = unpad.update(plain) + unpad.finalize()
88
+ if mime_type(plain):
89
+ return plain
90
+ except ValueError:
91
+ pass
92
+ return None
93
+
94
+
95
+ def cdn_url(raw):
96
+ try:
97
+ url = urlsplit(raw)
98
+ hostname = url.hostname
99
+ if hostname == "snsvideo.c2c.wechat.com":
100
+ hostname = "socwxsns.video.qq.com"
101
+ if (
102
+ url.scheme not in {"http", "https"}
103
+ or hostname not in CDN_HOSTS
104
+ or url.port
105
+ or url.username
106
+ or url.password
107
+ ):
108
+ raise ValueError()
109
+ return urlunsplit(("https", hostname, url.path, url.query, ""))
110
+ except (ValueError, TypeError):
111
+ raise AppError("media_url_denied", "Media URL is outside the supported WeChat CDN", 422) from None
112
+
113
+
114
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
115
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
116
+ return None
117
+
118
+
119
+ def download_media(raw):
120
+ opener = urllib.request.build_opener(
121
+ urllib.request.ProxyHandler({}), NoRedirect(), urllib.request.HTTPSHandler(context=ssl_context())
122
+ )
123
+ url = cdn_url(raw)
124
+ for _ in range(3):
125
+ try:
126
+ with opener.open(url, timeout=10) as response:
127
+ data = response.read(MAX_MEDIA + 1)
128
+ if len(data) > MAX_MEDIA:
129
+ raise AppError("media_too_large", "Media exceeds the 32 MiB limit", 413)
130
+ return data
131
+ except urllib.error.HTTPError as error:
132
+ if error.code in {301, 302, 303, 307, 308} and error.headers.get("Location"):
133
+ url = cdn_url(urljoin(url, error.headers["Location"]))
134
+ continue
135
+ raise AppError("media_download_failed", "WeChat CDN could not return this media", 502) from None
136
+ except OSError:
137
+ raise AppError("media_download_failed", "Cannot reach the WeChat CDN", 502) from None
138
+ raise AppError("media_download_failed", "Too many CDN redirects", 502)
139
+
140
+
141
+ def find_message(native, chat_id, identifier):
142
+ message = next((m for m in native.messages(chat_id, 100)["items"] if m["id"] == identifier), None)
143
+ if message is None:
144
+ raise AppError("native_message_not_found", "Message is outside recent history", 404)
145
+ return message
146
+
147
+
148
+ def read_media(native, chat_id, identifier, variant="full"):
149
+ message = find_message(native, chat_id, identifier)
150
+ if message["kind"] == "file":
151
+ details = message["details"]
152
+ size = details.get("sizeBytes", 0)
153
+ if not 1 <= size <= 128 * 1024**2:
154
+ raise AppError("media_too_large", "Attachment must be 1 byte through 128 MiB", 413)
155
+ result = native.request("attachmentData", {"chatId": chat_id, "id": identifier})
156
+ files = result.get("files", [])
157
+ if len(files) > 1:
158
+ raise AppError("native_attachment_ambiguous", "Multiple cached attachments match", 409)
159
+ if not files:
160
+ raise AppError("native_media_not_cached", "Download the attachment in WeChat first", 404)
161
+ data = decode_upload(files[0]["dataBase64"], maximum=128 * 1024**2)
162
+ if len(data) != size or details.get("md5") and hashlib.md5(data).hexdigest() != details["md5"]:
163
+ raise AppError("native_attachment_invalid", "Cached attachment does not match its message", 422)
164
+ mime = mime_type(data) or "application/octet-stream"
165
+ if variant == "preview" and not mime.startswith("image/"):
166
+ raise AppError("native_preview_unavailable", "Download this file to view it", 404)
167
+ return data, mime, details.get("fileName", "attachment")
168
+ if message["kind"] not in {"image", "voice", "video", "sticker"}:
169
+ raise AppError("invalid_media_type", "This message has no supported media")
170
+ descriptor = media_descriptor(message)
171
+ if message["kind"] == "voice":
172
+ result = native.request("voiceData", {"chatId": chat_id, "id": identifier})
173
+ voice = next((m for m in result.get("items", []) if m["id"] == identifier), {})
174
+ descriptor["md5"] = voice.get("nativeMediaFields", {}).get("voiceFile")
175
+
176
+ def matches(data):
177
+ mime = mime_type(data) or ""
178
+ return (
179
+ mime.startswith(("image/", "video/"))
180
+ if message["kind"] == "sticker"
181
+ else mime.startswith("audio/")
182
+ if message["kind"] == "voice"
183
+ else mime.startswith("video/") or variant == "preview" and mime.startswith("image/")
184
+ if message["kind"] == "video"
185
+ else mime.startswith("image/")
186
+ )
187
+
188
+ hashes = [
189
+ descriptor.get("md5"),
190
+ *descriptor["cacheKeys"],
191
+ message["details"].get("media", {}).get("md5"),
192
+ message.get("serverId"),
193
+ message.get("id"),
194
+ ]
195
+ seen = set()
196
+ for hashed in hashes:
197
+ if not hashed or hashed in seen:
198
+ continue
199
+ seen.add(hashed)
200
+ try:
201
+ cached = native.request("cachedMedia", {"hash": hashed})
202
+ except AppError as error:
203
+ if error.code == "native_invalid_media_id":
204
+ continue
205
+ raise
206
+ files = sorted(
207
+ cached.get("files", []), key=lambda f: bool(f.get("isThumbnail")), reverse=variant == "preview"
208
+ )
209
+ for item in files:
210
+ data = decode_media(decode_upload(item["dataBase64"], MAX_MEDIA), descriptor.get("key"))
211
+ if data and matches(data):
212
+ return (
213
+ voice_preview(data),
214
+ ("audio/wav" if mime_type(data) == "audio/silk" else mime_type(data)),
215
+ None,
216
+ )
217
+ remotes = [
218
+ (descriptor.get("url"), descriptor.get("key")),
219
+ (descriptor.get("encryptedUrl"), descriptor.get("key")),
220
+ (descriptor.get("thumbnailUrl"), descriptor.get("thumbnailKey")),
221
+ ]
222
+ if variant == "preview":
223
+ remotes = remotes[-1:] + remotes[:-1]
224
+ for remote, key in remotes:
225
+ if remote:
226
+ data = decode_media(download_media(remote), key)
227
+ if data and matches(data):
228
+ return data, mime_type(data), None
229
+ raise AppError("native_media_not_cached", "Open or download this media in WeChat, then retry", 404)
230
+
231
+
232
+ def validate_image(data):
233
+ if not 1 <= len(data) <= 8 * 1024**2:
234
+ raise AppError("image_too_large", "OCR images must be 1 byte through 8 MiB", 413)
235
+ try:
236
+ with Image.open(io.BytesIO(data)) as image:
237
+ width, height = image.size
238
+ if width > 16384 or height > 16384 or width * height > 16000000:
239
+ raise AppError(
240
+ "image_dimensions_unsupported", "OCR images must be at most 16 megapixels", 413
241
+ )
242
+ if image.format not in {"PNG", "JPEG"}:
243
+ raise ValueError()
244
+ image.verify()
245
+ except (OSError, ValueError, Image.DecompressionBombError):
246
+ raise AppError("invalid_image_format", "OCR supports valid PNG and JPEG images", 422) from None
247
+ return {"width": width, "height": height}
248
+
249
+
250
+ def silk_duration(data):
251
+ offset = 10 if data.startswith(b"\x02#!SILK_V3") else 9 if data.startswith(b"#!SILK_V3") else -1
252
+ if offset < 0 or len(data) > 1048576:
253
+ raise AppError("invalid_voice", "Invalid SILK header or size")
254
+ frames = 0
255
+ while offset < len(data):
256
+ if offset + 2 > len(data):
257
+ raise AppError("invalid_voice", "Truncated SILK frame")
258
+ length = int.from_bytes(data[offset : offset + 2], "little", signed=True)
259
+ offset += 2
260
+ if length == -1 and offset == len(data):
261
+ break
262
+ if length <= 0 or offset + length > len(data) or frames >= 3000:
263
+ raise AppError("invalid_voice", "Invalid SILK frame length")
264
+ frames += 1
265
+ offset += length
266
+ duration = frames * 20
267
+ if not 1000 <= duration <= 60000:
268
+ raise AppError("invalid_voice", "Voice messages must last 1–60 seconds")
269
+ return duration
270
+
271
+
272
+ def codec(action, data, rate=24000):
273
+ try:
274
+ import importlib.util
275
+
276
+ if importlib.util.find_spec("pilk") is None:
277
+ raise AppError(
278
+ "audio_extra_required",
279
+ "SILK codec is missing; reinstall wechat-cli with its dependencies",
280
+ 424,
281
+ )
282
+ with tempfile.TemporaryDirectory(prefix="wechat-audio-") as temporary:
283
+ source, destination = Path(temporary) / "input", Path(temporary) / "output"
284
+ source.write_bytes(data)
285
+ subprocess.run(
286
+ [sys.executable, "-m", "wechat_cli.codec", action, str(source), str(destination), str(rate)],
287
+ check=True,
288
+ timeout=15,
289
+ stdout=subprocess.DEVNULL,
290
+ stderr=subprocess.DEVNULL,
291
+ )
292
+ return destination.read_bytes()
293
+ except (OSError, subprocess.SubprocessError):
294
+ raise AppError("invalid_voice", "Audio conversion failed", 422) from None
295
+
296
+
297
+ def voice_preview(data):
298
+ if mime_type(data) != "audio/silk":
299
+ return data
300
+ pcm = codec("decode", data)
301
+ output = io.BytesIO()
302
+ with wave.open(output, "wb") as wav:
303
+ wav.setnchannels(1)
304
+ wav.setsampwidth(2)
305
+ wav.setframerate(24000)
306
+ wav.writeframes(pcm)
307
+ return output.getvalue()
308
+
309
+
310
+ def prepare_voice(input):
311
+ if set(input) - {"chatId", "dataBase64", "format", "sampleRate"}:
312
+ raise AppError("invalid_input", "Use chatId, dataBase64, format and optional sampleRate")
313
+ data = decode_upload(input.get("dataBase64"))
314
+ kind, rate = input.get("format"), input.get("sampleRate")
315
+ if kind == "silk":
316
+ if rate is not None:
317
+ raise AppError("invalid_voice", "sampleRate applies only to raw PCM")
318
+ duration = silk_duration(data)
319
+ else:
320
+ if kind == "wav" and rate is None:
321
+ try:
322
+ with wave.open(io.BytesIO(data), "rb") as wav:
323
+ if wav.getnchannels() != 1 or wav.getsampwidth() != 2 or wav.getcomptype() != "NONE":
324
+ raise ValueError()
325
+ rate = wav.getframerate()
326
+ data = wav.readframes(wav.getnframes())
327
+ except (wave.Error, EOFError, ValueError):
328
+ raise AppError("invalid_voice", "Use mono 16-bit PCM WAV") from None
329
+ elif kind != "pcm_s16le":
330
+ raise AppError("invalid_voice", "Use silk, wav, or pcm_s16le")
331
+ if (
332
+ type(rate) is not int
333
+ or rate not in {8000, 12000, 16000, 24000, 32000, 44100, 48000}
334
+ or len(data) % 2
335
+ ):
336
+ raise AppError("invalid_voice", "Invalid PCM sample rate or length")
337
+ if not 1 <= len(data) / (rate * 2) <= 60:
338
+ raise AppError("invalid_voice", "Voice messages must last 1–60 seconds")
339
+ data = codec("encode", data, rate)
340
+ duration = silk_duration(data)
341
+ return {"chatId": input["chatId"], "dataBase64": base64.b64encode(data).decode(), "durationMs": duration}
wechat_cli/models.py ADDED
@@ -0,0 +1,260 @@
1
+ """Decode bounded WeChat XML metadata and keep native cache keys out of API responses."""
2
+
3
+ import html
4
+ import re
5
+ import xml.etree.ElementTree as ET
6
+ from urllib.parse import urlsplit
7
+
8
+ TYPES = {
9
+ 1: ("text", "Text"),
10
+ 3: ("image", "Image"),
11
+ 34: ("voice", "Voice message"),
12
+ 37: ("friend_request", "Friend request"),
13
+ 40: ("friend_recommendation", "Friend recommendation"),
14
+ 42: ("contact_card", "Contact card"),
15
+ 43: ("video", "Video"),
16
+ 47: ("sticker", "Sticker"),
17
+ 48: ("location", "Location"),
18
+ 49: ("app", "App message"),
19
+ 50: ("call", "Voice/video call"),
20
+ 51: ("status", "Status update"),
21
+ 52: ("call_notification", "Call notification"),
22
+ 53: ("call_invitation", "Call invitation"),
23
+ 62: ("video", "Short video"),
24
+ 9999: ("system", "System notice"),
25
+ 10000: ("system", "System message"),
26
+ 10002: ("recalled", "Recalled message"),
27
+ }
28
+ APP_TYPES = {
29
+ 1: ("text", "Text"),
30
+ 2: ("image", "Image"),
31
+ 3: ("music", "Music"),
32
+ 4: ("video", "Video"),
33
+ 5: ("link", "Link"),
34
+ 6: ("file", "File"),
35
+ 7: ("app", "App attachment"),
36
+ 8: ("sticker", "Sticker"),
37
+ 19: ("chat_history", "Forwarded chat history"),
38
+ 24: ("note", "Note"),
39
+ 33: ("mini_program", "Mini program"),
40
+ 36: ("mini_program", "Mini program"),
41
+ 40: ("group_invitation", "Group invitation"),
42
+ 50: ("channels_live", "Channels live stream"),
43
+ 51: ("channels_video", "Channels video"),
44
+ 57: ("quote", "Quoted reply"),
45
+ 62: ("pat", "Pat"),
46
+ 63: ("channels_live", "Channels live stream"),
47
+ 2000: ("transfer", "Transfer"),
48
+ 2001: ("red_packet", "Red packet"),
49
+ }
50
+ FOLDERS = {
51
+ "@placeholder_foldgroup": ("minimized_groups", "Minimized groups"),
52
+ "brandsessionholder": ("subscriptions", "Subscriptions"),
53
+ "brandservicesessionholder": ("service_accounts", "Service accounts"),
54
+ }
55
+
56
+
57
+ class Message(dict):
58
+ def __init__(self, value):
59
+ super().__init__(value)
60
+ self.native_media = self.pop("nativeMediaFields", {}) or {}
61
+
62
+
63
+ def xml(content):
64
+ if (
65
+ not isinstance(content, str)
66
+ or len(content) > 1048576
67
+ or re.search(r"<!DOCTYPE|<!ENTITY", content, re.I)
68
+ ):
69
+ return None
70
+ try:
71
+ root = ET.fromstring(content[content.index("<") :])
72
+ pending, nodes = [(root, 0)], 0
73
+ while pending:
74
+ node, depth = pending.pop()
75
+ nodes += 1
76
+ if nodes > 12000 or depth > 48:
77
+ return None
78
+ pending.extend((child, depth + 1) for child in node)
79
+ return root
80
+ except (ET.ParseError, ValueError, RecursionError):
81
+ return None
82
+
83
+
84
+ def find(root, name):
85
+ return next(root.iter(name), None) if root is not None else None
86
+
87
+
88
+ def text(node):
89
+ return "".join(node.itertext()).strip() if node is not None else ""
90
+
91
+
92
+ def value(node, path):
93
+ return text(node.find(path)) if node is not None else ""
94
+
95
+
96
+ def web_url(raw):
97
+ try:
98
+ return raw if urlsplit(raw).scheme in {"http", "https"} and urlsplit(raw).hostname else None
99
+ except ValueError:
100
+ return None
101
+
102
+
103
+ def resolve_chat(chat):
104
+ chat = dict(chat)
105
+ identifier = chat["id"]
106
+ if identifier in FOLDERS:
107
+ kind, name = FOLDERS[identifier]
108
+ chat.update(
109
+ displayName=name,
110
+ chatType="folder",
111
+ folderKind=kind,
112
+ isGroup=False,
113
+ folderMembershipKnown=kind == "minimized_groups",
114
+ )
115
+ else:
116
+ group = identifier.endswith("@chatroom")
117
+ kind = (
118
+ "group"
119
+ if group
120
+ else "file_transfer"
121
+ if identifier == "filehelper"
122
+ else "official_account"
123
+ if identifier.startswith(("gh_", "biz_"))
124
+ else "private"
125
+ )
126
+ chat.update(
127
+ chatType=kind,
128
+ isGroup=group,
129
+ parentFolderId="@placeholder_foldgroup" if group and chat.get("isMinimized") else None,
130
+ )
131
+ return chat
132
+
133
+
134
+ def resolve_message(raw, depth=0):
135
+ message = Message(raw)
136
+ code = int(message.get("type") or 0)
137
+ kind, label = TYPES.get(code, ("unknown", f"Unknown message ({code})"))
138
+ content = message.get("content") or ""
139
+ message.update(kind=kind, typeLabel=label, summary=content or label, details={})
140
+ tree = xml(content) if code != 1 else None
141
+ msg = find(tree, "msg")
142
+ details = message["details"]
143
+ if code == 49:
144
+ app = msg.find("appmsg") if msg is not None else None
145
+ if app is None:
146
+ app = find(tree, "appmsg")
147
+ subtype = value(app, "type")
148
+ subtype = int(subtype) if subtype.isdigit() else None
149
+ kind, label = APP_TYPES.get(subtype, ("app", f"App message ({subtype})"))
150
+ message.update(kind=kind, typeLabel=label, appType=subtype)
151
+ details.update(title=value(app, "title"), description=value(app, "des"))
152
+ url = web_url(value(app, "url"))
153
+ if url:
154
+ details["url"] = url
155
+ if kind == "file":
156
+ details.update(fileName=details["title"], extension=value(app, "appattach/fileext"))
157
+ size = value(app, "appattach/totallen")
158
+ if size.isdigit():
159
+ details["sizeBytes"] = int(size)
160
+ md5 = value(app, "appattach/filemd5") or value(app, "appattach/md5")
161
+ if re.fullmatch(r"[a-fA-F0-9]{32}", md5):
162
+ details["md5"] = md5.lower()
163
+ if kind == "quote":
164
+ source = value(app, "refermsg/content")
165
+ quoted_type = value(app, "refermsg/type")
166
+ quote = {
167
+ "sender": value(app, "refermsg/displayname"),
168
+ "content": source,
169
+ "type": int(quoted_type) if quoted_type.isdigit() else 0,
170
+ }
171
+ if depth < 4:
172
+ for _ in range(3):
173
+ source = html.unescape(source)
174
+ decoded = resolve_message(
175
+ {
176
+ "type": 49 if find(xml(source), "appmsg") is not None else quote["type"],
177
+ "content": source,
178
+ },
179
+ depth + 1,
180
+ )
181
+ quote.update(
182
+ content=decoded["summary"],
183
+ kind=decoded["kind"],
184
+ typeLabel=decoded["typeLabel"],
185
+ details=decoded["details"],
186
+ )
187
+ details["quote"] = quote
188
+ if kind in {"chat_history", "note"}:
189
+ record = find(app, "recorditem")
190
+ record = record if record is not None and len(record) else xml(text(record))
191
+ listing = find(record, "datalist")
192
+ details["items"] = [
193
+ {
194
+ "sender": value(n, "sourcename"),
195
+ "content": value(n, "datadesc") or value(n, "datatitle") or "Attachment",
196
+ "dataType": n.get("datatype"),
197
+ }
198
+ for n in list(listing if listing is not None else [])[:100]
199
+ ]
200
+ message["summary"] = details["title"] or details["description"] or label
201
+ elif code in {3, 34, 43, 47, 62}:
202
+ tag = {3: "img", 34: "voicemsg", 47: "emoji"}.get(code, "videomsg")
203
+ node = msg.find(tag) if msg is not None else None
204
+ attrs = node.attrib if node is not None else {}
205
+ media = {}
206
+ md5 = attrs.get("md5") or attrs.get("voicemd5")
207
+ if md5 and re.fullmatch(r"[a-fA-F0-9]{32}", md5):
208
+ media["md5"] = md5.lower()
209
+ cache_key = message.native_media.get({3: "392", 34: "voiceFile"}.get(code, "416"))
210
+ if isinstance(cache_key, str) and re.fullmatch(r"[\w.-]{8,128}", cache_key) and ".." not in cache_key:
211
+ media["cacheKey"] = cache_key
212
+ details.update(mediaAvailable=None, media=media)
213
+ duration = attrs.get("voicelength" if code == 34 else "playlength", "")
214
+ if duration.isdigit() and int(duration) > 0:
215
+ details["durationMs"] = int(duration) * (1 if code == 34 else 1000)
216
+ if code == 34 and message.native_media.get("264"):
217
+ details["transcript"] = message.native_media["264"]
218
+ message["summary"] = attrs.get("desc") or attrs.get("attachedtext") or label
219
+ elif code == 48:
220
+ node = find(tree, "location")
221
+ attrs = node.attrib if node is not None else {}
222
+ details.update(
223
+ label=attrs.get("label", ""),
224
+ name=attrs.get("poiname", ""),
225
+ latitude=attrs.get("x"),
226
+ longitude=attrs.get("y"),
227
+ )
228
+ message["summary"] = details["name"] or details["label"] or label
229
+ elif code == 42:
230
+ attrs = msg.attrib if msg is not None else {}
231
+ details.update(name=attrs.get("nickname", ""), userId=attrs.get("username", ""))
232
+ message["summary"] = details["name"] or label
233
+ elif code in {9999, 10000, 10002}:
234
+ message["summary"] = (
235
+ text(find(tree, "replacemsg")) or text(find(tree, "plain")) or text(msg) or content or label
236
+ )
237
+ elif tree is not None:
238
+ message["summary"] = text(find(tree, "title")) or text(find(tree, "wording")) or label
239
+ message["displaySummary"] = message["summary"]
240
+ return message
241
+
242
+
243
+ def media_descriptor(message):
244
+ tree = xml(message.get("content", ""))
245
+ tag = {3: "img", 34: "voicemsg", 43: "videomsg", 47: "emoji", 49: "emoji", 62: "videomsg"}.get(
246
+ int(message["type"])
247
+ )
248
+ node = find(tree, tag) if tag else None
249
+ attrs = node.attrib if node is not None else {}
250
+ native = getattr(message, "native_media", {})
251
+ media = message["details"].get("media", {})
252
+ return {
253
+ "cacheKeys": [native.get(k) for k in ["serverHash", "serverHashMiddle", "120"] if native.get(k)],
254
+ "md5": message["details"].get("md5") or media.get("cacheKey") or media.get("md5"),
255
+ "key": attrs.get("aeskey"),
256
+ "thumbnailKey": attrs.get("cdnthumbaeskey"),
257
+ "url": web_url(attrs.get("cdnurl", "")) if message["kind"] == "sticker" else None,
258
+ "encryptedUrl": web_url(attrs.get("encrypturl", "")) if message["kind"] == "sticker" else None,
259
+ "thumbnailUrl": web_url(attrs.get("thumburl", "")) if message["kind"] == "sticker" else None,
260
+ }