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/__init__.py +3 -0
- wechat_cli/__main__.py +3 -0
- wechat_cli/assets/app.js +79 -0
- wechat_cli/assets/bridge.cpp +643 -0
- wechat_cli/assets/controls.swift +218 -0
- wechat_cli/assets/entitlements.plist +38 -0
- wechat_cli/assets/favicon.svg +1 -0
- wechat_cli/assets/index.html +10 -0
- wechat_cli/assets/native-agent.js +334 -0
- wechat_cli/assets/pdf-thumbnail.swift +13 -0
- wechat_cli/assets/profile.json +232 -0
- wechat_cli/assets/profiles/4.1.13-269631.json +182 -0
- wechat_cli/assets/profiles/4.1.13.34-269602.json +232 -0
- wechat_cli/assets/profiles/4.1.15-270135.json +194 -0
- wechat_cli/assets/startup-notice.js +78 -0
- wechat_cli/assets/style.css +1 -0
- wechat_cli/cli.py +238 -0
- wechat_cli/client.py +59 -0
- wechat_cli/codec.py +14 -0
- wechat_cli/config.py +84 -0
- wechat_cli/controls.py +223 -0
- wechat_cli/errors.py +7 -0
- wechat_cli/idempotency.py +82 -0
- wechat_cli/lifecycle.py +141 -0
- wechat_cli/media.py +341 -0
- wechat_cli/models.py +260 -0
- wechat_cli/native.py +229 -0
- wechat_cli/network.py +9 -0
- wechat_cli/server.py +518 -0
- wechat_cli/setup.py +342 -0
- wechat_cli/worker.py +227 -0
- wechat_cli-0.1.0.dist-info/METADATA +178 -0
- wechat_cli-0.1.0.dist-info/RECORD +36 -0
- wechat_cli-0.1.0.dist-info/WHEEL +4 -0
- wechat_cli-0.1.0.dist-info/entry_points.txt +2 -0
- wechat_cli-0.1.0.dist-info/licenses/LICENSE +7 -0
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
|
+
}
|