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.
@@ -0,0 +1,194 @@
1
+ {
2
+ "version": "4.1.15",
3
+ "build": "270135",
4
+ "arch": "arm64",
5
+ "payloadSha256": "5f5a81c4d8b000dec1c7203738fc4dba75ce752b55874fc1b6b5869f9dfb1027",
6
+ "taskContextTlsOffset": "0xa24de80",
7
+ "sessionServiceMarkerOffset": "0x9da18d0",
8
+ "voiceFrontDescriptorOffset": "0x9c03d80",
9
+ "enumeration": {
10
+ "offset": "0x2be0d64",
11
+ "signature": "fa67bba9f85f01a9f65702a9f44f03a9fd7b04a9fd030191ff0308d1f30308aa",
12
+ "scanPattern": "fa 67 bb a9 f8 5f 01 a9 f6 57 02 a9 f4 4f 03 a9 fd 7b 04 a9 fd 03 01 91 ff 03 08 d1 f3 03 08 aa f5 c3 02 91 a8 7a 03 d0 08 4d 46 f9 08 01 40 f9 a8 83 1b f8 fb 7b 15 95 f4 03 00 aa 00 e4 00 6f 60 02 00 ad 08 f0 a7 d2 48 55 d5 f2 48 55 f5 f2 68 12 00 f9 40 e5 05 4f a0 02 00 ad a8 aa ca 92 48 55 f5 f2 e8 6b 00 f9 6a 22 6a 94 08 00 40 f9 09 35 40 f9 e8 63 03 91 20 01 3f d6 e0 6f 40 f9 : ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff 1f 00 00 9f ff ff ff ff ff ff ff ff ff ff ff ff 00 00 00 fc ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff 00 00 00 fc ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff"
13
+ },
14
+ "initialMessages": {
15
+ "offset": "0x31c0484",
16
+ "signature": "fc6fbaa9fa6701a9f85f02a9f65703a9f44f04a9fd7b05a9fd430191ff830cd1"
17
+ },
18
+ "pagedMessages": {
19
+ "offset": "0x31c15e4",
20
+ "signature": "fc6fbaa9fa6701a9f85f02a9f65703a9f44f04a9fd7b05a9fd430191ff430cd1"
21
+ },
22
+ "mapNodeDestructor": {
23
+ "offset": "0x2be59e0",
24
+ "signature": "c10400b4f44fbea9fd7b01a9fd430091f30301aa06000014e00313aa010b8052"
25
+ },
26
+ "serviceRegistry": {
27
+ "offset": "0x44e6b1c",
28
+ "signature": "ffc302d1fa6706a9f85f07a9f65708a9f44f09a9fd7b0aa9fd830291f40301aa"
29
+ },
30
+ "cachedMessageService": {
31
+ "offset": "0x454182c",
32
+ "signature": "ff8301d1f85f02a9f65703a9f44f04a9fd7b05a9fd430191f40300aaf30308aa"
33
+ },
34
+ "createMessageServiceTask": {
35
+ "offset": "0x666ab58",
36
+ "signature": "ff4301d1f44f03a9fd7b04a9fd030191f40300aaf30308aae8f301b2e8a301a9"
37
+ },
38
+ "messageUiConversion": {
39
+ "offset": "0x31f808c",
40
+ "signature": "fc6fbaa9fa6701a9f85f02a9f65703a9f44f04a9fd7b05a9fd430191ff8315d1"
41
+ },
42
+ "messageUiDestructor": {
43
+ "offset": "0x396780",
44
+ "signature": "f44fbea9fd7b01a9fd430091f30300aa009841f9c00000b4140040f901038052"
45
+ },
46
+ "sendInfoConstructor": {
47
+ "offset": "0x69a714",
48
+ "signature": "f44fbea9fd7b01a9fd430091f40301aaf30300aae003679e0004186e0080803c"
49
+ },
50
+ "sendResultDestructor": {
51
+ "offset": "0x3189a68",
52
+ "signature": "f657bda9f44f01a9fd7b02a9fd830091f30300aa141040f9d40300b4751640f9"
53
+ },
54
+ "sendFailureCtor": {
55
+ "offset": "0x30f6640",
56
+ "signature": "1f0000391f00013901080929c0035fd6ff8302d1fa6705a9f85f06a9f65707a9"
57
+ },
58
+ "sessionService": {
59
+ "offset": "0x44e6b1c",
60
+ "signature": "ffc302d1fa6706a9f85f07a9f65708a9f44f09a9fd7b0aa9fd830291f40301aa"
61
+ },
62
+ "sessionDestructor": {
63
+ "offset": "0x1ef570",
64
+ "signature": "f657bda9f44f01a9fd7b02a9fd830091f30300aa085cc839c802f83768dec739"
65
+ },
66
+ "isMinimizedSession": {
67
+ "offset": "0x4d64628",
68
+ "signature": "f44fbea9fd7b01a9fd430091f30300aacf3cf99780000034683e41b908010412"
69
+ },
70
+ "makeUiSendInfo": {
71
+ "offset": "0x6f3990",
72
+ "signature": "f44fbea9fd7b01a9fd430091f30308aa008a8052a331a995e003679e0004186e"
73
+ },
74
+ "voiceAudioPath": {
75
+ "offset": "0x35c18f8",
76
+ "signature": "ff4303d1f6570aa9f44f0ba9fd7b0ca9fd030391f50302aaf40300aaf30308aa"
77
+ },
78
+ "voiceDataGetter": {
79
+ "offset": "0x31c4144",
80
+ "signature": "ff8302d1f65707a9f44f08a9fd7b09a9fd430291f30302aaf60301aaf40300aa"
81
+ },
82
+ "voiceTranscriptionStart": {
83
+ "offset": "0x412eb70",
84
+ "signature": "09fc40f9200540f99b200214fc6fbda9f44f01a9fd7b02a9fd830091ff030fd1"
85
+ },
86
+ "ocrLocalRecognition": {
87
+ "offset": "0x36ef0c4",
88
+ "signature": "ff0304d1fa670ba9f85f0ca9f6570da9f44f0ea9fd7b0fa9fdc30391f60302aa"
89
+ },
90
+ "makeVoiceSendInfo": {
91
+ "offset": "0x448a844",
92
+ "signature": "fa67bba9f85f01a9f65702a9f44f03a9fd7b04a9fd030191f50304aaf60303aa"
93
+ },
94
+ "dictationStart": {
95
+ "offset": "0x1d07c20",
96
+ "signature": "ff4306d1fc6f14a9f85f15a9f65716a9f44f17a9fd7b18a9fd030691f7c30291"
97
+ },
98
+ "migrationStopServices": {
99
+ "offset": "0x42a5ed0",
100
+ "signature": "ffc301d1f44f05a9fd7b06a9fd830191250e0f94080040f9093540f9e8230091"
101
+ },
102
+ "migrationCancelTransfer": {
103
+ "offset": "0x42a5ed0",
104
+ "signature": "ffc301d1f44f05a9fd7b06a9fd830191250e0f94080040f9093540f9e8230091"
105
+ },
106
+ "isPinnedSession": {
107
+ "offset": "0x4d6461c",
108
+ "signature": "08f44439000d0353c0035fd6f44fbea9fd7b01a9fd430091f30300aacf3cf997"
109
+ },
110
+ "messageFormatter": {
111
+ "offset": "0x4cbe4cc",
112
+ "signature": "ffc301d1f44f05a9fd7b06a9fd830191f40300aaf30308aae8630091d6ffff97"
113
+ },
114
+ "accountFactory": {
115
+ "offset": "0x466aca8",
116
+ "signature": "ffc300d1f44f01a9fd7b02a9fd830091f30308aa88ef02f0009d47f9080040f9"
117
+ },
118
+ "messageService": {
119
+ "offset": "0x268f370",
120
+ "signature": "ffc301d1f44f05a9fd7b06a9fd830191f40300aaf30308aa88b803f008010091"
121
+ },
122
+ "messageServiceResult": {
123
+ "offset": "0x21b3684",
124
+ "signature": "f44fbea9fd7b01a9fd430091000040f96cdc1295200300b4f30300aa000040f9"
125
+ },
126
+ "messageTaskResult": {
127
+ "offset": "0x3206474",
128
+ "signature": "f44fbea9fd7b01a9fd430091000040f9f090d194200300b4f30300aa000040f9"
129
+ },
130
+ "taskContextGuard": {
131
+ "offset": "0x6676adc",
132
+ "signature": "f44fbea9fd7b01a9fd430091f30300aaa0de01f000003a91080040f900013fd6"
133
+ },
134
+ "contactDisplayName": {
135
+ "offset": "0x2ac697c",
136
+ "signature": "ff0307d1f65719a9f44f1aa9fd7b1ba9fdc30691f50302aaf40301aaf30308aa"
137
+ },
138
+ "makeNativeUuid": {
139
+ "offset": "0x33e1c",
140
+ "signature": "ff0302d1f44f06a9fd7b07a9fdc30191f30308aa08d804f0084d46f9080140f9"
141
+ },
142
+ "sendInfoClone": {
143
+ "offset": "0x4d10630",
144
+ "signature": "f657bda9f44f01a9fd7b02a9fd830091f50301aaf40308aa00bc805279be9094"
145
+ },
146
+ "sendInfoDestructor": {
147
+ "offset": "0x69b42c",
148
+ "signature": "f44fbea9fd7b01a9fd430091f30300aaa8c004f00801199108410091080000f9"
149
+ },
150
+ "sendGroupMessages": {
151
+ "offset": "0x3183c08",
152
+ "signature": "e923b96dfc6f01a9fa6702a9f85f03a9f65704a9f44f05a9fd7b06a9fd830191"
153
+ },
154
+ "sendStageCtor": {
155
+ "offset": "0x304f1cc",
156
+ "signature": "f657bda9f44f01a9fd7b02a9fd830091f30300aa0108002900e4006f0080803c"
157
+ },
158
+ "sendContextConstructor": {
159
+ "offset": "0x30ef9e0",
160
+ "signature": "ff8301d1fa6701a9f85f02a9f65703a9f44f04a9fd7b05a9fd430191f90302aa"
161
+ },
162
+ "copySessions": {
163
+ "offset": "0x27f5434",
164
+ "signature": "f657bda9f44f01a9fd7b02a9fd830091f40301aaf30300aa00e4006f000000ad"
165
+ },
166
+ "sendProcessor": {
167
+ "offset": "0x319a6a8",
168
+ "signature": "f85fbca9f65701a9f44f02a9fd7b03a9fdc30091ff0309d1f40302aaf50301aa"
169
+ },
170
+ "voiceFrontRegistry": {
171
+ "offset": "0x456f244",
172
+ "signature": "ffc307d1fa671aa9f85f1ba9f6571ca9f44f1da9fd7b1ea9fd830791f30301aa"
173
+ },
174
+ "ocrService": {
175
+ "offset": "0x21e9898",
176
+ "signature": "ffc301d1f44f05a9fd7b06a9fd830191f40300aaf30308aae8dc039008210191"
177
+ },
178
+ "ocrInitialize": {
179
+ "offset": "0x36ec308",
180
+ "signature": "ffc306d1f65718a9f44f19a9fd7b1aa9fd830691f30300aab4a302d1482203d0"
181
+ },
182
+ "accountServices": {
183
+ "offset": "0x17e9ca4",
184
+ "signature": "0aa442a90a2500a9890000b428210091"
185
+ },
186
+ "contactService": {
187
+ "offset": "0x230000c",
188
+ "signature": "ffc301d1f44f05a9fd7b06a9fd830191"
189
+ },
190
+ "voiceTranscriptionAttach": {
191
+ "offset": "0x21d834",
192
+ "signature": "ff0303d1f85f08a9f65709a9f44f0aa9fd7b0ba9fdc30291f30304aaf40303aa"
193
+ }
194
+ }
@@ -0,0 +1,78 @@
1
+ // Suppress only WeChat's installation-location notice. Installed before Gadget resumes main().
2
+ // Returning the normal OK response lets WeChat persist its own suppression preference.
3
+ (() => {
4
+ const policy = globalThis.WECHAT_INSTALL_NOTICE = {installed: false, suppressed: 0};
5
+ const keep = [];
6
+ policy.callbacks = keep;
7
+ try {
8
+ const exported = name => Module.getGlobalExportByName(name);
9
+ const getClass = new NativeFunction(exported('objc_getClass'), 'pointer', ['pointer']);
10
+ const getSelector = new NativeFunction(exported('sel_registerName'), 'pointer', ['pointer']);
11
+ const instanceMethod = new NativeFunction(exported('class_getInstanceMethod'), 'pointer', ['pointer', 'pointer']);
12
+ const implementation = new NativeFunction(exported('method_getImplementation'), 'pointer', ['pointer']);
13
+ const msg = new NativeFunction(exported('objc_msgSend'), 'pointer', ['pointer', 'pointer']);
14
+ const setState = new NativeFunction(exported('objc_msgSend'), 'void', ['pointer', 'pointer', 'long']);
15
+ const selector = name => getSelector(Memory.allocUtf8String(name));
16
+ const selectors = Object.fromEntries(['messageText', 'informativeText', 'UTF8String', 'suppressionButton'].map(name => [name, selector(name)]));
17
+ const stateSelector = selector('setState:');
18
+ const alertClass = getClass(Memory.allocUtf8String('NSAlert'));
19
+ if (alertClass.isNull()) return;
20
+ function text(object, field) {
21
+ const string = msg(object, selectors[field]);
22
+ if (string.isNull()) return '';
23
+ const bytes = msg(string, selectors.UTF8String);
24
+ return bytes.isNull() ? '' : bytes.readUtf8String();
25
+ }
26
+ function matches(alert) {
27
+ try {
28
+ const title = text(alert, 'messageText').replace(/Weixin/g, 'WeChat').trim();
29
+ const body = text(alert, 'informativeText').replace(/Weixin/g, 'WeChat').trim();
30
+ const english = title === 'WeChat was opened from the installation package'
31
+ && body === 'We recommend you move WeChat to the Applications folder before using it.';
32
+ const chinese = ['微信从安装包中打开', '微信是从安装包中打开的', '微信从安装包打开'].includes(title)
33
+ && /建议.*微信.*应用程序/.test(body);
34
+ if (!english && !chinese) return false;
35
+ const checkbox = msg(alert, selectors.suppressionButton);
36
+ if (!checkbox.isNull()) setState(checkbox, stateSelector, 1);
37
+ policy.suppressed++;
38
+ return true;
39
+ } catch (_) { return false; }
40
+ }
41
+ function method(name) {
42
+ const value = instanceMethod(alertClass, selector(name));
43
+ return value.isNull() ? null : implementation(value);
44
+ }
45
+ const modal = method('runModal');
46
+ if (modal) {
47
+ const original = new NativeFunction(modal, 'long', ['pointer', 'pointer']);
48
+ const replacement = new NativeCallback((self, cmd) => matches(self) ? 1000 : original(self, cmd),
49
+ 'long', ['pointer', 'pointer']);
50
+ Interceptor.replace(modal, replacement); keep.push(replacement, original);
51
+ }
52
+ const sheet = method('beginSheetModalForWindow:completionHandler:');
53
+ if (sheet) {
54
+ const original = new NativeFunction(sheet, 'void', ['pointer', 'pointer', 'pointer', 'pointer']);
55
+ const copyBlock = new NativeFunction(exported('_Block_copy'), 'pointer', ['pointer']);
56
+ const releaseBlock = new NativeFunction(exported('_Block_release'), 'void', ['pointer']);
57
+ const queueGetter = Module.findGlobalExportByName('dispatch_get_main_queue');
58
+ const mainQueue = queueGetter ? new NativeFunction(queueGetter, 'pointer', []) : () => exported('_dispatch_main_q');
59
+ const dispatch = new NativeFunction(exported('dispatch_async_f'), 'void', ['pointer', 'pointer', 'pointer']);
60
+ const complete = new NativeCallback(block => {
61
+ try {
62
+ const callback = new NativeFunction(block.add(16).readPointer(), 'void', ['pointer', 'long']);
63
+ callback(block, 1000);
64
+ } finally { releaseBlock(block); }
65
+ }, 'void', ['pointer']);
66
+ const replacement = new NativeCallback((self, cmd, window, block) => {
67
+ if (!matches(self)) return original(self, cmd, window, block);
68
+ if (!block.isNull()) dispatch(mainQueue(), copyBlock(block), complete);
69
+ }, 'void', ['pointer', 'pointer', 'pointer', 'pointer']);
70
+ Interceptor.replace(sheet, replacement); keep.push(complete, replacement, original);
71
+ }
72
+ policy.installed = Boolean(modal || sheet);
73
+ // Keep callbacks alive for the lifetime of this script.
74
+ policy.callbacks = keep;
75
+ } catch (_) {
76
+ // If AppKit differs, preserve normal app behavior instead of dismissing an unknown alert.
77
+ }
78
+ })();
@@ -0,0 +1 @@
1
+ :root{font:15px system-ui,-apple-system,sans-serif;color:#192d28;background:#f6f8f7}*{box-sizing:border-box}body{margin:0}header{background:#163f34;color:white;padding:18px 26px;display:flex;align-items:center;gap:12px}header div{flex:1}header strong{font-size:22px}header span{display:block;opacity:.7;font-size:12px;margin-top:4px}button,input,textarea,select{font:inherit}button{background:white;border:1px solid #c7d7d0;border-radius:6px;padding:8px 12px;cursor:pointer;color:#163f34}button:hover{background:#edf5f0}button:disabled{opacity:.5;cursor:default}button:focus-visible,input:focus,textarea:focus{outline:2px solid #298667;outline-offset:2px}#status{padding:12px 26px;margin:0;min-height:46px;white-space:pre-wrap;border-bottom:1px solid #dce4df;font-size:13px}main{display:grid;grid-template-columns:300px 1fr;min-height:70vh}aside{padding:18px;border-right:1px solid #dce4df}label{display:block;font-size:13px;font-weight:600;margin-bottom:8px}input[type=search],textarea{width:100%;padding:10px;border:1px solid #c7d7d0;border-radius:6px;background:white}nav{display:flex;flex-direction:column;gap:6px;margin-top:14px;max-height:75vh;overflow:auto}nav button{text-align:left;padding:12px}nav button[aria-current=true]{background:#dceee4;border-color:#298667}nav small{display:block;color:#65776d;margin-top:5px;overflow-wrap:anywhere}.conversation-title{display:flex;align-items:center;gap:12px;padding:0 0 18px}h1{flex:1;font-size:20px;margin:0}.conversation-title label{margin:0}section{padding:22px;min-width:0}#messages{display:flex;flex-direction:column;gap:12px;max-height:60vh;overflow:auto}article{background:white;border:1px solid #dce4df;border-radius:8px;padding:14px;overflow-wrap:anywhere}article small{color:#65776d}article p{white-space:pre-wrap;line-height:1.6}article button{margin:4px 6px 0 0}article img,article video{max-width:100%;max-height:400px;display:block;margin-top:12px}article audio{display:block;margin-top:12px}form{border-top:1px solid #dce4df;padding-top:16px;margin-top:20px}#compose button{display:block;margin:10px 0 0 auto;background:#1b6d50;color:white}#upload{display:flex;flex-wrap:wrap;gap:8px;align-items:center}#upload label{width:100%}footer{padding:16px 26px;color:#65776d;font-size:12px}@media(max-width:750px){main{grid-template-columns:1fr}aside{border-right:0;border-bottom:1px solid #dce4df}nav{max-height:220px}.conversation-title{flex-wrap:wrap}header{padding:14px}section{padding:16px}}
wechat_cli/cli.py ADDED
@@ -0,0 +1,238 @@
1
+ """uvx wechat-cli: lifecycle commands and API subcommands with JSON output."""
2
+
3
+ import argparse
4
+ import base64
5
+ import json
6
+ import sys
7
+ import time
8
+ import uuid
9
+ import webbrowser
10
+ from pathlib import Path
11
+ from urllib.parse import urlencode
12
+
13
+ from . import __version__
14
+ from .client import ApiClient
15
+ from .config import data_directory
16
+ from .errors import AppError
17
+ from .lifecycle import configured_port, ensure_server, health, serve
18
+ from .setup import WECHAT_URL, doctor, setup
19
+
20
+
21
+ def port_number(value):
22
+ try:
23
+ port = int(value)
24
+ if not 1 <= port <= 65535:
25
+ raise ValueError()
26
+ return port
27
+ except ValueError:
28
+ raise argparse.ArgumentTypeError("port must be between 1 and 65535") from None
29
+
30
+
31
+ def parser():
32
+ common = argparse.ArgumentParser(add_help=False)
33
+ common.add_argument(
34
+ "--data-dir", default=argparse.SUPPRESS, help="Persistent app, token and service directory"
35
+ )
36
+ common.add_argument(
37
+ "--port", type=port_number, default=argparse.SUPPRESS, help="Local API port (default: 8787)"
38
+ )
39
+ root = argparse.ArgumentParser(
40
+ prog="wechat-cli",
41
+ parents=[common],
42
+ description="Python CLI, local API and web interface for macOS WeChat",
43
+ )
44
+ root.add_argument("--version", action="version", version=__version__)
45
+ commands = root.add_subparsers(dest="command")
46
+
47
+ def add(name, description):
48
+ return commands.add_parser(name, help=description, description=description, parents=[common])
49
+
50
+ init = add("setup", "Download and patch a separate compatible WeChat copy, or register an existing one")
51
+ sources = init.add_mutually_exclusive_group()
52
+ sources.add_argument("--app", help="Register an already patched app")
53
+ sources.add_argument("--source", help="Copy and patch a local stock WeChat.app")
54
+ init.add_argument("--destination", help="New patched app location (must not already exist)")
55
+ init.add_argument("--gadget", help="Use a local Frida Gadget dylib instead of downloading it")
56
+ init.add_argument(
57
+ "--wechat-url", default=WECHAT_URL, help="HTTPS installer URL (payload must be supported)"
58
+ )
59
+ add("doctor", "Check app, payload, Gadget and Frida installation")
60
+ start = add("start", "Start the patched WeChat and persistent local API")
61
+ start.add_argument("--foreground", action="store_true", help="Keep the service in this terminal")
62
+ add("serve", "Run WeChat and the API in the foreground")
63
+ add("stop", "Stop the local API; keep WeChat signed in and running")
64
+ web = add("web", "Start the service and open its web interface")
65
+ web.add_argument("--no-open", action="store_true", help="Print the URL without opening a browser")
66
+ add("status", "Read native WeChat connection status")
67
+ add("chats", "List native conversations")
68
+ messages = add("messages", "Read recent messages, newest first")
69
+ messages.add_argument("chat_id")
70
+ messages.add_argument("--limit", type=int, choices=range(1, 101), default=50, metavar="1..100")
71
+ send = add("send", "Send a text message")
72
+ send.add_argument("chat_id")
73
+ send.add_argument("text")
74
+ send.add_argument(
75
+ "--dry-run", action="store_true", help="Validate native text preparation without sending"
76
+ )
77
+ send.add_argument("--key", help="Reuse this idempotency key for retries (retained for 24 hours)")
78
+ for name in ["send-file", "send-voice"]:
79
+ command = add(
80
+ name, "Send a local file" if name == "send-file" else "Send SILK, mono PCM WAV or raw PCM audio"
81
+ )
82
+ command.add_argument("chat_id")
83
+ command.add_argument("file", type=Path)
84
+ command.add_argument("--key")
85
+ if name == "send-voice":
86
+ command.add_argument("--format", choices=["silk", "wav", "pcm_s16le"], default="wav")
87
+ command.add_argument("--sample-rate", type=int)
88
+ for name, description in [
89
+ ("media", "Download media for a recent message"),
90
+ ("ocr", "Extract text from an image"),
91
+ ("transcribe", "Start WeChat voice-to-text"),
92
+ ("transcript", "Read voice-to-text status"),
93
+ ("download", "Ask the active WeChat chat to download an attachment"),
94
+ ]:
95
+ command = add(name, description)
96
+ command.add_argument("chat_id")
97
+ command.add_argument("message_id", help="Exact string ID from messages")
98
+ if name == "media":
99
+ command.add_argument("--output", required=True, type=Path)
100
+ command.add_argument("--variant", choices=["full", "preview"], default="full")
101
+ if name in {"transcribe", "download"}:
102
+ command.add_argument("--key")
103
+ request = add("api", "Call any local /api/ route")
104
+ request.add_argument("path")
105
+ request.add_argument("--method", choices=["GET", "POST", "DELETE", "PUT", "PATCH"], default="GET")
106
+ request.add_argument("--json", dest="body", help="JSON object body")
107
+ request.add_argument("--key")
108
+ request.add_argument("--output", type=Path, help="Save the response as binary")
109
+ return root
110
+
111
+
112
+ def upload(path):
113
+ if path.stat().st_size > 8 * 1024**2:
114
+ raise AppError("file_too_large", "CLI uploads must be at most 8 MiB", 413)
115
+ return base64.b64encode(path.read_bytes()).decode()
116
+
117
+
118
+ def run(args):
119
+ directory = data_directory(getattr(args, "data_dir", None))
120
+ command = args.command or "start"
121
+ port = configured_port(directory, getattr(args, "port", None))
122
+ if command == "setup":
123
+ if args.app and (args.destination or args.gadget):
124
+ raise AppError("invalid_options", "--destination and --gadget apply to preparing a new copy")
125
+ return setup(
126
+ directory,
127
+ app=args.app,
128
+ source=args.source,
129
+ destination=args.destination,
130
+ gadget=args.gadget,
131
+ wechat_url=args.wechat_url,
132
+ )
133
+ if command == "doctor":
134
+ return doctor(directory)
135
+ if command == "serve" or command == "start" and getattr(args, "foreground", False):
136
+ return serve(directory, port)
137
+ if command == "stop":
138
+ result = ApiClient(directory, port).request("/api/server/stop", method="POST", data={})
139
+ for _ in range(100):
140
+ try:
141
+ health(directory, port)
142
+ except AppError:
143
+ return result
144
+ time.sleep(0.05)
145
+ raise AppError("server_stop_timeout", "Service shutdown is still in progress", 504)
146
+ ensure_server(directory, port)
147
+ if command in {"start", "web"}:
148
+ url = f"http://127.0.0.1:{port}/"
149
+ if command == "web" and not args.no_open:
150
+ webbrowser.open(url)
151
+ return {"running": True, "url": url, "tokenFile": str(directory / "api-token")}
152
+ client = ApiClient(directory, port)
153
+ if command == "status":
154
+ return client.request("/api/native/status")
155
+ if command == "chats":
156
+ return client.chats()
157
+ if command == "messages":
158
+ return client.messages(args.chat_id, args.limit)
159
+ key = getattr(args, "key", None)
160
+ mutation = (
161
+ command in {"send", "send-file", "send-voice", "transcribe", "download"}
162
+ or command == "api"
163
+ and args.method == "POST"
164
+ )
165
+ mutation = mutation and not (command == "send" and args.dry_run)
166
+ if mutation:
167
+ key = key or str(uuid.uuid4())
168
+ print(f"Idempotency-Key: {key} (reuse --key for retries)", file=sys.stderr)
169
+ if command == "send" and args.dry_run:
170
+ return client.request(
171
+ "/api/messages/prepare", method="POST", data={"chatId": args.chat_id, "text": args.text}
172
+ )
173
+ if command == "send":
174
+ return client.send(args.chat_id, args.text, idempotency_key=key)
175
+ if command in {"send-file", "send-voice"}:
176
+ input = {"chatId": args.chat_id, "dataBase64": upload(args.file)}
177
+ if command == "send-file":
178
+ input["name"] = args.file.name
179
+ else:
180
+ input["format"] = args.format
181
+ if args.sample_rate is not None:
182
+ input["sampleRate"] = args.sample_rate
183
+ return client.request(
184
+ "/api/messages/file" if command == "send-file" else "/api/messages/voice",
185
+ method="POST",
186
+ data=input,
187
+ key=key,
188
+ )
189
+ if command in {"media", "ocr", "transcribe", "transcript", "download"}:
190
+ input = {"chatId": args.chat_id, "id": args.message_id}
191
+ paths = {
192
+ "media": "/api/native/media",
193
+ "ocr": "/api/native/images/text",
194
+ "transcribe": "/api/native/voice/transcribe",
195
+ "transcript": "/api/native/voice/transcript",
196
+ "download": "/api/native/attachments/download",
197
+ }
198
+ if command in {"media", "transcript"}:
199
+ if command == "media":
200
+ input["variant"] = args.variant
201
+ result = client.request(paths[command] + "?" + urlencode(input), binary=command == "media")
202
+ if command == "media":
203
+ with args.output.open("xb") as output:
204
+ output.write(result)
205
+ return {"saved": str(args.output.resolve()), "bytes": len(result)}
206
+ return result
207
+ return client.request(paths[command], method="POST", data=input, key=key)
208
+ if command == "api":
209
+ try:
210
+ input = json.loads(args.body) if args.body is not None else {} if args.method == "POST" else None
211
+ if input is not None and not isinstance(input, dict):
212
+ raise ValueError()
213
+ except ValueError:
214
+ raise AppError("invalid_json", "--json must be a JSON object") from None
215
+ result = client.request(args.path, method=args.method, data=input, key=key, binary=bool(args.output))
216
+ if args.output:
217
+ with args.output.open("xb") as output:
218
+ output.write(result)
219
+ return {"saved": str(args.output.resolve()), "bytes": len(result)}
220
+ return result
221
+ raise AppError("unknown_command", command)
222
+
223
+
224
+ def main(argv=None):
225
+ args = parser().parse_args(argv)
226
+ try:
227
+ result = run(args)
228
+ if result is not None:
229
+ print(json.dumps(result, ensure_ascii=False, indent=2))
230
+ return 0
231
+ except AppError as error:
232
+ print(json.dumps(error.as_dict(), ensure_ascii=False), file=sys.stderr)
233
+ return 1
234
+ except OSError as error:
235
+ print(json.dumps({"error": "filesystem_error", "message": str(error)}), file=sys.stderr)
236
+ return 1
237
+ except KeyboardInterrupt:
238
+ return 130
wechat_cli/client.py ADDED
@@ -0,0 +1,59 @@
1
+ """Small synchronous HTTP client used by the CLI and available to Python callers."""
2
+
3
+ import http.client
4
+ import json
5
+ import urllib.error
6
+ import urllib.request
7
+ from urllib.parse import urlencode
8
+
9
+ from .config import api_token
10
+ from .errors import AppError
11
+ from .media import NoRedirect
12
+
13
+
14
+ class ApiClient:
15
+ def __init__(self, directory, port=8787, timeout=100):
16
+ self.base = f"http://127.0.0.1:{port}"
17
+ self.token = api_token(directory)
18
+ self.timeout = timeout
19
+ self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
20
+
21
+ def request(self, path, *, method="GET", data=None, key=None, binary=False):
22
+ if not path.startswith("/api/") or "#" in path or "\r" in path or "\n" in path:
23
+ raise AppError("invalid_api_path", "Use a local /api/ path")
24
+ headers = {"Authorization": "Bearer " + self.token}
25
+ if data is not None:
26
+ headers["Content-Type"] = "application/json"
27
+ if key:
28
+ headers["Idempotency-Key"] = key
29
+ request = urllib.request.Request(
30
+ self.base + path,
31
+ data=None if data is None else json.dumps(data).encode(),
32
+ headers=headers,
33
+ method=method,
34
+ )
35
+ try:
36
+ with self.opener.open(request, timeout=self.timeout) as response:
37
+ raw = response.read(128 * 1024**2 + 1)
38
+ if len(raw) > 128 * 1024**2:
39
+ raise AppError("response_too_large", "API response exceeds 128 MiB", 413)
40
+ return raw if binary else json.loads(raw)
41
+ except urllib.error.HTTPError as error:
42
+ try:
43
+ value = json.loads(error.read(65536))
44
+ raise AppError(value["error"], value.get("message", value["error"]), error.code) from None
45
+ except (ValueError, KeyError):
46
+ raise AppError("api_http_error", f"API returned HTTP {error.code}", error.code) from None
47
+ except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.HTTPException):
48
+ raise AppError("api_unavailable", "Local API is unavailable; run wechat-cli start", 503) from None
49
+
50
+ def chats(self):
51
+ return self.request("/api/native/chats")
52
+
53
+ def messages(self, chat_id, limit=50):
54
+ return self.request("/api/native/messages?" + urlencode({"chatId": chat_id, "limit": limit}))
55
+
56
+ def send(self, chat_id, text, *, idempotency_key):
57
+ return self.request(
58
+ "/api/messages/send", method="POST", data={"chatId": chat_id, "text": text}, key=idempotency_key
59
+ )
wechat_cli/codec.py ADDED
@@ -0,0 +1,14 @@
1
+ """Isolated optional SILK codec process (bounded by the parent process timeout)."""
2
+
3
+ import sys
4
+
5
+ import pilk
6
+
7
+ if __name__ == "__main__":
8
+ action, source, destination, rate = sys.argv[1:]
9
+ if action == "decode":
10
+ pilk.decode(source, destination, pcm_rate=int(rate))
11
+ elif action == "encode":
12
+ pilk.encode(source, destination, pcm_rate=int(rate), tencent=True)
13
+ else:
14
+ raise SystemExit(2)