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.
Files changed (233) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +2 -1
  3. package/dist/application/process-lifecycle-transitions.d.ts +13 -2
  4. package/dist/application/process-lifecycle-transitions.d.ts.map +1 -1
  5. package/dist/application/process-lifecycle.d.ts +2 -1
  6. package/dist/application/process-lifecycle.d.ts.map +1 -1
  7. package/dist/entrypoints/agent-runtime/browser.js +119 -119
  8. package/dist/entrypoints/agent-runtime/coding-tools.js +15 -20
  9. package/dist/entrypoints/agent-runtime/harness.js +55 -63
  10. package/dist/entrypoints/agent-runtime/openrouter.js +6 -6
  11. package/dist/entrypoints/agent-runtime/sandbox.js +20 -25
  12. package/dist/entrypoints/agent-runtime/sqlite/bun.js +11 -13
  13. package/dist/entrypoints/agent-runtime/sqlite/node.js +11 -13
  14. package/dist/entrypoints/agent-runtime/testing.js +32 -34
  15. package/dist/entrypoints/agent-runtime.js +395 -402
  16. package/dist/entrypoints/application/diagnostic-journal.js +26 -27
  17. package/dist/entrypoints/application/directory-inbox.js +16 -17
  18. package/dist/entrypoints/application/grammy.js +13 -13
  19. package/dist/entrypoints/application/opentelemetry.js +13 -13
  20. package/dist/entrypoints/application/schemas.js +24 -24
  21. package/dist/entrypoints/application.d.ts +1 -1
  22. package/dist/entrypoints/application.d.ts.map +1 -1
  23. package/dist/entrypoints/application.js +218 -214
  24. package/dist/entrypoints/bin/upgrade-cli.js +2 -2
  25. package/dist/entrypoints/cli.js +81 -84
  26. package/dist/entrypoints/contract.js +54 -57
  27. package/dist/entrypoints/declaration.js +22 -22
  28. package/dist/entrypoints/files.js +18 -21
  29. package/dist/entrypoints/geo.js +7 -8
  30. package/dist/entrypoints/google.js +2 -2
  31. package/dist/entrypoints/index.js +150 -154
  32. package/dist/entrypoints/live.js +45 -47
  33. package/dist/entrypoints/node.js +68 -74
  34. package/dist/entrypoints/oauth.js +6 -6
  35. package/dist/entrypoints/observability.d.ts +1 -0
  36. package/dist/entrypoints/observability.d.ts.map +1 -1
  37. package/dist/entrypoints/observability.js +115 -87
  38. package/dist/entrypoints/primitives.js +35 -35
  39. package/dist/entrypoints/react/keyboard.d.ts +4 -0
  40. package/dist/entrypoints/react/keyboard.d.ts.map +1 -0
  41. package/dist/entrypoints/react/keyboard.js +397 -0
  42. package/dist/entrypoints/react.js +10 -10
  43. package/dist/entrypoints/release.js +7 -7
  44. package/dist/entrypoints/remote.js +15 -17
  45. package/dist/entrypoints/server.js +198 -200
  46. package/dist/entrypoints/telegram/html.d.ts +5 -0
  47. package/dist/entrypoints/telegram/html.d.ts.map +1 -0
  48. package/dist/entrypoints/telegram/html.js +26 -0
  49. package/dist/entrypoints/telegram/screens.d.ts +14 -0
  50. package/dist/entrypoints/telegram/screens.d.ts.map +1 -0
  51. package/dist/entrypoints/telegram/screens.js +2028 -0
  52. package/dist/entrypoints/telegram.d.ts +5 -1
  53. package/dist/entrypoints/telegram.d.ts.map +1 -1
  54. package/dist/entrypoints/telegram.js +548 -156
  55. package/dist/entrypoints/testing.js +75 -79
  56. package/dist/entrypoints/tools/connections.js +30 -32
  57. package/dist/entrypoints/tools/contract.js +18 -20
  58. package/dist/entrypoints/tools/invoker.js +12 -17
  59. package/dist/entrypoints/tools.js +223 -229
  60. package/dist/entrypoints/tracking/server.js +6 -6
  61. package/dist/entrypoints/tracking.js +40 -42
  62. package/dist/entrypoints/voice.js +3 -3
  63. package/dist/{index-eaysmdtq.js → index-0ndk4555.js} +56 -56
  64. package/dist/{index-2bxbyab0.js → index-1bx1xv1k.js} +1 -1
  65. package/dist/{index-p5r7qpdd.js → index-1eeqpadm.js} +4 -4
  66. package/dist/{index-hadmp193.js → index-3ct7hhq6.js} +210 -210
  67. package/dist/{index-e01r054k.js → index-45tdvqa3.js} +26 -26
  68. package/dist/{index-dce5wxcq.js → index-4s0fep5q.js} +8 -8
  69. package/dist/index-50ejfz75.js +9 -0
  70. package/dist/{index-m9zn6cpq.js → index-5107q189.js} +12 -12
  71. package/dist/{index-sbdmyz75.js → index-5cc6r990.js} +3 -3
  72. package/dist/index-6k1svmjh.js +141 -0
  73. package/dist/{index-c6bxbsnc.js → index-7kxw1awn.js} +2 -2
  74. package/dist/{index-0r0fbk7z.js → index-7z9ym7t8.js} +28 -28
  75. package/dist/{index-p7hgdyhb.js → index-814xpr6d.js} +2 -2
  76. package/dist/{index-gtj3zc53.js → index-8kzct1d6.js} +99 -99
  77. package/dist/{index-1zbbzgfh.js → index-9pw0a51a.js} +4 -4
  78. package/dist/{index-79z7m7dt.js → index-a9pd417t.js} +35 -35
  79. package/dist/{index-ham5sz8m.js → index-aaj97r4y.js} +45 -45
  80. package/dist/{index-s812ycat.js → index-aan6hqb9.js} +3 -3
  81. package/dist/{index-10x2hbx0.js → index-adhgd4pe.js} +44 -44
  82. package/dist/{index-2gxzjvyr.js → index-afjvjyck.js} +23 -23
  83. package/dist/{index-qmy6zq75.js → index-ahvsksw5.js} +16 -16
  84. package/dist/{index-hr4tzbwq.js → index-ap3gqe56.js} +3 -3
  85. package/dist/{index-znfs20ss.js → index-b2bz0kdm.js} +16 -16
  86. package/dist/{index-r066jx76.js → index-c6aagrbd.js} +148 -120
  87. package/dist/{index-k30h66yd.js → index-ck71tbcf.js} +9 -9
  88. package/dist/{index-eqqvgdb3.js → index-ct75c3at.js} +2 -2
  89. package/dist/{index-gw8bmyaj.js → index-djjhhgjk.js} +41 -41
  90. package/dist/{index-8qt2m66q.js → index-dvwj1f43.js} +43 -43
  91. package/dist/{index-fhwqwxe0.js → index-ej331hah.js} +1 -1
  92. package/dist/{index-9t3dffv2.js → index-ewbaywk5.js} +5 -5
  93. package/dist/{index-7p1f0vp2.js → index-fyphpknh.js} +25 -25
  94. package/dist/{index-83bdg97s.js → index-g149e9pk.js} +1 -1
  95. package/dist/{index-ngfhvfpd.js → index-g6a3na27.js} +4 -4
  96. package/dist/{index-qwhynv6x.js → index-h124epcf.js} +20 -20
  97. package/dist/{index-h6eeys1v.js → index-h97tqqp1.js} +9 -9
  98. package/dist/{index-zcgf3gqf.js → index-hby4qhax.js} +10 -10
  99. package/dist/{index-5jrd0yng.js → index-j8aw2c0e.js} +4 -4
  100. package/dist/{index-d8m8zpd8.js → index-jzczzcsb.js} +14 -14
  101. package/dist/index-kqnd6a85.js +26 -0
  102. package/dist/{index-35mh5bhj.js → index-kw1ws0cs.js} +12 -12
  103. package/dist/{index-yw4fzmdz.js → index-kzz6rq0w.js} +28 -17
  104. package/dist/{index-4gcnakhx.js → index-m38ej7e5.js} +2 -2
  105. package/dist/{index-ndddxtyx.js → index-m79yr938.js} +19 -19
  106. package/dist/{index-5p2m7we1.js → index-m9fndta6.js} +11 -11
  107. package/dist/index-mnkk1ffz.js +150 -0
  108. package/dist/index-n15jny5b.js +4 -0
  109. package/dist/{index-2vtcmrj9.js → index-n53mswt3.js} +20 -20
  110. package/dist/{index-bt14acb5.js → index-n5yqxd3d.js} +21 -21
  111. package/dist/{index-bm5rx7n6.js → index-n9tyz8z3.js} +12 -12
  112. package/dist/index-nmaqxdx7.js +463 -0
  113. package/dist/{index-mqv3b5rh.js → index-nx6eazt0.js} +30 -30
  114. package/dist/{index-3z73fh2c.js → index-q0th8q0z.js} +9 -9
  115. package/dist/{index-v260df89.js → index-qjeggp8w.js} +20 -20
  116. package/dist/{index-zfrhfn82.js → index-r1z0rd9c.js} +20 -20
  117. package/dist/{index-wqgjfstn.js → index-r5c4fedv.js} +3 -3
  118. package/dist/{index-cyktc4bp.js → index-r5hsgkz5.js} +118 -118
  119. package/dist/{index-0b1ess5e.js → index-r7a7r7ha.js} +1 -1
  120. package/dist/{index-1j8pm5t0.js → index-rfkh44ss.js} +15 -15
  121. package/dist/index-rz3z13e9.js +47 -0
  122. package/dist/{index-k6gpggb9.js → index-s80awwsw.js} +35 -35
  123. package/dist/{index-vqr7f7v8.js → index-ssmd41kh.js} +124 -124
  124. package/dist/{index-ensd0r74.js → index-v14qqmys.js} +41 -41
  125. package/dist/{index-paz6sgdp.js → index-v4a2fjtk.js} +36 -36
  126. package/dist/{index-d9pk3td4.js → index-w0eqdhyd.js} +14 -14
  127. package/dist/{index-5qn1kx34.js → index-w575evhq.js} +29 -29
  128. package/dist/{index-3gye9wzb.js → index-w5s3xftz.js} +26 -26
  129. package/dist/{index-tm88avnj.js → index-wykebpn9.js} +3 -3
  130. package/dist/{index-sh3s86na.js → index-xkhk5d6g.js} +17 -17
  131. package/dist/{index-6k1937bx.js → index-xqhede6x.js} +4 -4
  132. package/dist/{index-f4fx2tza.js → index-zs4ms3th.js} +181 -181
  133. package/dist/{index-k5hsfc3j.js → index-zsvzhjtk.js} +1 -1
  134. package/dist/observability/bounded-logger.d.ts +2 -0
  135. package/dist/observability/bounded-logger.d.ts.map +1 -1
  136. package/dist/observability/sanitize.d.ts +6 -0
  137. package/dist/observability/sanitize.d.ts.map +1 -1
  138. package/dist/observability/secret-values.d.ts +25 -0
  139. package/dist/observability/secret-values.d.ts.map +1 -0
  140. package/dist/react/keyboard/hooks.d.ts +71 -0
  141. package/dist/react/keyboard/hooks.d.ts.map +1 -0
  142. package/dist/react/keyboard/layers.d.ts +98 -0
  143. package/dist/react/keyboard/layers.d.ts.map +1 -0
  144. package/dist/react/keyboard/zone.d.ts +139 -0
  145. package/dist/react/keyboard/zone.d.ts.map +1 -0
  146. package/dist/telegram/crypto.d.ts +16 -0
  147. package/dist/telegram/crypto.d.ts.map +1 -0
  148. package/dist/telegram/html/check.d.ts +26 -0
  149. package/dist/telegram/html/check.d.ts.map +1 -0
  150. package/dist/telegram/html/nodes.d.ts +70 -0
  151. package/dist/telegram/html/nodes.d.ts.map +1 -0
  152. package/dist/telegram/html/parse.d.ts +35 -0
  153. package/dist/telegram/html/parse.d.ts.map +1 -0
  154. package/dist/telegram/html/render.d.ts +39 -0
  155. package/dist/telegram/html/render.d.ts.map +1 -0
  156. package/dist/telegram/init-data.d.ts.map +1 -1
  157. package/dist/telegram/operator-channel.d.ts +36 -3
  158. package/dist/telegram/operator-channel.d.ts.map +1 -1
  159. package/dist/telegram/screens/callback-codec.d.ts +34 -0
  160. package/dist/telegram/screens/callback-codec.d.ts.map +1 -0
  161. package/dist/telegram/screens/dispatch.d.ts +22 -0
  162. package/dist/telegram/screens/dispatch.d.ts.map +1 -0
  163. package/dist/telegram/screens/display.d.ts +70 -0
  164. package/dist/telegram/screens/display.d.ts.map +1 -0
  165. package/dist/telegram/screens/execute.d.ts +57 -0
  166. package/dist/telegram/screens/execute.d.ts.map +1 -0
  167. package/dist/telegram/screens/html.d.ts +36 -0
  168. package/dist/telegram/screens/html.d.ts.map +1 -0
  169. package/dist/telegram/screens/outcome.d.ts +74 -0
  170. package/dist/telegram/screens/outcome.d.ts.map +1 -0
  171. package/dist/telegram/screens/path.d.ts +29 -0
  172. package/dist/telegram/screens/path.d.ts.map +1 -0
  173. package/dist/telegram/screens/reconcile.d.ts +67 -0
  174. package/dist/telegram/screens/reconcile.d.ts.map +1 -0
  175. package/dist/telegram/screens/registry.d.ts +23 -0
  176. package/dist/telegram/screens/registry.d.ts.map +1 -0
  177. package/dist/telegram/screens/render.d.ts +70 -0
  178. package/dist/telegram/screens/render.d.ts.map +1 -0
  179. package/dist/telegram/screens/runtime.d.ts +133 -0
  180. package/dist/telegram/screens/runtime.d.ts.map +1 -0
  181. package/dist/telegram/screens/screen-types.d.ts +130 -0
  182. package/dist/telegram/screens/screen-types.d.ts.map +1 -0
  183. package/dist/telegram/screens/screen.d.ts +138 -0
  184. package/dist/telegram/screens/screen.d.ts.map +1 -0
  185. package/dist/telegram/screens/state.d.ts +115 -0
  186. package/dist/telegram/screens/state.d.ts.map +1 -0
  187. package/dist/telegram/screens/telegram-screens.d.ts +18 -0
  188. package/dist/telegram/screens/telegram-screens.d.ts.map +1 -0
  189. package/dist/telegram/screens/test-chat-telegram.d.ts +47 -0
  190. package/dist/telegram/screens/test-chat-telegram.d.ts.map +1 -0
  191. package/dist/telegram/screens/test-chat.d.ts +66 -0
  192. package/dist/telegram/screens/test-chat.d.ts.map +1 -0
  193. package/dist/telegram/screens/view.d.ts +89 -0
  194. package/dist/telegram/screens/view.d.ts.map +1 -0
  195. package/dist/telegram/send-failure.d.ts +19 -0
  196. package/dist/telegram/send-failure.d.ts.map +1 -1
  197. package/dist/telegram/update-intake.d.ts +78 -0
  198. package/dist/telegram/update-intake.d.ts.map +1 -0
  199. package/dist/telegram/update-store-sqlite.d.ts +26 -0
  200. package/dist/telegram/update-store-sqlite.d.ts.map +1 -0
  201. package/dist/telegram/update-store.d.ts +67 -0
  202. package/dist/telegram/update-store.d.ts.map +1 -0
  203. package/dist/telegram/webhook.d.ts +105 -0
  204. package/dist/telegram/webhook.d.ts.map +1 -0
  205. package/dist/tools/cli/args-fields.d.ts +8 -0
  206. package/dist/tools/cli/args-fields.d.ts.map +1 -1
  207. package/dist/tools/cli/args-route.d.ts.map +1 -1
  208. package/dist/tools/cli/args.d.ts.map +1 -1
  209. package/dist/tools/cli/help.d.ts.map +1 -1
  210. package/llms/application.2.txt +26 -1
  211. package/llms/application.txt +18 -27
  212. package/llms/cli.txt +8 -2
  213. package/llms/observability.txt +2 -1
  214. package/llms/react-keyboard.txt +193 -0
  215. package/llms/stitchkit.txt +7 -1
  216. package/llms/telegram-html.txt +35 -0
  217. package/llms/telegram-screens.txt +365 -0
  218. package/llms/telegram.txt +160 -4
  219. package/llms-full.txt +755 -11
  220. package/llms.txt +11 -6
  221. package/package.json +17 -2
  222. package/dist/index-2k4yrqkc.js +0 -9
  223. package/dist/index-crykmqkr.js +0 -26
  224. package/dist/index-y64k3s0h.js +0 -150
  225. package/dist/index-y91ry9h2.js +0 -4
  226. /package/dist/{index-hffjpmq9.js → index-9jfdm5tg.js} +0 -0
  227. /package/dist/{index-zjjyynm3.js → index-d4pa2bgs.js} +0 -0
  228. /package/dist/{index-rxfy4cq7.js → index-dwfv9a85.js} +0 -0
  229. /package/dist/{index-5jm36zv1.js → index-gfk7nrht.js} +0 -0
  230. /package/dist/{index-b9aefrqd.js → index-n1dqr7gf.js} +0 -0
  231. /package/dist/{index-q3pyb9n0.js → index-sykb5ag7.js} +0 -0
  232. /package/dist/{index-48ffdxgk.js → index-xhzq2qvj.js} +0 -0
  233. /package/dist/{index-kzfs85xp.js → index-zsarb5xp.js} +0 -0
@@ -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
+ ---
package/llms/telegram.txt CHANGED
@@ -5,6 +5,9 @@
5
5
 
6
6
  Contains: Guide: Telegram bots (docs/guide/telegram.md) · API reference: `stitchkit/telegram` (docs/api/reference.md)
7
7
 
8
+ Also read:
9
+ - see `llms/telegram-screens.txt` for the Telegram screens guide
10
+
8
11
  ==============================================================================
9
12
  # Guide: Telegram bots (docs/guide/telegram.md)
10
13
  ==============================================================================
@@ -21,6 +24,8 @@ way: [ADR 0201](../decisions/0201-a-telegram-bot-is-assembled-from-primitives.md
21
24
  | Job | Where |
22
25
  |---|---|
23
26
  | the bot as resources: command menu, then long polling with updates admitted by batch | `grammyBotResources` — [application kernel](application-kernel.md#optional-grammy-adapter) |
27
+ | 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) |
28
+ | text for Telegram's HTML: cleaned, cut to the limit, checked | `stitchkit/telegram/html` — [below](#message-markup) |
24
29
  | the process journal | `createJsonLogger` from `stitchkit/observability` |
25
30
  | the operator's chat: product events, apart from the journal | `createTelegramOperatorChannel` |
26
31
  | a broadcast that survives crashes, deploys and Ctrl-C | `runTelegramBroadcast` |
@@ -28,6 +33,7 @@ way: [ADR 0201](../decisions/0201-a-telegram-bot-is-assembled-from-primitives.md
28
33
  | why a send was refused | `classifyTelegramSendFailure` — [auth and errors](auth-and-errors.md#telegram-mini-apps) |
29
34
  | Mini App `initData` | `verifyTelegramInitData` — [auth and errors](auth-and-errors.md#telegram-mini-apps) |
30
35
  | one Bot API call without a bot library | `callTelegramBotApi` |
36
+ | menus: screens, buttons, actions, input and message editing | `stitchkit/telegram/screens` — [Telegram screens](telegram-screens.md) |
31
37
 
32
38
  ## The journal
33
39
 
@@ -51,6 +57,24 @@ export const logger = createJsonLogger({
51
57
  logger.error('Application startup failed', { error })
52
58
  ```
53
59
 
60
+ A provider's key, a webhook secret or a database password has no shape a
61
+ pattern could guess, so the journal also masks exact values — and the
62
+ environment already names them. `secretValuesFromEnv` collects every variable
63
+ named as a token, secret, key, password or credential, the password and secret
64
+ query values inside any URL variable (`DATABASE_URL`), and each URL-encoded:
65
+
66
+ ```ts
67
+ import { createJsonLogger, secretValuesFromEnv } from 'stitchkit/observability'
68
+
69
+ export const logger = createJsonLogger({
70
+ sensitiveUrlPatterns: [TELEGRAM_BOT_TOKEN_PATTERN],
71
+ sensitiveValues: secretValuesFromEnv(process.env),
72
+ })
73
+ ```
74
+
75
+ A key added to the environment is masked from its first line; a URL's user name
76
+ is left alone, since it names a role that appears in ordinary text.
77
+
54
78
  The error line contract, for whatever recognises a failure from the journal: a
55
79
  line whose `level` is `50` or more is an error. There is no separate `fatal`;
56
80
  an unrecoverable end is an `error` line followed by a non-zero exit.
@@ -79,8 +103,32 @@ operators.post(`New user ${user.id}`, 'users')
79
103
 
80
104
  Every message is masked before it leaves: a bot token always — bots post their
81
105
  errors here, and an error about a local Bot API file carries the token in its
82
- path — plus any `sensitivePatterns` the bot adds; `telegramOperatorSender` also
83
- masks its own exact token. What to post stays the bot's. On the way down, drain it within the grace period
106
+ path — plus any `sensitivePatterns` and exact `sensitiveValues` the bot adds;
107
+ `telegramOperatorSender` also masks its own exact token, and with `parseMode:
108
+ 'HTML'` repairs the markup — a text cut at Telegram's limit never leaves a tag
109
+ open.
110
+
111
+ A failure that repeats a hundred times, or a storm of a hundred different ones
112
+ when a database goes down, is a line with a count for the person reading, not a
113
+ hundred messages. `dedupe` sends one message per fingerprint per window — the
114
+ topic and the text with numbers, UUIDs and long identifiers blanked — and the
115
+ next one of that kind carries how many were held back; past `maxPerWindow`
116
+ messages in a window the rest are counted onto the next message sent:
117
+
118
+ ```ts
119
+ const operators = createTelegramOperatorChannel<'errors'>({
120
+ chatId: env.OPERATOR_CHAT_ID,
121
+ send: telegramOperatorSender({ token: env.BOT_TOKEN }),
122
+ sensitiveValues: secretValuesFromEnv(process.env),
123
+ dedupe: {
124
+ windowMs: 10 * 60_000,
125
+ maxPerWindow: 20,
126
+ repeatedLine: (count) => `Same again: ${count}`,
127
+ },
128
+ })
129
+ ```
130
+
131
+ 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
84
132
  and close it: `drain: (context) => operators.drain(context.signal)`,
85
133
  `close: () => operators.close()` in a resource.
86
134
 
@@ -124,6 +172,102 @@ application (the application's signal, with admission held by the caller).
124
172
  The recipient state contains Telegram ids: keep it in the state directory, out
125
173
  of git.
126
174
 
175
+ ## Message markup
176
+
177
+ `stitchkit/telegram/html` is Telegram's HTML parse mode as a tree, with no peer
178
+ and no DOM, so a server sending a message and a browser previewing it share one
179
+ parser. Text written by someone else — a model, an editor, an owner's greeting —
180
+ becomes markup Telegram accepts: synonyms renamed (`strong` → `b`), block tags
181
+ turned into line breaks, a link kept only with an `href` Telegram opens,
182
+ anything Telegram forbids (a tag it does not know, formatting inside `code`, a
183
+ quote inside a quote) left as its words.
184
+
185
+ ```ts
186
+ import { sanitizeTelegramHtml, splitTelegramHtml } from 'stitchkit/telegram/html'
187
+
188
+ for (const part of splitTelegramHtml(summary)) {
189
+ await ctx.reply(part, { parse_mode: 'HTML' })
190
+ }
191
+ ```
192
+
193
+ Telegram's limits — 4096 for a message, 1024 for a caption (`{ limit:
194
+ TELEGRAM_CAPTION_LIMIT }`) — count the text after the markup is parsed, so a
195
+ text is cut by what the reader sees, not by the length of its tags. A cut
196
+ prefers a paragraph, then a line, then a space; it never splits a character or
197
+ a custom emoji, and an element open across it is closed in one part and opened
198
+ again, attributes and all, in the next. `truncateTelegramHtml` keeps one part
199
+ with an ellipsis; `telegramHtmlText` is what the reader sees; `parseTelegramHtml`
200
+ gives the nodes to render a preview or an email from. `checkTelegramHtml` says,
201
+ without repairing anything, what Telegram would refuse — for an editor that
202
+ should warn a person rather than change their text.
203
+
204
+ ## A bot on a webhook
205
+
206
+ A webhook is one place in all of Telegram, and `setWebhook` takes it without
207
+ asking whose it was: a staging host or a developer's machine started with the
208
+ production token takes the production bot with one call. `claimTelegramWebhook`
209
+ sets it only where it already points here — address *and* secret — or where the
210
+ bot names the current owner's host as the one it takes over from (`'none'` when
211
+ there is no webhook: a bot on long polling is somebody's bot too). Telegram never
212
+ returns the secret, so the address carries an `owner` tag derived from it; a
213
+ copy of the production address with another secret is refused as
214
+ `other-secret` instead of quietly installing its own.
215
+
216
+ ```ts
217
+ import {
218
+ checkTelegramWebhook,
219
+ claimTelegramWebhook,
220
+ createTelegramUpdateIntake,
221
+ receiveTelegramWebhook,
222
+ sqliteTelegramUpdateStore,
223
+ } from 'stitchkit/telegram'
224
+ import { Database } from 'bun:sqlite'
225
+ import type { Update } from 'grammy/types'
226
+
227
+ const webhook = { token: env.BOT_TOKEN, url: env.WEBHOOK_URL, secret: env.WEBHOOK_SECRET }
228
+
229
+ const intake = createTelegramUpdateIntake<Update>({
230
+ store: sqliteTelegramUpdateStore({ database: new Database(env.UPDATES_DB) }),
231
+ handle: (update) => bot.handleUpdate(update),
232
+ onFailure: (failure) => logger.warn('Update failed', { ...failure }),
233
+ })
234
+
235
+ // start: set the webhook, then handle what was recorded before the restart
236
+ await bot.init()
237
+ await claimTelegramWebhook({ ...webhook, takeoverFrom: env.WEBHOOK_TAKEOVER_FROM })
238
+ await intake.start()
239
+
240
+ // the route
241
+ const route = (request: Request) =>
242
+ receiveTelegramWebhook(request, { secret: webhook.secret, accept: intake.accept })
243
+
244
+ // stop admission: `await intake.close()` waits for the handlers in flight
245
+ ```
246
+
247
+ A refusal names the owner's host, never its path — a foreign webhook's path may
248
+ itself be a secret — and says what to pass: set the takeover for one start and
249
+ remove it. `checkTelegramWebhook` on a timer notices a webhook that moved
250
+ elsewhere, which this process cannot see any other way.
251
+
252
+ **The update is recorded before Telegram is answered.** A handler that runs
253
+ longer than Telegram waits — a large file from a local Bot API server — gets the
254
+ same update again while it still works, and a process that answers and then
255
+ dies loses the update for good. `receiveTelegramWebhook` answers 200 once the
256
+ intake recorded the body; the handler runs after, outside the request. Updates
257
+ of one chat run in order, other chats alongside (`maxConcurrent`, 32). An
258
+ attempt holds a lease it renews while the handler lives; a sweep takes over what
259
+ was left — `pending` whose process never got to it (`pendingGraceMs`), `failed`
260
+ whose retry is due, `processing` whose process died. A failure is retried with
261
+ backoff or after Telegram's `retry_after`; one Telegram said repeating cannot
262
+ fix (a refused message, a blocked user) is abandoned; `maxAttempts` (5) ends
263
+ the rest. Delivery is at least once: a handler that finished but could not be
264
+ recorded as finished runs again.
265
+
266
+ The store is an interface of six atomic steps (`TelegramUpdateStore`), so the
267
+ bot's own database can hold it; `sqliteTelegramUpdateStore` takes a
268
+ `bun:sqlite` or `node:sqlite` handle as it is, and `memoryTelegramUpdateStore`
269
+ is for tests.
270
+
127
271
  ## Files from a local Bot API server
128
272
 
129
273
  A local `telegram-bot-api` keeps downloaded files under `<dir>/<bot token>/`,
@@ -179,11 +323,23 @@ injected grammY bot. → ADR 0143, ADR 0201 — [guide](../guide/telegram.md)
179
323
  | `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` |
180
324
  | `telegramBroadcastSender` / `TelegramBroadcastSenderConfig` / `TelegramBroadcastMessage` | function / _type_ | the standard `send`: `sendMessage` with a text or `copyMessage` of a prepared message, through the Bot API |
181
325
  | `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 |
182
- | `TelegramOperatorChannelConfig` / `TelegramOperatorChannel` / `TelegramOperatorMessage` / `TelegramOperatorDrop` / `TelegramChatId` | _type_ | `chatId`, `topics`, `send`, `maxQueued`, `minIntervalMs`, `maxAttempts`, `onDropped`, `sensitivePatterns` (a bot token is always masked); a drop is `overflow` / `refused` / `attempts` / `closed` with its classification |
183
- | `telegramOperatorSender` / `TelegramOperatorSenderConfig` | function / _type_ | the standard `send`: `sendMessage` into the chat and topic, plain text unless `parseMode` |
326
+ | `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 |
327
+ | `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` |
328
+ | `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 |
184
329
  | `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` |
185
330
  | `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 |
186
331
  | `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 |
187
332
  | `TELEGRAM_BOT_TOKEN_PATTERN` | constant | the shape of a bot token, for a logger's `sensitiveUrlPatterns` |
333
+ | `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` |
334
+ | `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` |
335
+ | `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 |
336
+ | `telegramWebhookUrl` | function | the address with an `owner` tag derived from the secret: equal for one secret, different for another, the path untouched |
337
+ | `checkTelegramWebhook` / `TelegramWebhookState` | function / _type_ | whether the webhook still points here: `owned`, `ownerHost`, `pendingUpdates`, Telegram's `lastError` |
338
+ | `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 |
339
+ | `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 |
340
+ | `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()` |
341
+ | `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 |
342
+ | `memoryTelegramUpdateStore` | function | the store in this process's memory — tests, or a bot that accepts losing unhandled updates to a restart |
343
+ | `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 |
188
344
 
189
345
  ---