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/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
|
+
}
|