zettcode-gateway 0.3.4__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.
Files changed (40) hide show
  1. zettcode/gateway/__init__.py +22 -0
  2. zettcode/gateway/app.py +454 -0
  3. zettcode/gateway/backend.py +368 -0
  4. zettcode/gateway/chat.py +142 -0
  5. zettcode/gateway/cli.py +495 -0
  6. zettcode/gateway/py.typed +0 -0
  7. zettcode/gateway/runfile.py +145 -0
  8. zettcode/gateway/service.py +1219 -0
  9. zettcode/gateway/settings.py +62 -0
  10. zettcode/gateway/static/app.css +1434 -0
  11. zettcode/gateway/static/app.js +3 -0
  12. zettcode/gateway/static/assets/KaTeX_AMS-Regular-U6PRYMIZ.woff2 +0 -0
  13. zettcode/gateway/static/assets/KaTeX_Caligraphic-Bold-5QL5CMTE.woff2 +0 -0
  14. zettcode/gateway/static/assets/KaTeX_Caligraphic-Regular-KX5MEWCF.woff2 +0 -0
  15. zettcode/gateway/static/assets/KaTeX_Fraktur-Bold-2QVFK6NQ.woff2 +0 -0
  16. zettcode/gateway/static/assets/KaTeX_Fraktur-Regular-2PEIFJSJ.woff2 +0 -0
  17. zettcode/gateway/static/assets/KaTeX_Main-Bold-YP5VVQRP.woff2 +0 -0
  18. zettcode/gateway/static/assets/KaTeX_Main-BoldItalic-N4V3DX7S.woff2 +0 -0
  19. zettcode/gateway/static/assets/KaTeX_Main-Italic-RELBIK7M.woff2 +0 -0
  20. zettcode/gateway/static/assets/KaTeX_Main-Regular-ARRPAO67.woff2 +0 -0
  21. zettcode/gateway/static/assets/KaTeX_Math-BoldItalic-K4WTGH3J.woff2 +0 -0
  22. zettcode/gateway/static/assets/KaTeX_Math-Italic-6KGCHLFN.woff2 +0 -0
  23. zettcode/gateway/static/assets/KaTeX_SansSerif-Bold-RRNVJFFW.woff2 +0 -0
  24. zettcode/gateway/static/assets/KaTeX_SansSerif-Italic-HMPFTM52.woff2 +0 -0
  25. zettcode/gateway/static/assets/KaTeX_SansSerif-Regular-XIQ62X4E.woff2 +0 -0
  26. zettcode/gateway/static/assets/KaTeX_Script-Regular-APUWIHLP.woff2 +0 -0
  27. zettcode/gateway/static/assets/KaTeX_Size1-Regular-5LRUTBFT.woff2 +0 -0
  28. zettcode/gateway/static/assets/KaTeX_Size2-Regular-LELKET5D.woff2 +0 -0
  29. zettcode/gateway/static/assets/KaTeX_Size3-Regular-WQRQ47UD.woff2 +0 -0
  30. zettcode/gateway/static/assets/KaTeX_Size4-Regular-CDMV7U5C.woff2 +0 -0
  31. zettcode/gateway/static/assets/KaTeX_Typewriter-Regular-VBYJ4NRC.woff2 +0 -0
  32. zettcode/gateway/static/chunks/chunk-Y6SLVHK3.js +1 -0
  33. zettcode/gateway/static/chunks/render-G6G3TSB3.js +341 -0
  34. zettcode/gateway/static/icon.png +0 -0
  35. zettcode/gateway/static/index.html +23 -0
  36. zettcode/gateway/static/logo.svg +205 -0
  37. zettcode/gateway/static/manifest.webmanifest +11 -0
  38. zettcode_gateway-0.3.4.dist-info/METADATA +21 -0
  39. zettcode_gateway-0.3.4.dist-info/RECORD +40 -0
  40. zettcode_gateway-0.3.4.dist-info/WHEEL +4 -0
@@ -0,0 +1,22 @@
1
+ """The web gateway: a browser front end for the background agents on this machine.
2
+
3
+ zettcode gateway # then open the address it prints
4
+
5
+ A page lists the daemons (``zettcode daemon``) and the other sessions that are running, starts and stops daemons, and
6
+ lets a person talk to any of them as to a contact: what is typed is sent as a message from this gateway, and what the
7
+ session says comes back into the same conversation. The gateway is not an agent. It is one more session of the kind a
8
+ terminal is, with a person at it and no model of its own to run, and it reaches the agent through ``zettcode.sdk``
9
+ alone: starting and stopping daemons, who is registered, sending, and reading a stored conversation.
10
+
11
+ * :mod:`.chat` -- a stored conversation as chat items (no I/O);
12
+ * :mod:`.backend` -- what the gateway needs from the SDK, as an interface, and the one implementation that uses it;
13
+ * :mod:`.service` -- the gateway itself: threads, sending, daemons, unread marks and the events a page listens to;
14
+ * :mod:`.app` -- the web application: sign-in, the JSON endpoints, the event socket and the page;
15
+ * :mod:`.cli` -- the ``zettcode gateway`` command.
16
+
17
+ ``docs/internal/gateway.md`` has the design and the rules the tests pin.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ __all__ = ["app", "backend", "chat", "cli", "service", "settings"]
@@ -0,0 +1,454 @@
1
+ """The gateway as a web application: sign-in, the JSON endpoints, the event socket, and the page itself.
2
+
3
+ Nothing here decides anything about sessions (that is :mod:`.service`); this is what any web service has: who is let
4
+ in, how a body is read, how a refusal is answered, how a WebSocket is read and written. Every route but the page, its
5
+ files and the sign-in asks for a signed-in browser (a cookie) or the token as a bearer.
6
+
7
+ The gateway can start agents that run commands, so a browser is trusted only after it has shown the token, the cookie
8
+ is not readable by the page's scripts and is not sent across sites, and a request that carries an ``Origin`` has to
9
+ carry the gateway's own.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import contextlib
16
+ import hmac
17
+ import json
18
+ import logging
19
+ import os
20
+ import secrets
21
+ import sys
22
+ import time
23
+ from collections import deque
24
+ from collections.abc import AsyncIterator, Awaitable, Callable
25
+ from contextlib import asynccontextmanager
26
+ from pathlib import Path
27
+ from typing import Any
28
+ from urllib.parse import urlsplit
29
+
30
+ from starlette.applications import Starlette
31
+ from starlette.middleware import Middleware
32
+ from starlette.requests import HTTPConnection, Request
33
+ from starlette.responses import FileResponse, JSONResponse, PlainTextResponse, Response
34
+ from starlette.routing import Mount, Route, WebSocketRoute
35
+ from starlette.staticfiles import StaticFiles
36
+ from starlette.types import ASGIApp, Receive, Scope, Send
37
+ from starlette.websockets import WebSocket, WebSocketDisconnect
38
+
39
+ from .backend import Backend, GatewayStartError, SdkBackend
40
+ from .runfile import clear as clear_runfile
41
+ from .runfile import write as write_runfile
42
+ from .service import Gateway, GatewayError
43
+ from .settings import Settings
44
+
45
+ logger = logging.getLogger("zettcode.gateway")
46
+
47
+ #: The folder that holds the page, its script and its style.
48
+ STATIC = Path(__file__).parent / "static"
49
+
50
+ #: What a checkout that has not built the page says instead of it.
51
+ NOT_BUILT = (
52
+ "The gateway's page has not been built in this checkout. Run `make gateway-ui` (needs Node 20+), or install the "
53
+ "published package, which carries it. The API is up."
54
+ )
55
+
56
+ #: The cookie a signed-in browser carries, and how long it stays good.
57
+ COOKIE = "zg_session"
58
+ COOKIE_SECONDS = 30 * 24 * 3600
59
+
60
+ #: The most a request body may be, in bytes. A message is at most 32,000 characters.
61
+ MAX_BODY = 256 * 1024
62
+
63
+ #: The most a message that sends pictures may be: what the broker takes (``MAX_MESSAGE_BODY``, which a test keeps equal).
64
+ MAX_SEND_BODY = (1 << 20) + (8 << 20) // 3 * 4 + 4
65
+
66
+ #: Wrong tokens tolerated in a window before sign-in is refused for a while.
67
+ LOGIN_FAILURES = 8
68
+ LOGIN_WINDOW = 600.0
69
+
70
+ #: What a WebSocket is closed with when it is not signed in.
71
+ UNAUTHORIZED = 4401
72
+
73
+ #: The names a browser on this machine reaches it by.
74
+ LOOPBACK_NAMES = frozenset({"127.0.0.1", "::1", "localhost"})
75
+
76
+ #: What the page may load: its own files, and inline styles (the math typesetter writes them) but never inline script, so a message with
77
+ #: markup in it has nowhere to run. Images from other sites are not loaded: a message cannot make the page call out.
78
+ POLICY = (
79
+ "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; "
80
+ "object-src 'none'; base-uri 'none'; "
81
+ "form-action 'self'; frame-ancestors 'none'"
82
+ )
83
+
84
+
85
+ class Auth:
86
+ """Who is signed in: the token, the cookies issued for it, and how many wrong tokens have been tried."""
87
+
88
+ def __init__(self, token: str, clock: Callable[[], float] = time.monotonic) -> None:
89
+ self.token = token
90
+ self.clock = clock
91
+ self._sessions: dict[str, float] = {}
92
+ self._failures: deque[float] = deque()
93
+
94
+ def token_ok(self, given: str) -> bool:
95
+ """Return whether ``given`` is the token, compared in constant time."""
96
+ return bool(self.token) and hmac.compare_digest(given.encode(), self.token.encode())
97
+
98
+ def locked_out(self) -> bool:
99
+ """Return whether too many wrong tokens were tried lately."""
100
+ now = self.clock()
101
+ while self._failures and now - self._failures[0] > LOGIN_WINDOW:
102
+ self._failures.popleft()
103
+ return len(self._failures) >= LOGIN_FAILURES
104
+
105
+ def failed(self) -> None:
106
+ """Count a wrong token."""
107
+ self._failures.append(self.clock())
108
+
109
+ def issue(self) -> str:
110
+ """Return a new cookie value for a browser that showed the token."""
111
+ value = secrets.token_urlsafe(32)
112
+ self._sessions[value] = self.clock() + COOKIE_SECONDS
113
+ return value
114
+
115
+ def revoke(self, value: str | None) -> None:
116
+ """Forget a cookie, so it no longer signs anybody in."""
117
+ if value:
118
+ self._sessions.pop(value, None)
119
+
120
+ def cookie_ok(self, value: str | None) -> bool:
121
+ """Return whether a cookie was issued and has not run out."""
122
+ if not value:
123
+ return False
124
+ expires = self._sessions.get(value)
125
+ if expires is None:
126
+ return False
127
+ if expires < self.clock():
128
+ self._sessions.pop(value, None)
129
+ return False
130
+ return True
131
+
132
+ @property
133
+ def open(self) -> bool:
134
+ """Return whether there is no token, so nothing is asked of a browser but that it is on this machine."""
135
+ return not self.token
136
+
137
+ def allowed(self, connection: HTTPConnection) -> bool:
138
+ """Return whether a request or socket is signed in, by cookie or by bearer token.
139
+
140
+ With no token every request is let in, but only one addressed to this machine by name: a page on another site
141
+ can make a browser send requests to a local port, and a name that resolves to it (DNS rebinding) would
142
+ otherwise be trusted as the page itself.
143
+ """
144
+ if self.open:
145
+ return urlsplit("//" + connection.headers.get("host", "")).hostname in LOOPBACK_NAMES
146
+ if self.cookie_ok(connection.cookies.get(COOKIE)):
147
+ return True
148
+ scheme, _, given = connection.headers.get("authorization", "").partition(" ")
149
+ return scheme.lower() == "bearer" and self.token_ok(given.strip())
150
+
151
+
152
+ def same_origin(connection: HTTPConnection, *, required: bool = False) -> bool:
153
+ """Return whether a request did not come from another site: it names this host in ``Origin``.
154
+
155
+ A request with no ``Origin`` is let through unless ``required``: a browser sends one on every write and every socket,
156
+ and with no token (nothing but this check stands between a page on another site and a gateway that runs commands) a
157
+ program that writes to the gateway says where it is from too.
158
+ """
159
+ origin = connection.headers.get("origin")
160
+ if not origin:
161
+ return not required
162
+ return urlsplit(origin).netloc == connection.headers.get("host", "")
163
+
164
+
165
+ class Headers:
166
+ """ASGI middleware that puts the response headers every answer of the gateway carries on it."""
167
+
168
+ def __init__(self, app: ASGIApp) -> None:
169
+ self.app = app
170
+
171
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
172
+ if scope["type"] != "http":
173
+ await self.app(scope, receive, send)
174
+ return
175
+
176
+ async def wrapped(message: Any) -> None:
177
+ if message["type"] == "http.response.start":
178
+ headers = message.setdefault("headers", [])
179
+ headers.extend(
180
+ (name.encode(), value.encode())
181
+ for name, value in (
182
+ ("content-security-policy", POLICY),
183
+ ("x-content-type-options", "nosniff"),
184
+ ("referrer-policy", "no-referrer"),
185
+ ("x-frame-options", "DENY"),
186
+ )
187
+ )
188
+ await send(message)
189
+
190
+ await self.app(scope, receive, wrapped)
191
+
192
+
193
+ class Files(StaticFiles):
194
+ """The page's files, always revalidated: the page and its script change together and must not be mixed."""
195
+
196
+ def file_response(self, *args: Any, **kwargs: Any) -> Response:
197
+ response = super().file_response(*args, **kwargs)
198
+ response.headers["cache-control"] = "no-cache"
199
+ return response
200
+
201
+
202
+ Handler = Callable[[Request], Awaitable[Any]]
203
+
204
+
205
+ def create_app(settings: Settings, backend: Backend | None = None, *, static: Path = STATIC) -> Starlette:
206
+ """Return the application for these settings; a test gives a backend of its own, and the folder the page is in."""
207
+ auth = Auth(settings.token)
208
+ gateway = Gateway(backend if backend is not None else SdkBackend(settings.workspace, settings.directory), settings)
209
+
210
+ @asynccontextmanager
211
+ async def lifespan(_: Starlette) -> AsyncIterator[None]:
212
+ try:
213
+ await gateway.start()
214
+ except Exception as error:
215
+ logger.exception("start.failed", extra={"host": settings.host, "port": settings.port})
216
+ if isinstance(error, GatewayStartError):
217
+ sys.stderr.write(f"zettcode gateway: {error}\n")
218
+ raise
219
+ # Written only once the gateway is a session: a second one that lost the session would otherwise replace
220
+ # the record of the one that kept it. The record is what `zettcode gateway stop|restart` finds.
221
+ write_runfile(settings)
222
+ logger.info("start", extra={"host": settings.host, "port": settings.port})
223
+ try:
224
+ yield
225
+ finally:
226
+ clear_runfile(settings.directory, pid=os.getpid())
227
+ await gateway.close()
228
+ logger.info("stop", extra={"host": settings.host, "port": settings.port})
229
+
230
+ # -- plumbing ---------------------------------------------------------------------------------
231
+ def api(handler: Handler, *, public: bool = False) -> Callable[[Request], Awaitable[Response]]:
232
+ """Wrap an endpoint: check who is asking, run it, and answer a refusal or a fault in the one shape."""
233
+
234
+ async def route(request: Request) -> Response:
235
+ try:
236
+ if not public and not auth.allowed(request):
237
+ raise GatewayError(401, "unauthorized", "Sign in first")
238
+ if request.method not in ("GET", "HEAD") and not same_origin(request, required=auth.open):
239
+ raise GatewayError(403, "forbidden", "This request came from another site")
240
+ result = await handler(request)
241
+ return result if isinstance(result, Response) else JSONResponse(result)
242
+ except GatewayError as error:
243
+ headers = {"retry-after": str(error.extra["retry_after"])} if "retry_after" in error.extra else None
244
+ return JSONResponse(error.to_dict(), error.status, headers=headers)
245
+ except Exception as error: # noqa: BLE001 - one request failing must not end the server
246
+ logger.exception("request.failed", extra={"method": request.method, "route": request.url.path})
247
+ return JSONResponse(
248
+ {"error": {"code": "internal", "message": f"The request failed ({type(error).__name__})"}}, 500
249
+ )
250
+
251
+ return route
252
+
253
+ async def body(request: Request, limit: int = MAX_BODY) -> dict[str, Any]:
254
+ declared = request.headers.get("content-length", "")
255
+ if declared.isdigit() and int(declared) > limit:
256
+ raise GatewayError(413, "too_long", "The request is too large")
257
+ data = b""
258
+ async for chunk in request.stream():
259
+ data += chunk
260
+ if len(data) > limit:
261
+ raise GatewayError(413, "too_long", "The request is too large")
262
+ try:
263
+ parsed = json.loads(data or b"{}")
264
+ except ValueError:
265
+ raise GatewayError(400, "invalid", "The body is not JSON") from None
266
+ if not isinstance(parsed, dict):
267
+ raise GatewayError(400, "invalid", "The body is a JSON object")
268
+ return parsed
269
+
270
+ # -- sign-in ----------------------------------------------------------------------------------
271
+ async def session(request: Request) -> Any:
272
+ return {"authenticated": auth.allowed(request), "open": auth.open}
273
+
274
+ async def login(request: Request) -> Any:
275
+ if auth.locked_out():
276
+ raise GatewayError(429, "rate", "Too many wrong tokens; wait a few minutes", retry_after=60)
277
+ given = (await body(request)).get("token")
278
+ if not isinstance(given, str) or not auth.token_ok(given.strip()):
279
+ auth.failed()
280
+ await asyncio.sleep(0.4)
281
+ raise GatewayError(401, "unauthorized", "That is not the token")
282
+ response = JSONResponse({"authenticated": True})
283
+ response.set_cookie(
284
+ COOKIE,
285
+ auth.issue(),
286
+ max_age=COOKIE_SECONDS,
287
+ httponly=True,
288
+ samesite="strict",
289
+ secure=request.url.scheme == "https",
290
+ path="/",
291
+ )
292
+ return response
293
+
294
+ async def logout(request: Request) -> Any:
295
+ auth.revoke(request.cookies.get(COOKIE))
296
+ response = JSONResponse({"authenticated": False})
297
+ response.delete_cookie(COOKIE, path="/")
298
+ return response
299
+
300
+ # -- the endpoints ----------------------------------------------------------------------------
301
+ async def status(_: Request) -> Any:
302
+ return gateway.status()
303
+
304
+ async def config(_: Request) -> Any:
305
+ return gateway.config()
306
+
307
+ async def add_model(request: Request) -> Any:
308
+ return await gateway.add_model(await body(request))
309
+
310
+ async def change_model(request: Request) -> Any:
311
+ return await gateway.change_model(request.path_params["name"], await body(request))
312
+
313
+ async def remove_model(request: Request) -> Any:
314
+ return await gateway.remove_model(request.path_params["name"])
315
+
316
+ async def dirs(request: Request) -> Any:
317
+ return gateway.browse(request.query_params.get("path", ""))
318
+
319
+ async def threads(_: Request) -> Any:
320
+ return {"threads": gateway.threads(), "status": gateway.status()}
321
+
322
+ async def graph(_: Request) -> Any:
323
+ return await gateway.graph()
324
+
325
+ async def older(request: Request) -> Any:
326
+ query = request.query_params
327
+ return await gateway.older(query.get("before"), query.get("limit"), query.get("group"))
328
+
329
+ async def send_group(request: Request) -> Any:
330
+ given = await body(request)
331
+ return await gateway.send_group(request.path_params["name"], str(given.get("text") or ""))
332
+
333
+ async def timeline(request: Request) -> Any:
334
+ query = request.query_params
335
+ return await gateway.timeline(query.get("since"), query.get("until"), query.get("limit"))
336
+
337
+ async def thread(request: Request) -> Any:
338
+ everything = request.query_params.get("all") in ("1", "true")
339
+ return await gateway.thread(request.path_params["id"], everything=everything)
340
+
341
+ async def read(request: Request) -> Any:
342
+ await gateway.read(request.path_params["id"])
343
+ return {"ok": True}
344
+
345
+ async def recheck(request: Request) -> Any:
346
+ return {"thread": await gateway.recheck(request.path_params["id"])}
347
+
348
+ async def send(request: Request) -> Any:
349
+ data = await body(request, MAX_SEND_BODY)
350
+ text = data.get("text")
351
+ reply = data.get("reply")
352
+ if not isinstance(text, str) or (reply is not None and not isinstance(reply, bool)):
353
+ raise GatewayError(400, "invalid", "text is a string and reply a boolean")
354
+ sent = await gateway.send(request.path_params["id"], text, reply=reply, images=data.get("images"))
355
+ return sent | {"status": gateway.status()}
356
+
357
+ async def picture(request: Request) -> Any:
358
+ data, media_type = await gateway.picture(request.path_params["id"], request.path_params["picture"])
359
+ # Named by its own hash, so it never changes: a browser keeps it. Only a type a model takes is served (the service
360
+ # checks it as it serves, whatever the stored file says), and the app never lets a browser guess another.
361
+ return Response(
362
+ data,
363
+ media_type=media_type,
364
+ headers={"cache-control": "private, max-age=31536000, immutable"},
365
+ )
366
+
367
+ async def spawn(request: Request) -> Any:
368
+ return await gateway.spawn(await body(request))
369
+
370
+ async def stop(request: Request) -> Any:
371
+ given = await body(request)
372
+ return await gateway.stop(request.path_params["id"], force=given.get("force") is True)
373
+
374
+ async def delete(request: Request) -> Any:
375
+ return await gateway.delete(request.path_params["id"])
376
+
377
+ async def log(request: Request) -> Any:
378
+ return await gateway.log(request.path_params["id"])
379
+
380
+ async def index(_: Request) -> Response:
381
+ page = static / "index.html"
382
+ if not page.is_file():
383
+ # The page is built from clients/gateway when a distribution is made and is not kept in the repository, so a
384
+ # checkout that has not built it has an API and no page: say so, rather than answer 404 to the first request.
385
+ return PlainTextResponse(NOT_BUILT, status_code=503)
386
+ return FileResponse(page, headers={"cache-control": "no-store"})
387
+
388
+ async def manifest(_: Request) -> Response:
389
+ return FileResponse(
390
+ static / "manifest.webmanifest",
391
+ media_type="application/manifest+json",
392
+ headers={"cache-control": "no-cache"},
393
+ )
394
+
395
+ # -- the event socket -------------------------------------------------------------------------
396
+ async def events(websocket: WebSocket) -> None:
397
+ if not auth.allowed(websocket) or not same_origin(websocket, required=auth.open):
398
+ await websocket.close(UNAUTHORIZED)
399
+ return
400
+ await websocket.accept()
401
+ queue = gateway.subscribe()
402
+
403
+ async def pump() -> None:
404
+ with contextlib.suppress(Exception):
405
+ while True:
406
+ event = await queue.get()
407
+ if event.get("type") == "close":
408
+ await websocket.close(1013) # "try again later": the page reconnects and reads everything again
409
+ return
410
+ await websocket.send_text(json.dumps(event, separators=(",", ":")))
411
+
412
+ writer = asyncio.ensure_future(pump())
413
+ try:
414
+ while True:
415
+ await websocket.receive_text()
416
+ except (WebSocketDisconnect, RuntimeError):
417
+ pass
418
+ finally:
419
+ writer.cancel()
420
+ await asyncio.gather(writer, return_exceptions=True)
421
+ gateway.unsubscribe(queue)
422
+
423
+ routes = [
424
+ Route("/", index),
425
+ Route("/manifest.webmanifest", manifest),
426
+ Route("/api/session", api(session, public=True)),
427
+ Route("/api/login", api(login, public=True), methods=["POST"]),
428
+ Route("/api/logout", api(logout, public=True), methods=["POST"]),
429
+ Route("/api/status", api(status)),
430
+ Route("/api/config", api(config)),
431
+ Route("/api/models", api(add_model), methods=["POST"]),
432
+ Route("/api/models/{name:path}", api(change_model), methods=["PATCH"]),
433
+ Route("/api/models/{name:path}", api(remove_model), methods=["DELETE"]),
434
+ Route("/api/dirs", api(dirs)),
435
+ Route("/api/graph", api(graph)),
436
+ Route("/api/graph/timeline", api(timeline)),
437
+ Route("/api/graph/older", api(older)),
438
+ Route("/api/threads", api(threads)),
439
+ Route("/api/threads/{id}", api(thread)),
440
+ Route("/api/threads/{id}/read", api(read), methods=["POST"]),
441
+ Route("/api/threads/{id}/recheck", api(recheck), methods=["POST"]),
442
+ Route("/api/threads/{id}/send", api(send), methods=["POST"]),
443
+ Route("/api/threads/{id}/pictures/{picture}", api(picture)),
444
+ Route("/api/daemons", api(spawn), methods=["POST"]),
445
+ Route("/api/daemons/{id}", api(delete), methods=["DELETE"]),
446
+ Route("/api/daemons/{id}/stop", api(stop), methods=["POST"]),
447
+ Route("/api/daemons/{id}/log", api(log)),
448
+ WebSocketRoute("/api/events", events),
449
+ Mount("/static", Files(directory=static, check_dir=False), name="static"),
450
+ ]
451
+ app = Starlette(routes=routes, lifespan=lifespan, middleware=[Middleware(Headers)])
452
+ app.state.gateway = gateway
453
+ app.state.auth = auth
454
+ return app