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/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
+ }
wechat_cli/network.py ADDED
@@ -0,0 +1,9 @@
1
+ """Use macOS trust settings with uv-managed Python, without changing global SSL behavior."""
2
+
3
+ import ssl
4
+
5
+ import truststore
6
+
7
+
8
+ def ssl_context():
9
+ return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)