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/server.py ADDED
@@ -0,0 +1,518 @@
1
+ """Authenticated loopback HTTP API. All CLI commands use the same service as the web UI."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import re
8
+ import secrets
9
+ import threading
10
+ import time
11
+ from http.cookies import SimpleCookie
12
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13
+ from urllib.parse import parse_qs, quote, urlsplit
14
+
15
+ from .config import ASSETS, api_token
16
+ from .controls import Controls
17
+ from .errors import AppError
18
+ from .idempotency import Idempotency
19
+ from .media import find_message, prepare_voice, read_media, validate_image
20
+ from .native import NativeBridge, receipt
21
+
22
+
23
+ def chat_id(value):
24
+ if (
25
+ not isinstance(value, str)
26
+ or not value
27
+ or len(value) > 1024
28
+ or re.search(r"[\x00-\x1f\x7f]", value)
29
+ or value in {"@placeholder_foldgroup", "brandsessionholder", "brandservicesessionholder"}
30
+ ):
31
+ raise AppError("invalid_input", "A valid chatId is required")
32
+ return value
33
+
34
+
35
+ def message_id(value):
36
+ if not isinstance(value, str) or not re.fullmatch(r"\d{1,20}", value, re.ASCII):
37
+ raise AppError("invalid_input", "Use an exact string message ID")
38
+ return value
39
+
40
+
41
+ def fields(input, allowed, required=None):
42
+ if set(input) - set(allowed) or set(required if required is not None else allowed) - set(input):
43
+ raise AppError("invalid_input", "Use " + ", ".join(allowed))
44
+
45
+
46
+ def transcript(native, chat, identifier, trigger=False):
47
+ message = find_message(native, chat, identifier)
48
+ if message["kind"] != "voice":
49
+ raise AppError("invalid_media_type", "Choose a voice message")
50
+ params = {"chatId": chat, "id": identifier}
51
+ value = native.request("voiceTranscriptionResult", params)
52
+ if value.get("status") == "unavailable":
53
+ text = message["details"].get("transcript")
54
+ if text and text.strip():
55
+ return {
56
+ "source": "native",
57
+ **params,
58
+ "status": "completed",
59
+ "text": text,
60
+ "recognition": "wechat",
61
+ "resultSource": "local_message_record",
62
+ "conversionTriggered": False,
63
+ }
64
+ if trigger:
65
+ value = native.request("transcribeVoice", params)
66
+ if value.get("status") not in {"pending", "completed", "failed", "timed_out", "unavailable"}:
67
+ raise AppError("native_invalid_response", "Invalid transcription response", 502)
68
+ if value["status"] == "completed" and (
69
+ not isinstance(value.get("text"), str) or not value["text"].strip()
70
+ ):
71
+ raise AppError("native_invalid_response", "Empty completed transcription", 502)
72
+ return {
73
+ "source": "native",
74
+ **params,
75
+ **value,
76
+ "recognition": "wechat",
77
+ "resultSource": "native_task",
78
+ "conversionTriggered": value["status"] != "unavailable",
79
+ }
80
+
81
+
82
+ ROUTES = {
83
+ "/api/health": ["GET"],
84
+ "/api/capabilities": ["GET"],
85
+ "/api/openapi.json": ["GET"],
86
+ "/api/native/status": ["GET"],
87
+ "/api/login": ["GET"],
88
+ "/api/native/chats": ["GET"],
89
+ "/api/chats": ["GET"],
90
+ "/api/native/messages": ["GET"],
91
+ "/api/messages": ["GET", "POST"],
92
+ "/api/messages/send": ["POST"],
93
+ "/api/messages/prepare": ["POST"],
94
+ "/api/messages/file": ["POST"],
95
+ "/api/messages/voice": ["POST"],
96
+ "/api/native/media": ["GET"],
97
+ "/api/native/voice/transcript": ["GET"],
98
+ "/api/native/voice/transcribe": ["POST"],
99
+ "/api/native/images/text": ["POST"],
100
+ "/api/native/attachments/download": ["POST"],
101
+ "/api/native/controls": ["GET"],
102
+ "/api/server/stop": ["POST"],
103
+ }
104
+
105
+
106
+ class ApiServer(ThreadingHTTPServer):
107
+ daemon_threads = False
108
+ block_on_close = True
109
+ allow_reuse_address = True
110
+
111
+ def __init__(self, address, directory, native=None, controls=None):
112
+ self.directory = directory
113
+ self.token = api_token(directory)
114
+ self.native = native or NativeBridge(directory)
115
+ self.controls = controls or Controls(directory)
116
+ self.idempotency = Idempotency(directory)
117
+ self.browser_secret = secrets.token_hex(32)
118
+ self.browser_expires = 0
119
+ self.instance = secrets.token_hex(16)
120
+ super().__init__(address, Handler)
121
+
122
+ def server_close(self):
123
+ super().server_close()
124
+ self.native.close()
125
+ self.idempotency.close()
126
+
127
+
128
+ class Handler(BaseHTTPRequestHandler):
129
+ server_version = "wechat-cli"
130
+ sys_version = ""
131
+ protocol_version = "HTTP/1.0"
132
+
133
+ def setup(self):
134
+ super().setup()
135
+ self.connection.settimeout(15)
136
+
137
+ def log_message(self, *args):
138
+ pass # Requests contain private chat identifiers; never write access logs.
139
+
140
+ def send(self, value, status=200, mime="application/json; charset=utf-8", extra=None):
141
+ data = value if isinstance(value, bytes) else json.dumps(value, ensure_ascii=False).encode()
142
+ self.send_response(status)
143
+ self.send_header("Content-Type", mime)
144
+ self.send_header("Content-Length", str(len(data)))
145
+ self.send_header("Cache-Control", "no-store")
146
+ self.send_header("X-Content-Type-Options", "nosniff")
147
+ self.send_header(
148
+ "Content-Security-Policy",
149
+ "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
150
+ )
151
+ for key, val in (extra or {}).items():
152
+ self.send_header(key, val)
153
+ self.end_headers()
154
+ self.wfile.write(data)
155
+
156
+ def same_origin(self):
157
+ return self.headers.get("Origin") == f"http://{self.headers.get('Host')}"
158
+
159
+ def body(self, maximum=65536):
160
+ if self.headers.get_content_type() != "application/json":
161
+ raise AppError("invalid_content_type", "Use application/json", 415)
162
+ raw = self.headers.get("Content-Length", "")
163
+ if self.headers.get("Transfer-Encoding") or not re.fullmatch(r"\d{1,9}", raw):
164
+ raise AppError("invalid_content_length", "Supply a bounded Content-Length", 411)
165
+ length = int(raw)
166
+ if length > maximum:
167
+ raise AppError("body_too_large", "Request body exceeds the upload limit", 413)
168
+ data = self.rfile.read(length)
169
+ if len(data) != length:
170
+ raise AppError("invalid_json", "Incomplete request body")
171
+ try:
172
+ value = json.loads(data)
173
+ if not isinstance(value, dict):
174
+ raise ValueError()
175
+ return value
176
+ except (ValueError, UnicodeError):
177
+ raise AppError("invalid_json", "Body must be a JSON object") from None
178
+
179
+ def query(self, query, allowed):
180
+ if set(query) - set(allowed) or any(len(v) != 1 for v in query.values()):
181
+ raise AppError("invalid_query", "Unknown or duplicate query parameter")
182
+ return {key: values[0] for key, values in query.items()}
183
+
184
+ def once(self, path, input, action):
185
+ key = self.headers.get("Idempotency-Key", "")
186
+ if not re.fullmatch(r"[A-Za-z0-9_-]{8,128}", key):
187
+ raise AppError(
188
+ "idempotency_key_required",
189
+ "Supply Idempotency-Key with 8–128 letters, digits, underscores or hyphens",
190
+ )
191
+ # Bind keys to account as well as operation so a login change cannot reuse receipts.
192
+ account = self.server.native.request("selfId").get("id")
193
+ if not isinstance(account, str) or not account:
194
+ raise AppError("native_account_not_ready", "No active WeChat account", 409)
195
+ canonical = "/api/messages/send" if path == "/api/messages" else path
196
+ return self.server.idempotency.once(account + ":" + canonical, key, input, action)
197
+
198
+ def handle_api(self):
199
+ if len(self.path) > 4096 or not self.path.startswith("/") or self.path.startswith("//"):
200
+ raise AppError("invalid_url", "Invalid request URL")
201
+ port = self.server.server_address[1]
202
+ if self.headers.get("Host") not in {f"127.0.0.1:{port}", f"localhost:{port}"}:
203
+ raise AppError("invalid_host", "Use the loopback API address", 403)
204
+ for header in [
205
+ "Host",
206
+ "Authorization",
207
+ "Content-Length",
208
+ "Idempotency-Key",
209
+ "Origin",
210
+ "Content-Type",
211
+ ]:
212
+ if len(self.headers.get_all(header, [])) > 1:
213
+ raise AppError("invalid_headers", "Duplicate request header")
214
+ url = urlsplit(self.path)
215
+ path = url.path
216
+ method = self.command
217
+ static = {
218
+ "/": ("index.html", "text/html; charset=utf-8"),
219
+ "/app.js": ("app.js", "text/javascript; charset=utf-8"),
220
+ "/style.css": ("style.css", "text/css; charset=utf-8"),
221
+ "/favicon.svg": ("favicon.svg", "image/svg+xml"),
222
+ }
223
+ if method == "GET" and path in static:
224
+ filename, mime = static[path]
225
+ return self.send((ASSETS / filename).read_bytes(), mime=mime)
226
+ if method == "POST" and path == "/api/browser/session":
227
+ if not self.same_origin() or self.headers.get("Sec-Fetch-Site", "same-origin") != "same-origin":
228
+ raise AppError("browser_origin_denied", "Open the web interface directly on localhost", 403)
229
+ fields(self.body(1024), [])
230
+ self.server.browser_expires = time.monotonic() + 7200
231
+ return self.send(
232
+ {"authenticated": True},
233
+ extra={
234
+ "Set-Cookie": f"wechat_cli_browser={self.server.browser_secret}; HttpOnly; SameSite=Strict; Path=/; Max-Age=7200"
235
+ },
236
+ )
237
+ supplied = self.headers.get("Authorization", "")
238
+ bearer = supplied.startswith("Bearer ") and hmac.compare_digest(
239
+ hashlib.sha256(supplied[7:].encode()).digest(),
240
+ hashlib.sha256(self.server.token.encode()).digest(),
241
+ )
242
+ cookie = SimpleCookie()
243
+ cookie.load(self.headers.get("Cookie", ""))
244
+ browser = cookie.get("wechat_cli_browser")
245
+ browser = bool(
246
+ browser
247
+ and self.server.browser_expires > time.monotonic()
248
+ and hmac.compare_digest(browser.value, self.server.browser_secret)
249
+ )
250
+ if not bearer and not browser:
251
+ raise AppError("unauthorized", "Bearer authentication required", 401)
252
+ if not bearer and method != "GET" and not self.same_origin():
253
+ raise AppError("browser_origin_denied", "Browser mutations require the local page origin", 403)
254
+ if path not in ROUTES:
255
+ raise AppError("not_found", "Unknown API route", 404)
256
+ if method not in ROUTES[path]:
257
+ return self.send({"error": "method_not_allowed"}, 405, extra={"Allow": ", ".join(ROUTES[path])})
258
+ query = parse_qs(url.query, keep_blank_values=True)
259
+ native = self.server.native
260
+ if path == "/api/health":
261
+ self.query(query, [])
262
+ return self.send(
263
+ {
264
+ "status": "ok",
265
+ "service": "wechat-cli",
266
+ "instance": self.server.instance,
267
+ "wechatConnected": native.observed.get("connected", False),
268
+ }
269
+ )
270
+ if path == "/api/capabilities":
271
+ self.query(query, [])
272
+ return self.send(
273
+ {
274
+ "protocol": "wechat-macos",
275
+ "nativeBridge": True,
276
+ "independentClient": False,
277
+ "chatHistory": True,
278
+ "sendMessages": True,
279
+ "mediaPreviews": True,
280
+ "voiceToText": "experimental_native_task",
281
+ "imageTextExtraction": "wechat_local_ocr",
282
+ "fileSending": "wechat_native_ui",
283
+ "voiceSending": "wechat_native_queue",
284
+ }
285
+ )
286
+ if path == "/api/openapi.json":
287
+ self.query(query, [])
288
+ return self.send(openapi())
289
+ if path in {"/api/native/status", "/api/login"}:
290
+ self.query(query, [])
291
+ return self.send(native.status())
292
+ if path in {"/api/native/chats", "/api/chats"}:
293
+ self.query(query, [])
294
+ return self.send(native.chats())
295
+ if method == "GET" and path in {"/api/native/messages", "/api/messages"}:
296
+ input = self.query(query, ["chatId", "limit"])
297
+ chat = chat_id(input.get("chatId"))
298
+ limit = input.get("limit", "50")
299
+ if not re.fullmatch(r"[1-9]\d{0,2}", limit) or not 1 <= int(limit) <= 100:
300
+ raise AppError("invalid_query", "limit must be between 1 and 100")
301
+ return self.send(native.messages(chat, int(limit)))
302
+ if path == "/api/native/controls":
303
+ self.query(query, [])
304
+ return self.send(self.server.controls.invoke("status"))
305
+ if path == "/api/native/media":
306
+ input = self.query(query, ["chatId", "id", "variant"])
307
+ chat, identifier = chat_id(input.get("chatId")), message_id(input.get("id"))
308
+ variant = input.get("variant", "full")
309
+ if variant not in {"full", "preview"}:
310
+ raise AppError("invalid_query", "variant must be full or preview")
311
+ data, mime, name = read_media(native, chat, identifier, variant)
312
+ return self.send(
313
+ data,
314
+ mime=mime,
315
+ extra={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(name, safe='')}"}
316
+ if name
317
+ else {},
318
+ )
319
+ if path == "/api/native/voice/transcript":
320
+ input = self.query(query, ["chatId", "id"])
321
+ return self.send(transcript(native, chat_id(input.get("chatId")), message_id(input.get("id"))))
322
+ self.query(query, [])
323
+ input = self.body(12 * 1024**2 if path in {"/api/messages/file", "/api/messages/voice"} else 65536)
324
+ if path == "/api/server/stop":
325
+ fields(input, [])
326
+ self.send({"stopping": True})
327
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
328
+ return
329
+ chat = chat_id(input.get("chatId"))
330
+ if path in {"/api/messages/send", "/api/messages", "/api/messages/prepare"}:
331
+ fields(input, ["chatId", "text"])
332
+ text = input["text"]
333
+ if not isinstance(text, str) or not text.strip() or len(text) > 4096 or "\0" in text:
334
+ raise AppError("invalid_input", "Text must contain 1–4096 characters without NUL bytes")
335
+ if path == "/api/messages/prepare":
336
+ result = native.request("prepareText", input)
337
+ if (
338
+ result.get("prepared") is not True
339
+ or result.get("queued") is not False
340
+ or result.get("receiverVerified") is not True
341
+ ):
342
+ raise AppError("native_invalid_response", "Text preparation could not be verified", 502)
343
+ return self.send(result)
344
+ return self.send(self.once(path, input, lambda: native.send(chat, text)))
345
+ if path == "/api/messages/file":
346
+ fields(input, ["chatId", "name", "dataBase64"])
347
+ return self.send(self.once(path, input, lambda: self.server.controls.send_file(native, input)))
348
+ if path == "/api/messages/voice":
349
+ prepared = prepare_voice(input)
350
+ return self.send(
351
+ self.once(
352
+ path,
353
+ input,
354
+ lambda: {
355
+ **receipt(native.request("sendVoice", prepared), chat),
356
+ "durationMs": prepared["durationMs"],
357
+ },
358
+ )
359
+ )
360
+ fields(input, ["chatId", "id"])
361
+ identifier = message_id(input["id"])
362
+ if path == "/api/native/attachments/download":
363
+ return self.send(
364
+ self.once(
365
+ path, input, lambda: self.server.controls.download_attachment(native, chat, identifier)
366
+ )
367
+ )
368
+ if path == "/api/native/voice/transcribe":
369
+ result = self.once(path, input, lambda: transcript(native, chat, identifier, True))
370
+ return self.send(result, 202 if result["status"] == "pending" else 200)
371
+ if path == "/api/native/images/text":
372
+ message = find_message(native, chat, identifier)
373
+ if message["kind"] != "image":
374
+ raise AppError("invalid_media_type", "Choose an image message")
375
+ data, _, _ = read_media(native, chat, identifier)
376
+ dimensions = validate_image(data)
377
+ result = native.request("extractImageText", {"dataBase64": base64.b64encode(data).decode()})
378
+ if (
379
+ result.get("recognition") != "wechat"
380
+ or result.get("processing") != "local"
381
+ or not isinstance(result.get("text"), str)
382
+ or len(result["text"]) > 1048576
383
+ or not isinstance(result.get("lines"), list)
384
+ or len(result["lines"]) > 10000
385
+ or any(not isinstance(line, str) or len(line) > 65536 for line in result["lines"])
386
+ ):
387
+ raise AppError("native_invalid_response", "Invalid OCR result", 502)
388
+ return self.send({"source": "native", **input, **result, **dimensions, "status": "completed"})
389
+ raise AppError("not_found", "Unknown API route", 404)
390
+
391
+ def dispatch(self):
392
+ try:
393
+ self.handle_api()
394
+ except AppError as error:
395
+ self.send(error.as_dict(), error.status)
396
+ except (BrokenPipeError, ConnectionResetError, TimeoutError):
397
+ pass
398
+ except Exception:
399
+ self.send({"error": "internal_error", "message": "The operation failed"}, 500)
400
+
401
+ do_GET = do_POST = do_DELETE = do_PUT = do_PATCH = do_OPTIONS = do_HEAD = dispatch
402
+
403
+
404
+ def openapi():
405
+ string = {"type": "string"}
406
+ chat = {"type": "string", "minLength": 1, "maxLength": 1024}
407
+ identifier = {
408
+ "type": "string",
409
+ "pattern": "^[0-9]{1,20}$",
410
+ "description": "Exact message ID; never convert to a float",
411
+ }
412
+ properties = {
413
+ "chatId": chat,
414
+ "id": identifier,
415
+ "text": {"type": "string", "minLength": 1, "maxLength": 4096},
416
+ "name": {"type": "string", "minLength": 1, "maxLength": 200},
417
+ "dataBase64": {"type": "string", "contentEncoding": "base64", "description": "At most 8 MiB decoded"},
418
+ "format": {"type": "string", "enum": ["silk", "wav", "pcm_s16le"]},
419
+ "sampleRate": {"type": "integer", "enum": [8000, 12000, 16000, 24000, 32000, 44100, 48000]},
420
+ }
421
+ json_body = {
422
+ "/api/messages": ["chatId", "text"],
423
+ "/api/messages/send": ["chatId", "text"],
424
+ "/api/messages/prepare": ["chatId", "text"],
425
+ "/api/messages/file": ["chatId", "name", "dataBase64"],
426
+ "/api/messages/voice": ["chatId", "dataBase64", "format", "sampleRate"],
427
+ "/api/native/images/text": ["chatId", "id"],
428
+ "/api/native/voice/transcribe": ["chatId", "id"],
429
+ "/api/native/attachments/download": ["chatId", "id"],
430
+ "/api/server/stop": [],
431
+ }
432
+ idempotent = {
433
+ "/api/messages",
434
+ "/api/messages/send",
435
+ "/api/messages/file",
436
+ "/api/messages/voice",
437
+ "/api/native/voice/transcribe",
438
+ "/api/native/attachments/download",
439
+ }
440
+ queries = {
441
+ "/api/messages": {
442
+ "chatId": chat,
443
+ "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
444
+ },
445
+ "/api/native/messages": {
446
+ "chatId": chat,
447
+ "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50},
448
+ },
449
+ "/api/native/media": {
450
+ "chatId": chat,
451
+ "id": identifier,
452
+ "variant": {"type": "string", "enum": ["full", "preview"], "default": "full"},
453
+ },
454
+ "/api/native/voice/transcript": {"chatId": chat, "id": identifier},
455
+ }
456
+ paths = {}
457
+ for path, methods in ROUTES.items():
458
+ paths[path] = {}
459
+ for method in methods:
460
+ operation = {
461
+ "operationId": method.lower() + path.replace("/", "_"),
462
+ "summary": path,
463
+ "responses": {
464
+ "200": {"description": "Success"},
465
+ "400": {"description": "Invalid input"},
466
+ "401": {"description": "Authentication required"},
467
+ "409": {"description": "Native state or idempotency conflict"},
468
+ "424": {"description": "App or runtime not configured"},
469
+ "502": {"description": "Native operation failed or send outcome uncertain"},
470
+ "504": {"description": "Native operation timed out"},
471
+ },
472
+ }
473
+ parameters = []
474
+ if method == "GET":
475
+ parameters.extend(
476
+ {"name": name, "in": "query", "required": name in {"chatId", "id"}, "schema": schema}
477
+ for name, schema in queries.get(path, {}).items()
478
+ )
479
+ if method == "POST" and path in idempotent:
480
+ parameters.append(
481
+ {
482
+ "name": "Idempotency-Key",
483
+ "in": "header",
484
+ "required": True,
485
+ "description": "Reuse with identical input for retries; retained for 24 hours",
486
+ "schema": {"type": "string", "pattern": "^[A-Za-z0-9_-]{8,128}$"},
487
+ }
488
+ )
489
+ if parameters:
490
+ operation["parameters"] = parameters
491
+ if method == "POST" and path in json_body:
492
+ names = json_body[path]
493
+ schema = {
494
+ "type": "object",
495
+ "additionalProperties": False,
496
+ "properties": {name: properties[name] for name in names},
497
+ }
498
+ if names:
499
+ schema["required"] = [name for name in names if name != "sampleRate"]
500
+ operation["requestBody"] = {
501
+ "required": True,
502
+ "content": {"application/json": {"schema": schema}},
503
+ }
504
+ if path == "/api/native/media":
505
+ operation["responses"]["200"]["content"] = {"application/octet-stream": {"schema": string}}
506
+ if path == "/api/native/voice/transcribe":
507
+ operation["responses"]["202"] = {
508
+ "description": "Recognition pending; poll /api/native/voice/transcript"
509
+ }
510
+ paths[path][method.lower()] = operation
511
+ return {
512
+ "openapi": "3.1.0",
513
+ "info": {"title": "wechat-cli local API", "version": "0.1.0"},
514
+ "servers": [{"url": "/"}],
515
+ "security": [{"bearerAuth": []}],
516
+ "components": {"securitySchemes": {"bearerAuth": {"type": "http", "scheme": "bearer"}}},
517
+ "paths": paths,
518
+ }