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/config.py ADDED
@@ -0,0 +1,84 @@
1
+ """Private machine configuration; never store runtime state in uv's disposable cache."""
2
+
3
+ import json
4
+ import os
5
+ import secrets
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from .errors import AppError
10
+
11
+ ASSETS = Path(__file__).parent / "assets"
12
+
13
+
14
+ def data_directory(value=None):
15
+ return (
16
+ Path(
17
+ value
18
+ or os.environ.get("WECHAT_CLI_DATA_DIR")
19
+ or Path.home() / "Library/Application Support/wechat-cli"
20
+ )
21
+ .expanduser()
22
+ .resolve()
23
+ )
24
+
25
+
26
+ def private_directory(path):
27
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
28
+ path.chmod(0o700)
29
+ return path
30
+
31
+
32
+ def private_write(path, value):
33
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
34
+ fd, name = tempfile.mkstemp(dir=path.parent, prefix=".write-")
35
+ try:
36
+ with os.fdopen(fd, "w") as output:
37
+ output.write(value)
38
+ os.replace(name, path)
39
+ finally:
40
+ Path(name).unlink(missing_ok=True)
41
+
42
+
43
+ def save_json(path, value):
44
+ private_write(path, json.dumps(value, indent=2) + "\n")
45
+
46
+
47
+ def api_token(directory):
48
+ private_directory(directory)
49
+ path = directory / "api-token"
50
+ # Exclusive creation keeps concurrent CLI invocations on the same token.
51
+ try:
52
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
53
+ except FileExistsError:
54
+ pass
55
+ else:
56
+ with os.fdopen(fd, "w") as output:
57
+ output.write(secrets.token_hex(32) + "\n")
58
+ token = path.read_text().strip()
59
+ if not 16 <= len(token) <= 512 or any(c.isspace() for c in token):
60
+ raise AppError("invalid_api_token", f"Invalid API token in {path}", 424)
61
+ path.chmod(0o600)
62
+ return token
63
+
64
+
65
+ def load_runtime(directory):
66
+ try:
67
+ value = json.loads((directory / "native-runtime.json").read_text())
68
+ app = Path(value["app"])
69
+ library = value["supportLibrary"]
70
+ if not app.is_absolute() or not isinstance(library, str):
71
+ raise ValueError()
72
+ if (
73
+ Path(library).name != library
74
+ or not library.startswith("native-reader-")
75
+ or not library.endswith(".dylib")
76
+ ):
77
+ raise ValueError()
78
+ if not (app / "Contents/Frameworks" / library).is_file():
79
+ raise ValueError()
80
+ return {"app": str(app.resolve()), "supportLibrary": library}
81
+ except (OSError, ValueError, KeyError, TypeError):
82
+ raise AppError(
83
+ "native_setup_required", "Run wechat-cli setup --app /path/to/patched/WeChat.app", 424
84
+ ) from None
wechat_cli/controls.py ADDED
@@ -0,0 +1,223 @@
1
+ """macOS Accessibility operations scoped to the configured WeChat process."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import tempfile
9
+ import threading
10
+ import time
11
+ import unicodedata
12
+ from pathlib import Path
13
+
14
+ from .config import ASSETS, load_runtime, private_directory
15
+ from .errors import AppError
16
+ from .media import decode_upload
17
+ from .setup import execute
18
+
19
+
20
+ class Controls:
21
+ def __init__(self, directory):
22
+ self.directory = directory
23
+ self.lock = threading.Lock()
24
+
25
+ def invoke(self, action, **extra):
26
+ runtime = load_runtime(self.directory)
27
+ source = ASSETS / "controls.swift"
28
+ digest = hashlib.sha256(source.read_bytes()).hexdigest()[:16]
29
+ binary = self.directory / f"native-controls-{digest}"
30
+ if not binary.exists():
31
+ temporary = binary.with_suffix(f".{os.getpid()}.tmp")
32
+ try:
33
+ execute(
34
+ [
35
+ "/usr/bin/swiftc",
36
+ "-module-cache-path",
37
+ self.directory / "swift-cache",
38
+ source,
39
+ "-o",
40
+ temporary,
41
+ ]
42
+ )
43
+ os.replace(temporary, binary)
44
+ finally:
45
+ temporary.unlink(missing_ok=True)
46
+ processes = execute(["/bin/ps", "-axo", "pid=,comm="], timeout=5).decode()
47
+ expected = Path(runtime["app"]) / "Contents/MacOS/WeChat"
48
+ matches = matching_processes(processes, expected)
49
+ if len(matches) != 1:
50
+ raise AppError("native_process_identity_mismatch", "Configured WeChat is not running", 409)
51
+ try:
52
+ result = subprocess.run(
53
+ [str(binary)],
54
+ input=json.dumps({"pid": int(matches[0]), "app": runtime["app"], "action": action, **extra}),
55
+ text=True,
56
+ capture_output=True,
57
+ timeout=20,
58
+ check=True,
59
+ )
60
+ value = json.loads(result.stdout)
61
+ except (OSError, ValueError, subprocess.SubprocessError):
62
+ raise AppError(
63
+ "native_control_failed", "Native control failed; inspect WeChat before retrying", 502
64
+ ) from None
65
+ if value.get("error"):
66
+ code = value["error"]
67
+ message = (
68
+ "Allow Accessibility for the terminal running wechat-cli in macOS System Settings."
69
+ if code == "native_accessibility_required"
70
+ else code.replace("_", " ")
71
+ )
72
+ error = AppError(code, message, 409)
73
+ error.possible_send = value.get("possibleSend", True)
74
+ raise error
75
+ return value
76
+
77
+ @staticmethod
78
+ def chat_name(native, chat_id):
79
+ chats = native.chats()["items"]
80
+ chat = next((chat for chat in chats if chat["id"] == chat_id), None)
81
+ name = chat.get("displayName") or chat.get("displayString") if chat else None
82
+ if not name or sum((c.get("displayName") or c.get("displayString")) == name for c in chats) != 1:
83
+ raise AppError("native_chat_name_ambiguous", "This operation requires a uniquely named chat", 409)
84
+ return name
85
+
86
+ def download_attachment(self, native, chat_id, identifier):
87
+ if not self.lock.acquire(blocking=False):
88
+ raise AppError("native_control_busy", "Another control action is running", 409)
89
+ try:
90
+ name = self.chat_name(native, chat_id)
91
+ from .media import find_message
92
+
93
+ message = find_message(native, chat_id, identifier)
94
+ if message["kind"] != "file":
95
+ raise AppError("invalid_media_type", "Choose a file attachment")
96
+ state = self.invoke("status")
97
+ if state.get("chatName") != name or state.get("fileUiReady") is False:
98
+ raise AppError(
99
+ "native_active_chat_mismatch", "Open the requested chat and close other dialogs", 409
100
+ )
101
+ self.invoke("attachment.download", chatName=name, fileName=message["details"]["fileName"])
102
+ for _ in range(40):
103
+ result = native.request("attachmentData", {"chatId": chat_id, "id": identifier})
104
+ if len(result.get("files", [])) == 1:
105
+ return {"source": "native", "chatId": chat_id, "id": identifier, "status": "available"}
106
+ self.invoke("attachment.status", fileName=message["details"]["fileName"])
107
+ time.sleep(0.5)
108
+ return {"source": "native", "chatId": chat_id, "id": identifier, "status": "pending"}
109
+ finally:
110
+ self.lock.release()
111
+
112
+ def send_file(self, native, input):
113
+ if set(input) != {"chatId", "name", "dataBase64"}:
114
+ raise AppError("invalid_input", "Use chatId, name and dataBase64")
115
+ name = input["name"]
116
+ if (
117
+ not isinstance(name, str)
118
+ or not name.strip()
119
+ or name != name.strip()
120
+ or len(name.encode()) > 200
121
+ or re.search(r"[\\/\x00-\x1f\x7f]", name)
122
+ or name in {".", ".."}
123
+ ):
124
+ raise AppError("invalid_input", "Use a single filename of at most 200 UTF-8 bytes")
125
+ data = decode_upload(input["dataBase64"])
126
+ if not self.lock.acquire(blocking=False):
127
+ raise AppError("native_control_busy", "Another control action is running", 409)
128
+ invoked, directory = False, None
129
+ try:
130
+ chat_id = input["chatId"]
131
+ chat_name = self.chat_name(native, chat_id)
132
+ state = self.invoke("status")
133
+ if state.get("chatName") != chat_name or state.get("fileUiReady") is False:
134
+ raise AppError(
135
+ "native_active_chat_mismatch", "Open the requested chat and close other dialogs", 409
136
+ )
137
+ if state.get("draft"):
138
+ raise AppError("native_draft_not_empty", "Clear or save the existing WeChat draft first", 409)
139
+ self_id = native.request("selfId")["id"]
140
+ before = {m["id"] for m in native.messages(chat_id, 100)["items"]}
141
+ root = private_directory(self.directory / "outgoing")
142
+ retained = []
143
+ for path in root.glob("file-*"):
144
+ if path.is_dir() and not path.is_symlink():
145
+ if time.time() - path.stat().st_mtime > 600:
146
+ import shutil
147
+
148
+ shutil.rmtree(path)
149
+ else:
150
+ retained.append(path)
151
+ if len(retained) >= 16:
152
+ raise AppError("native_file_staging_full", "Wait for retained uploads to expire", 429)
153
+ directory = Path(tempfile.mkdtemp(prefix="file-", dir=root))
154
+ path = directory / name
155
+ path.write_bytes(data)
156
+ path.chmod(0o600)
157
+ invoked = True
158
+ try:
159
+ self.invoke("file.send", chatName=chat_name, filePath=str(path), fileName=name)
160
+ except AppError as error:
161
+ if getattr(error, "possible_send", True) is False:
162
+ invoked = False
163
+ raise
164
+ deadline, accepted = time.monotonic() + 45, None
165
+ while time.monotonic() < deadline:
166
+ matches = [
167
+ m
168
+ for m in native.messages(chat_id, 100)["items"]
169
+ if m["id"] not in before
170
+ and m.get("from") == self_id
171
+ and m.get("to") == chat_id
172
+ and m.get("kind") == "file"
173
+ and m["details"].get("sizeBytes") == len(data)
174
+ and unicodedata.normalize("NFC", m["details"].get("fileName", ""))
175
+ == unicodedata.normalize("NFC", name)
176
+ ]
177
+ if len(matches) > 1:
178
+ raise AppError(
179
+ "native_send_outcome_unknown", "Multiple matching file messages observed", 502
180
+ )
181
+ if matches:
182
+ message = matches[0]
183
+ accepted = {
184
+ "source": "native",
185
+ "chatId": chat_id,
186
+ "requestId": message["id"],
187
+ "messageId": message["id"],
188
+ "name": name,
189
+ "size": len(data),
190
+ "acceptedByClient": True,
191
+ "acknowledgedByServer": bool(re.fullmatch(r"[1-9]\d*", message.get("serverId", ""))),
192
+ }
193
+ if accepted["acknowledgedByServer"]:
194
+ return accepted
195
+ time.sleep(0.25)
196
+ if accepted:
197
+ return accepted
198
+ raise AppError(
199
+ "native_send_outcome_unknown", "File selection ran; check WeChat history before retrying", 504
200
+ )
201
+ except Exception as error:
202
+ if invoked and not (isinstance(error, AppError) and error.code == "native_send_outcome_unknown"):
203
+ raise AppError(
204
+ "native_send_outcome_unknown", "File send outcome is unknown; inspect WeChat", 502
205
+ ) from None
206
+ raise
207
+ finally:
208
+ if directory and not invoked:
209
+ import shutil
210
+
211
+ shutil.rmtree(directory)
212
+ self.lock.release()
213
+
214
+
215
+ def matching_processes(snapshot, expected):
216
+ expected = expected.resolve()
217
+ matches = []
218
+ for line in snapshot.splitlines():
219
+ fields = line.strip().split(None, 1)
220
+ if len(fields) == 2 and fields[0].isdigit() and fields[1].endswith("/Contents/MacOS/WeChat"):
221
+ if Path(fields[1]).resolve() == expected:
222
+ matches.append(fields[0])
223
+ return matches
wechat_cli/errors.py ADDED
@@ -0,0 +1,7 @@
1
+ class AppError(Exception):
2
+ def __init__(self, code: str, message: str, status: int = 400):
3
+ super().__init__(message)
4
+ self.code, self.status = code, status
5
+
6
+ def as_dict(self):
7
+ return {"error": self.code, "message": str(self)}
@@ -0,0 +1,82 @@
1
+ """Durable send deduplication, including uncertain results after a server restart."""
2
+
3
+ import hashlib
4
+ import json
5
+ import sqlite3
6
+ import threading
7
+ import time
8
+
9
+ from .config import private_directory
10
+ from .errors import AppError
11
+
12
+
13
+ class Idempotency:
14
+ def __init__(self, directory):
15
+ private_directory(directory)
16
+ path = directory / "requests.sqlite3"
17
+ self.connection = sqlite3.connect(path, check_same_thread=False)
18
+ path.chmod(0o600)
19
+ self.lock = threading.Lock()
20
+ self.connection.execute(
21
+ "CREATE TABLE IF NOT EXISTS requests "
22
+ "(route TEXT, key TEXT, digest TEXT, created REAL, status INTEGER, response TEXT, "
23
+ "PRIMARY KEY(route,key))"
24
+ )
25
+ self.connection.commit()
26
+
27
+ def once(self, route, key, payload, action):
28
+ signature = hashlib.sha256(
29
+ json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
30
+ ).hexdigest()
31
+ # Serialize mutations through completion; concurrent retries see the same result.
32
+ with self.lock:
33
+ db = self.connection
34
+ db.execute("DELETE FROM requests WHERE created < ?", (time.time() - 86400,))
35
+ old = db.execute(
36
+ "SELECT digest,status,response FROM requests WHERE route=? AND key=?", (route, key)
37
+ ).fetchone()
38
+ if old:
39
+ if old[0] != signature:
40
+ raise AppError("idempotency_conflict", "This key was used with a different request", 409)
41
+ if old[2] is None:
42
+ raise AppError(
43
+ "native_send_outcome_unknown",
44
+ "An earlier attempt was interrupted. Inspect WeChat; do not resend with another key.",
45
+ 409,
46
+ )
47
+ response = json.loads(old[2])
48
+ if old[1] >= 400:
49
+ raise AppError(response["error"], response["message"], old[1])
50
+ return response
51
+ if db.execute("SELECT COUNT(*) FROM requests").fetchone()[0] >= 10000:
52
+ raise AppError("idempotency_capacity", "Request retention capacity reached", 503)
53
+ db.execute(
54
+ "INSERT INTO requests VALUES (?,?,?,?,NULL,NULL)", (route, key, signature, time.time())
55
+ )
56
+ db.commit() # Commit intent before a native operation can enqueue anything.
57
+ try:
58
+ response = action()
59
+ except AppError as error:
60
+ db.execute(
61
+ "UPDATE requests SET status=?,response=? WHERE route=? AND key=?",
62
+ (error.status, json.dumps(error.as_dict()), route, key),
63
+ )
64
+ db.commit()
65
+ raise
66
+ except Exception:
67
+ # Leave the durable intent unresolved: retry must never send again.
68
+ raise AppError(
69
+ "native_send_outcome_unknown",
70
+ "Operation was interrupted; inspect WeChat before retrying",
71
+ 502,
72
+ ) from None
73
+ db.execute(
74
+ "UPDATE requests SET status=200,response=? WHERE route=? AND key=?",
75
+ (json.dumps(response), route, key),
76
+ )
77
+ db.commit()
78
+ return response
79
+
80
+ def close(self):
81
+ with self.lock:
82
+ self.connection.close()
@@ -0,0 +1,141 @@
1
+ """One persistent Python service per data directory; no PID-based killing."""
2
+
3
+ import fcntl
4
+ import json
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import time
11
+ from contextlib import contextmanager
12
+
13
+ from .client import ApiClient
14
+ from .config import private_directory, save_json
15
+ from .errors import AppError
16
+ from .native import NativeBridge
17
+
18
+
19
+ @contextmanager
20
+ def lock_file(path, blocking=True):
21
+ private_directory(path.parent)
22
+ with path.open("a+") as handle:
23
+ path.chmod(0o600)
24
+ try:
25
+ fcntl.flock(handle, fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
26
+ except BlockingIOError:
27
+ raise AppError(
28
+ "server_already_running", "A service is already running for this data directory", 409
29
+ ) from None
30
+ try:
31
+ yield
32
+ finally:
33
+ fcntl.flock(handle, fcntl.LOCK_UN)
34
+
35
+
36
+ def configured_port(directory, explicit=None):
37
+ if explicit is not None:
38
+ return explicit
39
+ try:
40
+ value = json.loads((directory / "server.json").read_text())
41
+ return int(value["port"])
42
+ except (OSError, ValueError, KeyError):
43
+ return 8787
44
+
45
+
46
+ def health(directory, port):
47
+ value = ApiClient(directory, port, timeout=1).request("/api/health")
48
+ if value.get("service") != "wechat-cli":
49
+ raise AppError("port_in_use", "Port belongs to another service", 409)
50
+ return value
51
+
52
+
53
+ def ensure_server(directory, port, timeout=75):
54
+ with lock_file(directory / "launch.lock"):
55
+ try:
56
+ return health(directory, port)
57
+ except AppError as error:
58
+ if error.code != "api_unavailable":
59
+ raise
60
+ # Wait for a shutting-down service to finish releasing its worker and socket.
61
+ with lock_file(directory / "server.lock"):
62
+ pass
63
+ # Validate before spawning so configuration errors are immediately actionable.
64
+ from .config import load_runtime
65
+
66
+ load_runtime(directory)
67
+ log = directory / "server.log"
68
+ fd = os.open(log, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
69
+ with os.fdopen(fd, "w") as output:
70
+ child = subprocess.Popen(
71
+ [
72
+ sys.executable,
73
+ "-m",
74
+ "wechat_cli",
75
+ "serve",
76
+ "--data-dir",
77
+ str(directory),
78
+ "--port",
79
+ str(port),
80
+ ],
81
+ stdin=subprocess.DEVNULL,
82
+ stdout=output,
83
+ stderr=output,
84
+ start_new_session=True,
85
+ )
86
+ deadline = time.monotonic() + timeout
87
+ while time.monotonic() < deadline:
88
+ if child.poll() is not None:
89
+ try:
90
+ error = json.loads(log.read_text().splitlines()[-1])
91
+ raise AppError(error["error"], error["message"], 424)
92
+ except (ValueError, KeyError, IndexError):
93
+ raise AppError(
94
+ "server_start_failed", f"Service failed to start; inspect {log}", 424
95
+ ) from None
96
+ try:
97
+ return health(directory, port)
98
+ except AppError as error:
99
+ if error.code != "api_unavailable":
100
+ raise
101
+ time.sleep(0.1)
102
+ # This is our own child, never a process selected from a stale PID file.
103
+ child.terminate()
104
+ raise AppError("server_start_timeout", f"Service startup timed out; inspect {log}", 504)
105
+
106
+
107
+ def serve(directory, port):
108
+ from .server import ApiServer
109
+
110
+ with lock_file(directory / "server.lock", blocking=False):
111
+ native = NativeBridge(directory)
112
+ server = None
113
+ try:
114
+ # Connect eagerly: start really launches WeChat, rather than waiting for the first API call.
115
+ native.connect()
116
+ server = ApiServer(("127.0.0.1", port), directory, native=native)
117
+ save_json(
118
+ directory / "server.json",
119
+ {"port": server.server_address[1], "instance": server.instance, "pid": os.getpid()},
120
+ )
121
+
122
+ def stop(_signal, _frame):
123
+ threading.Thread(target=server.shutdown, daemon=True).start()
124
+
125
+ previous = {sig: signal.signal(sig, stop) for sig in [signal.SIGINT, signal.SIGTERM]}
126
+ print(json.dumps({"listening": f"http://127.0.0.1:{server.server_address[1]}"}), flush=True)
127
+ try:
128
+ server.serve_forever(poll_interval=0.1)
129
+ finally:
130
+ for sig, handler in previous.items():
131
+ signal.signal(sig, handler)
132
+ except OSError as error:
133
+ raise AppError(
134
+ "server_bind_failed", f"Cannot bind 127.0.0.1:{port}; choose another --port", 409
135
+ ) from error
136
+ finally:
137
+ if server:
138
+ server.server_close()
139
+ else:
140
+ native.close()
141
+ (directory / "server.json").unlink(missing_ok=True)