stitchkit 0.100.2 → 0.101.0
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 +153 -0
- package/README.md +2 -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.js +209 -210
- 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 +116 -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 +11 -3
- package/dist/entrypoints/telegram.d.ts.map +1 -1
- package/dist/entrypoints/telegram.js +779 -159
- 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 +224 -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-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-e05cjgtf.js +144 -0
- package/dist/{index-yw4fzmdz.js → index-e6c2spsd.js} +79 -30
- 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-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-sznqf8fx.js +20 -0
- 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/internal/secret-shapes.d.ts +21 -0
- package/dist/internal/secret-shapes.d.ts.map +1 -0
- 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/bot-api.d.ts +7 -4
- package/dist/telegram/bot-api.d.ts.map +1 -1
- 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 +3 -44
- package/dist/telegram/init-data.d.ts.map +1 -1
- package/dist/telegram/operator-channel.d.ts +19 -3
- package/dist/telegram/operator-channel.d.ts.map +1 -1
- package/dist/telegram/operator-dedupe.d.ts +44 -0
- package/dist/telegram/operator-dedupe.d.ts.map +1 -0
- 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 +25 -0
- package/dist/telegram/send-failure.d.ts.map +1 -1
- package/dist/telegram/update-intake.d.ts +82 -0
- package/dist/telegram/update-intake.d.ts.map +1 -0
- package/dist/telegram/update-store-check.d.ts +22 -0
- package/dist/telegram/update-store-check.d.ts.map +1 -0
- package/dist/telegram/update-store-postgres.d.ts +33 -0
- package/dist/telegram/update-store-postgres.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/user.d.ts +51 -0
- package/dist/telegram/user.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/cli.txt +8 -2
- package/llms/observability.txt +15 -2
- package/llms/react-keyboard.txt +193 -0
- package/llms/server.4.txt +16 -0
- package/llms/stitchkit.txt +10 -4
- package/llms/telegram-html.txt +35 -0
- package/llms/telegram-screens.txt +365 -0
- package/llms/telegram.txt +245 -11
- package/llms/{upgrading-0.74.0-0.100.0.txt → upgrading-0.74.0-0.101.0.txt} +39 -3
- package/llms/upgrading.txt +1 -1
- package/llms-full.txt +889 -19
- package/llms.txt +12 -7
- 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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# stitchkit/telegram/html — stitchkit docs slice
|
|
2
|
+
|
|
3
|
+
> Generated from the `docs/` tree by `bun run gen:llms`. The index of all slices is
|
|
4
|
+
> `llms.txt` in this package.
|
|
5
|
+
|
|
6
|
+
Contains: API reference: `stitchkit/telegram/html` (docs/api/reference.md)
|
|
7
|
+
|
|
8
|
+
Also read:
|
|
9
|
+
- see `llms/telegram.txt` for the Telegram bots guide
|
|
10
|
+
|
|
11
|
+
==============================================================================
|
|
12
|
+
# API reference: `stitchkit/telegram/html` (docs/api/reference.md)
|
|
13
|
+
==============================================================================
|
|
14
|
+
|
|
15
|
+
## `stitchkit/telegram/html`
|
|
16
|
+
|
|
17
|
+
Telegram's HTML parse mode as a tree: any markup in, markup Telegram accepts
|
|
18
|
+
out, cut to its limits by what the reader sees. No peer and no DOM — the same
|
|
19
|
+
parse serves a server send and a browser preview. → ADR 0213 —
|
|
20
|
+
[guide](../guide/telegram.md#message-markup)
|
|
21
|
+
|
|
22
|
+
| Export | Kind | Summary |
|
|
23
|
+
|--------|------|---------|
|
|
24
|
+
| `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 |
|
|
25
|
+
| `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` |
|
|
26
|
+
| `renderTelegramHtml` | function | nodes back to markup Telegram parses into exactly them |
|
|
27
|
+
| `sanitizeTelegramHtml` | function | any markup as markup Telegram accepts — `renderTelegramHtml(parseTelegramHtml(markup))` |
|
|
28
|
+
| `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 |
|
|
29
|
+
| `truncateTelegramHtml` / `TelegramHtmlTruncateOptions` | function / _type_ | one part: cut to `limit` with `ellipsis` (`…`) inside it, still valid |
|
|
30
|
+
| `telegramHtmlText` | function | the text a reader sees, entities decoded |
|
|
31
|
+
| `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 |
|
|
32
|
+
| `escapeTelegramHtml` | function | `&`, `<`, `>` and `"` escaped for Telegram's HTML |
|
|
33
|
+
| `TELEGRAM_TEXT_LIMIT` / `TELEGRAM_CAPTION_LIMIT` | constant | 4096 and 1024, counted after the markup is parsed, in UTF-16 code units |
|
|
34
|
+
|
|
35
|
+
---
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# stitchkit/telegram/screens — stitchkit docs slice
|
|
2
|
+
|
|
3
|
+
> Generated from the `docs/` tree by `bun run gen:llms`. The index of all slices is
|
|
4
|
+
> `llms.txt` in this package.
|
|
5
|
+
|
|
6
|
+
Contains: Guide: Telegram screens (docs/guide/telegram-screens.md) · API reference: `stitchkit/telegram/screens` (docs/api/reference.md)
|
|
7
|
+
|
|
8
|
+
==============================================================================
|
|
9
|
+
# Guide: Telegram screens (docs/guide/telegram-screens.md)
|
|
10
|
+
==============================================================================
|
|
11
|
+
|
|
12
|
+
# Telegram screens
|
|
13
|
+
|
|
14
|
+
`stitchkit/telegram/screens` turns a bot's menus into declared screens. A screen
|
|
15
|
+
says what the chat should show — a message or several, with their buttons — and
|
|
16
|
+
the runtime decides what to send, what to edit in place and what to delete. It
|
|
17
|
+
is grammY middleware: it answers the presses and messages that belong to a
|
|
18
|
+
screen and hands every other update to the next handler. Why it is shaped this
|
|
19
|
+
way: [ADR 0211](../decisions/0211-telegram-screens-are-declared-views-over-grammy.md).
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bun add grammy zod
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
grammY is the bot's own; screens use its `ctx.api` and name its types, and
|
|
26
|
+
nothing imports grammY at run time.
|
|
27
|
+
|
|
28
|
+
## A menu, end to end
|
|
29
|
+
|
|
30
|
+
A bot that lets its owner manage projects: a list, a project's page, a
|
|
31
|
+
two-message greeting screen and a screen that waits for the new greeting.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { back, html, link, telegramScreens } from 'stitchkit/telegram/screens'
|
|
35
|
+
import { z } from 'zod'
|
|
36
|
+
|
|
37
|
+
export const tg = telegramScreens<BotContext>()
|
|
38
|
+
|
|
39
|
+
export const home = tg.screen('/')
|
|
40
|
+
.load((c) => listProjects(c.ctx.from?.id))
|
|
41
|
+
.view(({ data: projects }) => ({
|
|
42
|
+
text: html`<b>Your projects</b>`,
|
|
43
|
+
keyboard: [
|
|
44
|
+
...projects.map((project) => [link(project.name, projectPage, { projectId: project.id })]),
|
|
45
|
+
[link('➕ New project', newProject)],
|
|
46
|
+
],
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
// A group shares a path prefix, a params schema and a load. The load runs
|
|
50
|
+
// before every screen under it — its load, its actions, its input — and is
|
|
51
|
+
// where access is checked: params arrive from a button, so they are input.
|
|
52
|
+
const owned = tg.group('/project/:projectId', { params: z.object({ projectId: z.uuid() }) })
|
|
53
|
+
.load(async (c) => {
|
|
54
|
+
const project = await findProject(c.params.projectId)
|
|
55
|
+
return project?.ownerId === c.ctx.from?.id ? project : c.go(home).toast('Project not found')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
export const projectPage = owned.screen('/')
|
|
59
|
+
.action('archive', async (c) => {
|
|
60
|
+
await archiveProject(c.data.id)
|
|
61
|
+
return c.toast('Archived').go(home)
|
|
62
|
+
})
|
|
63
|
+
.view(({ data: project, params, act }) => ({
|
|
64
|
+
text: html`📁 <b>${project.name}</b>\n\nStatus: ${project.status}`,
|
|
65
|
+
keyboard: [
|
|
66
|
+
[link('👋 Greeting', greeting, params)],
|
|
67
|
+
project.status === 'active' && [act.archive('🗄 Archive')],
|
|
68
|
+
[back('« Back')],
|
|
69
|
+
],
|
|
70
|
+
}))
|
|
71
|
+
|
|
72
|
+
export const greeting = owned.screen('/greeting')
|
|
73
|
+
.action('clear', (c) => clearGreeting(c.data.id)) // nothing returned: stay, redrawn
|
|
74
|
+
.action('rule', z.object({ every: z.boolean() }), (c) => setRule(c.data.id, c.input.every))
|
|
75
|
+
.view(({ data: project, params, act }) => project.greeting
|
|
76
|
+
? [
|
|
77
|
+
{ key: 'header', text: html`<b>New members receive ↓</b>` },
|
|
78
|
+
{
|
|
79
|
+
text: project.greeting, // a plain string is escaped
|
|
80
|
+
keyboard: [
|
|
81
|
+
[link('✏️ Edit', greetingEdit, params), act.clear('🗑 Clear')],
|
|
82
|
+
[act.rule(project.every ? 'Every message' : 'First message only', { every: !project.every })],
|
|
83
|
+
[back('« Back')],
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
: { text: html`🗑 <b>No greeting.</b>`, keyboard: [[back('« Back')]] })
|
|
88
|
+
|
|
89
|
+
export const greetingEdit = owned.screen('/greeting/edit')
|
|
90
|
+
.on('text', async (c) => {
|
|
91
|
+
if (c.text.length < 3) return c.notice(html`❌ Too short`, { expiresInMs: 5_000 })
|
|
92
|
+
await c.pending('⏳')
|
|
93
|
+
await saveGreeting(c.data.id, c.text)
|
|
94
|
+
return c.notice(html`<blockquote>✅ Greeting saved</blockquote>`).back()
|
|
95
|
+
})
|
|
96
|
+
.view(() => ({ text: html`📝 Send the new greeting ↓`, keyboard: [[back('« Back')]] }))
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// bot.ts
|
|
101
|
+
const screens = tg.create({
|
|
102
|
+
screens: [home, projectPage, greeting, greetingEdit],
|
|
103
|
+
storage: myStorageAdapter, // any grammY StorageAdapter
|
|
104
|
+
})
|
|
105
|
+
bot.use(screens)
|
|
106
|
+
bot.command('start', (ctx) => screens.open(ctx, home))
|
|
107
|
+
bot.catch((error) => logger.error('update failed', { error }))
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## The model
|
|
111
|
+
|
|
112
|
+
- **A screen** is a path, an optional `load`, any number of `action` and `on`,
|
|
113
|
+
and a `view`. `load` comes first or not at all — actions and input read what
|
|
114
|
+
it returned — and `view` ends the declaration.
|
|
115
|
+
- **Params** come from the path literal: `/project/:projectId` gives
|
|
116
|
+
`params.projectId`. A group's or screen's `params` schema parses them (a
|
|
117
|
+
`z.coerce.number()` makes a number). The schema must accept what it produces,
|
|
118
|
+
because a button carries the parsed value and the schema parses it again when
|
|
119
|
+
it is pressed: `z.coerce.number()` does, `z.string().transform(Number)` does
|
|
120
|
+
not and is a compile error. A `link` to a screen whose path has params must
|
|
121
|
+
carry them; a link to a screen without params takes none — both are compile
|
|
122
|
+
errors.
|
|
123
|
+
- **The parent** of a screen is the nearest declared screen whose path is a
|
|
124
|
+
prefix of its own, by segments. `back()` and the `back('« Back')` button go
|
|
125
|
+
there. There is no history stack: navigation is a tree of paths, so it
|
|
126
|
+
survives a restart and never grows.
|
|
127
|
+
- **`load`** returns data, or an outcome instead of data — `c.go(home).toast('…')`
|
|
128
|
+
shows another screen. A chain of such redirects stops after four steps.
|
|
129
|
+
- **`view`** is a pure function of `ctx`, `params`, `data` and `act`. It returns
|
|
130
|
+
one message or several. A message has exactly one content — `text`, `rich`
|
|
131
|
+
(a Bot API rich message), or a media file id or URL (`photo`, `video`,
|
|
132
|
+
`animation`, `document`, `audio`) with an optional `caption` — plus
|
|
133
|
+
`keyboard`, `key` and, for text, `linkPreview`. Rows and buttons may be
|
|
134
|
+
`false`, `null` or `undefined`, so a conditional button is `cond && [...]`.
|
|
135
|
+
- **Text** is a string, which is always escaped, or a `TelegramHtml` from the
|
|
136
|
+
`html` tag, whose interpolations are escaped. `html.raw(markup)` marks markup
|
|
137
|
+
that is already safe and `html.join(parts, separator)` joins pieces. Every
|
|
138
|
+
message is sent with `parse_mode: 'HTML'` set explicitly, so an application's
|
|
139
|
+
own `parse_mode` transformer changes nothing.
|
|
140
|
+
|
|
141
|
+
## Outcomes
|
|
142
|
+
|
|
143
|
+
A load, an action and an input handler answer with an outcome, built by
|
|
144
|
+
chaining; returning nothing is `stay()`.
|
|
145
|
+
|
|
146
|
+
| Builder | Does |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `c.stay()` | show this screen again, with fresh data |
|
|
149
|
+
| `c.go(screen, params?)` | show another screen |
|
|
150
|
+
| `c.back()` | show the parent screen |
|
|
151
|
+
| `c.toast(text, { alert? })` | answer the press — up to 200 characters; the last toast wins |
|
|
152
|
+
| `c.notice(content)` | a receipt that stays: posted, and the screen moves below it |
|
|
153
|
+
| `c.notice(content, { expiresInMs })` | a remark under the screen that removes itself — `expiresInMs` up to 2³¹ − 1, a timer's limit |
|
|
154
|
+
|
|
155
|
+
A remark is removed when its time comes, on the chat's next update after that,
|
|
156
|
+
before anything new is sent into the chat and when the chat leaves its screen —
|
|
157
|
+
so a screen is never split by a remark. A chat keeps at most 100 remarks; the
|
|
158
|
+
oldest is removed to make room for the next. After a message there is no press
|
|
159
|
+
to answer, so an input handler has no `toast`.
|
|
160
|
+
|
|
161
|
+
## What the chat shows: reconciliation
|
|
162
|
+
|
|
163
|
+
Each message of a view has a key: `key` when given, otherwise `'main'` for the
|
|
164
|
+
last message and `s0`, `s1`, … for the ones before it. Between two views,
|
|
165
|
+
messages with the same key are the same message, and only what changed is
|
|
166
|
+
edited:
|
|
167
|
+
|
|
168
|
+
| From → to | Call |
|
|
169
|
+
|---|---|
|
|
170
|
+
| nothing changed | none |
|
|
171
|
+
| only the keyboard | `editMessageReplyMarkup` |
|
|
172
|
+
| text → text, rich → rich | `editMessageText` |
|
|
173
|
+
| text or rich → media, media → media | `editMessageMedia` |
|
|
174
|
+
| a caption | `editMessageCaption` |
|
|
175
|
+
| media → text or rich, text ↔ rich | delete and send |
|
|
176
|
+
|
|
177
|
+
Order holds: once a message has to be sent, every message after it is sent
|
|
178
|
+
too, and a message is deleted only after its replacement is in the chat. So
|
|
179
|
+
"header + body" → "body" edits the body in place and deletes the header, and
|
|
180
|
+
the reverse sends both and deletes the old body.
|
|
181
|
+
|
|
182
|
+
When Telegram refuses a step:
|
|
183
|
+
|
|
184
|
+
- **"message is not modified"** is success.
|
|
185
|
+
- **A message gone or not editable** — deleted by the user, too old — is sent
|
|
186
|
+
again with every message after it.
|
|
187
|
+
- **A deletion refused** (Telegram deletes only messages younger than 48 hours)
|
|
188
|
+
strips the message's buttons instead and reports it as `message-kept`. A
|
|
189
|
+
message the user already deleted counts as deleted.
|
|
190
|
+
- **Anything else** — a rate limit, a network failure, HTML Telegram cannot
|
|
191
|
+
parse — stops the update, and the transition does not happen: the record stays
|
|
192
|
+
on the screen the chat was on, so its input still answers, and lists every
|
|
193
|
+
message the chat now shows, in the chat's order, with the buttons of both
|
|
194
|
+
renders. The next update repairs the rest. A receipt or a remark Telegram
|
|
195
|
+
refuses stops the update the same way, with the view already drawn recorded.
|
|
196
|
+
|
|
197
|
+
A view is checked before anything is sent: at most 100 messages, keys of 1 to
|
|
198
|
+
64 characters, at most 100 buttons whose address is kept in the record. A view
|
|
199
|
+
past these fails in the handler's place, with the chat untouched.
|
|
200
|
+
|
|
201
|
+
## Buttons and presses
|
|
202
|
+
|
|
203
|
+
A screen button's `callback_data` is
|
|
204
|
+
`‹prefix›‹screen id›[.‹action›](:‹param›)*(:‹key›=‹value›)*`. Values are typed,
|
|
205
|
+
a UUID takes 24 bytes, and data that would pass Telegram's 64 bytes is kept in
|
|
206
|
+
the chat's record behind a token derived from it — the same button renders the
|
|
207
|
+
same token, so an unchanged keyboard is never edited. A screen's id is a
|
|
208
|
+
short hash of its path, or its explicit `id` — give one to a screen whose path
|
|
209
|
+
may change, so buttons already in chats keep working.
|
|
210
|
+
|
|
211
|
+
A press is answered exactly once, after the outcome is known and before the
|
|
212
|
+
chat is edited. It is trusted only as far as Telegram vouches for it: the
|
|
213
|
+
`callback_data` must be on the keyboard of the message it came with, otherwise
|
|
214
|
+
the press was forged (a client can send any bytes) and goes to `onStale`.
|
|
215
|
+
|
|
216
|
+
A press on a message the record does not know — an older menu higher up — is
|
|
217
|
+
answered in place: a one-message view takes that message over, a longer view is
|
|
218
|
+
sent below it and the pressed message is deleted. Either way the previously
|
|
219
|
+
recorded view is removed: one live menu per chat.
|
|
220
|
+
|
|
221
|
+
For a message the screens do not own — a notification from the backend —
|
|
222
|
+
`screens.button(link('🔑 Refresh token', refreshToken, { botId }))` makes a
|
|
223
|
+
button that opens the screen below and leaves the notification as it is.
|
|
224
|
+
|
|
225
|
+
## Input
|
|
226
|
+
|
|
227
|
+
`.on(kinds, handler, { keepMessage? })` takes a message while the screen is
|
|
228
|
+
shown. Kinds: `text`, `rich`, `photo`, `video`, `animation`, `document`,
|
|
229
|
+
`audio`, `voice`, `video_note`, `sticker`, `location`, `contact`. A screen may
|
|
230
|
+
have several `.on`; a message of a kind no `.on` takes, and every command
|
|
231
|
+
(`/start`), go to the next handler.
|
|
232
|
+
|
|
233
|
+
The user's message is deleted **before** any load or the handler runs — input
|
|
234
|
+
can be a secret, such as a bot token, and a load may redirect or fail — unless
|
|
235
|
+
`keepMessage: true`; then the view is sent below the message. A message for a
|
|
236
|
+
screen whose record cannot be read is not passed on to the next handler: the
|
|
237
|
+
update fails, and `onError` decides. `c.pending(text)` shows a "working on it" message that is
|
|
238
|
+
removed when the handler ends, however it ends. `c.message` is the message as
|
|
239
|
+
Telegram sent it; it is never stored.
|
|
240
|
+
|
|
241
|
+
## Opening a screen
|
|
242
|
+
|
|
243
|
+
`screens.open(ctx, screen, params?, { previous })` shows a screen as a new
|
|
244
|
+
message at the bottom of the chat, from a command, a deep link or a handler.
|
|
245
|
+
The previous view stays (`'keep'`, the default) or is deleted (`'delete'`).
|
|
246
|
+
Called from inside a handler of the same chat, it runs at once rather than
|
|
247
|
+
waiting behind the update that called it, and the screen it opened stands: the
|
|
248
|
+
handler may still answer the press with a toast, and returning a navigation or
|
|
249
|
+
a notice as well is an error — drawn after it, the handler's own outcome would
|
|
250
|
+
put the record back on the screen the chat left.
|
|
251
|
+
|
|
252
|
+
## State and concurrency
|
|
253
|
+
|
|
254
|
+
One record per chat, in any grammY `StorageAdapter<ScreenChatState>`, under
|
|
255
|
+
`screens:<chat id>` by default (`chatKey` changes it; it never collides with a
|
|
256
|
+
grammY session). It holds the shown screen, its params, message ids with
|
|
257
|
+
fingerprints of their content, overflow tokens and expiring remarks — never
|
|
258
|
+
message text or input. A record from another schema version is set aside
|
|
259
|
+
(`state-discarded`); a storage failure is an update failure.
|
|
260
|
+
|
|
261
|
+
Updates of one chat are handled one at a time within a process. Two processes
|
|
262
|
+
serving one chat — releases overlapping, a webhook fan-out — are not
|
|
263
|
+
serialised: the last write wins. The record is written after Telegram answers,
|
|
264
|
+
so it never names a message that was not sent — and it is written when Telegram
|
|
265
|
+
refused halfway too.
|
|
266
|
+
|
|
267
|
+
## Configuration
|
|
268
|
+
|
|
269
|
+
| Option | Default | Does |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| `screens` | — | the declared screens; ids, paths and access are checked at `create` |
|
|
272
|
+
| `storage` | — | where each chat's record survives a restart |
|
|
273
|
+
| `chatKey` | `screens:<chat id>` | the storage key of a chat's record |
|
|
274
|
+
| `callbackPrefix` | `~` | what every screen button's `callback_data` starts with |
|
|
275
|
+
| `chats` | `'private'` | `'all'` answers in groups too, where any member can press or type |
|
|
276
|
+
| `onStale(c)` | answered silently | a press nothing can answer, with its `reason`; its outcome is shown |
|
|
277
|
+
| `onError(error, c)` | none | a load, action or input that threw; its outcome is shown |
|
|
278
|
+
| `onEvent(event)` | none | `transition`, `state-discarded`, `message-kept`, `sweep-failed` (a remark's timer could not delete it) — never message text |
|
|
279
|
+
| `clock` | `Date.now` | the time expiring remarks are measured by |
|
|
280
|
+
|
|
281
|
+
Without `onError` the press is answered silently and the error reaches
|
|
282
|
+
`bot.catch` — which a bot must set. `screens.close()` cancels pending expiry
|
|
283
|
+
timers on the way down; a remark left behind is removed on the chat's next
|
|
284
|
+
update.
|
|
285
|
+
|
|
286
|
+
## Security
|
|
287
|
+
|
|
288
|
+
- **A group's `load` checks access** for every screen, action and input under
|
|
289
|
+
it. A param added below the last load is unchecked until a load of its own
|
|
290
|
+
runs: `create` refuses a screen whose params no load sees.
|
|
291
|
+
- **Never `html.raw(c.text)`** — or anything else a user wrote. `html` escapes
|
|
292
|
+
interpolations; `raw` is for markup the application built.
|
|
293
|
+
- Private chats only by default: in a group anyone could press an owner's menu.
|
|
294
|
+
- A forged `callback_data` never reaches a handler; params and action input
|
|
295
|
+
still go through their schemas.
|
|
296
|
+
|
|
297
|
+
## Testing
|
|
298
|
+
|
|
299
|
+
`createScreenTestChat(bot)` takes the application's own `Bot` — its middleware,
|
|
300
|
+
commands and error boundary in their real order — and answers its Bot API calls
|
|
301
|
+
from memory. Updates go through `bot.handleUpdate`, and an error reaches
|
|
302
|
+
`bot.catch` as it does under long polling.
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
import { Bot, MemorySessionStorage } from 'grammy'
|
|
306
|
+
import { createScreenTestChat } from 'stitchkit/telegram/screens'
|
|
307
|
+
|
|
308
|
+
const chat = createScreenTestChat(makeBot(new MemorySessionStorage()))
|
|
309
|
+
await chat.send('/start')
|
|
310
|
+
await chat.press('Alpha')
|
|
311
|
+
expect(chat.messages.at(-1)?.text).toBe('📁 <b>Alpha</b>')
|
|
312
|
+
expect(chat.calls.map((call) => call.method)).toEqual(['sendMessage', 'answerCallbackQuery', 'editMessageText'])
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
It keeps the Telegram rules screens depend on — an edit that changes nothing is
|
|
316
|
+
"not modified", a deleted message cannot be edited, HTML it cannot parse, text
|
|
317
|
+
past 4096 characters, a caption past 1024 and `callback_data` past 64 bytes are
|
|
318
|
+
refused — and offers `failNext` to
|
|
319
|
+
refuse a call, `deleteByUser`, `pressData` for a forged press and
|
|
320
|
+
`restart(bot)` for a new process over the same storage.
|
|
321
|
+
|
|
322
|
+
## Not included
|
|
323
|
+
|
|
324
|
+
Reply keyboards, inline mode, media uploads (`InputFile`) in a view, showing a
|
|
325
|
+
screen without an update (use `screens.button` in a notification), converting
|
|
326
|
+
an incoming message to HTML, and a history stack.
|
|
327
|
+
|
|
328
|
+
==============================================================================
|
|
329
|
+
# API reference: `stitchkit/telegram/screens` (docs/api/reference.md)
|
|
330
|
+
==============================================================================
|
|
331
|
+
|
|
332
|
+
## `stitchkit/telegram/screens`
|
|
333
|
+
|
|
334
|
+
A bot's menus as declared screens over grammY: a screen says what the chat
|
|
335
|
+
should show, and the runtime decides what to send, edit and delete. Server-only,
|
|
336
|
+
Bun or Node; `grammy` and `zod` appear in the declarations, and nothing imports
|
|
337
|
+
grammY at run time — the bot's own `ctx.api` and a grammY `StorageAdapter` are
|
|
338
|
+
the whole dependency. → ADR 0211 — [guide](../guide/telegram-screens.md)
|
|
339
|
+
|
|
340
|
+
| Export | Kind | Summary |
|
|
341
|
+
|--------|------|---------|
|
|
342
|
+
| `telegramScreens` | function | `telegramScreens<Ctx>()` — the root the bot's screens are declared from; the context type is named once |
|
|
343
|
+
| `TelegramScreensRoot` / `ScreenScope` | class | `screen(path, options?)`, `group(prefix, { params?, id? })`, and on the root `create(config)` |
|
|
344
|
+
| `ScreenGroupBuilder` / `ScreenBuilder` / `ScreenBodyBuilder` | class | the chain: `load` (first or never) → `action` / `on` → `view`, which ends the declaration |
|
|
345
|
+
| `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 |
|
|
346
|
+
| `TelegramScreen` / `AnyTelegramScreen` | class / _type_ | a declared screen, typed by its context, params, data and actions |
|
|
347
|
+
| `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 |
|
|
348
|
+
| `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 |
|
|
349
|
+
| `ScreenActions` / `ScreenActionButtons` / `NoActionInput` | _type_ | action name → input; `act.<name>(label, input?)` typed by the action's schema |
|
|
350
|
+
| `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) |
|
|
351
|
+
| `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` |
|
|
352
|
+
| `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 |
|
|
353
|
+
| `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` |
|
|
354
|
+
| `link` / `back` / `ScreenButton` | function / class | a button to a screen with its params; a button to the parent screen |
|
|
355
|
+
| `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? }` |
|
|
356
|
+
| `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) |
|
|
357
|
+
| `ActionValue` | _type_ | what a button can carry: a string, a finite number, a boolean or `null` |
|
|
358
|
+
| `TelegramScreensConfig` | _type_ | `screens`, `storage`, `chatKey` (default `screens:<chat id>`), `callbackPrefix` (default `~`), `chats` (`'private'` by default, or `'all'`), `onStale`, `onError`, `onEvent`, `clock` |
|
|
359
|
+
| `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 |
|
|
360
|
+
| `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` |
|
|
361
|
+
| `ScreenEvent` / `ExecutionReport` | _type_ | `transition` (trigger, path templates, duration, messages sent / edited / kept / deleted), `state-discarded`, `message-kept`, `sweep-failed`; never message text |
|
|
362
|
+
| `ScreenChatState` / `ScreenChatStateSchema` / `TelegramScreenStorage` | _type_ / schema | one record per chat — the shown view and expiring remarks, no message text; any grammY `StorageAdapter<ScreenChatState>` |
|
|
363
|
+
| `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` |
|
|
364
|
+
|
|
365
|
+
---
|