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/native.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Bounded subprocess transport. A blocked native call cannot hang the HTTP server."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from concurrent.futures import Future, TimeoutError
|
|
9
|
+
|
|
10
|
+
from .config import load_runtime
|
|
11
|
+
from .errors import AppError
|
|
12
|
+
from .models import resolve_chat, resolve_message
|
|
13
|
+
|
|
14
|
+
SEND_METHODS = {"sendText", "sendVoice"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NativeBridge:
|
|
18
|
+
def __init__(self, directory, timeout=20, popen=subprocess.Popen):
|
|
19
|
+
self.directory, self.timeout, self.popen = directory, timeout, popen
|
|
20
|
+
self.lock = threading.RLock()
|
|
21
|
+
self.connect_lock = threading.Lock()
|
|
22
|
+
self.pending = {}
|
|
23
|
+
self.sequence = 0
|
|
24
|
+
self.process = None
|
|
25
|
+
self.ready = None
|
|
26
|
+
self.closed = False
|
|
27
|
+
self.observed = {"connected": False}
|
|
28
|
+
|
|
29
|
+
def connect(self):
|
|
30
|
+
with self.connect_lock:
|
|
31
|
+
if self.closed:
|
|
32
|
+
raise AppError("native_bridge_unavailable", "Native bridge is closed", 503)
|
|
33
|
+
if self.process and self.process.poll() is None:
|
|
34
|
+
return
|
|
35
|
+
runtime = load_runtime(self.directory)
|
|
36
|
+
environment = {
|
|
37
|
+
**os.environ,
|
|
38
|
+
"WECHAT_NATIVE_APP": runtime["app"],
|
|
39
|
+
"WECHAT_NATIVE_SUPPORT": runtime["supportLibrary"],
|
|
40
|
+
"WECHAT_DATA_DIR": str(self.directory),
|
|
41
|
+
}
|
|
42
|
+
self.ready = Future()
|
|
43
|
+
try:
|
|
44
|
+
process = self.popen(
|
|
45
|
+
[sys.executable, "-m", "wechat_cli.worker"],
|
|
46
|
+
env=environment,
|
|
47
|
+
stdin=subprocess.PIPE,
|
|
48
|
+
stdout=subprocess.PIPE,
|
|
49
|
+
stderr=subprocess.DEVNULL,
|
|
50
|
+
text=True,
|
|
51
|
+
bufsize=1,
|
|
52
|
+
)
|
|
53
|
+
except OSError:
|
|
54
|
+
raise AppError(
|
|
55
|
+
"native_bridge_unavailable", "Cannot launch the Python Frida worker", 424
|
|
56
|
+
) from None
|
|
57
|
+
self.process = process
|
|
58
|
+
threading.Thread(target=self._read, args=(process, self.ready), daemon=True).start()
|
|
59
|
+
try:
|
|
60
|
+
self.ready.result(timeout=max(60, self.timeout))
|
|
61
|
+
except TimeoutError:
|
|
62
|
+
process.terminate()
|
|
63
|
+
raise AppError(
|
|
64
|
+
"native_bridge_timeout", "WeChat did not start in time; inspect its window and retry", 504
|
|
65
|
+
) from None
|
|
66
|
+
|
|
67
|
+
def _read(self, process, ready):
|
|
68
|
+
try:
|
|
69
|
+
for line in process.stdout:
|
|
70
|
+
value = json.loads(line)
|
|
71
|
+
if value.get("event") == "ready":
|
|
72
|
+
if not ready.done():
|
|
73
|
+
ready.set_result(True)
|
|
74
|
+
elif value.get("event") == "failure":
|
|
75
|
+
if not ready.done():
|
|
76
|
+
code = value.get("error", "native_bridge_unavailable")
|
|
77
|
+
ready.set_exception(
|
|
78
|
+
AppError(
|
|
79
|
+
code,
|
|
80
|
+
"Cannot connect to the patched WeChat app. Quit stock WeChat and run doctor.",
|
|
81
|
+
424,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
elif "id" in value:
|
|
85
|
+
with self.lock:
|
|
86
|
+
entry = self.pending.pop(value["id"], None)
|
|
87
|
+
if entry:
|
|
88
|
+
method, future = entry
|
|
89
|
+
if "error" in value:
|
|
90
|
+
code = value["error"]
|
|
91
|
+
if method in SEND_METHODS and code in {
|
|
92
|
+
"native_read_failed",
|
|
93
|
+
"native_send_failed",
|
|
94
|
+
}:
|
|
95
|
+
code = "native_send_outcome_unknown"
|
|
96
|
+
messages = {
|
|
97
|
+
"native_account_not_ready": "Sign into the configured WeChat app, then retry.",
|
|
98
|
+
"native_task_context_unavailable": "WeChat is starting or busy. Open its main window and retry.",
|
|
99
|
+
"native_send_outcome_unknown": "Send outcome is unknown. Inspect WeChat history and reuse the same key for retries.",
|
|
100
|
+
"native_feature_unavailable": "This feature has not been validated for this WeChat payload.",
|
|
101
|
+
}
|
|
102
|
+
future.set_exception(
|
|
103
|
+
AppError(code, messages.get(code, code.replace("_", " ")), 409)
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
future.set_result(value.get("result"))
|
|
107
|
+
except (OSError, ValueError):
|
|
108
|
+
pass
|
|
109
|
+
finally:
|
|
110
|
+
if not ready.done():
|
|
111
|
+
ready.set_exception(
|
|
112
|
+
AppError("native_bridge_unavailable", "Frida worker exited during startup", 424)
|
|
113
|
+
)
|
|
114
|
+
with self.lock:
|
|
115
|
+
# Reader of an old process must never fail a replacement process's requests.
|
|
116
|
+
if self.process is process:
|
|
117
|
+
self.observed["connected"] = False
|
|
118
|
+
for method, future in self.pending.values():
|
|
119
|
+
code = (
|
|
120
|
+
"native_send_outcome_unknown"
|
|
121
|
+
if method in SEND_METHODS
|
|
122
|
+
else "native_bridge_unavailable"
|
|
123
|
+
)
|
|
124
|
+
future.set_exception(
|
|
125
|
+
AppError(
|
|
126
|
+
code, "Worker disconnected. Check WeChat history before retrying a send.", 502
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
self.pending.clear()
|
|
130
|
+
process.stdout.close()
|
|
131
|
+
|
|
132
|
+
def request(self, method, params=None):
|
|
133
|
+
self.connect()
|
|
134
|
+
future = Future()
|
|
135
|
+
with self.lock:
|
|
136
|
+
if len(self.pending) >= 8:
|
|
137
|
+
raise AppError("native_bridge_busy", "Too many pending native requests", 429)
|
|
138
|
+
self.sequence += 1
|
|
139
|
+
identifier = self.sequence
|
|
140
|
+
self.pending[identifier] = method, future
|
|
141
|
+
try:
|
|
142
|
+
self.process.stdin.write(
|
|
143
|
+
json.dumps({"id": identifier, "method": method, "params": params or {}}) + "\n"
|
|
144
|
+
)
|
|
145
|
+
self.process.stdin.flush()
|
|
146
|
+
except (OSError, ValueError):
|
|
147
|
+
self.pending.pop(identifier, None)
|
|
148
|
+
code = (
|
|
149
|
+
"native_send_outcome_unknown" if method in SEND_METHODS else "native_bridge_unavailable"
|
|
150
|
+
)
|
|
151
|
+
raise AppError(code, "Worker disconnected; check WeChat before retrying", 502) from None
|
|
152
|
+
try:
|
|
153
|
+
return future.result(timeout=65 if method == "extractImageText" else self.timeout)
|
|
154
|
+
except TimeoutError:
|
|
155
|
+
with self.lock:
|
|
156
|
+
self.pending.pop(identifier, None)
|
|
157
|
+
code = "native_send_outcome_unknown" if method in SEND_METHODS else "native_read_timeout"
|
|
158
|
+
raise AppError(code, "Native call timed out; check WeChat before retrying", 504) from None
|
|
159
|
+
|
|
160
|
+
def status(self):
|
|
161
|
+
value = self.request("status")
|
|
162
|
+
self.observed.update(value)
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
def chats(self):
|
|
166
|
+
value = self.request("listChats")
|
|
167
|
+
items = [resolve_chat(item) for item in value["items"]]
|
|
168
|
+
for folder in items:
|
|
169
|
+
if folder.get("folderMembershipKnown"):
|
|
170
|
+
children = [c for c in items if c.get("parentFolderId") == folder["id"]]
|
|
171
|
+
folder["childCount"] = len(children)
|
|
172
|
+
folder["lastMessageTime"] = max(
|
|
173
|
+
[folder.get("lastMessageTime", 0)] + [c.get("lastMessageTime", 0) for c in children]
|
|
174
|
+
)
|
|
175
|
+
items.sort(key=lambda c: (bool(c.get("isPinned")), c.get("lastMessageTime", 0)), reverse=True)
|
|
176
|
+
self.observed["enumerationObserved"] = True
|
|
177
|
+
return {**value, "items": items, "total": len(items), "order": "pinned_then_recent"}
|
|
178
|
+
|
|
179
|
+
def messages(self, chat_id, limit=50):
|
|
180
|
+
value = self.request("listMessages", {"chatId": chat_id, "limit": limit})
|
|
181
|
+
items = [resolve_message(item) for item in value["items"]]
|
|
182
|
+
items.sort(
|
|
183
|
+
key=lambda m: (m.get("timestamp", 0), int(m["id"]) if str(m.get("id", "")).isdigit() else 0),
|
|
184
|
+
reverse=True,
|
|
185
|
+
)
|
|
186
|
+
self.observed["historyVerified"] = bool(items) or self.observed.get("historyVerified", False)
|
|
187
|
+
return {**value, "items": items, "order": "newest_first"}
|
|
188
|
+
|
|
189
|
+
def send(self, chat_id, text):
|
|
190
|
+
result = self.request("sendText", {"chatId": chat_id, "text": text})
|
|
191
|
+
return receipt(result, chat_id)
|
|
192
|
+
|
|
193
|
+
def close(self):
|
|
194
|
+
self.closed = True
|
|
195
|
+
process = self.process
|
|
196
|
+
if process:
|
|
197
|
+
try:
|
|
198
|
+
process.stdin.close()
|
|
199
|
+
process.wait(timeout=3)
|
|
200
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
201
|
+
process.terminate()
|
|
202
|
+
try:
|
|
203
|
+
process.wait(timeout=3)
|
|
204
|
+
except subprocess.TimeoutExpired:
|
|
205
|
+
process.kill()
|
|
206
|
+
process.wait(timeout=3)
|
|
207
|
+
# The worker detaches, leaving WeChat and its login session running.
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def receipt(result, chat_id):
|
|
211
|
+
if (
|
|
212
|
+
not isinstance(result, dict)
|
|
213
|
+
or result.get("source") != "native"
|
|
214
|
+
or result.get("receiverVerified") is not True
|
|
215
|
+
or type(result.get("queued")) is not bool
|
|
216
|
+
or not isinstance(result.get("requestId"), str)
|
|
217
|
+
):
|
|
218
|
+
raise AppError(
|
|
219
|
+
"native_send_outcome_unknown", "Unverified send result; check WeChat history before retrying", 502
|
|
220
|
+
)
|
|
221
|
+
if not result["queued"]:
|
|
222
|
+
raise AppError("native_send_rejected", "WeChat declined to queue the message", 409)
|
|
223
|
+
return {
|
|
224
|
+
"source": "native",
|
|
225
|
+
"chatId": chat_id,
|
|
226
|
+
"requestId": result["requestId"],
|
|
227
|
+
"acceptedByClient": True,
|
|
228
|
+
"acknowledgedByServer": False,
|
|
229
|
+
}
|