stitchkit 0.100.1 → 0.100.3
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.
- package/CHANGELOG.md +99 -0
- package/README.md +2 -1
- package/dist/application/process-lifecycle-transitions.d.ts +13 -2
- package/dist/application/process-lifecycle-transitions.d.ts.map +1 -1
- package/dist/application/process-lifecycle.d.ts +2 -1
- package/dist/application/process-lifecycle.d.ts.map +1 -1
- package/dist/entrypoints/agent-runtime/browser.js +119 -119
- package/dist/entrypoints/agent-runtime/coding-tools.js +15 -20
- package/dist/entrypoints/agent-runtime/harness.js +55 -63
- package/dist/entrypoints/agent-runtime/openrouter.js +6 -6
- package/dist/entrypoints/agent-runtime/sandbox.js +20 -25
- package/dist/entrypoints/agent-runtime/sqlite/bun.js +11 -13
- package/dist/entrypoints/agent-runtime/sqlite/node.js +11 -13
- package/dist/entrypoints/agent-runtime/testing.js +32 -34
- package/dist/entrypoints/agent-runtime.js +395 -402
- package/dist/entrypoints/application/diagnostic-journal.js +26 -27
- package/dist/entrypoints/application/directory-inbox.js +16 -17
- package/dist/entrypoints/application/grammy.js +13 -13
- package/dist/entrypoints/application/opentelemetry.js +13 -13
- package/dist/entrypoints/application/schemas.js +24 -24
- package/dist/entrypoints/application.d.ts +1 -1
- package/dist/entrypoints/application.d.ts.map +1 -1
- package/dist/entrypoints/application.js +218 -214
- package/dist/entrypoints/bin/upgrade-cli.js +2 -2
- package/dist/entrypoints/cli.js +81 -84
- package/dist/entrypoints/contract.js +54 -57
- package/dist/entrypoints/declaration.js +22 -22
- package/dist/entrypoints/files.js +18 -21
- package/dist/entrypoints/geo.js +7 -8
- package/dist/entrypoints/google.js +2 -2
- package/dist/entrypoints/index.js +150 -154
- package/dist/entrypoints/live.js +45 -47
- package/dist/entrypoints/node.js +68 -74
- package/dist/entrypoints/oauth.js +6 -6
- package/dist/entrypoints/observability.d.ts +1 -0
- package/dist/entrypoints/observability.d.ts.map +1 -1
- package/dist/entrypoints/observability.js +115 -87
- package/dist/entrypoints/primitives.js +35 -35
- package/dist/entrypoints/react/keyboard.d.ts +4 -0
- package/dist/entrypoints/react/keyboard.d.ts.map +1 -0
- package/dist/entrypoints/react/keyboard.js +397 -0
- package/dist/entrypoints/react.js +10 -10
- package/dist/entrypoints/release.js +7 -7
- package/dist/entrypoints/remote.js +15 -17
- package/dist/entrypoints/server.js +198 -200
- package/dist/entrypoints/telegram/html.d.ts +5 -0
- package/dist/entrypoints/telegram/html.d.ts.map +1 -0
- package/dist/entrypoints/telegram/html.js +26 -0
- package/dist/entrypoints/telegram/screens.d.ts +14 -0
- package/dist/entrypoints/telegram/screens.d.ts.map +1 -0
- package/dist/entrypoints/telegram/screens.js +2028 -0
- package/dist/entrypoints/telegram.d.ts +5 -1
- package/dist/entrypoints/telegram.d.ts.map +1 -1
- package/dist/entrypoints/telegram.js +548 -156
- package/dist/entrypoints/testing.js +75 -79
- package/dist/entrypoints/tools/connections.js +30 -32
- package/dist/entrypoints/tools/contract.js +18 -20
- package/dist/entrypoints/tools/invoker.js +12 -17
- package/dist/entrypoints/tools.js +223 -229
- package/dist/entrypoints/tracking/server.js +6 -6
- package/dist/entrypoints/tracking.js +40 -42
- package/dist/entrypoints/voice.js +3 -3
- package/dist/{index-eaysmdtq.js → index-0ndk4555.js} +56 -56
- package/dist/{index-2bxbyab0.js → index-1bx1xv1k.js} +1 -1
- package/dist/{index-p5r7qpdd.js → index-1eeqpadm.js} +4 -4
- package/dist/{index-hadmp193.js → index-3ct7hhq6.js} +210 -210
- package/dist/{index-e01r054k.js → index-45tdvqa3.js} +26 -26
- package/dist/{index-dce5wxcq.js → index-4s0fep5q.js} +8 -8
- package/dist/index-50ejfz75.js +9 -0
- package/dist/{index-m9zn6cpq.js → index-5107q189.js} +12 -12
- package/dist/{index-sbdmyz75.js → index-5cc6r990.js} +3 -3
- package/dist/index-6k1svmjh.js +141 -0
- package/dist/{index-c6bxbsnc.js → index-7kxw1awn.js} +2 -2
- package/dist/{index-0r0fbk7z.js → index-7z9ym7t8.js} +28 -28
- package/dist/{index-p7hgdyhb.js → index-814xpr6d.js} +2 -2
- package/dist/{index-gtj3zc53.js → index-8kzct1d6.js} +99 -99
- package/dist/{index-1zbbzgfh.js → index-9pw0a51a.js} +4 -4
- package/dist/{index-79z7m7dt.js → index-a9pd417t.js} +35 -35
- package/dist/{index-ham5sz8m.js → index-aaj97r4y.js} +45 -45
- package/dist/{index-s812ycat.js → index-aan6hqb9.js} +3 -3
- package/dist/{index-10x2hbx0.js → index-adhgd4pe.js} +44 -44
- package/dist/{index-2gxzjvyr.js → index-afjvjyck.js} +23 -23
- package/dist/{index-qmy6zq75.js → index-ahvsksw5.js} +16 -16
- package/dist/{index-hr4tzbwq.js → index-ap3gqe56.js} +3 -3
- package/dist/{index-znfs20ss.js → index-b2bz0kdm.js} +16 -16
- package/dist/{index-r066jx76.js → index-c6aagrbd.js} +148 -120
- package/dist/{index-k30h66yd.js → index-ck71tbcf.js} +9 -9
- package/dist/{index-eqqvgdb3.js → index-ct75c3at.js} +2 -2
- package/dist/{index-gw8bmyaj.js → index-djjhhgjk.js} +41 -41
- package/dist/{index-8qt2m66q.js → index-dvwj1f43.js} +43 -43
- package/dist/{index-fhwqwxe0.js → index-ej331hah.js} +1 -1
- package/dist/{index-9t3dffv2.js → index-ewbaywk5.js} +5 -5
- package/dist/{index-7p1f0vp2.js → index-fyphpknh.js} +25 -25
- package/dist/{index-83bdg97s.js → index-g149e9pk.js} +1 -1
- package/dist/{index-ngfhvfpd.js → index-g6a3na27.js} +4 -4
- package/dist/{index-qwhynv6x.js → index-h124epcf.js} +20 -20
- package/dist/{index-h6eeys1v.js → index-h97tqqp1.js} +9 -9
- package/dist/{index-zcgf3gqf.js → index-hby4qhax.js} +10 -10
- package/dist/{index-5jrd0yng.js → index-j8aw2c0e.js} +4 -4
- package/dist/{index-d8m8zpd8.js → index-jzczzcsb.js} +14 -14
- package/dist/index-kqnd6a85.js +26 -0
- package/dist/{index-35mh5bhj.js → index-kw1ws0cs.js} +12 -12
- package/dist/{index-yw4fzmdz.js → index-kzz6rq0w.js} +28 -17
- package/dist/{index-4gcnakhx.js → index-m38ej7e5.js} +2 -2
- package/dist/{index-ndddxtyx.js → index-m79yr938.js} +19 -19
- package/dist/{index-5p2m7we1.js → index-m9fndta6.js} +11 -11
- package/dist/index-mnkk1ffz.js +150 -0
- package/dist/index-n15jny5b.js +4 -0
- package/dist/{index-2vtcmrj9.js → index-n53mswt3.js} +20 -20
- package/dist/{index-bt14acb5.js → index-n5yqxd3d.js} +21 -21
- package/dist/{index-bm5rx7n6.js → index-n9tyz8z3.js} +12 -12
- package/dist/index-nmaqxdx7.js +463 -0
- package/dist/{index-mqv3b5rh.js → index-nx6eazt0.js} +30 -30
- package/dist/{index-3z73fh2c.js → index-q0th8q0z.js} +9 -9
- package/dist/{index-v260df89.js → index-qjeggp8w.js} +20 -20
- package/dist/{index-zfrhfn82.js → index-r1z0rd9c.js} +20 -20
- package/dist/{index-wqgjfstn.js → index-r5c4fedv.js} +3 -3
- package/dist/{index-cyktc4bp.js → index-r5hsgkz5.js} +118 -118
- package/dist/{index-0b1ess5e.js → index-r7a7r7ha.js} +1 -1
- package/dist/{index-1j8pm5t0.js → index-rfkh44ss.js} +15 -15
- package/dist/index-rz3z13e9.js +47 -0
- package/dist/{index-k6gpggb9.js → index-s80awwsw.js} +35 -35
- package/dist/{index-vqr7f7v8.js → index-ssmd41kh.js} +124 -124
- package/dist/{index-ensd0r74.js → index-v14qqmys.js} +41 -41
- package/dist/{index-paz6sgdp.js → index-v4a2fjtk.js} +36 -36
- package/dist/{index-d9pk3td4.js → index-w0eqdhyd.js} +14 -14
- package/dist/{index-5qn1kx34.js → index-w575evhq.js} +29 -29
- package/dist/{index-3gye9wzb.js → index-w5s3xftz.js} +26 -26
- package/dist/{index-tm88avnj.js → index-wykebpn9.js} +3 -3
- package/dist/{index-sh3s86na.js → index-xkhk5d6g.js} +17 -17
- package/dist/{index-6k1937bx.js → index-xqhede6x.js} +4 -4
- package/dist/{index-f4fx2tza.js → index-zs4ms3th.js} +181 -181
- package/dist/{index-k5hsfc3j.js → index-zsvzhjtk.js} +1 -1
- package/dist/observability/bounded-logger.d.ts +2 -0
- package/dist/observability/bounded-logger.d.ts.map +1 -1
- package/dist/observability/sanitize.d.ts +6 -0
- package/dist/observability/sanitize.d.ts.map +1 -1
- package/dist/observability/secret-values.d.ts +25 -0
- package/dist/observability/secret-values.d.ts.map +1 -0
- package/dist/react/keyboard/hooks.d.ts +71 -0
- package/dist/react/keyboard/hooks.d.ts.map +1 -0
- package/dist/react/keyboard/layers.d.ts +98 -0
- package/dist/react/keyboard/layers.d.ts.map +1 -0
- package/dist/react/keyboard/zone.d.ts +139 -0
- package/dist/react/keyboard/zone.d.ts.map +1 -0
- package/dist/telegram/crypto.d.ts +16 -0
- package/dist/telegram/crypto.d.ts.map +1 -0
- package/dist/telegram/html/check.d.ts +26 -0
- package/dist/telegram/html/check.d.ts.map +1 -0
- package/dist/telegram/html/nodes.d.ts +70 -0
- package/dist/telegram/html/nodes.d.ts.map +1 -0
- package/dist/telegram/html/parse.d.ts +35 -0
- package/dist/telegram/html/parse.d.ts.map +1 -0
- package/dist/telegram/html/render.d.ts +39 -0
- package/dist/telegram/html/render.d.ts.map +1 -0
- package/dist/telegram/init-data.d.ts.map +1 -1
- package/dist/telegram/operator-channel.d.ts +36 -3
- package/dist/telegram/operator-channel.d.ts.map +1 -1
- package/dist/telegram/screens/callback-codec.d.ts +34 -0
- package/dist/telegram/screens/callback-codec.d.ts.map +1 -0
- package/dist/telegram/screens/dispatch.d.ts +22 -0
- package/dist/telegram/screens/dispatch.d.ts.map +1 -0
- package/dist/telegram/screens/display.d.ts +70 -0
- package/dist/telegram/screens/display.d.ts.map +1 -0
- package/dist/telegram/screens/execute.d.ts +57 -0
- package/dist/telegram/screens/execute.d.ts.map +1 -0
- package/dist/telegram/screens/html.d.ts +36 -0
- package/dist/telegram/screens/html.d.ts.map +1 -0
- package/dist/telegram/screens/outcome.d.ts +74 -0
- package/dist/telegram/screens/outcome.d.ts.map +1 -0
- package/dist/telegram/screens/path.d.ts +29 -0
- package/dist/telegram/screens/path.d.ts.map +1 -0
- package/dist/telegram/screens/reconcile.d.ts +67 -0
- package/dist/telegram/screens/reconcile.d.ts.map +1 -0
- package/dist/telegram/screens/registry.d.ts +23 -0
- package/dist/telegram/screens/registry.d.ts.map +1 -0
- package/dist/telegram/screens/render.d.ts +70 -0
- package/dist/telegram/screens/render.d.ts.map +1 -0
- package/dist/telegram/screens/runtime.d.ts +133 -0
- package/dist/telegram/screens/runtime.d.ts.map +1 -0
- package/dist/telegram/screens/screen-types.d.ts +130 -0
- package/dist/telegram/screens/screen-types.d.ts.map +1 -0
- package/dist/telegram/screens/screen.d.ts +138 -0
- package/dist/telegram/screens/screen.d.ts.map +1 -0
- package/dist/telegram/screens/state.d.ts +115 -0
- package/dist/telegram/screens/state.d.ts.map +1 -0
- package/dist/telegram/screens/telegram-screens.d.ts +18 -0
- package/dist/telegram/screens/telegram-screens.d.ts.map +1 -0
- package/dist/telegram/screens/test-chat-telegram.d.ts +47 -0
- package/dist/telegram/screens/test-chat-telegram.d.ts.map +1 -0
- package/dist/telegram/screens/test-chat.d.ts +66 -0
- package/dist/telegram/screens/test-chat.d.ts.map +1 -0
- package/dist/telegram/screens/view.d.ts +89 -0
- package/dist/telegram/screens/view.d.ts.map +1 -0
- package/dist/telegram/send-failure.d.ts +19 -0
- package/dist/telegram/send-failure.d.ts.map +1 -1
- package/dist/telegram/update-intake.d.ts +78 -0
- package/dist/telegram/update-intake.d.ts.map +1 -0
- package/dist/telegram/update-store-sqlite.d.ts +26 -0
- package/dist/telegram/update-store-sqlite.d.ts.map +1 -0
- package/dist/telegram/update-store.d.ts +67 -0
- package/dist/telegram/update-store.d.ts.map +1 -0
- package/dist/telegram/webhook.d.ts +105 -0
- package/dist/telegram/webhook.d.ts.map +1 -0
- package/dist/tools/cli/args-fields.d.ts +8 -0
- package/dist/tools/cli/args-fields.d.ts.map +1 -1
- package/dist/tools/cli/args-route.d.ts.map +1 -1
- package/dist/tools/cli/args.d.ts.map +1 -1
- package/dist/tools/cli/help.d.ts.map +1 -1
- package/llms/application.2.txt +26 -1
- package/llms/application.txt +18 -27
- package/llms/cli.txt +8 -2
- package/llms/observability.txt +2 -1
- package/llms/react-keyboard.txt +193 -0
- package/llms/stitchkit.txt +7 -1
- package/llms/telegram-html.txt +35 -0
- package/llms/telegram-screens.txt +365 -0
- package/llms/telegram.txt +160 -4
- package/llms-full.txt +755 -11
- package/llms.txt +11 -6
- package/package.json +17 -2
- package/dist/index-2k4yrqkc.js +0 -9
- package/dist/index-crykmqkr.js +0 -26
- package/dist/index-y64k3s0h.js +0 -150
- package/dist/index-y91ry9h2.js +0 -4
- /package/dist/{index-hffjpmq9.js → index-9jfdm5tg.js} +0 -0
- /package/dist/{index-zjjyynm3.js → index-d4pa2bgs.js} +0 -0
- /package/dist/{index-rxfy4cq7.js → index-dwfv9a85.js} +0 -0
- /package/dist/{index-5jm36zv1.js → index-gfk7nrht.js} +0 -0
- /package/dist/{index-b9aefrqd.js → index-n1dqr7gf.js} +0 -0
- /package/dist/{index-q3pyb9n0.js → index-sykb5ag7.js} +0 -0
- /package/dist/{index-48ffdxgk.js → index-xhzq2qvj.js} +0 -0
- /package/dist/{index-kzfs85xp.js → index-zsarb5xp.js} +0 -0
package/llms-full.txt
CHANGED
|
@@ -60,7 +60,9 @@ own, recorded as an ADR.
|
|
|
60
60
|
| `stitchkit/cli` | server | stable | `createCli` — the CLI transport, light (no MCP SDK / `ai`) |
|
|
61
61
|
| `stitchkit/remote` | browser **and** server | stable | peer-free `implementRemote` for thin HTTP proxy processes |
|
|
62
62
|
| `stitchkit/files` | server (Bun or Node) | stable | peer-free managed local-file boundary |
|
|
63
|
-
| `stitchkit/telegram` | server (Bun or Node) | evolving | peer-free Telegram platform primitives — Mini App `initData` verification, send-failure classification, resumable broadcasts, the operator channel
|
|
63
|
+
| `stitchkit/telegram` | server (Bun or Node) | evolving | peer-free Telegram platform primitives — Mini App `initData` verification, send-failure classification, resumable broadcasts, the operator channel with dedupe, local Bot API files, webhook ownership and durable webhook update intake |
|
|
64
|
+
| `stitchkit/telegram/html` | browser and server | evolving | Telegram's HTML as a tree — clean any markup to what Telegram accepts, cut long text into valid parts by what the reader sees, check markup before it is sent |
|
|
65
|
+
| `stitchkit/telegram/screens` | server (Bun or Node) | evolving | a bot's menus as declared screens over grammY — typed paths, actions and input, message reconciliation, state in any `StorageAdapter`, a test chat |
|
|
64
66
|
| `stitchkit/tracking` | browser **and** server | evolving | visitor-tracking mechanics — `createTrackingClient`, the tab-shared outbox, the page-leave beacon, attribution, the contract factory; no event vocabulary, no React |
|
|
65
67
|
| `stitchkit/tracking/server` | server (Bun or Node) | evolving | the decisions a tracking backend makes — dispositions, visit lease over an application-owned store, active intervals, presence; no database |
|
|
66
68
|
| `stitchkit/release` | browser **and** server | evolving | a page follows the release it was built for — `createReleaseMarker` on the server, `createReleaseWatcher` in the browser, the `X-Build-Id` header and a socket event between them |
|
|
@@ -72,6 +74,7 @@ own, recorded as an ADR.
|
|
|
72
74
|
| `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, plus the managed-resource conformance kit |
|
|
73
75
|
| `stitchkit/declaration` | browser + build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
|
|
74
76
|
| `stitchkit/react` | browser + server rendering | stable | `createCursorQuery`, `createCacheBridge`, QueryClient and `ApiError` retry policy |
|
|
77
|
+
| `stitchkit/react/keyboard` | browser | evolving | which part of a screen gets a key — ordered layers, one Escape per level, list zones with real focus; see [keyboard guide](keyboard.md) |
|
|
75
78
|
| `stitchkit/agent-runtime` | server | evolving<br>_redefined in 22 of the 45 minors since 0.56.2, most recently 0.97.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
|
|
76
79
|
| `stitchkit/agent-runtime/sandbox` | server | evolving | optional Linux Bubblewrap sessions, durable workspaces and a host HTTP credential gateway; see [sandbox guide](sandbox.md) |
|
|
77
80
|
| `stitchkit/agent-runtime/testing` | tests on Bun or Node | evolving | credential-free replay, scripted provider faults, deterministic race controls and the agent-store conformance kit |
|
|
@@ -194,6 +197,7 @@ map — feature → packages:
|
|
|
194
197
|
| MCP host/client tests | `@modelcontextprotocol/client` |
|
|
195
198
|
| MCP Apps UI widgets | `@modelcontextprotocol/ext-apps` |
|
|
196
199
|
| React data layer (`stitchkit/react`) | `@tanstack/react-query` `react-query-kit` |
|
|
200
|
+
| Keyboard layers and list zones (`stitchkit/react/keyboard`) | `react` |
|
|
197
201
|
| MaxMind GeoIP (`stitchkit/geo`) | `maxmind` |
|
|
198
202
|
| Browser OAuth client (`stitchkit/oauth`) | — |
|
|
199
203
|
| Google OIDC verifier (`stitchkit/google`) | `google-auth-library` |
|
|
@@ -202,6 +206,8 @@ map — feature → packages:
|
|
|
202
206
|
| Socket.IO client | `socket.io-client` (runtime peer; unrelated root declarations remain peer-free) |
|
|
203
207
|
| grammY lifecycle adapters (`stitchkit/application/grammy`) | `grammy` |
|
|
204
208
|
| Telegram platform primitives (`stitchkit/telegram`) | — (peer-free) |
|
|
209
|
+
| Telegram message markup (`stitchkit/telegram/html`) | — (peer-free) |
|
|
210
|
+
| Telegram screens (`stitchkit/telegram/screens`) | `grammy` (types only; the bot brings it) |
|
|
205
211
|
| OpenTelemetry gauges (`stitchkit/application/opentelemetry`) | `@opentelemetry/api` |
|
|
206
212
|
|
|
207
213
|
```bash
|
|
@@ -7290,7 +7296,8 @@ ever became ready:
|
|
|
7290
7296
|
| none | — | — | `first-boot` | — |
|
|
7291
7297
|
| recorded `stoppedAt` | — | — | `clean` / `forced` / `startup-failed` / `abnormal` as recorded | unchanged |
|
|
7292
7298
|
| still open | yes | — | `hot-reload` | `hot-reload`, closed at the new start |
|
|
7293
|
-
| still open | no | changed | `handoff` | stays open; it records its own stop later |
|
|
7299
|
+
| still open | no | changed | `handoff` (default) | stays open; it records its own stop later |
|
|
7300
|
+
| still open | no | changed, `versionChangeOverlap: 'abnormal'` | `abnormal` / `startup-failed` | closed at the new start, as for the same version |
|
|
7294
7301
|
| still open, was ready | no | same | `abnormal` (default) | `abnormal`, closed at the new start — an upper bound, the crash time is unknown |
|
|
7295
7302
|
| still open, never ready | no | same | `startup-failed` (default) | `startup-failed`, closed at the new start |
|
|
7296
7303
|
|
|
@@ -7332,7 +7339,22 @@ zero-downtime reload of the same build — pass `sameVersionOverlap: 'handoff'`
|
|
|
7332
7339
|
to the ledger, and every open run stays open until it records its own
|
|
7333
7340
|
shutdown. The cost of that setting is symmetric: a real crash under it is
|
|
7334
7341
|
reported as a handoff and the dead run stays open in the ledger until
|
|
7335
|
-
retention drops it.
|
|
7342
|
+
retention drops it.
|
|
7343
|
+
|
|
7344
|
+
A version change is the other axis, and its default is the opposite:
|
|
7345
|
+
`handoff`, because a release that starts the new build before stopping the
|
|
7346
|
+
old one is the common zero-downtime shape. Where builds never overlap — one
|
|
7347
|
+
process per deployment (a pm2 fork, a systemd unit, one container), the old
|
|
7348
|
+
one stopped before the new one starts — pass `versionChangeOverlap:
|
|
7349
|
+
'abnormal'`. An open predecessor of another build then died without
|
|
7350
|
+
recording its exit, most often killed during the release itself: it is closed
|
|
7351
|
+
as `abnormal` (or `startup-failed`) at the new start, `downtimeMs` is `null`
|
|
7352
|
+
instead of `0`, and `previousVersion` / `versionChanged` still describe the
|
|
7353
|
+
release. A predecessor that did record its stop is read as before, and its
|
|
7354
|
+
downtime is measured. The two settings compose: `sameVersionOverlap:
|
|
7355
|
+
'handoff'` keeps open only the runs of a build allowed to overlap the new one.
|
|
7356
|
+
|
|
7357
|
+
The list is kept in the order the transitions wrote it —
|
|
7336
7358
|
every write goes through one atomic update, so that order is the causal one,
|
|
7337
7359
|
and a successor whose clock lags its predecessor still finds it at the head;
|
|
7338
7360
|
`startedAt` is data, not the sort key. Retention (`retain`, default 20) drops
|
|
@@ -8152,12 +8174,18 @@ string, the schema says what it should be:
|
|
|
8152
8174
|
| -------------------- | ---------------------------------------------- |
|
|
8153
8175
|
| `z.string()` | `--name "box"` or a positional |
|
|
8154
8176
|
| `z.number()` | `--count 3` → `3` |
|
|
8155
|
-
| `z.boolean()` | `--active` (presence) / `--no-active`
|
|
8177
|
+
| `z.boolean()` | `--active` (presence) / `--active false` / `--no-active` |
|
|
8156
8178
|
| `z.enum([...])` | `--size large` |
|
|
8157
8179
|
| `z.array(z.string())`| `--tag a --tag b` → `["a","b"]` |
|
|
8158
8180
|
| `z.object({...})` | `--opts '{"k":"v"}'` (JSON) or `--opts.k v` |
|
|
8159
8181
|
| `.optional()` / `.default()` | not required |
|
|
8160
8182
|
|
|
8183
|
+
After a bare boolean flag, the next token is its value when it is exactly
|
|
8184
|
+
`true` or `false` — `--active false` turns it off, as it reads. Any other word
|
|
8185
|
+
there stays a positional, and `--` makes a literal `false` one too. The same
|
|
8186
|
+
holds for a short alias, a framework flag such as `--json false` and an
|
|
8187
|
+
application option.
|
|
8188
|
+
|
|
8161
8189
|
Without presentation configuration, positional arguments fill non-boolean
|
|
8162
8190
|
fields in declaration order, so `myapp generate "a fox"` is
|
|
8163
8191
|
`--prompt "a fox"`. A piped value fills the first required unset field:
|
|
@@ -8216,7 +8244,7 @@ framework globals may precede that explicit command. Top-level `--help`, `-h`
|
|
|
8216
8244
|
and `--version` never execute the default, and top-level help marks it.
|
|
8217
8245
|
|
|
8218
8246
|
Aliases are command-local, one ASCII letter and validated against the resolved
|
|
8219
|
-
command schema. `-f` / `-f=false` are boolean forms; values accept `-n 100` and
|
|
8247
|
+
command schema. `-f` / `-f=false` / `-f false` are boolean forms; values accept `-n 100` and
|
|
8220
8248
|
`-n=100`. Arrays accumulate across short and long forms. `-h` is reserved,
|
|
8221
8249
|
bundles such as `-fn`, attached values such as `-n100`, `--no-f` and unknown
|
|
8222
8250
|
short flags are rejected. Canonical `--no-follow` remains available.
|
|
@@ -12974,6 +13002,165 @@ session query with `cache`; the generic starter does not invent a `getSession`
|
|
|
12974
13002
|
endpoint or cookie vocabulary.
|
|
12975
13003
|
|
|
12976
13004
|
|
|
13005
|
+
==============================================================================
|
|
13006
|
+
# Guide: Keyboard layers and list zones (docs/guide/keyboard.md)
|
|
13007
|
+
==============================================================================
|
|
13008
|
+
|
|
13009
|
+
---
|
|
13010
|
+
title: Keyboard layers and list zones
|
|
13011
|
+
description: One listener decides which part of a screen gets a key; lists move real focus with the arrows
|
|
13012
|
+
type: guide
|
|
13013
|
+
status: active
|
|
13014
|
+
created: 2026-09-29
|
|
13015
|
+
updated: 2026-09-29
|
|
13016
|
+
---
|
|
13017
|
+
|
|
13018
|
+
# Keyboard layers and list zones
|
|
13019
|
+
|
|
13020
|
+
`stitchkit/react/keyboard` answers one question for a React screen: **who gets
|
|
13021
|
+
this key?** It is headless — three hooks and no component, no styles, no
|
|
13022
|
+
router. The application keeps its URL, its sections and its markup; the hooks
|
|
13023
|
+
decide which part of the screen hears a key and move focus through lists.
|
|
13024
|
+
|
|
13025
|
+
```bash
|
|
13026
|
+
bun add react # the only peer
|
|
13027
|
+
```
|
|
13028
|
+
|
|
13029
|
+
## Why layers
|
|
13030
|
+
|
|
13031
|
+
A screen with a menu, a dialog, a list and section shortcuts usually grows a
|
|
13032
|
+
`keydown` listener per concern, and they run in the order they mounted:
|
|
13033
|
+
Escape closes the dialog *and* the page behind it, an arrow in a dialog also
|
|
13034
|
+
switches the section underneath, a hidden panel still reacts. Here every
|
|
13035
|
+
concern is a layer on one stack, and the stack asks them in a fixed order until
|
|
13036
|
+
one takes the key:
|
|
13037
|
+
|
|
13038
|
+
| Kind | What it is | Example |
|
|
13039
|
+
|---|---|---|
|
|
13040
|
+
| `overlay` | something modal; while one is live nothing below hears any key | a dialog |
|
|
13041
|
+
| `local` | a component with the user's attention, not modal | an open command menu |
|
|
13042
|
+
| `route` | the page's "up one level" | Escape closes the open entity |
|
|
13043
|
+
| `zone` | a list the arrows move through (see below) | the chat list |
|
|
13044
|
+
| `global` | shortcuts of the whole screen | ← / → switch sections |
|
|
13045
|
+
|
|
13046
|
+
Within a kind the innermost layer wins: of two layers whose elements nest, the
|
|
13047
|
+
inner one, otherwise the one activated last. One press of Escape closes one
|
|
13048
|
+
level, and holding Escape does not close several.
|
|
13049
|
+
|
|
13050
|
+
The stack listens on the window, in the bubbling phase. Everything closer to the
|
|
13051
|
+
key hears it first — a widget's own `onKeyDown`, a text field, a popover that
|
|
13052
|
+
closes on Escape — and keeps it with `preventDefault()` or `stopPropagation()`.
|
|
13053
|
+
A layer only gets what nobody nearer wanted, so third-party components keep
|
|
13054
|
+
working unchanged.
|
|
13055
|
+
|
|
13056
|
+
## Shortcuts and Escape
|
|
13057
|
+
|
|
13058
|
+
```tsx
|
|
13059
|
+
import { useRef } from 'react'
|
|
13060
|
+
import { useEscapeLayer, useKeyLayer } from 'stitchkit/react/keyboard'
|
|
13061
|
+
|
|
13062
|
+
function Workspace({ next, previous, children }) {
|
|
13063
|
+
useKeyLayer({
|
|
13064
|
+
kind: 'global',
|
|
13065
|
+
onKey: (event) => {
|
|
13066
|
+
if (event.key === 'ArrowRight') next()
|
|
13067
|
+
else if (event.key === 'ArrowLeft') previous()
|
|
13068
|
+
else return false // not taken: the key keeps its default
|
|
13069
|
+
return true
|
|
13070
|
+
},
|
|
13071
|
+
})
|
|
13072
|
+
return children
|
|
13073
|
+
}
|
|
13074
|
+
|
|
13075
|
+
function EntityPanel({ close }) {
|
|
13076
|
+
const panel = useRef<HTMLElement>(null)
|
|
13077
|
+
useEscapeLayer({ kind: 'route', scope: panel, onEscape: close })
|
|
13078
|
+
return <section ref={panel}>…</section>
|
|
13079
|
+
}
|
|
13080
|
+
```
|
|
13081
|
+
|
|
13082
|
+
`onKey` returns `true` when it took the key: its default is prevented and no
|
|
13083
|
+
layer below hears it. `onEscape` returns `false` when there was nothing to close,
|
|
13084
|
+
and the next layer is asked.
|
|
13085
|
+
|
|
13086
|
+
**`scope`** ties a layer to an element. A scoped layer is heard only while the
|
|
13087
|
+
element is mounted, visible and outside any `inert` subtree — a panel kept
|
|
13088
|
+
mounted in the background stops hearing keys without an `active` flag — and it
|
|
13089
|
+
outranks layers of its kind whose elements contain it. React runs a child's
|
|
13090
|
+
effects before its parent's, so when a page and the panel inside it mount
|
|
13091
|
+
together, only `scope` tells the stack which one is inside.
|
|
13092
|
+
|
|
13093
|
+
**`active`** (default `true`) switches a layer off without unmounting it;
|
|
13094
|
+
becoming active again puts it on top of its kind.
|
|
13095
|
+
|
|
13096
|
+
**Fields keep their keys.** Keys pressed in an input, a text area, a select, an
|
|
13097
|
+
editable region, a combobox, a slider or any area marked `data-local-keys` do not
|
|
13098
|
+
reach layers — except Escape, which a field that wants it keeps with
|
|
13099
|
+
`preventDefault()`. `fields: 'include'` lets a layer hear them anyway.
|
|
13100
|
+
`isKeyFieldTarget(event.target)` is the same test, for a handler of your own.
|
|
13101
|
+
|
|
13102
|
+
## List zones
|
|
13103
|
+
|
|
13104
|
+
A zone is a list the arrows move through with **real focus**: one item is the
|
|
13105
|
+
tab stop (roving `tabIndex`), Tab enters the list at it and leaves the list, the
|
|
13106
|
+
arrows move DOM focus between items. Screen readers follow it, `:focus-visible`
|
|
13107
|
+
draws it, the browser scrolls to it.
|
|
13108
|
+
|
|
13109
|
+
```tsx
|
|
13110
|
+
import { useNavZone } from 'stitchkit/react/keyboard'
|
|
13111
|
+
|
|
13112
|
+
function ChatList({ chats, openId, open }) {
|
|
13113
|
+
const zone = useNavZone({
|
|
13114
|
+
items: chats.map((chat) => chat.id),
|
|
13115
|
+
value: openId,
|
|
13116
|
+
onChange: (id) => open(id),
|
|
13117
|
+
label: 'Chats',
|
|
13118
|
+
})
|
|
13119
|
+
return (
|
|
13120
|
+
<ul {...zone.zoneProps}>
|
|
13121
|
+
{chats.map((chat) => (
|
|
13122
|
+
<li key={chat.id} {...zone.itemProps(chat.id)}>
|
|
13123
|
+
{chat.title}
|
|
13124
|
+
</li>
|
|
13125
|
+
))}
|
|
13126
|
+
</ul>
|
|
13127
|
+
)
|
|
13128
|
+
}
|
|
13129
|
+
```
|
|
13130
|
+
|
|
13131
|
+
| Option | Default | Meaning |
|
|
13132
|
+
|---|---|---|
|
|
13133
|
+
| `items`, `value`, `onChange` | — | the ids in order, the chosen one, and the report of a choice; the zone never holds the value |
|
|
13134
|
+
| `mode` | `'select'` | `'select'`: an arrow chooses (`onChange(id, 'arrow')`). `'highlight'`: an arrow only moves focus, Enter chooses (`onChange(id, 'confirm')`) |
|
|
13135
|
+
| `orientation` | `'vertical'` | which arrows move; the other axis enters and exits |
|
|
13136
|
+
| `role` | `'listbox'` | `'listbox'` of options or `'tablist'` of tabs, marked `aria-selected`; `'none'` keeps the items' own semantics (links, buttons) and marks the chosen one `aria-current` |
|
|
13137
|
+
| `loop` | `false` | wrap past the ends instead of calling `onBoundary` |
|
|
13138
|
+
| `onEnter(id)` | — | → in a vertical zone (↓ in a horizontal one), and Enter in select mode: go into what the item opens |
|
|
13139
|
+
| `onExit()` | — | ← in a vertical zone (↑ in a horizontal one) |
|
|
13140
|
+
| `onBoundary(side)` | — | a move past `'start'` or `'end'`: hand focus to the neighbouring zone |
|
|
13141
|
+
| `active` | `true` | whether arrows pressed outside every zone land here |
|
|
13142
|
+
| `label` | — | the zone's accessible name |
|
|
13143
|
+
| `onReveal(id)` | — | focus went to an item with no element yet — a row of a virtualized list outside the rendered range; scroll it in, and focus lands on it when it mounts |
|
|
13144
|
+
|
|
13145
|
+
The callbacks return `false` to leave the key to the page, anything else takes
|
|
13146
|
+
it. A key the zone does not take bubbles on to the layers — in a vertical list
|
|
13147
|
+
← and → are not the list's keys, so the section switch above hears them.
|
|
13148
|
+
|
|
13149
|
+
Each zone keeps its own focused item; two zones never share one. When focus is
|
|
13150
|
+
in no zone at all — right after the page loads — the first arrow on the zone's
|
|
13151
|
+
axis puts focus on the active zone's current item. To move between zones
|
|
13152
|
+
(sidebar → table), call `zone.focus(id?)` of the other zone from `onEnter` or
|
|
13153
|
+
`onBoundary`. `zone.element` is the zone's element, for a layer scoped to it.
|
|
13154
|
+
|
|
13155
|
+
## What stays in the application
|
|
13156
|
+
|
|
13157
|
+
The URL, the router, sections and which screen is open. A layer calls the
|
|
13158
|
+
application's own functions — `router.push`, `close()` — and nothing here reads
|
|
13159
|
+
or writes a location. Keep entity coordinates in the path and filters in the
|
|
13160
|
+
query, parse and build them in one pure module with tests, and use explicit
|
|
13161
|
+
`push` / `replace` rather than a positional flag.
|
|
13162
|
+
|
|
13163
|
+
|
|
12977
13164
|
==============================================================================
|
|
12978
13165
|
# Guide: Voice (docs/guide/voice.md)
|
|
12979
13166
|
==============================================================================
|
|
@@ -13072,6 +13259,8 @@ way: [ADR 0201](../decisions/0201-a-telegram-bot-is-assembled-from-primitives.md
|
|
|
13072
13259
|
| Job | Where |
|
|
13073
13260
|
|---|---|
|
|
13074
13261
|
| the bot as resources: command menu, then long polling with updates admitted by batch | `grammyBotResources` — [application kernel](application-kernel.md#optional-grammy-adapter) |
|
|
13262
|
+
| a bot on a webhook: set only where it is this process's, updates recorded before Telegram is answered | `claimTelegramWebhook`, `createTelegramUpdateIntake`, `receiveTelegramWebhook` — [below](#a-bot-on-a-webhook) |
|
|
13263
|
+
| text for Telegram's HTML: cleaned, cut to the limit, checked | `stitchkit/telegram/html` — [below](#message-markup) |
|
|
13075
13264
|
| the process journal | `createJsonLogger` from `stitchkit/observability` |
|
|
13076
13265
|
| the operator's chat: product events, apart from the journal | `createTelegramOperatorChannel` |
|
|
13077
13266
|
| a broadcast that survives crashes, deploys and Ctrl-C | `runTelegramBroadcast` |
|
|
@@ -13079,6 +13268,7 @@ way: [ADR 0201](../decisions/0201-a-telegram-bot-is-assembled-from-primitives.md
|
|
|
13079
13268
|
| why a send was refused | `classifyTelegramSendFailure` — [auth and errors](auth-and-errors.md#telegram-mini-apps) |
|
|
13080
13269
|
| Mini App `initData` | `verifyTelegramInitData` — [auth and errors](auth-and-errors.md#telegram-mini-apps) |
|
|
13081
13270
|
| one Bot API call without a bot library | `callTelegramBotApi` |
|
|
13271
|
+
| menus: screens, buttons, actions, input and message editing | `stitchkit/telegram/screens` — [Telegram screens](telegram-screens.md) |
|
|
13082
13272
|
|
|
13083
13273
|
## The journal
|
|
13084
13274
|
|
|
@@ -13102,6 +13292,24 @@ export const logger = createJsonLogger({
|
|
|
13102
13292
|
logger.error('Application startup failed', { error })
|
|
13103
13293
|
```
|
|
13104
13294
|
|
|
13295
|
+
A provider's key, a webhook secret or a database password has no shape a
|
|
13296
|
+
pattern could guess, so the journal also masks exact values — and the
|
|
13297
|
+
environment already names them. `secretValuesFromEnv` collects every variable
|
|
13298
|
+
named as a token, secret, key, password or credential, the password and secret
|
|
13299
|
+
query values inside any URL variable (`DATABASE_URL`), and each URL-encoded:
|
|
13300
|
+
|
|
13301
|
+
```ts
|
|
13302
|
+
import { createJsonLogger, secretValuesFromEnv } from 'stitchkit/observability'
|
|
13303
|
+
|
|
13304
|
+
export const logger = createJsonLogger({
|
|
13305
|
+
sensitiveUrlPatterns: [TELEGRAM_BOT_TOKEN_PATTERN],
|
|
13306
|
+
sensitiveValues: secretValuesFromEnv(process.env),
|
|
13307
|
+
})
|
|
13308
|
+
```
|
|
13309
|
+
|
|
13310
|
+
A key added to the environment is masked from its first line; a URL's user name
|
|
13311
|
+
is left alone, since it names a role that appears in ordinary text.
|
|
13312
|
+
|
|
13105
13313
|
The error line contract, for whatever recognises a failure from the journal: a
|
|
13106
13314
|
line whose `level` is `50` or more is an error. There is no separate `fatal`;
|
|
13107
13315
|
an unrecoverable end is an `error` line followed by a non-zero exit.
|
|
@@ -13130,8 +13338,32 @@ operators.post(`New user ${user.id}`, 'users')
|
|
|
13130
13338
|
|
|
13131
13339
|
Every message is masked before it leaves: a bot token always — bots post their
|
|
13132
13340
|
errors here, and an error about a local Bot API file carries the token in its
|
|
13133
|
-
path — plus any `sensitivePatterns` the bot adds;
|
|
13134
|
-
masks its own exact token
|
|
13341
|
+
path — plus any `sensitivePatterns` and exact `sensitiveValues` the bot adds;
|
|
13342
|
+
`telegramOperatorSender` also masks its own exact token, and with `parseMode:
|
|
13343
|
+
'HTML'` repairs the markup — a text cut at Telegram's limit never leaves a tag
|
|
13344
|
+
open.
|
|
13345
|
+
|
|
13346
|
+
A failure that repeats a hundred times, or a storm of a hundred different ones
|
|
13347
|
+
when a database goes down, is a line with a count for the person reading, not a
|
|
13348
|
+
hundred messages. `dedupe` sends one message per fingerprint per window — the
|
|
13349
|
+
topic and the text with numbers, UUIDs and long identifiers blanked — and the
|
|
13350
|
+
next one of that kind carries how many were held back; past `maxPerWindow`
|
|
13351
|
+
messages in a window the rest are counted onto the next message sent:
|
|
13352
|
+
|
|
13353
|
+
```ts
|
|
13354
|
+
const operators = createTelegramOperatorChannel<'errors'>({
|
|
13355
|
+
chatId: env.OPERATOR_CHAT_ID,
|
|
13356
|
+
send: telegramOperatorSender({ token: env.BOT_TOKEN }),
|
|
13357
|
+
sensitiveValues: secretValuesFromEnv(process.env),
|
|
13358
|
+
dedupe: {
|
|
13359
|
+
windowMs: 10 * 60_000,
|
|
13360
|
+
maxPerWindow: 20,
|
|
13361
|
+
repeatedLine: (count) => `Same again: ${count}`,
|
|
13362
|
+
},
|
|
13363
|
+
})
|
|
13364
|
+
```
|
|
13365
|
+
|
|
13366
|
+
Held-back messages reach `onDropped` as `repeated` or `over-budget`. What to post stays the bot's. On the way down, drain it within the grace period
|
|
13135
13367
|
and close it: `drain: (context) => operators.drain(context.signal)`,
|
|
13136
13368
|
`close: () => operators.close()` in a resource.
|
|
13137
13369
|
|
|
@@ -13175,6 +13407,102 @@ application (the application's signal, with admission held by the caller).
|
|
|
13175
13407
|
The recipient state contains Telegram ids: keep it in the state directory, out
|
|
13176
13408
|
of git.
|
|
13177
13409
|
|
|
13410
|
+
## Message markup
|
|
13411
|
+
|
|
13412
|
+
`stitchkit/telegram/html` is Telegram's HTML parse mode as a tree, with no peer
|
|
13413
|
+
and no DOM, so a server sending a message and a browser previewing it share one
|
|
13414
|
+
parser. Text written by someone else — a model, an editor, an owner's greeting —
|
|
13415
|
+
becomes markup Telegram accepts: synonyms renamed (`strong` → `b`), block tags
|
|
13416
|
+
turned into line breaks, a link kept only with an `href` Telegram opens,
|
|
13417
|
+
anything Telegram forbids (a tag it does not know, formatting inside `code`, a
|
|
13418
|
+
quote inside a quote) left as its words.
|
|
13419
|
+
|
|
13420
|
+
```ts
|
|
13421
|
+
import { sanitizeTelegramHtml, splitTelegramHtml } from 'stitchkit/telegram/html'
|
|
13422
|
+
|
|
13423
|
+
for (const part of splitTelegramHtml(summary)) {
|
|
13424
|
+
await ctx.reply(part, { parse_mode: 'HTML' })
|
|
13425
|
+
}
|
|
13426
|
+
```
|
|
13427
|
+
|
|
13428
|
+
Telegram's limits — 4096 for a message, 1024 for a caption (`{ limit:
|
|
13429
|
+
TELEGRAM_CAPTION_LIMIT }`) — count the text after the markup is parsed, so a
|
|
13430
|
+
text is cut by what the reader sees, not by the length of its tags. A cut
|
|
13431
|
+
prefers a paragraph, then a line, then a space; it never splits a character or
|
|
13432
|
+
a custom emoji, and an element open across it is closed in one part and opened
|
|
13433
|
+
again, attributes and all, in the next. `truncateTelegramHtml` keeps one part
|
|
13434
|
+
with an ellipsis; `telegramHtmlText` is what the reader sees; `parseTelegramHtml`
|
|
13435
|
+
gives the nodes to render a preview or an email from. `checkTelegramHtml` says,
|
|
13436
|
+
without repairing anything, what Telegram would refuse — for an editor that
|
|
13437
|
+
should warn a person rather than change their text.
|
|
13438
|
+
|
|
13439
|
+
## A bot on a webhook
|
|
13440
|
+
|
|
13441
|
+
A webhook is one place in all of Telegram, and `setWebhook` takes it without
|
|
13442
|
+
asking whose it was: a staging host or a developer's machine started with the
|
|
13443
|
+
production token takes the production bot with one call. `claimTelegramWebhook`
|
|
13444
|
+
sets it only where it already points here — address *and* secret — or where the
|
|
13445
|
+
bot names the current owner's host as the one it takes over from (`'none'` when
|
|
13446
|
+
there is no webhook: a bot on long polling is somebody's bot too). Telegram never
|
|
13447
|
+
returns the secret, so the address carries an `owner` tag derived from it; a
|
|
13448
|
+
copy of the production address with another secret is refused as
|
|
13449
|
+
`other-secret` instead of quietly installing its own.
|
|
13450
|
+
|
|
13451
|
+
```ts
|
|
13452
|
+
import {
|
|
13453
|
+
checkTelegramWebhook,
|
|
13454
|
+
claimTelegramWebhook,
|
|
13455
|
+
createTelegramUpdateIntake,
|
|
13456
|
+
receiveTelegramWebhook,
|
|
13457
|
+
sqliteTelegramUpdateStore,
|
|
13458
|
+
} from 'stitchkit/telegram'
|
|
13459
|
+
import { Database } from 'bun:sqlite'
|
|
13460
|
+
import type { Update } from 'grammy/types'
|
|
13461
|
+
|
|
13462
|
+
const webhook = { token: env.BOT_TOKEN, url: env.WEBHOOK_URL, secret: env.WEBHOOK_SECRET }
|
|
13463
|
+
|
|
13464
|
+
const intake = createTelegramUpdateIntake<Update>({
|
|
13465
|
+
store: sqliteTelegramUpdateStore({ database: new Database(env.UPDATES_DB) }),
|
|
13466
|
+
handle: (update) => bot.handleUpdate(update),
|
|
13467
|
+
onFailure: (failure) => logger.warn('Update failed', { ...failure }),
|
|
13468
|
+
})
|
|
13469
|
+
|
|
13470
|
+
// start: set the webhook, then handle what was recorded before the restart
|
|
13471
|
+
await bot.init()
|
|
13472
|
+
await claimTelegramWebhook({ ...webhook, takeoverFrom: env.WEBHOOK_TAKEOVER_FROM })
|
|
13473
|
+
await intake.start()
|
|
13474
|
+
|
|
13475
|
+
// the route
|
|
13476
|
+
const route = (request: Request) =>
|
|
13477
|
+
receiveTelegramWebhook(request, { secret: webhook.secret, accept: intake.accept })
|
|
13478
|
+
|
|
13479
|
+
// stop admission: `await intake.close()` waits for the handlers in flight
|
|
13480
|
+
```
|
|
13481
|
+
|
|
13482
|
+
A refusal names the owner's host, never its path — a foreign webhook's path may
|
|
13483
|
+
itself be a secret — and says what to pass: set the takeover for one start and
|
|
13484
|
+
remove it. `checkTelegramWebhook` on a timer notices a webhook that moved
|
|
13485
|
+
elsewhere, which this process cannot see any other way.
|
|
13486
|
+
|
|
13487
|
+
**The update is recorded before Telegram is answered.** A handler that runs
|
|
13488
|
+
longer than Telegram waits — a large file from a local Bot API server — gets the
|
|
13489
|
+
same update again while it still works, and a process that answers and then
|
|
13490
|
+
dies loses the update for good. `receiveTelegramWebhook` answers 200 once the
|
|
13491
|
+
intake recorded the body; the handler runs after, outside the request. Updates
|
|
13492
|
+
of one chat run in order, other chats alongside (`maxConcurrent`, 32). An
|
|
13493
|
+
attempt holds a lease it renews while the handler lives; a sweep takes over what
|
|
13494
|
+
was left — `pending` whose process never got to it (`pendingGraceMs`), `failed`
|
|
13495
|
+
whose retry is due, `processing` whose process died. A failure is retried with
|
|
13496
|
+
backoff or after Telegram's `retry_after`; one Telegram said repeating cannot
|
|
13497
|
+
fix (a refused message, a blocked user) is abandoned; `maxAttempts` (5) ends
|
|
13498
|
+
the rest. Delivery is at least once: a handler that finished but could not be
|
|
13499
|
+
recorded as finished runs again.
|
|
13500
|
+
|
|
13501
|
+
The store is an interface of six atomic steps (`TelegramUpdateStore`), so the
|
|
13502
|
+
bot's own database can hold it; `sqliteTelegramUpdateStore` takes a
|
|
13503
|
+
`bun:sqlite` or `node:sqlite` handle as it is, and `memoryTelegramUpdateStore`
|
|
13504
|
+
is for tests.
|
|
13505
|
+
|
|
13178
13506
|
## Files from a local Bot API server
|
|
13179
13507
|
|
|
13180
13508
|
A local `telegram-bot-api` keeps downloaded files under `<dir>/<bot token>/`,
|
|
@@ -13205,6 +13533,327 @@ throws on `{ ready: false }` in `start` keeps the bot from answering before it
|
|
|
13205
13533
|
can read its media.
|
|
13206
13534
|
|
|
13207
13535
|
|
|
13536
|
+
==============================================================================
|
|
13537
|
+
# Guide: Telegram screens (docs/guide/telegram-screens.md)
|
|
13538
|
+
==============================================================================
|
|
13539
|
+
|
|
13540
|
+
# Telegram screens
|
|
13541
|
+
|
|
13542
|
+
`stitchkit/telegram/screens` turns a bot's menus into declared screens. A screen
|
|
13543
|
+
says what the chat should show — a message or several, with their buttons — and
|
|
13544
|
+
the runtime decides what to send, what to edit in place and what to delete. It
|
|
13545
|
+
is grammY middleware: it answers the presses and messages that belong to a
|
|
13546
|
+
screen and hands every other update to the next handler. Why it is shaped this
|
|
13547
|
+
way: [ADR 0211](../decisions/0211-telegram-screens-are-declared-views-over-grammy.md).
|
|
13548
|
+
|
|
13549
|
+
```bash
|
|
13550
|
+
bun add grammy zod
|
|
13551
|
+
```
|
|
13552
|
+
|
|
13553
|
+
grammY is the bot's own; screens use its `ctx.api` and name its types, and
|
|
13554
|
+
nothing imports grammY at run time.
|
|
13555
|
+
|
|
13556
|
+
## A menu, end to end
|
|
13557
|
+
|
|
13558
|
+
A bot that lets its owner manage projects: a list, a project's page, a
|
|
13559
|
+
two-message greeting screen and a screen that waits for the new greeting.
|
|
13560
|
+
|
|
13561
|
+
```ts
|
|
13562
|
+
import { back, html, link, telegramScreens } from 'stitchkit/telegram/screens'
|
|
13563
|
+
import { z } from 'zod'
|
|
13564
|
+
|
|
13565
|
+
export const tg = telegramScreens<BotContext>()
|
|
13566
|
+
|
|
13567
|
+
export const home = tg.screen('/')
|
|
13568
|
+
.load((c) => listProjects(c.ctx.from?.id))
|
|
13569
|
+
.view(({ data: projects }) => ({
|
|
13570
|
+
text: html`<b>Your projects</b>`,
|
|
13571
|
+
keyboard: [
|
|
13572
|
+
...projects.map((project) => [link(project.name, projectPage, { projectId: project.id })]),
|
|
13573
|
+
[link('➕ New project', newProject)],
|
|
13574
|
+
],
|
|
13575
|
+
}))
|
|
13576
|
+
|
|
13577
|
+
// A group shares a path prefix, a params schema and a load. The load runs
|
|
13578
|
+
// before every screen under it — its load, its actions, its input — and is
|
|
13579
|
+
// where access is checked: params arrive from a button, so they are input.
|
|
13580
|
+
const owned = tg.group('/project/:projectId', { params: z.object({ projectId: z.uuid() }) })
|
|
13581
|
+
.load(async (c) => {
|
|
13582
|
+
const project = await findProject(c.params.projectId)
|
|
13583
|
+
return project?.ownerId === c.ctx.from?.id ? project : c.go(home).toast('Project not found')
|
|
13584
|
+
})
|
|
13585
|
+
|
|
13586
|
+
export const projectPage = owned.screen('/')
|
|
13587
|
+
.action('archive', async (c) => {
|
|
13588
|
+
await archiveProject(c.data.id)
|
|
13589
|
+
return c.toast('Archived').go(home)
|
|
13590
|
+
})
|
|
13591
|
+
.view(({ data: project, params, act }) => ({
|
|
13592
|
+
text: html`📁 <b>${project.name}</b>\n\nStatus: ${project.status}`,
|
|
13593
|
+
keyboard: [
|
|
13594
|
+
[link('👋 Greeting', greeting, params)],
|
|
13595
|
+
project.status === 'active' && [act.archive('🗄 Archive')],
|
|
13596
|
+
[back('« Back')],
|
|
13597
|
+
],
|
|
13598
|
+
}))
|
|
13599
|
+
|
|
13600
|
+
export const greeting = owned.screen('/greeting')
|
|
13601
|
+
.action('clear', (c) => clearGreeting(c.data.id)) // nothing returned: stay, redrawn
|
|
13602
|
+
.action('rule', z.object({ every: z.boolean() }), (c) => setRule(c.data.id, c.input.every))
|
|
13603
|
+
.view(({ data: project, params, act }) => project.greeting
|
|
13604
|
+
? [
|
|
13605
|
+
{ key: 'header', text: html`<b>New members receive ↓</b>` },
|
|
13606
|
+
{
|
|
13607
|
+
text: project.greeting, // a plain string is escaped
|
|
13608
|
+
keyboard: [
|
|
13609
|
+
[link('✏️ Edit', greetingEdit, params), act.clear('🗑 Clear')],
|
|
13610
|
+
[act.rule(project.every ? 'Every message' : 'First message only', { every: !project.every })],
|
|
13611
|
+
[back('« Back')],
|
|
13612
|
+
],
|
|
13613
|
+
},
|
|
13614
|
+
]
|
|
13615
|
+
: { text: html`🗑 <b>No greeting.</b>`, keyboard: [[back('« Back')]] })
|
|
13616
|
+
|
|
13617
|
+
export const greetingEdit = owned.screen('/greeting/edit')
|
|
13618
|
+
.on('text', async (c) => {
|
|
13619
|
+
if (c.text.length < 3) return c.notice(html`❌ Too short`, { expiresInMs: 5_000 })
|
|
13620
|
+
await c.pending('⏳')
|
|
13621
|
+
await saveGreeting(c.data.id, c.text)
|
|
13622
|
+
return c.notice(html`<blockquote>✅ Greeting saved</blockquote>`).back()
|
|
13623
|
+
})
|
|
13624
|
+
.view(() => ({ text: html`📝 Send the new greeting ↓`, keyboard: [[back('« Back')]] }))
|
|
13625
|
+
```
|
|
13626
|
+
|
|
13627
|
+
```ts
|
|
13628
|
+
// bot.ts
|
|
13629
|
+
const screens = tg.create({
|
|
13630
|
+
screens: [home, projectPage, greeting, greetingEdit],
|
|
13631
|
+
storage: myStorageAdapter, // any grammY StorageAdapter
|
|
13632
|
+
})
|
|
13633
|
+
bot.use(screens)
|
|
13634
|
+
bot.command('start', (ctx) => screens.open(ctx, home))
|
|
13635
|
+
bot.catch((error) => logger.error('update failed', { error }))
|
|
13636
|
+
```
|
|
13637
|
+
|
|
13638
|
+
## The model
|
|
13639
|
+
|
|
13640
|
+
- **A screen** is a path, an optional `load`, any number of `action` and `on`,
|
|
13641
|
+
and a `view`. `load` comes first or not at all — actions and input read what
|
|
13642
|
+
it returned — and `view` ends the declaration.
|
|
13643
|
+
- **Params** come from the path literal: `/project/:projectId` gives
|
|
13644
|
+
`params.projectId`. A group's or screen's `params` schema parses them (a
|
|
13645
|
+
`z.coerce.number()` makes a number). The schema must accept what it produces,
|
|
13646
|
+
because a button carries the parsed value and the schema parses it again when
|
|
13647
|
+
it is pressed: `z.coerce.number()` does, `z.string().transform(Number)` does
|
|
13648
|
+
not and is a compile error. A `link` to a screen whose path has params must
|
|
13649
|
+
carry them; a link to a screen without params takes none — both are compile
|
|
13650
|
+
errors.
|
|
13651
|
+
- **The parent** of a screen is the nearest declared screen whose path is a
|
|
13652
|
+
prefix of its own, by segments. `back()` and the `back('« Back')` button go
|
|
13653
|
+
there. There is no history stack: navigation is a tree of paths, so it
|
|
13654
|
+
survives a restart and never grows.
|
|
13655
|
+
- **`load`** returns data, or an outcome instead of data — `c.go(home).toast('…')`
|
|
13656
|
+
shows another screen. A chain of such redirects stops after four steps.
|
|
13657
|
+
- **`view`** is a pure function of `ctx`, `params`, `data` and `act`. It returns
|
|
13658
|
+
one message or several. A message has exactly one content — `text`, `rich`
|
|
13659
|
+
(a Bot API rich message), or a media file id or URL (`photo`, `video`,
|
|
13660
|
+
`animation`, `document`, `audio`) with an optional `caption` — plus
|
|
13661
|
+
`keyboard`, `key` and, for text, `linkPreview`. Rows and buttons may be
|
|
13662
|
+
`false`, `null` or `undefined`, so a conditional button is `cond && [...]`.
|
|
13663
|
+
- **Text** is a string, which is always escaped, or a `TelegramHtml` from the
|
|
13664
|
+
`html` tag, whose interpolations are escaped. `html.raw(markup)` marks markup
|
|
13665
|
+
that is already safe and `html.join(parts, separator)` joins pieces. Every
|
|
13666
|
+
message is sent with `parse_mode: 'HTML'` set explicitly, so an application's
|
|
13667
|
+
own `parse_mode` transformer changes nothing.
|
|
13668
|
+
|
|
13669
|
+
## Outcomes
|
|
13670
|
+
|
|
13671
|
+
A load, an action and an input handler answer with an outcome, built by
|
|
13672
|
+
chaining; returning nothing is `stay()`.
|
|
13673
|
+
|
|
13674
|
+
| Builder | Does |
|
|
13675
|
+
|---|---|
|
|
13676
|
+
| `c.stay()` | show this screen again, with fresh data |
|
|
13677
|
+
| `c.go(screen, params?)` | show another screen |
|
|
13678
|
+
| `c.back()` | show the parent screen |
|
|
13679
|
+
| `c.toast(text, { alert? })` | answer the press — up to 200 characters; the last toast wins |
|
|
13680
|
+
| `c.notice(content)` | a receipt that stays: posted, and the screen moves below it |
|
|
13681
|
+
| `c.notice(content, { expiresInMs })` | a remark under the screen that removes itself — `expiresInMs` up to 2³¹ − 1, a timer's limit |
|
|
13682
|
+
|
|
13683
|
+
A remark is removed when its time comes, on the chat's next update after that,
|
|
13684
|
+
before anything new is sent into the chat and when the chat leaves its screen —
|
|
13685
|
+
so a screen is never split by a remark. A chat keeps at most 100 remarks; the
|
|
13686
|
+
oldest is removed to make room for the next. After a message there is no press
|
|
13687
|
+
to answer, so an input handler has no `toast`.
|
|
13688
|
+
|
|
13689
|
+
## What the chat shows: reconciliation
|
|
13690
|
+
|
|
13691
|
+
Each message of a view has a key: `key` when given, otherwise `'main'` for the
|
|
13692
|
+
last message and `s0`, `s1`, … for the ones before it. Between two views,
|
|
13693
|
+
messages with the same key are the same message, and only what changed is
|
|
13694
|
+
edited:
|
|
13695
|
+
|
|
13696
|
+
| From → to | Call |
|
|
13697
|
+
|---|---|
|
|
13698
|
+
| nothing changed | none |
|
|
13699
|
+
| only the keyboard | `editMessageReplyMarkup` |
|
|
13700
|
+
| text → text, rich → rich | `editMessageText` |
|
|
13701
|
+
| text or rich → media, media → media | `editMessageMedia` |
|
|
13702
|
+
| a caption | `editMessageCaption` |
|
|
13703
|
+
| media → text or rich, text ↔ rich | delete and send |
|
|
13704
|
+
|
|
13705
|
+
Order holds: once a message has to be sent, every message after it is sent
|
|
13706
|
+
too, and a message is deleted only after its replacement is in the chat. So
|
|
13707
|
+
"header + body" → "body" edits the body in place and deletes the header, and
|
|
13708
|
+
the reverse sends both and deletes the old body.
|
|
13709
|
+
|
|
13710
|
+
When Telegram refuses a step:
|
|
13711
|
+
|
|
13712
|
+
- **"message is not modified"** is success.
|
|
13713
|
+
- **A message gone or not editable** — deleted by the user, too old — is sent
|
|
13714
|
+
again with every message after it.
|
|
13715
|
+
- **A deletion refused** (Telegram deletes only messages younger than 48 hours)
|
|
13716
|
+
strips the message's buttons instead and reports it as `message-kept`. A
|
|
13717
|
+
message the user already deleted counts as deleted.
|
|
13718
|
+
- **Anything else** — a rate limit, a network failure, HTML Telegram cannot
|
|
13719
|
+
parse — stops the update, and the transition does not happen: the record stays
|
|
13720
|
+
on the screen the chat was on, so its input still answers, and lists every
|
|
13721
|
+
message the chat now shows, in the chat's order, with the buttons of both
|
|
13722
|
+
renders. The next update repairs the rest. A receipt or a remark Telegram
|
|
13723
|
+
refuses stops the update the same way, with the view already drawn recorded.
|
|
13724
|
+
|
|
13725
|
+
A view is checked before anything is sent: at most 100 messages, keys of 1 to
|
|
13726
|
+
64 characters, at most 100 buttons whose address is kept in the record. A view
|
|
13727
|
+
past these fails in the handler's place, with the chat untouched.
|
|
13728
|
+
|
|
13729
|
+
## Buttons and presses
|
|
13730
|
+
|
|
13731
|
+
A screen button's `callback_data` is
|
|
13732
|
+
`‹prefix›‹screen id›[.‹action›](:‹param›)*(:‹key›=‹value›)*`. Values are typed,
|
|
13733
|
+
a UUID takes 24 bytes, and data that would pass Telegram's 64 bytes is kept in
|
|
13734
|
+
the chat's record behind a token derived from it — the same button renders the
|
|
13735
|
+
same token, so an unchanged keyboard is never edited. A screen's id is a
|
|
13736
|
+
short hash of its path, or its explicit `id` — give one to a screen whose path
|
|
13737
|
+
may change, so buttons already in chats keep working.
|
|
13738
|
+
|
|
13739
|
+
A press is answered exactly once, after the outcome is known and before the
|
|
13740
|
+
chat is edited. It is trusted only as far as Telegram vouches for it: the
|
|
13741
|
+
`callback_data` must be on the keyboard of the message it came with, otherwise
|
|
13742
|
+
the press was forged (a client can send any bytes) and goes to `onStale`.
|
|
13743
|
+
|
|
13744
|
+
A press on a message the record does not know — an older menu higher up — is
|
|
13745
|
+
answered in place: a one-message view takes that message over, a longer view is
|
|
13746
|
+
sent below it and the pressed message is deleted. Either way the previously
|
|
13747
|
+
recorded view is removed: one live menu per chat.
|
|
13748
|
+
|
|
13749
|
+
For a message the screens do not own — a notification from the backend —
|
|
13750
|
+
`screens.button(link('🔑 Refresh token', refreshToken, { botId }))` makes a
|
|
13751
|
+
button that opens the screen below and leaves the notification as it is.
|
|
13752
|
+
|
|
13753
|
+
## Input
|
|
13754
|
+
|
|
13755
|
+
`.on(kinds, handler, { keepMessage? })` takes a message while the screen is
|
|
13756
|
+
shown. Kinds: `text`, `rich`, `photo`, `video`, `animation`, `document`,
|
|
13757
|
+
`audio`, `voice`, `video_note`, `sticker`, `location`, `contact`. A screen may
|
|
13758
|
+
have several `.on`; a message of a kind no `.on` takes, and every command
|
|
13759
|
+
(`/start`), go to the next handler.
|
|
13760
|
+
|
|
13761
|
+
The user's message is deleted **before** any load or the handler runs — input
|
|
13762
|
+
can be a secret, such as a bot token, and a load may redirect or fail — unless
|
|
13763
|
+
`keepMessage: true`; then the view is sent below the message. A message for a
|
|
13764
|
+
screen whose record cannot be read is not passed on to the next handler: the
|
|
13765
|
+
update fails, and `onError` decides. `c.pending(text)` shows a "working on it" message that is
|
|
13766
|
+
removed when the handler ends, however it ends. `c.message` is the message as
|
|
13767
|
+
Telegram sent it; it is never stored.
|
|
13768
|
+
|
|
13769
|
+
## Opening a screen
|
|
13770
|
+
|
|
13771
|
+
`screens.open(ctx, screen, params?, { previous })` shows a screen as a new
|
|
13772
|
+
message at the bottom of the chat, from a command, a deep link or a handler.
|
|
13773
|
+
The previous view stays (`'keep'`, the default) or is deleted (`'delete'`).
|
|
13774
|
+
Called from inside a handler of the same chat, it runs at once rather than
|
|
13775
|
+
waiting behind the update that called it, and the screen it opened stands: the
|
|
13776
|
+
handler may still answer the press with a toast, and returning a navigation or
|
|
13777
|
+
a notice as well is an error — drawn after it, the handler's own outcome would
|
|
13778
|
+
put the record back on the screen the chat left.
|
|
13779
|
+
|
|
13780
|
+
## State and concurrency
|
|
13781
|
+
|
|
13782
|
+
One record per chat, in any grammY `StorageAdapter<ScreenChatState>`, under
|
|
13783
|
+
`screens:<chat id>` by default (`chatKey` changes it; it never collides with a
|
|
13784
|
+
grammY session). It holds the shown screen, its params, message ids with
|
|
13785
|
+
fingerprints of their content, overflow tokens and expiring remarks — never
|
|
13786
|
+
message text or input. A record from another schema version is set aside
|
|
13787
|
+
(`state-discarded`); a storage failure is an update failure.
|
|
13788
|
+
|
|
13789
|
+
Updates of one chat are handled one at a time within a process. Two processes
|
|
13790
|
+
serving one chat — releases overlapping, a webhook fan-out — are not
|
|
13791
|
+
serialised: the last write wins. The record is written after Telegram answers,
|
|
13792
|
+
so it never names a message that was not sent — and it is written when Telegram
|
|
13793
|
+
refused halfway too.
|
|
13794
|
+
|
|
13795
|
+
## Configuration
|
|
13796
|
+
|
|
13797
|
+
| Option | Default | Does |
|
|
13798
|
+
|---|---|---|
|
|
13799
|
+
| `screens` | — | the declared screens; ids, paths and access are checked at `create` |
|
|
13800
|
+
| `storage` | — | where each chat's record survives a restart |
|
|
13801
|
+
| `chatKey` | `screens:<chat id>` | the storage key of a chat's record |
|
|
13802
|
+
| `callbackPrefix` | `~` | what every screen button's `callback_data` starts with |
|
|
13803
|
+
| `chats` | `'private'` | `'all'` answers in groups too, where any member can press or type |
|
|
13804
|
+
| `onStale(c)` | answered silently | a press nothing can answer, with its `reason`; its outcome is shown |
|
|
13805
|
+
| `onError(error, c)` | none | a load, action or input that threw; its outcome is shown |
|
|
13806
|
+
| `onEvent(event)` | none | `transition`, `state-discarded`, `message-kept`, `sweep-failed` (a remark's timer could not delete it) — never message text |
|
|
13807
|
+
| `clock` | `Date.now` | the time expiring remarks are measured by |
|
|
13808
|
+
|
|
13809
|
+
Without `onError` the press is answered silently and the error reaches
|
|
13810
|
+
`bot.catch` — which a bot must set. `screens.close()` cancels pending expiry
|
|
13811
|
+
timers on the way down; a remark left behind is removed on the chat's next
|
|
13812
|
+
update.
|
|
13813
|
+
|
|
13814
|
+
## Security
|
|
13815
|
+
|
|
13816
|
+
- **A group's `load` checks access** for every screen, action and input under
|
|
13817
|
+
it. A param added below the last load is unchecked until a load of its own
|
|
13818
|
+
runs: `create` refuses a screen whose params no load sees.
|
|
13819
|
+
- **Never `html.raw(c.text)`** — or anything else a user wrote. `html` escapes
|
|
13820
|
+
interpolations; `raw` is for markup the application built.
|
|
13821
|
+
- Private chats only by default: in a group anyone could press an owner's menu.
|
|
13822
|
+
- A forged `callback_data` never reaches a handler; params and action input
|
|
13823
|
+
still go through their schemas.
|
|
13824
|
+
|
|
13825
|
+
## Testing
|
|
13826
|
+
|
|
13827
|
+
`createScreenTestChat(bot)` takes the application's own `Bot` — its middleware,
|
|
13828
|
+
commands and error boundary in their real order — and answers its Bot API calls
|
|
13829
|
+
from memory. Updates go through `bot.handleUpdate`, and an error reaches
|
|
13830
|
+
`bot.catch` as it does under long polling.
|
|
13831
|
+
|
|
13832
|
+
```ts
|
|
13833
|
+
import { Bot, MemorySessionStorage } from 'grammy'
|
|
13834
|
+
import { createScreenTestChat } from 'stitchkit/telegram/screens'
|
|
13835
|
+
|
|
13836
|
+
const chat = createScreenTestChat(makeBot(new MemorySessionStorage()))
|
|
13837
|
+
await chat.send('/start')
|
|
13838
|
+
await chat.press('Alpha')
|
|
13839
|
+
expect(chat.messages.at(-1)?.text).toBe('📁 <b>Alpha</b>')
|
|
13840
|
+
expect(chat.calls.map((call) => call.method)).toEqual(['sendMessage', 'answerCallbackQuery', 'editMessageText'])
|
|
13841
|
+
```
|
|
13842
|
+
|
|
13843
|
+
It keeps the Telegram rules screens depend on — an edit that changes nothing is
|
|
13844
|
+
"not modified", a deleted message cannot be edited, HTML it cannot parse, text
|
|
13845
|
+
past 4096 characters, a caption past 1024 and `callback_data` past 64 bytes are
|
|
13846
|
+
refused — and offers `failNext` to
|
|
13847
|
+
refuse a call, `deleteByUser`, `pressData` for a forged press and
|
|
13848
|
+
`restart(bot)` for a new process over the same storage.
|
|
13849
|
+
|
|
13850
|
+
## Not included
|
|
13851
|
+
|
|
13852
|
+
Reply keyboards, inline mode, media uploads (`InputFile`) in a view, showing a
|
|
13853
|
+
screen without an update (use `screens.button` in a notification), converting
|
|
13854
|
+
an incoming message to HTML, and a history stack.
|
|
13855
|
+
|
|
13856
|
+
|
|
13208
13857
|
==============================================================================
|
|
13209
13858
|
# Guide: GeoIP (docs/guide/geo.md)
|
|
13210
13859
|
==============================================================================
|
|
@@ -17811,7 +18460,7 @@ cutovers are covered by the executable
|
|
|
17811
18460
|
| `LifecycleRunSchema` / `LifecycleRun` / `LifecycleStateSchema` / `LifecycleState` / `LifecycleTerminationSchema` / `LifecycleTermination` | schema / _type_ | versioned process-run state — open (`active`, `draining` with `unavailableAt`) and ended (`clean`, `forced`, `startup-failed`, `hot-reload`, `abnormal`) |
|
|
17812
18461
|
| `PreviousExitSchema` / `PreviousExit` / `StartFact` / `ReadyFact` / `DrainingFact` / `ShutdownFact` / `ProcessLifecycleFact` | schema / _type_ | first-boot, hot-reload, clean, forced, startup-failed, handoff and abnormal lifecycle facts; `processGapMs` on start, `downtimeMs` (unavailable → ready) on ready |
|
|
17813
18462
|
| `transitionProcessStart` / `transitionProcessReady` / `transitionProcessDraining` / `transitionProcessShutdown` | function | pure idempotent runId+pid-owned transitions |
|
|
17814
|
-
| `LifecycleTransition` / `TransitionStartInput` / `TransitionRunInput` / `TransitionShutdownInput` / `SameVersionOverlap` | _type_ | pure transition inputs (`TransitionRunInput` for ready and draining, extended with `forced` for shutdown), the same-build overlap policy (`abnormal` default / `handoff`) and state+fact result |
|
|
18463
|
+
| `LifecycleTransition` / `TransitionStartInput` / `TransitionRunInput` / `TransitionShutdownInput` / `SameVersionOverlap` / `VersionChangeOverlap` | _type_ | pure transition inputs (`TransitionRunInput` for ready and draining, extended with `forced` for shutdown), the same-build overlap policy (`abnormal` default / `handoff`), the version-change overlap policy (`handoff` default / `abnormal`) and state+fact result |
|
|
17815
18464
|
| `lifecycleLedgerResource` / `LifecycleLedgerResource` / `LifecycleLedgerResourceConfig` | function / _type_ | managed-resource adapter that records start, readiness, admission stop (draining) and shutdown |
|
|
17816
18465
|
| `createNotificationOutbox` / `NotificationOutbox` / `NotificationOutboxConfig` | function / _type_ | bounded at-least-once queue with durable claims, leases, retries and supersede |
|
|
17817
18466
|
| `NotificationOutboxItem` / `NotificationOutboxReceipt` / `NotificationOutboxState` / `NotificationSend` / `NotificationFailureClassification` / `DroppedNotification` / `EnqueueNotification` | _type_ | versioned queue records, stable send identity and delivery decisions |
|
|
@@ -18625,7 +19274,8 @@ audit event. See the [Observability guide](../guide/observability.md).
|
|
|
18625
19274
|
| `createBoundedLogger` | function | decorate a `StitchLogger` with request context, shared sanitisation, redaction and total bounds |
|
|
18626
19275
|
| `DEFAULT_REDACT_PATHS` | constant | baseline credential/token paths added to the sanitizer's sensitive-key policy |
|
|
18627
19276
|
| `createJsonLogger` | function | the process journal: one JSON line per call in pino's shape (numeric `level`, epoch `time`, `msg`) on standard output, through `createBoundedLogger` — an `Error` in any field keeps name, message, stack and cause; secrets masked; `level` threshold, static `fields`, injectable `write` |
|
|
18628
|
-
| `JsonLoggerOptions` / `JsonLogLevel` / `JSON_LOG_LEVELS` | _type_ / constant | the bounded-logger options plus `level`, `fields`, `write`, `now`; pino's numbers `debug` 20 … `error` 50, which a line reader matches on |
|
|
19277
|
+
| `JsonLoggerOptions` / `JsonLogLevel` / `JSON_LOG_LEVELS` | _type_ / constant | the bounded-logger options plus `level`, `fields`, `write`, `now`; pino's numbers `debug` 20 … `error` 50, which a line reader matches on; `sensitiveValues` masks exact secrets in every string |
|
|
19278
|
+
| `secretValuesFromEnv` / `SecretValuesOptions` | function / _type_ | every secret an environment holds, for `sensitiveValues`: variables named as a token, secret, key, password or credential, the password and secret query values in any URL variable, each URL-encoded too, longest first; values under `minLength` (8) skipped |
|
|
18629
19279
|
| `BoundedLoggerBounds` / `BoundedLoggerOptions` | _type_ | per-value and total record ceilings (`stringLengthByKey` raises the string bound for named keys such as `stack`) plus sink/redaction configuration |
|
|
18630
19280
|
| `RequestEvent` | _type_ | the normalised audit event handed to the sink; opt-in HTTP cancellation rows carry `outcome: 'cancelled'` |
|
|
18631
19281
|
| `runUnitOfWork` / `RunUnitOfWorkOptions` | function / _type_ | run work that did not arrive over a transport — an agent loop, a scheduled broadcast — inside one context, with the same completion record a request gets |
|
|
@@ -19118,12 +19768,81 @@ injected grammY bot. → ADR 0143, ADR 0201 — [guide](../guide/telegram.md)
|
|
|
19118
19768
|
| `TelegramBroadcastConfig` / `TelegramBroadcastSend` / `TelegramBroadcastReport` / `TelegramBroadcastRunOutcome` / `TelegramBroadcastOutcome` / `TelegramBroadcastRecipient` | _type_ | `name`, `directory`, `recipients()`, `send({ recipient, attempt })`, `maxAttempts`, `signal`, `onProgress`; counts `delivered` / `unreachable` / `failed` / `uncertain` / `pending` and `finished` / `stopped` / `halted` / `dry-run` |
|
|
19119
19769
|
| `telegramBroadcastSender` / `TelegramBroadcastSenderConfig` / `TelegramBroadcastMessage` | function / _type_ | the standard `send`: `sendMessage` with a text or `copyMessage` of a prepared message, through the Bot API |
|
|
19120
19770
|
| `createTelegramOperatorChannel` | function | the operator chat, apart from the journal: `post(text, topic?)` returns at once and never throws; paced sends into forum topics, 429 honoured, oldest dropped on overflow, `drain(signal)` and `close()` for the way down |
|
|
19121
|
-
| `TelegramOperatorChannelConfig` / `TelegramOperatorChannel` / `TelegramOperatorMessage` / `TelegramOperatorDrop` / `TelegramChatId` | _type_ | `chatId`, `topics`, `send`, `maxQueued`, `minIntervalMs`, `maxAttempts`, `onDropped`, `sensitivePatterns` (a bot token is always masked)
|
|
19122
|
-
| `
|
|
19771
|
+
| `TelegramOperatorChannelConfig` / `TelegramOperatorChannel` / `TelegramOperatorMessage` / `TelegramOperatorDrop` / `TelegramChatId` | _type_ | `chatId`, `topics`, `send`, `maxQueued`, `minIntervalMs`, `maxAttempts`, `onDropped`, `sensitivePatterns` and `sensitiveValues` (a bot token is always masked), `dedupe`; a drop is `overflow` / `refused` / `attempts` / `closed` / `repeated` / `over-budget` with its classification |
|
|
19772
|
+
| `TelegramOperatorDedupe` | _type_ | `windowMs` (10 min): one message per `fingerprint` per window, the next one sent carries `repeatedLine(count)`; `maxPerWindow` (20) across fingerprints, the overflow counted onto the next one sent with `overBudgetLine(count)`; `now` |
|
|
19773
|
+
| `telegramOperatorSender` / `TelegramOperatorSenderConfig` | function / _type_ | the standard `send`: `sendMessage` into the chat and topic, plain text unless `parseMode`; with `'HTML'` the markup is cleaned and cut by what it shows, so the channel's cut never leaves a tag open |
|
|
19123
19774
|
| `createTelegramLocalFiles` | function | files the local Bot API server wrote under `<root>/<token>/`: `resolve(file_path)` and `remove(file_path)` only inside the bot's directory, judged on real paths so a link is judged by where it leads; `check()` for readiness. Recommended root variable: `BOT_API_FILES_ROOT` |
|
|
19124
19775
|
| `TelegramLocalFilesConfig` / `TelegramLocalFiles` / `TelegramLocalFilesCheck` / `TelegramLocalFileError` / `TelegramLocalFileRefusal` | _type_ / class | `root`, `token`; refusals `root-not-absolute` / `bot-directory-unavailable` / `outside-bot-directory` / `missing` / `not-a-file`, never carrying the path or the token |
|
|
19125
19776
|
| `callTelegramBotApi` / `TelegramBotApiCall` / `TelegramBotApiError` | function / _type_ / class | one Bot API method over `fetch`; a refusal keeps `error_code`, `description` and `parameters` for the classifier, and no error names the URL |
|
|
19126
19777
|
| `TELEGRAM_BOT_TOKEN_PATTERN` | constant | the shape of a bot token, for a logger's `sensitiveUrlPatterns` |
|
|
19778
|
+
| `claimTelegramWebhook` | function | set the webhook only where it already points here, or by naming the host it is taken from; confirm Telegram holds it; pending updates kept unless `dropPendingUpdates` |
|
|
19779
|
+
| `ClaimTelegramWebhookConfig` / `TelegramWebhookConfig` / `TelegramWebhookClaim` | _type_ | `token`, `url`, `secret`, `apiRoot`, `fetch`, `signal`; `takeoverFrom` (a host, or `'none'`), `allowedUpdates`, `maxConnections`, `dropPendingUpdates`; the claim's tagged `url`, `takenFrom`, `pendingUpdates` |
|
|
19780
|
+
| `TelegramWebhookClaimError` / `TelegramWebhookRefusal` / `TELEGRAM_WEBHOOK_NONE` | class / _type_ / constant | `owned-elsewhere` / `other-secret` / `not-confirmed` with `ownerHost` — a host, never a path; `'none'` names no webhook |
|
|
19781
|
+
| `telegramWebhookUrl` | function | the address with an `owner` tag derived from the secret: equal for one secret, different for another, the path untouched |
|
|
19782
|
+
| `checkTelegramWebhook` / `TelegramWebhookState` | function / _type_ | whether the webhook still points here: `owned`, `ownerHost`, `pendingUpdates`, Telegram's `lastError` |
|
|
19783
|
+
| `receiveTelegramWebhook` / `ReceiveTelegramWebhookOptions` / `TelegramUpdateAcceptance` | function / _type_ | one webhook `Request`: 403 without the secret (compared in constant time), 405 for another method, 400 for no update, 200 once `accept` recorded it |
|
|
19784
|
+
| `createTelegramUpdateIntake` | function | webhook updates recorded before Telegram is answered and handled after: chat order kept, other chats alongside up to `maxConcurrent`, a renewed lease per attempt, a sweep that takes over what a restart or a dead process left, retries, `maxAttempts`, finished updates remembered `retainMs` to refuse repeats; at least once |
|
|
19785
|
+
| `TelegramUpdateIntakeConfig` / `TelegramUpdateIntake` / `TelegramUpdateEnvelope` / `TelegramUpdateFailure` / `TelegramUpdateStoreStep` | _type_ | `store`, `handle`, `retry`, `maxAttempts` (5), `leaseMs` (120 000), `pendingGraceMs` (60 000), `sweepEveryMs` (30 000), `retainMs` (24 h), `maxConcurrent` (32), `onFailure`, `onStoreError`, `now`; `accept(body)`, `start()`, `sweep()`, `idle()`, `close()` |
|
|
19786
|
+
| `TelegramUpdateStore` / `StoredTelegramUpdate` / `TelegramUpdateState` / `TelegramUpdateSettlement` / `TelegramUpdateClaimOptions` / `TelegramUpdateDueQuery` | _type_ | the store an intake runs on: `add`, `claim`, `renew`, `settle`, `due`, `prune`, each atomic on one record — a claim takes `pending`, a due `failed` or a lapsed `processing`; renew and settle only by the attempt holding it |
|
|
19787
|
+
| `memoryTelegramUpdateStore` | function | the store in this process's memory — tests, or a bot that accepts losing unhandled updates to a restart |
|
|
19788
|
+
| `sqliteTelegramUpdateStore` / `SqliteTelegramUpdateStoreConfig` / `TelegramSqliteDatabase` | function / _type_ | the store in SQLite over the handle the application opened — `bun:sqlite` `Database` or `node:sqlite` `DatabaseSync`; `table` (default `telegram_updates`) created when missing |
|
|
19789
|
+
|
|
19790
|
+
---
|
|
19791
|
+
|
|
19792
|
+
## `stitchkit/telegram/html`
|
|
19793
|
+
|
|
19794
|
+
Telegram's HTML parse mode as a tree: any markup in, markup Telegram accepts
|
|
19795
|
+
out, cut to its limits by what the reader sees. No peer and no DOM — the same
|
|
19796
|
+
parse serves a server send and a browser preview. → ADR 0213 —
|
|
19797
|
+
[guide](../guide/telegram.md#message-markup)
|
|
19798
|
+
|
|
19799
|
+
| Export | Kind | Summary |
|
|
19800
|
+
|--------|------|---------|
|
|
19801
|
+
| `parseTelegramHtml` | function | markup into nodes Telegram accepts: synonyms renamed (`strong` → `b`), block tags into line breaks, a link only with an `http`/`https`/`tg`/`mailto` `href`, an element where Telegram forbids it kept as text, a closing tag closing what is open inside it; never throws |
|
|
19802
|
+
| `TelegramHtmlNode` / `TelegramHtmlElement` / `TelegramHtmlTextNode` / `TelegramHtmlTag` / `TelegramHtmlStyle` | _type_ | text nodes hold decoded text; elements `b` `i` `u` `s` `tg-spoiler` `code`, `a` with `href`, `pre` with `language`, `blockquote` with `expandable`, `tg-emoji` with `emojiId`, `tg-time` with `unix` and `format` |
|
|
19803
|
+
| `renderTelegramHtml` | function | nodes back to markup Telegram parses into exactly them |
|
|
19804
|
+
| `sanitizeTelegramHtml` | function | any markup as markup Telegram accepts — `renderTelegramHtml(parseTelegramHtml(markup))` |
|
|
19805
|
+
| `splitTelegramHtml` / `TelegramHtmlLimitOptions` | function / _type_ | parts within `limit` visible characters (default 4096), cut at a paragraph, a line, a space, else anywhere but inside a character or a custom emoji; an element across a cut is closed and reopened with its attributes; nothing visible is no parts |
|
|
19806
|
+
| `truncateTelegramHtml` / `TelegramHtmlTruncateOptions` | function / _type_ | one part: cut to `limit` with `ellipsis` (`…`) inside it, still valid |
|
|
19807
|
+
| `telegramHtmlText` | function | the text a reader sees, entities decoded |
|
|
19808
|
+
| `checkTelegramHtml` / `TelegramHtmlCheck` / `TelegramHtmlProblem` | function / _type_ | the first thing Telegram would refuse, unrepaired — `bare-character` / `unsupported-tag` / `unmatched-end-tag` / `unclosed-tag` — and the visible text before it |
|
|
19809
|
+
| `escapeTelegramHtml` | function | `&`, `<`, `>` and `"` escaped for Telegram's HTML |
|
|
19810
|
+
| `TELEGRAM_TEXT_LIMIT` / `TELEGRAM_CAPTION_LIMIT` | constant | 4096 and 1024, counted after the markup is parsed, in UTF-16 code units |
|
|
19811
|
+
|
|
19812
|
+
---
|
|
19813
|
+
|
|
19814
|
+
## `stitchkit/telegram/screens`
|
|
19815
|
+
|
|
19816
|
+
A bot's menus as declared screens over grammY: a screen says what the chat
|
|
19817
|
+
should show, and the runtime decides what to send, edit and delete. Server-only,
|
|
19818
|
+
Bun or Node; `grammy` and `zod` appear in the declarations, and nothing imports
|
|
19819
|
+
grammY at run time — the bot's own `ctx.api` and a grammY `StorageAdapter` are
|
|
19820
|
+
the whole dependency. → ADR 0211 — [guide](../guide/telegram-screens.md)
|
|
19821
|
+
|
|
19822
|
+
| Export | Kind | Summary |
|
|
19823
|
+
|--------|------|---------|
|
|
19824
|
+
| `telegramScreens` | function | `telegramScreens<Ctx>()` — the root the bot's screens are declared from; the context type is named once |
|
|
19825
|
+
| `TelegramScreensRoot` / `ScreenScope` | class | `screen(path, options?)`, `group(prefix, { params?, id? })`, and on the root `create(config)` |
|
|
19826
|
+
| `ScreenGroupBuilder` / `ScreenBuilder` / `ScreenBodyBuilder` | class | the chain: `load` (first or never) → `action` / `on` → `view`, which ends the declaration |
|
|
19827
|
+
| `ScopeOptions` / `ScreenOptions` / `ParamsSchemaFor` / `ActionInputSchema` | _type_ | a group's `params` schema and `id`; a screen's explicit `id`; the schema a path's params accept; an action's flat input schema |
|
|
19828
|
+
| `TelegramScreen` / `AnyTelegramScreen` | class / _type_ | a declared screen, typed by its context, params, data and actions |
|
|
19829
|
+
| `ScreenLoadContext` / `ScreenActionContext` / `ScreenInputContext` / `ScreenViewContext` | _type_ | `ctx`, `params`, `data`; an action's `input`; an input's `message`, `text` and `pending(text)`; a view's `act` buttons |
|
|
19830
|
+
| `ScreenParams` / `ScreenParamValue` / `ScreenPathParams` / `ScreenLinkArgs` | _type_ | params parsed from the path literal (or its schema); the argument a `link` or `go` takes — required exactly when the path has params |
|
|
19831
|
+
| `ScreenActions` / `ScreenActionButtons` / `NoActionInput` | _type_ | action name → input; `act.<name>(label, input?)` typed by the action's schema |
|
|
19832
|
+
| `ScreenInputKind` / `ScreenInputOptions` | _type_ | `text` `rich` `photo` `video` `animation` `document` `audio` `voice` `video_note` `sticker` `location` `contact`; `keepMessage` leaves the user's message (default: deleted before the handler) |
|
|
19833
|
+
| `ScreenHandlerResult` / `ScreenOutcome` / `ScreenOutcomeBuilders` / `ScreenInputOutcomeBuilders` | _type_ / class | what a load, action or input answers: `stay()`, `go(screen, params?)`, `back()`, `toast(text, { alert? })` (≤ 200 characters), `notice(content, { expiresInMs? })`; `void` is `stay()`; after input there is no press, so no `toast` |
|
|
19834
|
+
| `ScreenNavigation` / `ScreenToast` / `ScreenNotice` / `NoticeOptions` | _type_ | the parts of an outcome; a notice without `expiresInMs` is a receipt the screen moves below, with it a remark under the screen that removes itself |
|
|
19835
|
+
| `ViewMessage` / `ScreenViewResult` / `TextViewMessage` / `RichViewMessage` / `PhotoViewMessage` / `VideoViewMessage` / `AnimationViewMessage` / `DocumentViewMessage` / `AudioViewMessage` / `ScreenMediaKind` | _type_ | one message or several; exactly one content (`text`, `rich`, or a media file id or URL with `caption`), plus `keyboard`, `key` and `linkPreview` |
|
|
19836
|
+
| `link` / `back` / `ScreenButton` | function / class | a button to a screen with its params; a button to the parent screen |
|
|
19837
|
+
| `Keyboard` / `KeyboardRow` / `KeyboardButton` / `PlainKeyboardButton` / `ButtonLabel` | _type_ | rows of screen buttons and plain Telegram buttons without `callback_data`; `false`, `null` and `undefined` are skipped; a label is text or `{ text, style?, icon_custom_emoji_id? }` |
|
|
19838
|
+
| `html` / `TelegramHtml` / `HtmlTag` / `HtmlValue` / `TelegramText` | function / class / _type_ | Telegram HTML with every interpolation escaped; `html.raw()` for markup already safe, `html.join()`; a plain string is always escaped (`escapeTelegramHtml` is `stitchkit/telegram/html`'s) |
|
|
19839
|
+
| `ActionValue` | _type_ | what a button can carry: a string, a finite number, a boolean or `null` |
|
|
19840
|
+
| `TelegramScreensConfig` | _type_ | `screens`, `storage`, `chatKey` (default `screens:<chat id>`), `callbackPrefix` (default `~`), `chats` (`'private'` by default, or `'all'`), `onStale`, `onError`, `onEvent`, `clock` |
|
|
19841
|
+
| `TelegramScreens` / `OpenArgs` / `OpenOptions` | _type_ | `bot.use(screens)`; `open(ctx, screen, params?, { previous: 'keep' \| 'delete' })`; `button(link(…))` for a message the screens do not own; `close()` cancels expiry timers |
|
|
19842
|
+
| `ScreenStaleContext` / `ScreenStaleReason` / `ScreenErrorContext` / `ScreenTrigger` | _type_ | a press nothing can answer — `malformed` / `forged` / `inaccessible` / `unknown-token` / `unknown-screen` / `invalid-params` / `unknown-action` / `invalid-input`; an error with its trigger `open` / `press` / `input` |
|
|
19843
|
+
| `ScreenEvent` / `ExecutionReport` | _type_ | `transition` (trigger, path templates, duration, messages sent / edited / kept / deleted), `state-discarded`, `message-kept`, `sweep-failed`; never message text |
|
|
19844
|
+
| `ScreenChatState` / `ScreenChatStateSchema` / `TelegramScreenStorage` | _type_ / schema | one record per chat — the shown view and expiring remarks, no message text; any grammY `StorageAdapter<ScreenChatState>` |
|
|
19845
|
+
| `createScreenTestChat` / `ScreenTestChat` / `TestChatOptions` / `TestChatMessage` / `TestChatCall` / `TestMessageKind` | function / _type_ | a chat with the application's own `Bot` and no Telegram: `send`, `sendPhoto`, `press(label)`, `pressData`, `failNext`, `deleteByUser`, `restart(bot)`; `messages`, `calls`, `answers` |
|
|
19127
19846
|
|
|
19128
19847
|
---
|
|
19129
19848
|
|
|
@@ -19479,6 +20198,31 @@ two context types, why thin wrappers — see the
|
|
|
19479
20198
|
ends. Watch loss marks a query stale without triggering an independent refetch;
|
|
19480
20199
|
recovery belongs to the watched read.
|
|
19481
20200
|
|
|
20201
|
+
## `stitchkit/react/keyboard`
|
|
20202
|
+
|
|
20203
|
+
Browser-only, headless keyboard handling for React: which part of a screen gets a
|
|
20204
|
+
key, and lists the arrows move through with real focus. Needs the `react` peer;
|
|
20205
|
+
no component, no styles, no router — see the [keyboard guide](../guide/keyboard.md).
|
|
20206
|
+
|
|
20207
|
+
| Export | Kind | Summary |
|
|
20208
|
+
|--------|------|---------|
|
|
20209
|
+
| `useKeyLayer` | hook | put a layer on the window's one key stack — asked `overlay` → `local` → `route` → `zone` → `global`, innermost first within a kind |
|
|
20210
|
+
| `useEscapeLayer` | hook | an `overlay`, `local` or `route` layer for Escape: one press closes one level |
|
|
20211
|
+
| `useNavZone` | hook | a list zone — roving tab stop, arrows move DOM focus, select or highlight mode, cross-axis enter/exit, boundaries |
|
|
20212
|
+
| `nextZoneIndex` | function | where a move lands in a list of a given length: an index, `'before-start'` or `'after-end'` |
|
|
20213
|
+
| `isKeyFieldTarget` | function | whether a key was pressed in a field or a `data-local-keys` area, the test layers use |
|
|
20214
|
+
| `KeyLayerOptions` | _type_ | `kind`, `onKey`, `active`, `scope`, `fields` |
|
|
20215
|
+
| `KeyLayerKind` | _type_ | `'overlay' \| 'local' \| 'route' \| 'zone' \| 'global'` |
|
|
20216
|
+
| `EscapeLayerOptions` | _type_ | `kind`, `onEscape`, `active`, `scope` |
|
|
20217
|
+
| `NavZoneOptions` | _type_ | `items`, `value`, `onChange`, `mode`, `orientation`, `role`, `loop`, `onEnter`, `onExit`, `onBoundary`, `active`, `label`, `onReveal` |
|
|
20218
|
+
| `NavZone` | _type_ | what `useNavZone` returns — `zoneProps`, `itemProps(id)`, `element`, `current`, `focus(id?)` |
|
|
20219
|
+
| `NavZoneProps` / `NavZoneItemProps` | _type_ | the props spread on the zone's element and on each item |
|
|
20220
|
+
| `NextZoneIndexInput` | _type_ | `length`, `index`, `move`, `loop` |
|
|
20221
|
+
| `ZoneItemId` | _type_ | an item id — `string \| number` |
|
|
20222
|
+
| `ZoneChangeCause` | _type_ | `'arrow' \| 'confirm'` |
|
|
20223
|
+
| `ZoneMove` | _type_ | `'next' \| 'previous' \| 'first' \| 'last'` |
|
|
20224
|
+
| `ZoneOrientation` | _type_ | `'vertical' \| 'horizontal'` |
|
|
20225
|
+
|
|
19482
20226
|
## `stitchkit/agent-runtime/sandbox`
|
|
19483
20227
|
|
|
19484
20228
|
Optional server-only namespace sandbox. Exports:
|